proxmox-ve/usr/share/pve-manager/js/pvemanagerlib.js

61265 lines
1.3 MiB

const pveOnlineHelpInfo = {
"ceph_rados_block_devices" : {
"link" : "/pve-docs/chapter-pvesm.html#ceph_rados_block_devices",
"title" : "Ceph RADOS Block Devices (RBD)"
},
"chapter_gui" : {
"link" : "/pve-docs/chapter-pve-gui.html#chapter_gui",
"title" : "Graphical User Interface"
},
"chapter_ha_manager" : {
"link" : "/pve-docs/chapter-ha-manager.html#chapter_ha_manager",
"title" : "High Availability"
},
"chapter_lvm" : {
"link" : "/pve-docs/chapter-sysadmin.html#chapter_lvm",
"title" : "Logical Volume Manager (LVM)"
},
"chapter_notifications" : {
"link" : "/pve-docs/chapter-notifications.html#chapter_notifications",
"title" : "Notifications"
},
"chapter_pct" : {
"link" : "/pve-docs/chapter-pct.html#chapter_pct",
"title" : "Proxmox Container Toolkit"
},
"chapter_pve_firewall" : {
"link" : "/pve-docs/chapter-pve-firewall.html#chapter_pve_firewall",
"title" : "Proxmox VE Firewall"
},
"chapter_pveceph" : {
"link" : "/pve-docs/chapter-pveceph.html#chapter_pveceph",
"title" : "Deploy Hyper-Converged Ceph Cluster"
},
"chapter_pvecm" : {
"link" : "/pve-docs/chapter-pvecm.html#chapter_pvecm",
"title" : "Cluster Manager"
},
"chapter_pvesdn" : {
"link" : "/pve-docs/chapter-pvesdn.html#chapter_pvesdn",
"title" : "Software-Defined Network"
},
"chapter_pvesr" : {
"link" : "/pve-docs/chapter-pvesr.html#chapter_pvesr",
"title" : "Storage Replication"
},
"chapter_storage" : {
"link" : "/pve-docs/chapter-pvesm.html#chapter_storage",
"title" : "Proxmox VE Storage"
},
"chapter_system_administration" : {
"link" : "/pve-docs/chapter-sysadmin.html#chapter_system_administration",
"title" : "Host System Administration"
},
"chapter_user_management" : {
"link" : "/pve-docs/chapter-pveum.html#chapter_user_management",
"title" : "User Management"
},
"chapter_virtual_machines" : {
"link" : "/pve-docs/chapter-qm.html#chapter_virtual_machines",
"title" : "QEMU/KVM Virtual Machines"
},
"chapter_vzdump" : {
"link" : "/pve-docs/chapter-vzdump.html#chapter_vzdump",
"title" : "Backup and Restore"
},
"chapter_zfs" : {
"link" : "/pve-docs/chapter-sysadmin.html#chapter_zfs",
"title" : "ZFS on Linux"
},
"datacenter_configuration_file" : {
"link" : "/pve-docs/pve-admin-guide.html#datacenter_configuration_file",
"title" : "Datacenter Configuration"
},
"external_metric_server" : {
"link" : "/pve-docs/chapter-sysadmin.html#external_metric_server",
"title" : "External Metric Server"
},
"getting_help" : {
"link" : "/pve-docs/pve-admin-guide.html#getting_help",
"title" : "Getting Help"
},
"gui_my_settings" : {
"link" : "/pve-docs/chapter-pve-gui.html#gui_my_settings",
"subtitle" : "My Settings",
"title" : "Graphical User Interface"
},
"ha_manager_crs" : {
"link" : "/pve-docs/chapter-ha-manager.html#ha_manager_crs",
"subtitle" : "Cluster Resource Scheduling",
"title" : "High Availability"
},
"ha_manager_fencing" : {
"link" : "/pve-docs/chapter-ha-manager.html#ha_manager_fencing",
"subtitle" : "Fencing",
"title" : "High Availability"
},
"ha_manager_groups" : {
"link" : "/pve-docs/chapter-ha-manager.html#ha_manager_groups",
"subtitle" : "Groups",
"title" : "High Availability"
},
"ha_manager_resource_config" : {
"link" : "/pve-docs/chapter-ha-manager.html#ha_manager_resource_config",
"subtitle" : "Resources",
"title" : "High Availability"
},
"ha_manager_resources" : {
"link" : "/pve-docs/chapter-ha-manager.html#ha_manager_resources",
"subtitle" : "Resources",
"title" : "High Availability"
},
"ha_manager_shutdown_policy" : {
"link" : "/pve-docs/chapter-ha-manager.html#ha_manager_shutdown_policy",
"subtitle" : "Shutdown Policy",
"title" : "High Availability"
},
"markdown_basics" : {
"link" : "/pve-docs/pve-admin-guide.html#markdown_basics",
"title" : "Markdown Primer"
},
"metric_server_graphite" : {
"link" : "/pve-docs/chapter-sysadmin.html#metric_server_graphite",
"subtitle" : "Graphite server configuration",
"title" : "External Metric Server"
},
"metric_server_influxdb" : {
"link" : "/pve-docs/chapter-sysadmin.html#metric_server_influxdb",
"subtitle" : "Influxdb plugin configuration",
"title" : "External Metric Server"
},
"notification_matchers" : {
"link" : "/pve-docs/chapter-notifications.html#notification_matchers",
"subtitle" : "Notification Matchers",
"title" : "Notifications"
},
"notification_targets_gotify" : {
"link" : "/pve-docs/chapter-notifications.html#notification_targets_gotify",
"subtitle" : "Gotify",
"title" : "Notifications"
},
"notification_targets_sendmail" : {
"link" : "/pve-docs/chapter-notifications.html#notification_targets_sendmail",
"subtitle" : "Sendmail",
"title" : "Notifications"
},
"notification_targets_smtp" : {
"link" : "/pve-docs/chapter-notifications.html#notification_targets_smtp",
"subtitle" : "SMTP",
"title" : "Notifications"
},
"notification_targets_webhook" : {
"link" : "/pve-docs/chapter-notifications.html#notification_targets_webhook",
"subtitle" : "Webhook",
"title" : "Notifications"
},
"pct_configuration" : {
"link" : "/pve-docs/chapter-pct.html#pct_configuration",
"subtitle" : "Configuration",
"title" : "Proxmox Container Toolkit"
},
"pct_container_images" : {
"link" : "/pve-docs/chapter-pct.html#pct_container_images",
"subtitle" : "Container Images",
"title" : "Proxmox Container Toolkit"
},
"pct_container_network" : {
"link" : "/pve-docs/chapter-pct.html#pct_container_network",
"subtitle" : "Network",
"title" : "Proxmox Container Toolkit"
},
"pct_container_storage" : {
"link" : "/pve-docs/chapter-pct.html#pct_container_storage",
"subtitle" : "Container Storage",
"title" : "Proxmox Container Toolkit"
},
"pct_cpu" : {
"link" : "/pve-docs/chapter-pct.html#pct_cpu",
"subtitle" : "CPU",
"title" : "Proxmox Container Toolkit"
},
"pct_general" : {
"link" : "/pve-docs/chapter-pct.html#pct_general",
"subtitle" : "General Settings",
"title" : "Proxmox Container Toolkit"
},
"pct_memory" : {
"link" : "/pve-docs/chapter-pct.html#pct_memory",
"subtitle" : "Memory",
"title" : "Proxmox Container Toolkit"
},
"pct_migration" : {
"link" : "/pve-docs/chapter-pct.html#pct_migration",
"subtitle" : "Migration",
"title" : "Proxmox Container Toolkit"
},
"pct_options" : {
"link" : "/pve-docs/chapter-pct.html#pct_options",
"subtitle" : "Options",
"title" : "Proxmox Container Toolkit"
},
"pct_startup_and_shutdown" : {
"link" : "/pve-docs/chapter-pct.html#pct_startup_and_shutdown",
"subtitle" : "Automatic Start and Shutdown of Containers",
"title" : "Proxmox Container Toolkit"
},
"proxmox_node_management" : {
"link" : "/pve-docs/chapter-sysadmin.html#proxmox_node_management",
"title" : "Proxmox Node Management"
},
"pve_admin_guide" : {
"link" : "/pve-docs/pve-admin-guide.html",
"title" : "Proxmox VE Administration Guide"
},
"pve_ceph_install" : {
"link" : "/pve-docs/chapter-pveceph.html#pve_ceph_install",
"subtitle" : "CLI Installation of Ceph Packages",
"title" : "Deploy Hyper-Converged Ceph Cluster"
},
"pve_ceph_osds" : {
"link" : "/pve-docs/chapter-pveceph.html#pve_ceph_osds",
"subtitle" : "Ceph OSDs",
"title" : "Deploy Hyper-Converged Ceph Cluster"
},
"pve_ceph_pools" : {
"link" : "/pve-docs/chapter-pveceph.html#pve_ceph_pools",
"subtitle" : "Ceph Pools",
"title" : "Deploy Hyper-Converged Ceph Cluster"
},
"pve_documentation_index" : {
"link" : "/pve-docs/index.html",
"title" : "Proxmox VE Documentation Index"
},
"pve_firewall_cluster_wide_setup" : {
"link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_cluster_wide_setup",
"subtitle" : "Cluster Wide Setup",
"title" : "Proxmox VE Firewall"
},
"pve_firewall_host_specific_configuration" : {
"link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_host_specific_configuration",
"subtitle" : "Host Specific Configuration",
"title" : "Proxmox VE Firewall"
},
"pve_firewall_ip_aliases" : {
"link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_ip_aliases",
"subtitle" : "IP Aliases",
"title" : "Proxmox VE Firewall"
},
"pve_firewall_ip_sets" : {
"link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_ip_sets",
"subtitle" : "IP Sets",
"title" : "Proxmox VE Firewall"
},
"pve_firewall_security_groups" : {
"link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_security_groups",
"subtitle" : "Security Groups",
"title" : "Proxmox VE Firewall"
},
"pve_firewall_vm_container_configuration" : {
"link" : "/pve-docs/chapter-pve-firewall.html#pve_firewall_vm_container_configuration",
"subtitle" : "VM/Container Configuration",
"title" : "Proxmox VE Firewall"
},
"pve_service_daemons" : {
"link" : "/pve-docs/index.html#_service_daemons",
"title" : "Service Daemons"
},
"pveceph_fs" : {
"link" : "/pve-docs/chapter-pveceph.html#pveceph_fs",
"subtitle" : "CephFS",
"title" : "Deploy Hyper-Converged Ceph Cluster"
},
"pveceph_fs_create" : {
"link" : "/pve-docs/chapter-pveceph.html#pveceph_fs_create",
"subtitle" : "Create CephFS",
"title" : "Deploy Hyper-Converged Ceph Cluster"
},
"pvecm_create_cluster" : {
"link" : "/pve-docs/chapter-pvecm.html#pvecm_create_cluster",
"subtitle" : "Create a Cluster",
"title" : "Cluster Manager"
},
"pvecm_join_node_to_cluster" : {
"link" : "/pve-docs/chapter-pvecm.html#pvecm_join_node_to_cluster",
"subtitle" : "Adding Nodes to the Cluster",
"title" : "Cluster Manager"
},
"pvesdn_config_controllers" : {
"link" : "/pve-docs/chapter-pvesdn.html#pvesdn_config_controllers",
"subtitle" : "Controllers",
"title" : "Software-Defined Network"
},
"pvesdn_config_vnet" : {
"link" : "/pve-docs/chapter-pvesdn.html#pvesdn_config_vnet",
"subtitle" : "VNets",
"title" : "Software-Defined Network"
},
"pvesdn_config_zone" : {
"link" : "/pve-docs/chapter-pvesdn.html#pvesdn_config_zone",
"subtitle" : "Zones",
"title" : "Software-Defined Network"
},
"pvesdn_controller_plugin_evpn" : {
"link" : "/pve-docs/chapter-pvesdn.html#pvesdn_controller_plugin_evpn",
"subtitle" : "EVPN Controller",
"title" : "Software-Defined Network"
},
"pvesdn_dns_plugin_powerdns" : {
"link" : "/pve-docs/chapter-pvesdn.html#pvesdn_dns_plugin_powerdns",
"subtitle" : "PowerDNS Plugin",
"title" : "Software-Defined Network"
},
"pvesdn_firewall_integration" : {
"link" : "/pve-docs/chapter-pvesdn.html#pvesdn_firewall_integration",
"subtitle" : "Firewall Integration",
"title" : "Software-Defined Network"
},
"pvesdn_ipam_plugin_netbox" : {
"link" : "/pve-docs/chapter-pvesdn.html#pvesdn_ipam_plugin_netbox",
"subtitle" : "NetBox IPAM Plugin",
"title" : "Software-Defined Network"
},
"pvesdn_ipam_plugin_phpipam" : {
"link" : "/pve-docs/chapter-pvesdn.html#pvesdn_ipam_plugin_phpipam",
"subtitle" : "phpIPAM Plugin",
"title" : "Software-Defined Network"
},
"pvesdn_ipam_plugin_pveipam" : {
"link" : "/pve-docs/chapter-pvesdn.html#pvesdn_ipam_plugin_pveipam",
"subtitle" : "PVE IPAM Plugin",
"title" : "Software-Defined Network"
},
"pvesdn_zone_plugin_evpn" : {
"link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_evpn",
"subtitle" : "EVPN Zones",
"title" : "Software-Defined Network"
},
"pvesdn_zone_plugin_qinq" : {
"link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_qinq",
"subtitle" : "QinQ Zones",
"title" : "Software-Defined Network"
},
"pvesdn_zone_plugin_simple" : {
"link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_simple",
"subtitle" : "Simple Zones",
"title" : "Software-Defined Network"
},
"pvesdn_zone_plugin_vlan" : {
"link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_vlan",
"subtitle" : "VLAN Zones",
"title" : "Software-Defined Network"
},
"pvesdn_zone_plugin_vxlan" : {
"link" : "/pve-docs/chapter-pvesdn.html#pvesdn_zone_plugin_vxlan",
"subtitle" : "VXLAN Zones",
"title" : "Software-Defined Network"
},
"pvesr_schedule_time_format" : {
"link" : "/pve-docs/chapter-pvesr.html#pvesr_schedule_time_format",
"subtitle" : "Schedule Format",
"title" : "Storage Replication"
},
"pveum_authentication_realms" : {
"link" : "/pve-docs/chapter-pveum.html#pveum_authentication_realms",
"subtitle" : "Authentication Realms",
"title" : "User Management"
},
"pveum_configure_u2f" : {
"link" : "/pve-docs/chapter-pveum.html#pveum_configure_u2f",
"subtitle" : "Server Side U2F Configuration",
"title" : "User Management"
},
"pveum_configure_webauthn" : {
"link" : "/pve-docs/chapter-pveum.html#pveum_configure_webauthn",
"subtitle" : "Server Side Webauthn Configuration",
"title" : "User Management"
},
"pveum_groups" : {
"link" : "/pve-docs/chapter-pveum.html#pveum_groups",
"subtitle" : "Groups",
"title" : "User Management"
},
"pveum_ldap_sync" : {
"link" : "/pve-docs/chapter-pveum.html#pveum_ldap_sync",
"subtitle" : "Syncing LDAP-Based Realms",
"title" : "User Management"
},
"pveum_permission_management" : {
"link" : "/pve-docs/chapter-pveum.html#pveum_permission_management",
"subtitle" : "Permission Management",
"title" : "User Management"
},
"pveum_pools" : {
"link" : "/pve-docs/chapter-pveum.html#pveum_pools",
"subtitle" : "Pools",
"title" : "User Management"
},
"pveum_roles" : {
"link" : "/pve-docs/chapter-pveum.html#pveum_roles",
"subtitle" : "Roles",
"title" : "User Management"
},
"pveum_tokens" : {
"link" : "/pve-docs/chapter-pveum.html#pveum_tokens",
"subtitle" : "API Tokens",
"title" : "User Management"
},
"pveum_users" : {
"link" : "/pve-docs/chapter-pveum.html#pveum_users",
"subtitle" : "Users",
"title" : "User Management"
},
"qm_bios_and_uefi" : {
"link" : "/pve-docs/chapter-qm.html#qm_bios_and_uefi",
"subtitle" : "BIOS and UEFI",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_bootorder" : {
"link" : "/pve-docs/chapter-qm.html#qm_bootorder",
"subtitle" : "Device Boot Order",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_cloud_init" : {
"link" : "/pve-docs/chapter-qm.html#qm_cloud_init",
"title" : "Cloud-Init Support"
},
"qm_copy_and_clone" : {
"link" : "/pve-docs/chapter-qm.html#qm_copy_and_clone",
"subtitle" : "Copies and Clones",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_cpu" : {
"link" : "/pve-docs/chapter-qm.html#qm_cpu",
"subtitle" : "CPU",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_display" : {
"link" : "/pve-docs/chapter-qm.html#qm_display",
"subtitle" : "Display",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_general_settings" : {
"link" : "/pve-docs/chapter-qm.html#qm_general_settings",
"subtitle" : "General Settings",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_hard_disk" : {
"link" : "/pve-docs/chapter-qm.html#qm_hard_disk",
"subtitle" : "Hard Disk",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_import_virtual_machines" : {
"link" : "/pve-docs/chapter-qm.html#qm_import_virtual_machines",
"subtitle" : "Importing Virtual Machines",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_machine_type" : {
"link" : "/pve-docs/chapter-qm.html#qm_machine_type",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_memory" : {
"link" : "/pve-docs/chapter-qm.html#qm_memory",
"subtitle" : "Memory",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_migration" : {
"link" : "/pve-docs/chapter-qm.html#qm_migration",
"subtitle" : "Migration",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_network_device" : {
"link" : "/pve-docs/chapter-qm.html#qm_network_device",
"subtitle" : "Network Device",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_options" : {
"link" : "/pve-docs/chapter-qm.html#qm_options",
"subtitle" : "Options",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_os_settings" : {
"link" : "/pve-docs/chapter-qm.html#qm_os_settings",
"subtitle" : "OS Settings",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_pci_passthrough_vm_config" : {
"link" : "/pve-docs/chapter-qm.html#qm_pci_passthrough_vm_config",
"subtitle" : "VM Configuration",
"title" : "PCI(e) Passthrough"
},
"qm_qemu_agent" : {
"link" : "/pve-docs/chapter-qm.html#qm_qemu_agent",
"subtitle" : "QEMU Guest Agent",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_spice_enhancements" : {
"link" : "/pve-docs/chapter-qm.html#qm_spice_enhancements",
"subtitle" : "SPICE Enhancements",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_startup_and_shutdown" : {
"link" : "/pve-docs/chapter-qm.html#qm_startup_and_shutdown",
"subtitle" : "Automatic Start and Shutdown of Virtual Machines",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_system_settings" : {
"link" : "/pve-docs/chapter-qm.html#qm_system_settings",
"subtitle" : "System Settings",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_usb_passthrough" : {
"link" : "/pve-docs/chapter-qm.html#qm_usb_passthrough",
"subtitle" : "USB Passthrough",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_virtio_rng" : {
"link" : "/pve-docs/chapter-qm.html#qm_virtio_rng",
"subtitle" : "VirtIO RNG",
"title" : "QEMU/KVM Virtual Machines"
},
"qm_virtual_machines_settings" : {
"link" : "/pve-docs/chapter-qm.html#qm_virtual_machines_settings",
"subtitle" : "Virtual Machines Settings",
"title" : "QEMU/KVM Virtual Machines"
},
"resource_mapping" : {
"link" : "/pve-docs/chapter-qm.html#resource_mapping",
"subtitle" : "Resource Mapping",
"title" : "QEMU/KVM Virtual Machines"
},
"storage_btrfs" : {
"link" : "/pve-docs/chapter-pvesm.html#storage_btrfs",
"title" : "BTRFS Backend"
},
"storage_cephfs" : {
"link" : "/pve-docs/chapter-pvesm.html#storage_cephfs",
"title" : "Ceph Filesystem (CephFS)"
},
"storage_cifs" : {
"link" : "/pve-docs/chapter-pvesm.html#storage_cifs",
"title" : "CIFS Backend"
},
"storage_directory" : {
"link" : "/pve-docs/chapter-pvesm.html#storage_directory",
"title" : "Directory Backend"
},
"storage_glusterfs" : {
"link" : "/pve-docs/chapter-pvesm.html#storage_glusterfs",
"title" : "GlusterFS Backend"
},
"storage_lvm" : {
"link" : "/pve-docs/chapter-pvesm.html#storage_lvm",
"title" : "LVM Backend"
},
"storage_lvmthin" : {
"link" : "/pve-docs/chapter-pvesm.html#storage_lvmthin",
"title" : "LVM thin Backend"
},
"storage_nfs" : {
"link" : "/pve-docs/chapter-pvesm.html#storage_nfs",
"title" : "NFS Backend"
},
"storage_open_iscsi" : {
"link" : "/pve-docs/chapter-pvesm.html#storage_open_iscsi",
"title" : "Open-iSCSI initiator"
},
"storage_pbs" : {
"link" : "/pve-docs/chapter-pvesm.html#storage_pbs",
"title" : "Proxmox Backup Server"
},
"storage_pbs_encryption" : {
"link" : "/pve-docs/chapter-pvesm.html#storage_pbs_encryption",
"subtitle" : "Encryption",
"title" : "Proxmox Backup Server"
},
"storage_zfspool" : {
"link" : "/pve-docs/chapter-pvesm.html#storage_zfspool",
"title" : "Local ZFS Pool Backend"
},
"sysadmin_certificate_management" : {
"link" : "/pve-docs/chapter-sysadmin.html#sysadmin_certificate_management",
"title" : "Certificate Management"
},
"sysadmin_certs_acme_account" : {
"link" : "/pve-docs/chapter-sysadmin.html#sysadmin_certs_acme_account",
"subtitle" : "ACME Account",
"title" : "Certificate Management"
},
"sysadmin_network_configuration" : {
"link" : "/pve-docs/chapter-sysadmin.html#sysadmin_network_configuration",
"title" : "Network Configuration"
},
"sysadmin_package_repositories" : {
"link" : "/pve-docs/chapter-sysadmin.html#sysadmin_package_repositories",
"title" : "Package Repositories"
},
"user-realms-ad" : {
"link" : "/pve-docs/chapter-pveum.html#user-realms-ad",
"subtitle" : "Microsoft Active Directory (AD)",
"title" : "User Management"
},
"user-realms-ldap" : {
"link" : "/pve-docs/chapter-pveum.html#user-realms-ldap",
"subtitle" : "LDAP",
"title" : "User Management"
},
"user-realms-pam" : {
"link" : "/pve-docs/chapter-pveum.html#user-realms-pam",
"subtitle" : "Linux PAM Standard Authentication",
"title" : "User Management"
},
"user_mgmt" : {
"link" : "/pve-docs/chapter-pveum.html#user_mgmt",
"title" : "User Management"
},
"vzdump_retention" : {
"link" : "/pve-docs/chapter-vzdump.html#vzdump_retention",
"subtitle" : "Backup Retention",
"title" : "Backup and Restore"
}
};
// Some configuration values are complex strings - so we need parsers/generators for them.
Ext.define('PVE.Parser', {
statics: {
// this class only contains static functions
printACME: function(value) {
if (Ext.isArray(value.domains)) {
value.domains = value.domains.join(';');
}
return PVE.Parser.printPropertyString(value);
},
parseACME: function(value) {
if (!value) {
return {};
}
let res = {};
try {
value.split(',').forEach(property => {
let [k, v] = property.split('=', 2);
if (Ext.isDefined(v)) {
res[k] = v;
} else {
throw `Failed to parse key-value pair: ${property}`;
}
});
} catch (err) {
console.warn(err);
return undefined;
}
if (res.domains !== undefined) {
res.domains = res.domains.split(/;/);
}
return res;
},
parseBoolean: function(value, default_value) {
if (!Ext.isDefined(value)) {
return default_value;
}
value = value.toLowerCase();
return value === '1' ||
value === 'on' ||
value === 'yes' ||
value === 'true';
},
parsePropertyString: function(value, defaultKey) {
let res = {};
if (typeof value !== 'string' || value === '') {
return res;
}
try {
value.split(',').forEach(property => {
let [k, v] = property.split('=', 2);
if (Ext.isDefined(v)) {
res[k] = v;
} else if (Ext.isDefined(defaultKey)) {
if (Ext.isDefined(res[defaultKey])) {
throw 'defaultKey may be only defined once in propertyString';
}
res[defaultKey] = k; // k is the value in this case
} else {
throw `Failed to parse key-value pair: ${property}`;
}
});
} catch (err) {
console.warn(err);
return undefined;
}
return res;
},
printPropertyString: function(data, defaultKey) {
var stringparts = [],
gotDefaultKeyVal = false,
defaultKeyVal;
Ext.Object.each(data, function(key, value) {
if (defaultKey !== undefined && key === defaultKey) {
gotDefaultKeyVal = true;
defaultKeyVal = value;
} else if (value !== '') {
stringparts.push(key + '=' + value);
}
});
stringparts = stringparts.sort();
if (gotDefaultKeyVal) {
stringparts.unshift(defaultKeyVal);
}
return stringparts.join(',');
},
parseQemuNetwork: function(key, value) {
if (!(key && value)) {
return undefined;
}
let res = {},
errors = false;
Ext.Array.each(value.split(','), function(p) {
if (!p || p.match(/^\s*$/)) {
return undefined; // continue
}
let match_res;
if ((match_res = p.match(/^(ne2k_pci|e1000e?|e1000-82540em|e1000-82544gc|e1000-82545em|vmxnet3|rtl8139|pcnet|virtio|ne2k_isa|i82551|i82557b|i82559er)(=([0-9a-f]{2}(:[0-9a-f]{2}){5}))?$/i)) !== null) {
res.model = match_res[1].toLowerCase();
if (match_res[3]) {
res.macaddr = match_res[3];
}
} else if ((match_res = p.match(/^bridge=(\S+)$/)) !== null) {
res.bridge = match_res[1];
} else if ((match_res = p.match(/^rate=(\d+(\.\d+)?|\.\d+)$/)) !== null) {
res.rate = match_res[1];
} else if ((match_res = p.match(/^tag=(\d+(\.\d+)?)$/)) !== null) {
res.tag = match_res[1];
} else if ((match_res = p.match(/^firewall=(\d+)$/)) !== null) {
res.firewall = match_res[1];
} else if ((match_res = p.match(/^link_down=(\d+)$/)) !== null) {
res.disconnect = match_res[1];
} else if ((match_res = p.match(/^queues=(\d+)$/)) !== null) {
res.queues = match_res[1];
} else if ((match_res = p.match(/^trunks=(\d+(?:-\d+)?(?:;\d+(?:-\d+)?)*)$/)) !== null) {
res.trunks = match_res[1];
} else if ((match_res = p.match(/^mtu=(\d+)$/)) !== null) {
res.mtu = match_res[1];
} else {
errors = true;
return false; // break
}
return undefined; // continue
});
if (errors || !res.model) {
return undefined;
}
return res;
},
printQemuNetwork: function(net) {
var netstr = net.model;
if (net.macaddr) {
netstr += "=" + net.macaddr;
}
if (net.bridge) {
netstr += ",bridge=" + net.bridge;
if (net.tag) {
netstr += ",tag=" + net.tag;
}
if (net.firewall) {
netstr += ",firewall=" + net.firewall;
}
}
if (net.rate) {
netstr += ",rate=" + net.rate;
}
if (net.queues) {
netstr += ",queues=" + net.queues;
}
if (net.disconnect) {
netstr += ",link_down=" + net.disconnect;
}
if (net.trunks) {
netstr += ",trunks=" + net.trunks;
}
if (net.mtu) {
netstr += ",mtu=" + net.mtu;
}
return netstr;
},
parseQemuDrive: function(key, value) {
if (!(key && value)) {
return undefined;
}
const [, bus, index] = key.match(/^([a-z]+)(\d+)$/);
if (!bus) {
return undefined;
}
let res = {
'interface': bus,
index,
};
var errors = false;
Ext.Array.each(value.split(','), function(p) {
if (!p || p.match(/^\s*$/)) {
return undefined; // continue
}
let match = p.match(/^([a-z_]+)=(\S+)$/);
if (!match) {
if (!p.match(/[=]/)) {
res.file = p;
return undefined; // continue
}
errors = true;
return false; // break
}
let [, k, v] = match;
if (k === 'volume') {
k = 'file';
}
if (Ext.isDefined(res[k])) {
errors = true;
return false; // break
}
if (k === 'cache' && v === 'off') {
v = 'none';
}
res[k] = v;
return undefined; // continue
});
if (errors || !res.file) {
return undefined;
}
return res;
},
printQemuDrive: function(drive) {
var drivestr = drive.file;
Ext.Object.each(drive, function(key, value) {
if (!Ext.isDefined(value) || key === 'file' ||
key === 'index' || key === 'interface') {
return; // continue
}
drivestr += ',' + key + '=' + value;
});
return drivestr;
},
parseIPConfig: function(key, value) {
if (!(key && value)) {
return undefined; // continue
}
let res = {};
try {
value.split(',').forEach(p => {
if (!p || p.match(/^\s*$/)) {
return; // continue
}
const match = p.match(/^(ip|gw|ip6|gw6)=(\S+)$/);
if (!match) {
throw `could not parse as IP config: ${p}`;
}
let [, k, v] = match;
res[k] = v;
});
} catch (err) {
console.warn(err);
return undefined; // continue
}
return res;
},
printIPConfig: function(cfg) {
return Object.entries(cfg)
.filter(([k, v]) => v && k.match(/^(ip|gw|ip6|gw6)$/))
.map(([k, v]) => `${k}=${v}`)
.join(',');
},
parseLxcNetwork: function(value) {
if (!value) {
return undefined;
}
let data = {};
value.split(',').forEach(p => {
if (!p || p.match(/^\s*$/)) {
return; // continue
}
let match_res = p.match(/^(bridge|hwaddr|mtu|name|ip|ip6|gw|gw6|tag|rate)=(\S+)$/);
if (match_res) {
data[match_res[1]] = match_res[2];
} else if ((match_res = p.match(/^firewall=(\d+)$/)) !== null) {
data.firewall = PVE.Parser.parseBoolean(match_res[1]);
} else if ((match_res = p.match(/^link_down=(\d+)$/)) !== null) {
data.link_down = PVE.Parser.parseBoolean(match_res[1]);
} else if (!p.match(/^type=\S+$/)) {
console.warn(`could not parse LXC network string ${p}`);
}
});
return data;
},
printLxcNetwork: function(config) {
let knownKeys = {
bridge: 1,
firewall: 1,
gw6: 1,
gw: 1,
hwaddr: 1,
ip6: 1,
ip: 1,
mtu: 1,
name: 1,
rate: 1,
tag: 1,
link_down: 1,
};
return Object.entries(config)
.filter(([k, v]) => v !== undefined && v !== '' && knownKeys[k])
.map(([k, v]) => `${k}=${v}`)
.join(',');
},
parseLxcMountPoint: function(value) {
if (!value) {
return undefined;
}
let res = {};
let errors = false;
Ext.Array.each(value.split(','), function(p) {
if (!p || p.match(/^\s*$/)) {
return undefined; // continue
}
let match = p.match(/^([a-z_]+)=(.+)$/);
if (!match) {
if (!p.match(/[=]/)) {
res.file = p;
return undefined; // continue
}
errors = true;
return false; // break
}
let [, k, v] = match;
if (k === 'volume') {
k = 'file';
}
if (Ext.isDefined(res[k])) {
errors = true;
return false; // break
}
res[k] = v;
return undefined;
});
if (errors || !res.file) {
return undefined;
}
const match = res.file.match(/^([a-z][a-z0-9\-_.]*[a-z0-9]):/i);
if (match) {
res.storage = match[1];
res.type = 'volume';
} else if (res.file.match(/^\/dev\//)) {
res.type = 'device';
} else {
res.type = 'bind';
}
return res;
},
printLxcMountPoint: function(mp) {
let drivestr = mp.file;
for (const [key, value] of Object.entries(mp)) {
if (!Ext.isDefined(value) || key === 'file' || key === 'type' || key === 'storage') {
continue;
}
drivestr += `,${key}=${value}`;
}
return drivestr;
},
parseStartup: function(value) {
if (value === undefined) {
return undefined;
}
let res = {};
try {
value.split(',').forEach(p => {
if (!p || p.match(/^\s*$/)) {
return; // continue
}
let match_res;
if ((match_res = p.match(/^(order)?=(\d+)$/)) !== null) {
res.order = match_res[2];
} else if ((match_res = p.match(/^up=(\d+)$/)) !== null) {
res.up = match_res[1];
} else if ((match_res = p.match(/^down=(\d+)$/)) !== null) {
res.down = match_res[1];
} else {
throw `could not parse startup config ${p}`;
}
});
} catch (err) {
console.warn(err);
return undefined;
}
return res;
},
printStartup: function(startup) {
let arr = [];
if (startup.order !== undefined && startup.order !== '') {
arr.push('order=' + startup.order);
}
if (startup.up !== undefined && startup.up !== '') {
arr.push('up=' + startup.up);
}
if (startup.down !== undefined && startup.down !== '') {
arr.push('down=' + startup.down);
}
return arr.join(',');
},
parseQemuSmbios1: function(value) {
let res = value.split(',').reduce((acc, currentValue) => {
const [k, v] = currentValue.split(/[=](.+)/);
acc[k] = v;
return acc;
}, {});
if (PVE.Parser.parseBoolean(res.base64, false)) {
for (const [k, v] of Object.entries(res)) {
if (k !== 'uuid') {
res[k] = Ext.util.Base64.decode(v);
}
}
}
return res;
},
printQemuSmbios1: function(data) {
let base64 = false;
let datastr = Object.entries(data)
.map(([key, value]) => {
if (value === '') {
return undefined;
}
if (key !== 'uuid') {
base64 = true; // smbios values can be arbitrary, so encode and mark config as such
value = Ext.util.Base64.encode(value);
}
return `${key}=${value}`;
})
.filter(v => v !== undefined)
.join(',');
if (base64) {
datastr += ',base64=1';
}
return datastr;
},
parseTfaConfig: function(value) {
let res = {};
value.split(',').forEach(p => {
const [k, v] = p.split('=', 2);
res[k] = v;
});
return res;
},
parseTfaType: function(value) {
let match;
if (!value || !value.length) {
return undefined;
} else if (value === 'x!oath') {
return 'totp';
} else if ((match = value.match(/^x!(.+)$/)) !== null) {
return match[1];
} else {
return 1;
}
},
parseQemuCpu: function(value) {
if (!value) {
return {};
}
let res = {};
let errors = false;
Ext.Array.each(value.split(','), function(p) {
if (!p || p.match(/^\s*$/)) {
return undefined; // continue
}
if (!p.match(/[=]/)) {
if (Ext.isDefined(res.cpu)) {
errors = true;
return false; // break
}
res.cputype = p;
return undefined; // continue
}
let match = p.match(/^([a-z_]+)=(\S+)$/);
if (!match || Ext.isDefined(res[match[1]])) {
errors = true;
return false; // break
}
let [, k, v] = match;
res[k] = v;
return undefined;
});
if (errors || !res.cputype) {
return undefined;
}
return res;
},
printQemuCpu: function(cpu) {
let cpustr = cpu.cputype;
let optstr = '';
Ext.Object.each(cpu, function(key, value) {
if (!Ext.isDefined(value) || key === 'cputype') {
return; // continue
}
optstr += ',' + key + '=' + value;
});
if (!cpustr) {
if (optstr) {
return 'kvm64' + optstr;
} else {
return undefined;
}
}
return cpustr + optstr;
},
parseSSHKey: function(key) {
// |--- options can have quotes--| type key comment
let keyre = /^(?:((?:[^\s"]|"(?:\\.|[^"\\])*")+)\s+)?(\S+)\s+(\S+)(?:\s+(.*))?$/;
let typere = /^(?:(?:sk-)?(?:ssh-(?:dss|rsa|ed25519)|ecdsa-sha2-nistp\d+)(?:@(?:[a-z0-9_-]+\.)+[a-z]{2,})?)$/;
let m = key.match(keyre);
if (!m || m.length < 3 || !m[2]) { // [2] is always either type or key
return null;
}
if (m[1] && m[1].match(typere)) {
return {
type: m[1],
key: m[2],
comment: m[3],
};
}
if (m[2].match(typere)) {
return {
options: m[1],
type: m[2],
key: m[3],
comment: m[4],
};
}
return null;
},
parseACMEPluginData: function(data) {
let res = {};
let extradata = [];
data.split('\n').forEach((line) => {
// capture everything after the first = as value
let [key, value] = line.split(/[=](.+)/);
if (value !== undefined) {
res[key] = value;
} else {
extradata.push(line);
}
});
return [res, extradata];
},
filterPropertyStringList: function(list, filterFn, defaultKey) {
return list.filter((entry) => filterFn(PVE.Parser.parsePropertyString(entry, defaultKey)));
},
},
});
/* This state provider keeps part of the state inside the browser history.
*
* We compress (shorten) url using dictionary based compression, i.e., we use
* column separated list instead of url encoded hash:
* #v\d* version/format
* := indicates string values
* :\d+ lookup value in dictionary hash
* #v1:=value1:5:=value2:=value3:...
*/
Ext.define('PVE.StateProvider', {
extend: 'Ext.state.LocalStorageProvider',
// private
setHV: function(name, newvalue, fireEvents) {
let me = this;
let changes = false;
let oldtext = Ext.encode(me.UIState[name]);
let newtext = Ext.encode(newvalue);
if (newtext !== oldtext) {
changes = true;
me.UIState[name] = newvalue;
if (fireEvents) {
me.fireEvent("statechange", me, name, { value: newvalue });
}
}
return changes;
},
// private
hslist: [
// order is important for notifications
// [ name, default ]
['view', 'server'],
['rid', 'root'],
['ltab', 'tasks'],
['nodetab', ''],
['storagetab', ''],
['sdntab', ''],
['pooltab', ''],
['kvmtab', ''],
['lxctab', ''],
['dctab', ''],
],
hprefix: 'v1',
compDict: {
tfa: 54,
sdn: 53,
cloudinit: 52,
replication: 51,
system: 50,
monitor: 49,
'ha-fencing': 48,
'ha-groups': 47,
'ha-resources': 46,
'ceph-log': 45,
'ceph-crushmap': 44,
'ceph-pools': 43,
'ceph-osdtree': 42,
'ceph-disklist': 41,
'ceph-monlist': 40,
'ceph-config': 39,
ceph: 38,
'firewall-fwlog': 37,
'firewall-options': 36,
'firewall-ipset': 35,
'firewall-aliases': 34,
'firewall-sg': 33,
firewall: 32,
apt: 31,
members: 30,
snapshot: 29,
ha: 28,
support: 27,
pools: 26,
syslog: 25,
ubc: 24,
initlog: 23,
openvz: 22,
backup: 21,
resources: 20,
content: 19,
root: 18,
domains: 17,
roles: 16,
groups: 15,
users: 14,
time: 13,
dns: 12,
network: 11,
services: 10,
options: 9,
console: 8,
hardware: 7,
permissions: 6,
summary: 5,
tasks: 4,
clog: 3,
storage: 2,
folder: 1,
server: 0,
},
decodeHToken: function(token) {
let me = this;
let state = {};
if (!token) {
me.hslist.forEach(([k, v]) => { state[k] = v; });
return state;
}
let [prefix, ...items] = token.split(':');
if (prefix !== me.hprefix) {
return me.decodeHToken();
}
Ext.Array.each(me.hslist, function(rec) {
let value = items.shift();
if (value) {
if (value[0] === '=') {
value = decodeURIComponent(value.slice(1));
}
for (const [key, hash] of Object.entries(me.compDict)) {
if (String(value) === String(hash)) {
value = key;
break;
}
}
}
state[rec[0]] = value;
});
return state;
},
encodeHToken: function(state) {
let me = this;
let ctoken = me.hprefix;
Ext.Array.each(me.hslist, function(rec) {
let value = state[rec[0]];
if (!Ext.isDefined(value)) {
value = rec[1];
}
value = encodeURIComponent(value);
if (!value) {
ctoken += ':';
} else if (Ext.isDefined(me.compDict[value])) {
ctoken += ":" + me.compDict[value];
} else {
ctoken += ":=" + value;
}
});
return ctoken;
},
constructor: function(config) {
let me = this;
me.callParent([config]);
me.UIState = me.decodeHToken(); // set default
let history_change_cb = function(token) {
if (!token) {
Ext.History.back();
return;
}
let newstate = me.decodeHToken(token);
Ext.Array.each(me.hslist, function(rec) {
if (typeof newstate[rec[0]] === "undefined") {
return;
}
me.setHV(rec[0], newstate[rec[0]], true);
});
};
let start_token = Ext.History.getToken();
if (start_token) {
history_change_cb(start_token);
} else {
let htext = me.encodeHToken(me.UIState);
Ext.History.add(htext);
}
Ext.History.on('change', history_change_cb);
},
get: function(name, defaultValue) {
let me = this;
let data;
if (typeof me.UIState[name] !== "undefined") {
data = { value: me.UIState[name] };
} else {
data = me.callParent(arguments);
if (!data && name === 'GuiCap') {
data = {
vms: {},
storage: {},
access: {},
nodes: {},
dc: {},
sdn: {},
};
}
}
return data;
},
clear: function(name) {
let me = this;
if (typeof me.UIState[name] !== "undefined") {
me.UIState[name] = null;
}
me.callParent(arguments);
},
set: function(name, value, fireevent) {
let me = this;
if (typeof me.UIState[name] !== "undefined") {
var newvalue = value ? value.value : null;
if (me.setHV(name, newvalue, fireevent)) {
let htext = me.encodeHToken(me.UIState);
Ext.History.add(htext);
}
} else {
me.callParent(arguments);
}
},
});
Ext.ns('PVE');
console.log("Starting Proxmox VE Manager");
Ext.Ajax.defaultHeaders = {
'Accept': 'application/json',
};
Ext.define('PVE.Utils', {
utilities: {
// this singleton contains miscellaneous utilities
toolkit: undefined, // (extjs|touch), set inside Toolkit.js
bus_match: /^(ide|sata|virtio|scsi)(\d+)$/,
log_severity_hash: {
0: "panic",
1: "alert",
2: "critical",
3: "error",
4: "warning",
5: "notice",
6: "info",
7: "debug",
},
support_level_hash: {
'c': gettext('Community'),
'b': gettext('Basic'),
's': gettext('Standard'),
'p': gettext('Premium'),
},
noSubKeyHtml: 'You do not have a valid subscription for this server. Please visit '
+'<a target="_blank" href="https://www.proxmox.com/en/proxmox-virtual-environment/pricing">'
+'www.proxmox.com</a> to get a list of available options.',
getClusterSubscriptionLevel: async function() {
let { result } = await Proxmox.Async.api2({ url: '/cluster/status' });
let levelMap = Object.fromEntries(
result.data.filter(v => v.type === 'node').map(v => [v.name, v.level]),
);
return levelMap;
},
kvm_ostypes: {
'Linux': [
{ desc: '6.x - 2.6 Kernel', val: 'l26' },
{ desc: '2.4 Kernel', val: 'l24' },
],
'Microsoft Windows': [
{ desc: '11/2022/2025', val: 'win11' },
{ desc: '10/2016/2019', val: 'win10' },
{ desc: '8.x/2012/2012r2', val: 'win8' },
{ desc: '7/2008r2', val: 'win7' },
{ desc: 'Vista/2008', val: 'w2k8' },
{ desc: 'XP/2003', val: 'wxp' },
{ desc: '2000', val: 'w2k' },
],
'Solaris Kernel': [
{ desc: '-', val: 'solaris' },
],
'Other': [
{ desc: '-', val: 'other' },
],
},
is_windows: function(ostype) {
for (let entry of PVE.Utils.kvm_ostypes['Microsoft Windows']) {
if (entry.val === ostype) {
return true;
}
}
return false;
},
get_health_icon: function(state, circle) {
if (circle === undefined) {
circle = false;
}
if (state === undefined) {
state = 'uknown';
}
var icon = 'faded fa-question';
switch (state) {
case 'good':
icon = 'good fa-check';
break;
case 'upgrade':
icon = 'warning fa-upload';
break;
case 'old':
icon = 'warning fa-refresh';
break;
case 'warning':
icon = 'warning fa-exclamation';
break;
case 'critical':
icon = 'critical fa-times';
break;
default: break;
}
if (circle) {
icon += '-circle';
}
return icon;
},
parse_ceph_version: function(service) {
if (service.ceph_version_short) {
return service.ceph_version_short;
}
if (service.ceph_version) {
// See PVE/Ceph/Tools.pm - get_local_version
const match = service.ceph_version.match(/^ceph.*\sv?(\d+(?:\.\d+)+)/);
if (match) {
return match[1];
}
}
return undefined;
},
compare_ceph_versions: function(a, b) {
let avers = [];
let bvers = [];
if (a === b) {
return 0;
}
if (Ext.isArray(a)) {
avers = a.slice(); // copy array
} else {
avers = a.toString().split('.');
}
if (Ext.isArray(b)) {
bvers = b.slice(); // copy array
} else {
bvers = b.toString().split('.');
}
for (;;) {
let av = avers.shift();
let bv = bvers.shift();
if (av === undefined && bv === undefined) {
return 0;
} else if (av === undefined) {
return -1;
} else if (bv === undefined) {
return 1;
} else {
let diff = parseInt(av, 10) - parseInt(bv, 10);
if (diff !== 0) return diff;
// else we need to look at the next parts
}
}
},
get_ceph_icon_html: function(health, fw) {
var state = PVE.Utils.map_ceph_health[health];
var cls = PVE.Utils.get_health_icon(state);
if (fw) {
cls += ' fa-fw';
}
return "<i class='fa " + cls + "'></i> ";
},
map_ceph_health: {
'HEALTH_OK': 'good',
'HEALTH_UPGRADE': 'upgrade',
'HEALTH_OLD': 'old',
'HEALTH_WARN': 'warning',
'HEALTH_ERR': 'critical',
},
render_sdn_pending: function(rec, value, key, index) {
if (rec.data.state === undefined || rec.data.state === null) {
return Ext.htmlEncode(value);
}
if (rec.data.state === 'deleted') {
if (value === undefined) {
return ' ';
} else {
return `<div style="text-decoration: line-through;">${Ext.htmlEncode(value)}</div>`;
}
} else if (rec.data.pending[key] !== undefined && rec.data.pending[key] !== null) {
if (rec.data.pending[key] === 'deleted') {
return ' ';
} else {
return Ext.htmlEncode(rec.data.pending[key]);
}
}
return Ext.htmlEncode(value);
},
render_sdn_pending_state: function(rec, value) {
if (value === undefined || value === null) {
return ' ';
}
let icon = `<i class="fa fa-fw fa-refresh warning"></i>`;
if (value === 'deleted') {
return `<span>${icon}${Ext.htmlEncode(value)}</span>`;
}
let tip = gettext('Pending Changes') + ': <br>';
for (const [key, keyvalue] of Object.entries(rec.data.pending)) {
if ((rec.data[key] !== undefined && rec.data.pending[key] !== rec.data[key]) ||
rec.data[key] === undefined
) {
tip += `${Ext.htmlEncode(key)}: ${Ext.htmlEncode(keyvalue)} <br>`;
}
}
return `<span data-qtip="${Ext.htmlEncode(tip)}">${icon}${Ext.htmlEncode(value)}</span>`;
},
render_ceph_health: function(healthObj) {
var state = {
iconCls: PVE.Utils.get_health_icon(),
text: '',
};
if (!healthObj || !healthObj.status) {
return state;
}
var health = PVE.Utils.map_ceph_health[healthObj.status];
state.iconCls = PVE.Utils.get_health_icon(health, true);
state.text = healthObj.status;
return state;
},
render_zfs_health: function(value) {
if (typeof value === 'undefined') {
return "";
}
var iconCls = 'question-circle';
switch (value) {
case 'AVAIL':
case 'ONLINE':
iconCls = 'check-circle good';
break;
case 'REMOVED':
case 'DEGRADED':
iconCls = 'exclamation-circle warning';
break;
case 'UNAVAIL':
case 'FAULTED':
case 'OFFLINE':
iconCls = 'times-circle critical';
break;
default: //unknown
}
return '<i class="fa fa-' + iconCls + '"></i> ' + value;
},
render_pbs_fingerprint: fp => fp.substring(0, 23),
render_backup_encryption: function(v, meta, record) {
if (!v) {
return gettext('No');
}
let tip = '';
if (v.match(/^[a-fA-F0-9]{2}:/)) { // fingerprint
tip = `Key fingerprint ${PVE.Utils.render_pbs_fingerprint(v)}`;
}
let icon = `<i class="fa fa-fw fa-lock good"></i>`;
return `<span data-qtip="${tip}">${icon} ${gettext('Encrypted')}</span>`;
},
render_backup_verification: function(v, meta, record) {
let i = (cls, txt) => `<i class="fa fa-fw fa-${cls}"></i> ${txt}`;
if (v === undefined || v === null) {
return i('question-circle-o warning', gettext('None'));
}
let tip = "";
let txt = gettext('Failed');
let iconCls = 'times critical';
if (v.state === 'ok') {
txt = gettext('OK');
iconCls = 'check good';
let now = Date.now() / 1000;
let task = Proxmox.Utils.parse_task_upid(v.upid);
let verify_time = Proxmox.Utils.render_timestamp(task.starttime);
tip = `Last verify task started on ${verify_time}`;
if (now - v.starttime > 30 * 24 * 60 * 60) {
tip = `Last verify task over 30 days ago: ${verify_time}`;
iconCls = 'check warning';
}
}
return `<span data-qtip="${tip}"> ${i(iconCls, txt)} </span>`;
},
render_backup_status: function(value, meta, record) {
if (typeof value === 'undefined') {
return "";
}
let iconCls = 'check-circle good';
let text = gettext('Yes');
if (!PVE.Parser.parseBoolean(value.toString())) {
iconCls = 'times-circle critical';
text = gettext('No');
let reason = record.get('reason');
if (typeof reason !== 'undefined') {
if (reason in PVE.Utils.backup_reasons_table) {
reason = PVE.Utils.backup_reasons_table[record.get('reason')];
}
text = `${text} - ${reason}`;
}
}
return `<i class="fa fa-${iconCls}"></i> ${text}`;
},
render_backup_days_of_week: function(val) {
var dows = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
var selected = [];
var cur = -1;
val.split(',').forEach(function(day) {
cur++;
var dow = (dows.indexOf(day)+6)%7;
if (cur === dow) {
if (selected.length === 0 || selected[selected.length-1] === 0) {
selected.push(1);
} else {
selected[selected.length-1]++;
}
} else {
while (cur < dow) {
cur++;
selected.push(0);
}
selected.push(1);
}
});
cur = -1;
var days = [];
selected.forEach(function(item) {
cur++;
if (item > 2) {
days.push(Ext.Date.dayNames[cur+1] + '-' + Ext.Date.dayNames[(cur+item)%7]);
cur += item-1;
} else if (item === 2) {
days.push(Ext.Date.dayNames[cur+1]);
days.push(Ext.Date.dayNames[(cur+2)%7]);
cur++;
} else if (item === 1) {
days.push(Ext.Date.dayNames[(cur+1)%7]);
}
});
return days.join(', ');
},
render_backup_selection: function(value, metaData, record) {
let allExceptText = gettext('All except {0}');
let allText = '-- ' + gettext('All') + ' --';
if (record.data.all) {
if (record.data.exclude) {
return Ext.String.format(allExceptText, record.data.exclude);
}
return allText;
}
if (record.data.vmid) {
return record.data.vmid;
}
if (record.data.pool) {
return "Pool '"+ record.data.pool + "'";
}
return "-";
},
backup_reasons_table: {
'backup=yes': gettext('Enabled'),
'backup=no': gettext('Disabled'),
'enabled': gettext('Enabled'),
'disabled': gettext('Disabled'),
'not a volume': gettext('Not a volume'),
'efidisk but no OMVF BIOS': gettext('EFI Disk without OMVF BIOS'),
},
renderNotFound: what => Ext.String.format(gettext("No {0} found"), what),
get_kvm_osinfo: function(value) {
var info = { base: 'Other' }; // default
if (value) {
Ext.each(Object.keys(PVE.Utils.kvm_ostypes), function(k) {
Ext.each(PVE.Utils.kvm_ostypes[k], function(e) {
if (e.val === value) {
info = { desc: e.desc, base: k };
}
});
});
}
return info;
},
render_kvm_ostype: function(value) {
var osinfo = PVE.Utils.get_kvm_osinfo(value);
if (osinfo.desc && osinfo.desc !== '-') {
return osinfo.base + ' ' + osinfo.desc;
} else {
return osinfo.base;
}
},
render_hotplug_features: function(value) {
var fa = [];
if (!value || value === '0') {
return gettext('Disabled');
}
if (value === '1') {
value = 'disk,network,usb';
}
Ext.each(value.split(','), function(el) {
if (el === 'disk') {
fa.push(gettext('Disk'));
} else if (el === 'network') {
fa.push(gettext('Network'));
} else if (el === 'usb') {
fa.push('USB');
} else if (el === 'memory') {
fa.push(gettext('Memory'));
} else if (el === 'cpu') {
fa.push(gettext('CPU'));
} else {
fa.push(el);
}
});
return fa.join(', ');
},
render_localtime: function(value) {
if (value === '__default__') {
return Proxmox.Utils.defaultText + ' (' + gettext('Enabled for Windows') + ')';
}
return Proxmox.Utils.format_boolean(value);
},
render_qga_features: function(config) {
if (!config) {
return Proxmox.Utils.defaultText + ' (' + Proxmox.Utils.disabledText + ')';
}
let qga = PVE.Parser.parsePropertyString(config, 'enabled');
if (!PVE.Parser.parseBoolean(qga.enabled)) {
return Proxmox.Utils.disabledText;
}
delete qga.enabled;
let agentstring = Proxmox.Utils.enabledText;
for (const [key, value] of Object.entries(qga)) {
let displayText = Proxmox.Utils.disabledText;
if (key === 'type') {
let map = {
isa: "ISA",
virtio: "VirtIO",
};
displayText = map[value] || Proxmox.Utils.unknownText;
} else if (key === 'freeze-fs-on-backup' && PVE.Parser.parseBoolean(value)) {
continue;
} else if (PVE.Parser.parseBoolean(value)) {
displayText = Proxmox.Utils.enabledText;
}
agentstring += `, ${key}: ${displayText}`;
}
return agentstring;
},
render_qemu_machine: function(value) {
return value || Proxmox.Utils.defaultText + ' (i440fx)';
},
render_qemu_bios: function(value) {
if (!value) {
return Proxmox.Utils.defaultText + ' (SeaBIOS)';
} else if (value === 'seabios') {
return "SeaBIOS";
} else if (value === 'ovmf') {
return "OVMF (UEFI)";
} else {
return value;
}
},
render_dc_ha_opts: function(value) {
if (!value) {
return Proxmox.Utils.defaultText;
} else {
return PVE.Parser.printPropertyString(value);
}
},
render_as_property_string: v => !v ? Proxmox.Utils.defaultText : PVE.Parser.printPropertyString(v),
render_scsihw: function(value) {
if (!value || value === '__default__') {
return Proxmox.Utils.defaultText + ' (LSI 53C895A)';
} else if (value === 'lsi') {
return 'LSI 53C895A';
} else if (value === 'lsi53c810') {
return 'LSI 53C810';
} else if (value === 'megasas') {
return 'MegaRAID SAS 8708EM2';
} else if (value === 'virtio-scsi-pci') {
return 'VirtIO SCSI';
} else if (value === 'virtio-scsi-single') {
return 'VirtIO SCSI single';
} else if (value === 'pvscsi') {
return 'VMware PVSCSI';
} else {
return value;
}
},
render_spice_enhancements: function(values) {
let props = PVE.Parser.parsePropertyString(values);
if (Ext.Object.isEmpty(props)) {
return Proxmox.Utils.noneText;
}
let output = [];
if (PVE.Parser.parseBoolean(props.foldersharing)) {
output.push('Folder Sharing: ' + gettext('Enabled'));
}
if (props.videostreaming === 'all' || props.videostreaming === 'filter') {
output.push('Video Streaming: ' + props.videostreaming);
}
return output.join(', ');
},
// fixme: auto-generate this
// for now, please keep in sync with PVE::Tools::kvmkeymaps
kvm_keymaps: {
'__default__': Proxmox.Utils.defaultText,
//ar: 'Arabic',
da: 'Danish',
de: 'German',
'de-ch': 'German (Swiss)',
'en-gb': 'English (UK)',
'en-us': 'English (USA)',
es: 'Spanish',
//et: 'Estonia',
fi: 'Finnish',
//fo: 'Faroe Islands',
fr: 'French',
'fr-be': 'French (Belgium)',
'fr-ca': 'French (Canada)',
'fr-ch': 'French (Swiss)',
//hr: 'Croatia',
hu: 'Hungarian',
is: 'Icelandic',
it: 'Italian',
ja: 'Japanese',
lt: 'Lithuanian',
//lv: 'Latvian',
mk: 'Macedonian',
nl: 'Dutch',
//'nl-be': 'Dutch (Belgium)',
no: 'Norwegian',
pl: 'Polish',
pt: 'Portuguese',
'pt-br': 'Portuguese (Brazil)',
//ru: 'Russian',
sl: 'Slovenian',
sv: 'Swedish',
//th: 'Thai',
tr: 'Turkish',
},
kvm_vga_drivers: {
'__default__': Proxmox.Utils.defaultText,
std: gettext('Standard VGA'),
vmware: gettext('VMware compatible'),
qxl: 'SPICE',
qxl2: 'SPICE dual monitor',
qxl3: 'SPICE three monitors',
qxl4: 'SPICE four monitors',
serial0: gettext('Serial terminal') + ' 0',
serial1: gettext('Serial terminal') + ' 1',
serial2: gettext('Serial terminal') + ' 2',
serial3: gettext('Serial terminal') + ' 3',
virtio: 'VirtIO-GPU',
'virtio-gl': 'VirGL GPU',
none: Proxmox.Utils.noneText,
},
render_kvm_language: function(value) {
if (!value || value === '__default__') {
return Proxmox.Utils.defaultText;
}
let text = PVE.Utils.kvm_keymaps[value];
return text ? `${text} (${value})` : value;
},
console_map: {
'__default__': Proxmox.Utils.defaultText + ' (xterm.js)',
'vv': 'SPICE (remote-viewer)',
'html5': 'HTML5 (noVNC)',
'xtermjs': 'xterm.js',
},
render_console_viewer: function(value) {
value = value || '__default__';
return PVE.Utils.console_map[value] || value;
},
render_kvm_vga_driver: function(value) {
if (!value) {
return Proxmox.Utils.defaultText;
}
let vga = PVE.Parser.parsePropertyString(value, 'type');
let text = PVE.Utils.kvm_vga_drivers[vga.type];
if (!vga.type) {
text = Proxmox.Utils.defaultText;
}
return text ? `${text} (${value})` : value;
},
render_kvm_startup: function(value) {
var startup = PVE.Parser.parseStartup(value);
var res = 'order=';
if (startup.order === undefined) {
res += 'any';
} else {
res += startup.order;
}
if (startup.up !== undefined) {
res += ',up=' + startup.up;
}
if (startup.down !== undefined) {
res += ',down=' + startup.down;
}
return res;
},
extractFormActionError: function(action) {
var msg;
switch (action.failureType) {
case Ext.form.action.Action.CLIENT_INVALID:
msg = gettext('Form fields may not be submitted with invalid values');
break;
case Ext.form.action.Action.CONNECT_FAILURE:
msg = gettext('Connection error');
var resp = action.response;
if (resp.status && resp.statusText) {
msg += " " + resp.status + ": " + resp.statusText;
}
break;
case Ext.form.action.Action.LOAD_FAILURE:
case Ext.form.action.Action.SERVER_INVALID:
msg = Proxmox.Utils.extractRequestError(action.result, true);
break;
}
return msg;
},
contentTypes: {
'images': gettext('Disk image'),
'backup': gettext('VZDump backup file'),
'vztmpl': gettext('Container template'),
'iso': gettext('ISO image'),
'rootdir': gettext('Container'),
'snippets': gettext('Snippets'),
'import': gettext('Import'),
},
volume_is_qemu_backup: function(volid, format) {
return format === 'pbs-vm' || volid.match(':backup/vzdump-qemu-');
},
volume_is_lxc_backup: function(volid, format) {
return format === 'pbs-ct' || volid.match(':backup/vzdump-(lxc|openvz)-');
},
authSchema: {
ad: {
name: gettext('Active Directory Server'),
ipanel: 'pveAuthADPanel',
syncipanel: 'pveAuthLDAPSyncPanel',
add: true,
tfa: true,
pwchange: true,
},
ldap: {
name: gettext('LDAP Server'),
ipanel: 'pveAuthLDAPPanel',
syncipanel: 'pveAuthLDAPSyncPanel',
add: true,
tfa: true,
pwchange: true,
},
openid: {
name: gettext('OpenID Connect Server'),
ipanel: 'pveAuthOpenIDPanel',
add: true,
tfa: false,
pwchange: false,
iconCls: 'pmx-itype-icon-openid-logo',
},
pam: {
name: 'Linux PAM',
ipanel: 'pveAuthBasePanel',
add: false,
tfa: true,
pwchange: true,
},
pve: {
name: 'Proxmox VE authentication server',
ipanel: 'pveAuthBasePanel',
add: false,
tfa: true,
pwchange: true,
},
},
storageSchema: {
dir: {
name: Proxmox.Utils.directoryText,
ipanel: 'DirInputPanel',
faIcon: 'folder',
backups: true,
},
lvm: {
name: 'LVM',
ipanel: 'LVMInputPanel',
faIcon: 'folder',
backups: false,
},
lvmthin: {
name: 'LVM-Thin',
ipanel: 'LvmThinInputPanel',
faIcon: 'folder',
backups: false,
},
btrfs: {
name: 'BTRFS',
ipanel: 'BTRFSInputPanel',
faIcon: 'folder',
backups: true,
},
nfs: {
name: 'NFS',
ipanel: 'NFSInputPanel',
faIcon: 'building',
backups: true,
},
cifs: {
name: 'SMB/CIFS',
ipanel: 'CIFSInputPanel',
faIcon: 'building',
backups: true,
},
glusterfs: {
name: 'GlusterFS',
ipanel: 'GlusterFsInputPanel',
faIcon: 'building',
backups: true,
},
iscsi: {
name: 'iSCSI',
ipanel: 'IScsiInputPanel',
faIcon: 'building',
backups: false,
},
cephfs: {
name: 'CephFS',
ipanel: 'CephFSInputPanel',
faIcon: 'building',
backups: true,
},
pvecephfs: {
name: 'CephFS (PVE)',
ipanel: 'CephFSInputPanel',
hideAdd: true,
faIcon: 'building',
backups: true,
},
rbd: {
name: 'RBD',
ipanel: 'RBDInputPanel',
faIcon: 'building',
backups: false,
},
pveceph: {
name: 'RBD (PVE)',
ipanel: 'RBDInputPanel',
hideAdd: true,
faIcon: 'building',
backups: false,
},
zfs: {
name: 'ZFS over iSCSI',
ipanel: 'ZFSInputPanel',
faIcon: 'building',
backups: false,
},
zfspool: {
name: 'ZFS',
ipanel: 'ZFSPoolInputPanel',
faIcon: 'folder',
backups: false,
},
pbs: {
name: 'Proxmox Backup Server',
ipanel: 'PBSInputPanel',
faIcon: 'floppy-o',
backups: true,
},
drbd: {
name: 'DRBD',
hideAdd: true,
backups: false,
},
esxi: {
name: 'ESXi',
ipanel: 'ESXIInputPanel',
faIcon: 'cloud-download',
backups: false,
},
},
sdnvnetSchema: {
vnet: {
name: 'vnet',
faIcon: 'folder',
},
},
sdnzoneSchema: {
zone: {
name: 'zone',
hideAdd: true,
},
simple: {
name: 'Simple',
ipanel: 'SimpleInputPanel',
faIcon: 'th',
},
vlan: {
name: 'VLAN',
ipanel: 'VlanInputPanel',
faIcon: 'th',
},
qinq: {
name: 'QinQ',
ipanel: 'QinQInputPanel',
faIcon: 'th',
},
vxlan: {
name: 'VXLAN',
ipanel: 'VxlanInputPanel',
faIcon: 'th',
},
evpn: {
name: 'EVPN',
ipanel: 'EvpnInputPanel',
faIcon: 'th',
},
},
sdncontrollerSchema: {
controller: {
name: 'controller',
hideAdd: true,
},
evpn: {
name: 'evpn',
ipanel: 'EvpnInputPanel',
faIcon: 'crosshairs',
},
bgp: {
name: 'bgp',
ipanel: 'BgpInputPanel',
faIcon: 'crosshairs',
},
isis: {
name: 'isis',
ipanel: 'IsisInputPanel',
faIcon: 'crosshairs',
},
},
sdnipamSchema: {
ipam: {
name: 'ipam',
hideAdd: true,
},
pve: {
name: 'PVE',
ipanel: 'PVEIpamInputPanel',
faIcon: 'th',
hideAdd: true,
},
netbox: {
name: 'Netbox',
ipanel: 'NetboxInputPanel',
faIcon: 'th',
},
phpipam: {
name: 'PhpIpam',
ipanel: 'PhpIpamInputPanel',
faIcon: 'th',
},
},
sdndnsSchema: {
dns: {
name: 'dns',
hideAdd: true,
},
powerdns: {
name: 'powerdns',
ipanel: 'PowerdnsInputPanel',
faIcon: 'th',
},
},
format_sdnvnet_type: function(value, md, record) {
var schema = PVE.Utils.sdnvnetSchema[value];
if (schema) {
return schema.name;
}
return Proxmox.Utils.unknownText;
},
format_sdnzone_type: function(value, md, record) {
var schema = PVE.Utils.sdnzoneSchema[value];
if (schema) {
return schema.name;
}
return Proxmox.Utils.unknownText;
},
format_sdncontroller_type: function(value, md, record) {
var schema = PVE.Utils.sdncontrollerSchema[value];
if (schema) {
return schema.name;
}
return Proxmox.Utils.unknownText;
},
format_sdnipam_type: function(value, md, record) {
var schema = PVE.Utils.sdnipamSchema[value];
if (schema) {
return schema.name;
}
return Proxmox.Utils.unknownText;
},
format_sdndns_type: function(value, md, record) {
var schema = PVE.Utils.sdndnsSchema[value];
if (schema) {
return schema.name;
}
return Proxmox.Utils.unknownText;
},
format_storage_type: function(value, md, record) {
if (value === 'rbd') {
value = !record || record.get('monhost') ? 'rbd' : 'pveceph';
} else if (value === 'cephfs') {
value = !record || record.get('monhost') ? 'cephfs' : 'pvecephfs';
}
let schema = PVE.Utils.storageSchema[value];
return schema?.name ?? value;
},
format_ha: function(value) {
var text = Proxmox.Utils.noneText;
if (value.managed) {
text = value.state || Proxmox.Utils.noneText;
text += ', ' + Proxmox.Utils.groupText + ': ';
text += value.group || Proxmox.Utils.noneText;
}
return text;
},
format_content_types: function(value) {
return value.split(',').sort().map(function(ct) {
return PVE.Utils.contentTypes[ct] || ct;
}).join(', ');
},
render_storage_content: function(value, metaData, record) {
let data = record.data;
let result;
if (Ext.isNumber(data.channel) &&
Ext.isNumber(data.id) &&
Ext.isNumber(data.lun)) {
result = "CH " +
Ext.String.leftPad(data.channel, 2, '0') +
" ID " + data.id + " LUN " + data.lun;
} else if (data.content === 'import') {
if (data.volid.match(/^.*?:import\//)) {
// dir-based storages
result = data.volid.replace(/^.*?:import\//, '');
} else {
// esxi storage
result = data.volid.replace(/^.*?:/, '');
}
} else {
result = data.volid.replace(/^.*?:(.*?\/)?/, '');
}
return Ext.String.htmlEncode(result);
},
render_serverity: function(value) {
return PVE.Utils.log_severity_hash[value] || value;
},
calculate_hostcpu: function(data) {
if (!(data.uptime && Ext.isNumeric(data.cpu))) {
return -1;
}
if (data.type !== 'qemu' && data.type !== 'lxc') {
return -1;
}
var index = PVE.data.ResourceStore.findExact('id', 'node/' + data.node);
var node = PVE.data.ResourceStore.getAt(index);
if (!Ext.isDefined(node) || node === null) {
return -1;
}
var maxcpu = node.data.maxcpu || 1;
if (!Ext.isNumeric(maxcpu) && (maxcpu >= 1)) {
return -1;
}
return (data.cpu/maxcpu) * data.maxcpu;
},
render_hostcpu: function(value, metaData, record, rowIndex, colIndex, store) {
if (!(record.data.uptime && Ext.isNumeric(record.data.cpu))) {
return '';
}
if (record.data.type !== 'qemu' && record.data.type !== 'lxc') {
return '';
}
var index = PVE.data.ResourceStore.findExact('id', 'node/' + record.data.node);
var node = PVE.data.ResourceStore.getAt(index);
if (!Ext.isDefined(node) || node === null) {
return '';
}
var maxcpu = node.data.maxcpu || 1;
if (!Ext.isNumeric(maxcpu) || maxcpu < 1) {
return '';
}
var per = (record.data.cpu/maxcpu) * record.data.maxcpu * 100;
const cpu_label = maxcpu > 1 ? 'CPUs' : 'CPU';
return `${per.toFixed(1)}% of ${maxcpu} ${cpu_label}`;
},
render_bandwidth: function(value) {
if (!Ext.isNumeric(value)) {
return '';
}
return Proxmox.Utils.format_size(value) + '/s';
},
render_timestamp_human_readable: function(value) {
return Ext.Date.format(new Date(value * 1000), 'l d F Y H:i:s');
},
// render a timestamp or pending
render_next_event: function(value) {
if (!value) {
return '-';
}
let now = new Date(), next = new Date(value * 1000);
if (next < now) {
return gettext('pending');
}
return Proxmox.Utils.render_timestamp(value);
},
calculate_mem_usage: function(data) {
if (!Ext.isNumeric(data.mem) ||
data.maxmem === 0 ||
data.uptime < 1) {
return -1;
}
return data.mem / data.maxmem;
},
calculate_hostmem_usage: function(data) {
if (data.type !== 'qemu' && data.type !== 'lxc') {
return -1;
}
var index = PVE.data.ResourceStore.findExact('id', 'node/' + data.node);
var node = PVE.data.ResourceStore.getAt(index);
if (!Ext.isDefined(node) || node === null) {
return -1;
}
var maxmem = node.data.maxmem || 0;
if (!Ext.isNumeric(data.mem) ||
maxmem === 0 ||
data.uptime < 1) {
return -1;
}
return data.mem / maxmem;
},
render_mem_usage_percent: function(value, metaData, record, rowIndex, colIndex, store) {
if (!Ext.isNumeric(value) || value === -1) {
return '';
}
if (value > 1) {
// we got no percentage but bytes
var mem = value;
var maxmem = record.data.maxmem;
if (!record.data.uptime ||
maxmem === 0 ||
!Ext.isNumeric(mem)) {
return '';
}
return (mem*100/maxmem).toFixed(1) + " %";
}
return (value*100).toFixed(1) + " %";
},
render_hostmem_usage_percent: function(value, metaData, record, rowIndex, colIndex, store) {
if (!Ext.isNumeric(record.data.mem) || value === -1) {
return '';
}
if (record.data.type !== 'qemu' && record.data.type !== 'lxc') {
return '';
}
var index = PVE.data.ResourceStore.findExact('id', 'node/' + record.data.node);
var node = PVE.data.ResourceStore.getAt(index);
var maxmem = node.data.maxmem || 0;
if (record.data.mem > 1) {
// we got no percentage but bytes
var mem = record.data.mem;
if (!record.data.uptime ||
maxmem === 0 ||
!Ext.isNumeric(mem)) {
return '';
}
return ((mem*100)/maxmem).toFixed(1) + " %";
}
return (value*100).toFixed(1) + " %";
},
render_mem_usage: function(value, metaData, record, rowIndex, colIndex, store) {
var mem = value;
var maxmem = record.data.maxmem;
if (!record.data.uptime) {
return '';
}
if (!(Ext.isNumeric(mem) && maxmem)) {
return '';
}
return Proxmox.Utils.render_size(value);
},
calculate_disk_usage: function(data) {
if (!Ext.isNumeric(data.disk) ||
((data.type === 'qemu' || data.type === 'lxc') && data.uptime === 0) ||
data.maxdisk === 0
) {
return -1;
}
return data.disk / data.maxdisk;
},
render_disk_usage_percent: function(value, metaData, record, rowIndex, colIndex, store) {
if (!Ext.isNumeric(value) || value === -1) {
return '';
}
return (value * 100).toFixed(1) + " %";
},
render_disk_usage: function(value, metaData, record, rowIndex, colIndex, store) {
var disk = value;
var maxdisk = record.data.maxdisk;
var type = record.data.type;
if (!Ext.isNumeric(disk) ||
maxdisk === 0 ||
((type === 'qemu' || type === 'lxc') && record.data.uptime === 0)
) {
return '';
}
return Proxmox.Utils.render_size(value);
},
get_object_icon_class: function(type, record) {
var status = '';
var objType = type;
if (type === 'type') {
// for folder view
objType = record.groupbyid;
} else if (record.template) {
// templates
objType = 'template';
status = type;
} else if (type === 'storage' && record.content === 'import') {
return 'fa fa-cloud-download';
} else {
// everything else
status = record.status + ' ha-' + record.hastate;
}
if (record.lock) {
status += ' locked lock-' + record.lock;
}
var defaults = PVE.tree.ResourceTree.typeDefaults[objType];
if (defaults && defaults.iconCls) {
var retVal = defaults.iconCls + ' ' + status;
return retVal;
}
return '';
},
render_resource_type: function(value, metaData, record, rowIndex, colIndex, store) {
var cls = PVE.Utils.get_object_icon_class(value, record.data);
var fa = '<i class="fa-fw x-grid-icon-custom ' + cls + '"></i> ';
return fa + value;
},
render_support_level: function(value, metaData, record) {
return PVE.Utils.support_level_hash[value] || '-';
},
render_upid: function(value, metaData, record) {
var type = record.data.type;
var id = record.data.id;
return Proxmox.Utils.format_task_description(type, id);
},
render_optional_url: function(value) {
if (value && value.match(/^https?:\/\//)) {
return '<a target="_blank" href="' + value + '">' + value + '</a>';
}
return value;
},
render_san: function(value) {
var names = [];
if (Ext.isArray(value)) {
value.forEach(function(val) {
if (!Ext.isNumber(val)) {
names.push(val);
}
});
return names.join('<br>');
}
return value;
},
render_full_name: function(firstname, metaData, record) {
var first = firstname || '';
var last = record.data.lastname || '';
return Ext.htmlEncode(first + " " + last);
},
// expecting the following format:
// [v2:10.10.10.1:6802/2008,v1:10.10.10.1:6803/2008]
render_ceph_osd_addr: function(value) {
value = value.trim();
if (value.startsWith('[') && value.endsWith(']')) {
value = value.slice(1, -1); // remove []
}
value = value.replaceAll(',', '\n'); // split IPs in lines
let retVal = '';
for (const i of value.matchAll(/^(v[0-9]):(.*):([0-9]*)\/([0-9]*)$/gm)) {
retVal += `${i[1]}: ${i[2]}:${i[3]}<br>`;
}
return retVal.length < 1 ? value : retVal;
},
windowHostname: function() {
return window.location.hostname.replace(Proxmox.Utils.IP6_bracket_match,
function(m, addr, offset, original) { return addr; });
},
openDefaultConsoleWindow: function(consoles, consoleType, vmid, nodename, vmname, cmd) {
var dv = PVE.Utils.defaultViewer(consoles, consoleType);
PVE.Utils.openConsoleWindow(dv, consoleType, vmid, nodename, vmname, cmd);
},
openConsoleWindow: function(viewer, consoleType, vmid, nodename, vmname, cmd) {
if (vmid === undefined && (consoleType === 'kvm' || consoleType === 'lxc')) {
throw "missing vmid";
}
if (!nodename) {
throw "no nodename specified";
}
if (viewer === 'html5') {
PVE.Utils.openVNCViewer(consoleType, vmid, nodename, vmname, cmd);
} else if (viewer === 'xtermjs') {
Proxmox.Utils.openXtermJsViewer(consoleType, vmid, nodename, vmname, cmd);
} else if (viewer === 'vv') {
let url = '/nodes/' + nodename + '/spiceshell';
let params = {
proxy: PVE.Utils.windowHostname(),
};
if (consoleType === 'kvm') {
url = '/nodes/' + nodename + '/qemu/' + vmid.toString() + '/spiceproxy';
} else if (consoleType === 'lxc') {
url = '/nodes/' + nodename + '/lxc/' + vmid.toString() + '/spiceproxy';
} else if (consoleType === 'upgrade') {
params.cmd = 'upgrade';
} else if (consoleType === 'cmd') {
params.cmd = cmd;
} else if (consoleType !== 'shell') {
throw `unknown spice viewer type '${consoleType}'`;
}
PVE.Utils.openSpiceViewer(url, params);
} else {
throw `unknown viewer type '${viewer}'`;
}
},
defaultViewer: function(consoles, type) {
var allowSpice, allowXtermjs;
if (consoles === true) {
allowSpice = true;
allowXtermjs = true;
} else if (typeof consoles === 'object') {
allowSpice = consoles.spice;
allowXtermjs = !!consoles.xtermjs;
}
let dv = PVE.UIOptions.options.console || (type === 'kvm' ? 'vv' : 'xtermjs');
if (dv === 'vv' && !allowSpice) {
dv = allowXtermjs ? 'xtermjs' : 'html5';
} else if (dv === 'xtermjs' && !allowXtermjs) {
dv = allowSpice ? 'vv' : 'html5';
}
return dv;
},
openVNCViewer: function(vmtype, vmid, nodename, vmname, cmd) {
let scaling = 'off';
if (Proxmox.Utils.toolkit !== 'touch') {
var sp = Ext.state.Manager.getProvider();
scaling = sp.get('novnc-scaling', 'off');
}
var url = Ext.Object.toQueryString({
console: vmtype, // kvm, lxc, upgrade or shell
novnc: 1,
vmid: vmid,
vmname: vmname,
node: nodename,
resize: scaling,
cmd: cmd,
});
var nw = window.open("?" + url, '_blank', "innerWidth=745,innerheight=427");
if (nw) {
nw.focus();
}
},
openSpiceViewer: function(url, params) {
var downloadWithName = function(uri, name) {
var link = Ext.DomHelper.append(document.body, {
tag: 'a',
href: uri,
css: 'display:none;visibility:hidden;height:0px;',
});
// Note: we need to tell Android, AppleWebKit and Chrome
// the correct file name extension
// but we do not set 'download' tag for other environments, because
// It can have strange side effects (additional user prompt on firefox)
if (navigator.userAgent.match(/Android|AppleWebKit|Chrome/i)) {
link.download = name;
}
if (link.fireEvent) {
link.fireEvent('onclick');
} else {
let evt = document.createEvent("MouseEvents");
evt.initMouseEvent('click', true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null);
link.dispatchEvent(evt);
}
};
Proxmox.Utils.API2Request({
url: url,
params: params,
method: 'POST',
failure: function(response, opts) {
Ext.Msg.alert('Error', response.htmlStatus);
},
success: function(response, opts) {
let cfg = response.result.data;
let raw = Object.entries(cfg).reduce((acc, [k, v]) => acc + `${k}=${v}\n`, "[virt-viewer]\n");
let spiceDownload = 'data:application/x-virt-viewer;charset=UTF-8,' + encodeURIComponent(raw);
downloadWithName(spiceDownload, "pve-spice.vv");
},
});
},
openTreeConsole: function(tree, record, item, index, e) {
e.stopEvent();
let nodename = record.data.node;
let vmid = record.data.vmid;
let vmname = record.data.name;
if (record.data.type === 'qemu' && !record.data.template) {
Proxmox.Utils.API2Request({
url: `/nodes/${nodename}/qemu/${vmid}/status/current`,
failure: response => Ext.Msg.alert('Error', response.htmlStatus),
success: function(response, opts) {
let conf = response.result.data;
let consoles = {
spice: !!conf.spice,
xtermjs: !!conf.serial,
};
PVE.Utils.openDefaultConsoleWindow(consoles, 'kvm', vmid, nodename, vmname);
},
});
} else if (record.data.type === 'lxc' && !record.data.template) {
PVE.Utils.openDefaultConsoleWindow(true, 'lxc', vmid, nodename, vmname);
}
},
// test automation helper
call_menu_handler: function(menu, text) {
let item = menu.query('menuitem').find(el => el.text === text);
if (item && item.handler) {
item.handler();
}
},
createCmdMenu: function(v, record, item, index, event) {
event.stopEvent();
if (!(v instanceof Ext.tree.View)) {
v.select(record);
}
let menu;
let type = record.data.type;
if (record.data.template) {
if (type === 'qemu' || type === 'lxc') {
menu = Ext.create('PVE.menu.TemplateMenu', {
pveSelNode: record,
});
}
} else if (type === 'qemu' || type === 'lxc' || type === 'node') {
menu = Ext.create('PVE.' + type + '.CmdMenu', {
pveSelNode: record,
nodename: record.data.node,
});
} else {
return undefined;
}
menu.showAt(event.getXY());
return menu;
},
// helper for deleting field which are set to there default values
delete_if_default: function(values, fieldname, default_val, create) {
if (values[fieldname] === '' || values[fieldname] === default_val) {
if (!create) {
if (values.delete) {
if (Ext.isArray(values.delete)) {
values.delete.push(fieldname);
} else {
values.delete += ',' + fieldname;
}
} else {
values.delete = fieldname;
}
}
delete values[fieldname];
}
},
loadSSHKeyFromFile: function(file, callback) {
// ssh-keygen produces ~ 740 bytes for a 4096 bit RSA key, current max is 16 kbit, so assume:
// 740 * 8 for max. 32kbit (5920 bytes), round upwards to 8192 bytes, leaves lots of comment space
PVE.Utils.loadFile(file, callback, 8192);
},
loadFile: function(file, callback, maxSize) {
maxSize = maxSize || 32 * 1024;
if (file.size > maxSize) {
Ext.Msg.alert(gettext('Error'), `${gettext("Invalid file size")}: ${file.size} > ${maxSize}`);
return;
}
let reader = new FileReader();
reader.onload = evt => callback(evt.target.result);
reader.readAsText(file);
},
loadTextFromFile: function(file, callback, maxBytes) {
let maxSize = maxBytes || 8192;
if (file.size > maxSize) {
Ext.Msg.alert(gettext('Error'), gettext("Invalid file size: ") + file.size);
return;
}
let reader = new FileReader();
reader.onload = evt => callback(evt.target.result);
reader.readAsText(file);
},
diskControllerMaxIDs: {
ide: 4,
sata: 6,
scsi: 31,
virtio: 16,
unused: 256,
},
// types is either undefined (all busses), an array of busses, or a single bus
forEachBus: function(types, func) {
let busses = Object.keys(PVE.Utils.diskControllerMaxIDs);
if (Ext.isArray(types)) {
busses = types;
} else if (Ext.isDefined(types)) {
busses = [types];
}
// check if we only have valid busses
for (let i = 0; i < busses.length; i++) {
if (!PVE.Utils.diskControllerMaxIDs[busses[i]]) {
throw "invalid bus: '" + busses[i] + "'";
}
}
for (let i = 0; i < busses.length; i++) {
let count = PVE.Utils.diskControllerMaxIDs[busses[i]];
for (let j = 0; j < count; j++) {
let cont = func(busses[i], j);
if (!cont && cont !== undefined) {
return;
}
}
}
},
lxc_mp_counts: {
mp: 256,
unused: 256,
},
forEachLxcMP: function(func, includeUnused) {
for (let i = 0; i < PVE.Utils.lxc_mp_counts.mp; i++) {
let cont = func('mp', i, `mp${i}`);
if (!cont && cont !== undefined) {
return;
}
}
if (!includeUnused) {
return;
}
for (let i = 0; i < PVE.Utils.lxc_mp_counts.unused; i++) {
let cont = func('unused', i, `unused${i}`);
if (!cont && cont !== undefined) {
return;
}
}
},
lxc_dev_count: 256,
forEachLxcDev: function(func) {
for (let i = 0; i < PVE.Utils.lxc_dev_count; i++) {
let cont = func(i, `dev${i}`);
if (!cont && cont !== undefined) {
return;
}
}
},
hardware_counts: {
net: 32,
usb: 14,
usb_old: 5,
hostpci: 16,
audio: 1,
efidisk: 1,
serial: 4,
rng: 1,
tpmstate: 1,
},
// we can have usb6 and up only for specific machine/ostypes
get_max_usb_count: function(ostype, machine) {
if (!ostype) {
return PVE.Utils.hardware_counts.usb_old;
}
let match = /-(\d+).(\d+)/.exec(machine ?? '');
if (!match || PVE.Utils.qemu_min_version([match[1], match[2]], [7, 1])) {
if (ostype === 'l26') {
return PVE.Utils.hardware_counts.usb;
}
let os_match = /^win(\d+)$/.exec(ostype);
if (os_match && os_match[1] > 7) {
return PVE.Utils.hardware_counts.usb;
}
}
return PVE.Utils.hardware_counts.usb_old;
},
// parameters are expected to be arrays, e.g. [7,1], [4,0,1]
// returns true if toCheck is equal or greater than minVersion
qemu_min_version: function(toCheck, minVersion) {
let i;
for (i = 0; i < toCheck.length && i < minVersion.length; i++) {
if (toCheck[i] < minVersion[i]) {
return false;
}
}
if (minVersion.length > toCheck.length) {
for (; i < minVersion.length; i++) {
if (minVersion[i] !== 0) {
return false;
}
}
}
return true;
},
cleanEmptyObjectKeys: function(obj) {
for (const propName of Object.keys(obj)) {
if (obj[propName] === null || obj[propName] === undefined) {
delete obj[propName];
}
}
},
acmedomain_count: 5,
add_domain_to_acme: function(acme, domain) {
if (acme.domains === undefined) {
acme.domains = [domain];
} else {
acme.domains.push(domain);
acme.domains = acme.domains.filter((value, index, self) => self.indexOf(value) === index);
}
return acme;
},
remove_domain_from_acme: function(acme, domain) {
if (acme.domains !== undefined) {
acme.domains = acme
.domains
.filter((value, index, self) => self.indexOf(value) === index && value !== domain);
}
return acme;
},
handleStoreErrorOrMask: function(view, store, regex, callback) {
view.mon(store, 'load', function(proxy, response, success, operation) {
if (success) {
Proxmox.Utils.setErrorMask(view, false);
return;
}
let msg;
if (operation.error.statusText) {
if (operation.error.statusText.match(regex)) {
callback(view, operation.error);
return;
} else {
msg = operation.error.statusText + ' (' + operation.error.status + ')';
}
} else {
msg = gettext('Connection error');
}
Proxmox.Utils.setErrorMask(view, msg);
});
},
showCephInstallOrMask: function(container, msg, nodename, callback) {
if (msg.match(/not (installed|initialized)/i)) {
if (Proxmox.UserName === 'root@pam') {
container.el.mask();
if (!container.down('pveCephInstallWindow')) {
var isInstalled = !!msg.match(/not initialized/i);
var win = Ext.create('PVE.ceph.Install', {
nodename: nodename,
});
win.getViewModel().set('isInstalled', isInstalled);
container.add(win);
win.on('close', () => {
container.el.unmask();
});
win.show();
callback(win);
}
} else {
container.mask(Ext.String.format(gettext('{0} not installed.') +
' ' + gettext('Log in as root to install.'), 'Ceph'), ['pve-static-mask']);
}
return true;
} else {
return false;
}
},
monitor_ceph_installed: function(view, rstore, nodename, maskOwnerCt) {
PVE.Utils.handleStoreErrorOrMask(
view,
rstore,
/not (installed|initialized)/i,
(_, error) => {
nodename = nodename || Proxmox.NodeName;
let maskTarget = maskOwnerCt ? view.ownerCt : view;
rstore.stopUpdate();
PVE.Utils.showCephInstallOrMask(maskTarget, error.statusText, nodename, win => {
view.mon(win, 'cephInstallWindowClosed', () => rstore.startUpdate());
});
},
);
},
propertyStringSet: function(target, source, name, value) {
if (source) {
if (value === undefined) {
target[name] = source;
} else {
target[name] = value;
}
} else {
delete target[name];
}
},
forEachCorosyncLink: function(nodeinfo, cb) {
let re = /(?:ring|link)(\d+)_addr/;
Ext.iterate(nodeinfo, (prop, val) => {
let match = re.exec(prop);
if (match) {
cb(Number(match[1]), val);
}
});
},
cpu_vendor_map: {
'default': 'QEMU',
'AuthenticAMD': 'AMD',
'GenuineIntel': 'Intel',
},
cpu_vendor_order: {
"AMD": 1,
"Intel": 2,
"QEMU": 3,
"Host": 4,
"_default_": 5, // includes custom models
},
verify_ip64_address_list: function(value, with_suffix) {
for (let addr of value.split(/[ ,;]+/)) {
if (addr === '') {
continue;
}
if (with_suffix) {
let parts = addr.split('%');
addr = parts[0];
if (parts.length > 2) {
return false;
}
if (parts.length > 1 && !addr.startsWith('fe80:')) {
return false;
}
}
if (!Proxmox.Utils.IP64_match.test(addr)) {
return false;
}
}
return true;
},
sortByPreviousUsage: function(vmconfig, controllerList) {
if (!controllerList) {
controllerList = ['ide', 'virtio', 'scsi', 'sata'];
}
let usedControllers = {};
for (const type of Object.keys(PVE.Utils.diskControllerMaxIDs)) {
usedControllers[type] = 0;
}
for (const property of Object.keys(vmconfig)) {
if (property.match(PVE.Utils.bus_match) && !vmconfig[property].match(/media=cdrom/)) {
const foundController = property.match(PVE.Utils.bus_match)[1];
usedControllers[foundController]++;
}
}
let sortPriority = PVE.qemu.OSDefaults.getDefaults(vmconfig.ostype).busPriority;
let sortedList = Ext.clone(controllerList);
sortedList.sort(function(a, b) {
if (usedControllers[b] === usedControllers[a]) {
return sortPriority[b] - sortPriority[a];
}
return usedControllers[b] - usedControllers[a];
});
return sortedList;
},
nextFreeDisk: function(controllers, config) {
for (const controller of controllers) {
for (let i = 0; i < PVE.Utils.diskControllerMaxIDs[controller]; i++) {
let confid = controller + i.toString();
if (!Ext.isDefined(config[confid])) {
return {
controller,
id: i,
confid,
};
}
}
}
return undefined;
},
nextFreeLxcMP: function(type, config) {
for (let i = 0; i < PVE.Utils.lxc_mp_counts[type]; i++) {
let confid = `${type}${i}`;
if (!Ext.isDefined(config[confid])) {
return {
type,
id: i,
confid,
};
}
}
return undefined;
},
escapeNotesTemplate: function(value) {
let replace = {
'\\': '\\\\',
'\n': '\\n',
};
return value.replace(/(\\|[\n])/g, match => replace[match]);
},
unEscapeNotesTemplate: function(value) {
let replace = {
'\\\\': '\\',
'\\n': '\n',
};
return value.replace(/(\\\\|\\n)/g, match => replace[match]);
},
notesTemplateVars: ['cluster', 'guestname', 'node', 'vmid'],
renderTags: function(tagstext, overrides) {
let text = '';
if (tagstext) {
let tags = (tagstext.split(/[,; ]/) || []).filter(t => !!t);
if (PVE.UIOptions.shouldSortTags()) {
tags = tags.sort((a, b) => {
let alc = a.toLowerCase();
let blc = b.toLowerCase();
return alc < blc ? -1 : blc < alc ? 1 : a.localeCompare(b);
});
}
text += ' ';
tags.forEach((tag) => {
text += Proxmox.Utils.getTagElement(tag, overrides);
});
}
return text;
},
tagCharRegex: /^[a-z0-9+_.-]+$/i,
verificationStateOrder: {
'failed': 0,
'none': 1,
'ok': 2,
'__default__': 3,
},
isStandaloneNode: function() {
return PVE.data.ResourceStore.getNodes().length < 2;
},
// main use case of this helper is the login window
getUiLanguage: function() {
let languageCookie = Ext.util.Cookies.get('PVELangCookie');
if (languageCookie === 'kr') {
// fix-up 'kr' being used for Korean by mistake FIXME: remove with PVE 9
let dt = Ext.Date.add(new Date(), Ext.Date.YEAR, 10);
languageCookie = 'ko';
Ext.util.Cookies.set('PVELangCookie', languageCookie, dt);
}
return languageCookie || Proxmox.defaultLang || 'en';
},
formatGuestTaskConfirmation: function(taskType, vmid, guestName) {
return Proxmox.Utils.format_task_description(taskType, `${vmid} (${guestName})`);
},
},
singleton: true,
constructor: function() {
var me = this;
Ext.apply(me, me.utilities);
Proxmox.Utils.override_task_descriptions({
acmedeactivate: ['ACME Account', gettext('Deactivate')],
acmenewcert: ['SRV', gettext('Order Certificate')],
acmerefresh: ['ACME Account', gettext('Refresh')],
acmeregister: ['ACME Account', gettext('Register')],
acmerenew: ['SRV', gettext('Renew Certificate')],
acmerevoke: ['SRV', gettext('Revoke Certificate')],
acmeupdate: ['ACME Account', gettext('Update')],
'auth-realm-sync': [gettext('Realm'), gettext('Sync')],
'auth-realm-sync-test': [gettext('Realm'), gettext('Sync Preview')],
cephcreatemds: ['Ceph Metadata Server', gettext('Create')],
cephcreatemgr: ['Ceph Manager', gettext('Create')],
cephcreatemon: ['Ceph Monitor', gettext('Create')],
cephcreateosd: ['Ceph OSD', gettext('Create')],
cephcreatepool: ['Ceph Pool', gettext('Create')],
cephdestroymds: ['Ceph Metadata Server', gettext('Destroy')],
cephdestroymgr: ['Ceph Manager', gettext('Destroy')],
cephdestroymon: ['Ceph Monitor', gettext('Destroy')],
cephdestroyosd: ['Ceph OSD', gettext('Destroy')],
cephdestroypool: ['Ceph Pool', gettext('Destroy')],
cephdestroyfs: ['CephFS', gettext('Destroy')],
cephfscreate: ['CephFS', gettext('Create')],
cephsetpool: ['Ceph Pool', gettext('Edit')],
cephsetflags: ['', gettext('Change global Ceph flags')],
clustercreate: ['', gettext('Create Cluster')],
clusterjoin: ['', gettext('Join Cluster')],
dircreate: [gettext('Directory Storage'), gettext('Create')],
dirremove: [gettext('Directory'), gettext('Remove')],
download: [gettext('File'), gettext('Download')],
hamigrate: ['HA', gettext('Migrate')],
hashutdown: ['HA', gettext('Shutdown')],
hastart: ['HA', gettext('Start')],
hastop: ['HA', gettext('Stop')],
imgcopy: ['', gettext('Copy data')],
imgdel: ['', gettext('Erase data')],
lvmcreate: [gettext('LVM Storage'), gettext('Create')],
lvmremove: ['Volume Group', gettext('Remove')],
lvmthincreate: [gettext('LVM-Thin Storage'), gettext('Create')],
lvmthinremove: ['Thinpool', gettext('Remove')],
migrateall: ['', gettext('Bulk migrate VMs and Containers')],
'move_volume': ['CT', gettext('Move Volume')],
'pbs-download': ['VM/CT', gettext('File Restore Download')],
pull_file: ['CT', gettext('Pull file')],
push_file: ['CT', gettext('Push file')],
qmclone: ['VM', gettext('Clone')],
qmconfig: ['VM', gettext('Configure')],
qmcreate: ['VM', gettext('Create')],
qmdelsnapshot: ['VM', gettext('Delete Snapshot')],
qmdestroy: ['VM', gettext('Destroy')],
qmigrate: ['VM', gettext('Migrate')],
qmmove: ['VM', gettext('Move disk')],
qmpause: ['VM', gettext('Pause')],
qmreboot: ['VM', gettext('Reboot')],
qmreset: ['VM', gettext('Reset')],
qmrestore: ['VM', gettext('Restore')],
qmresume: ['VM', gettext('Resume')],
qmrollback: ['VM', gettext('Rollback')],
qmshutdown: ['VM', gettext('Shutdown')],
qmsnapshot: ['VM', gettext('Snapshot')],
qmstart: ['VM', gettext('Start')],
qmstop: ['VM', gettext('Stop')],
qmsuspend: ['VM', gettext('Hibernate')],
qmtemplate: ['VM', gettext('Convert to template')],
resize: ['VM/CT', gettext('Resize')],
spiceproxy: ['VM/CT', gettext('Console') + ' (Spice)'],
spiceshell: ['', gettext('Shell') + ' (Spice)'],
startall: ['', gettext('Bulk start VMs and Containers')],
stopall: ['', gettext('Bulk shutdown VMs and Containers')],
suspendall: ['', gettext('Suspend all VMs')],
unknownimgdel: ['', gettext('Destroy image from unknown guest')],
wipedisk: ['Device', gettext('Wipe Disk')],
vncproxy: ['VM/CT', gettext('Console')],
vncshell: ['', gettext('Shell')],
vzclone: ['CT', gettext('Clone')],
vzcreate: ['CT', gettext('Create')],
vzdelsnapshot: ['CT', gettext('Delete Snapshot')],
vzdestroy: ['CT', gettext('Destroy')],
vzdump: (type, id) => id ? `VM/CT ${id} - ${gettext('Backup')}` : gettext('Backup Job'),
vzmigrate: ['CT', gettext('Migrate')],
vzmount: ['CT', gettext('Mount')],
vzreboot: ['CT', gettext('Reboot')],
vzrestore: ['CT', gettext('Restore')],
vzresume: ['CT', gettext('Resume')],
vzrollback: ['CT', gettext('Rollback')],
vzshutdown: ['CT', gettext('Shutdown')],
vzsnapshot: ['CT', gettext('Snapshot')],
vzstart: ['CT', gettext('Start')],
vzstop: ['CT', gettext('Stop')],
vzsuspend: ['CT', gettext('Suspend')],
vztemplate: ['CT', gettext('Convert to template')],
vzumount: ['CT', gettext('Unmount')],
zfscreate: [gettext('ZFS Storage'), gettext('Create')],
zfsremove: ['ZFS Pool', gettext('Remove')],
});
Proxmox.Utils.overrideNotificationFieldName({
'job-id': gettext('Job ID'),
});
Proxmox.Utils.overrideNotificationFieldValue({
'package-updates': gettext('Package updates are available'),
'vzdump': gettext('Backup notifications'),
'replication': gettext('Replication job notifications'),
'fencing': gettext('Node fencing notifications'),
});
},
});
Ext.define('PVE.UIOptions', {
singleton: true,
options: {
'allowed-tags': [],
},
update: function() {
Proxmox.Utils.API2Request({
url: '/cluster/options',
method: 'GET',
success: function(response) {
for (const option of ['allowed-tags', 'console', 'tag-style']) {
PVE.UIOptions.options[option] = response?.result?.data?.[option];
}
PVE.UIOptions.updateTagList(PVE.UIOptions.options['allowed-tags']);
PVE.UIOptions.updateTagSettings(PVE.UIOptions.options['tag-style']);
PVE.UIOptions.fireUIConfigChanged();
},
});
},
tagList: [],
updateTagList: function(tags) {
PVE.UIOptions.tagList = [...new Set([...tags])].sort();
},
parseTagOverrides: function(overrides) {
let colors = {};
(overrides || "").split(';').forEach(color => {
if (!color) {
return;
}
let [tag, color_hex, font_hex] = color.split(':');
let r = parseInt(color_hex.slice(0, 2), 16);
let g = parseInt(color_hex.slice(2, 4), 16);
let b = parseInt(color_hex.slice(4, 6), 16);
colors[tag] = [r, g, b];
if (font_hex) {
colors[tag].push(parseInt(font_hex.slice(0, 2), 16));
colors[tag].push(parseInt(font_hex.slice(2, 4), 16));
colors[tag].push(parseInt(font_hex.slice(4, 6), 16));
}
});
return colors;
},
tagOverrides: {},
updateTagOverrides: function(colors) {
let sp = Ext.state.Manager.getProvider();
let color_state = sp.get('colors', '');
let browser_colors = PVE.UIOptions.parseTagOverrides(color_state);
PVE.UIOptions.tagOverrides = Ext.apply({}, browser_colors, colors);
},
updateTagSettings: function(style) {
let overrides = style?.['color-map'];
PVE.UIOptions.updateTagOverrides(PVE.UIOptions.parseTagOverrides(overrides ?? ""));
let shape = style?.shape ?? 'circle';
if (shape === '__default__') {
style = 'circle';
}
Ext.ComponentQuery.query('pveResourceTree')[0].setUserCls(`proxmox-tags-${shape}`);
},
tagTreeStyles: {
'__default__': `${Proxmox.Utils.defaultText} (${gettext('Circle')})`,
'full': gettext('Full'),
'circle': gettext('Circle'),
'dense': gettext('Dense'),
'none': Proxmox.Utils.NoneText,
},
tagOrderOptions: {
'__default__': `${Proxmox.Utils.defaultText} (${gettext('Alphabetical')})`,
'config': gettext('Configuration'),
'alphabetical': gettext('Alphabetical'),
},
shouldSortTags: function() {
return !(PVE.UIOptions.options['tag-style']?.ordering === 'config');
},
getTreeSortingValue: function(key) {
let localStorage = Ext.state.Manager.getProvider();
let browserValues = localStorage.get('pve-tree-sorting');
let defaults = {
'sort-field': 'vmid',
'group-templates': true,
'group-guest-types': true,
};
return browserValues?.[key] ?? defaults[key];
},
fireUIConfigChanged: function() {
PVE.data.ResourceStore.refresh();
Ext.GlobalEvents.fireEvent('loadedUiOptions');
},
});
// ExtJS related things
Proxmox.Utils.toolkit = 'extjs';
// custom PVE specific VTypes
Ext.apply(Ext.form.field.VTypes, {
QemuStartDate: function(v) {
return (/^(now|\d{4}-\d{1,2}-\d{1,2}(T\d{1,2}:\d{1,2}:\d{1,2})?)$/).test(v);
},
QemuStartDateText: gettext('Format') + ': "now" or "2006-06-17T16:01:21" or "2006-06-17"',
IP64AddressList: v => PVE.Utils.verify_ip64_address_list(v, false),
IP64AddressWithSuffixList: v => PVE.Utils.verify_ip64_address_list(v, true),
IP64AddressListText: gettext('Example') + ': 192.168.1.1,192.168.1.2',
IP64AddressListMask: /[A-Fa-f0-9,:.; ]/,
PciIdText: gettext('Example') + ': 0x8086',
PciId: v => /^0x[0-9a-fA-F]{4}$/.test(v),
});
Ext.define('PVE.form.field.Display', {
override: 'Ext.form.field.Display',
setSubmitValue: function(value) {
// do nothing, this is only to allow generalized bindings for the:
// `me.isCreate ? 'textfield' : 'displayfield'` cases we have.
},
});
Ext.define('PVE.noVncConsole', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveNoVncConsole',
nodename: undefined,
vmid: undefined,
cmd: undefined,
consoleType: undefined, // lxc, kvm, shell, cmd
xtermjs: false,
layout: 'fit',
border: false,
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (!me.consoleType) {
throw "no console type specified";
}
if (!me.vmid && me.consoleType !== 'shell' && me.consoleType !== 'cmd') {
throw "no VM ID specified";
}
// always use same iframe, to avoid running several noVnc clients
// at same time (to avoid performance problems)
var box = Ext.create('Ext.ux.IFrame', { itemid: "vncconsole" });
var type = me.xtermjs ? 'xtermjs' : 'novnc';
Ext.apply(me, {
items: box,
listeners: {
activate: function() {
let sp = Ext.state.Manager.getProvider();
if (Ext.isFunction(me.beforeLoad)) {
me.beforeLoad();
}
let queryDict = {
console: me.consoleType, // kvm, lxc, upgrade or shell
vmid: me.vmid,
node: me.nodename,
cmd: me.cmd,
'cmd-opts': me.cmdOpts,
resize: sp.get('novnc-scaling', 'scale'),
};
queryDict[type] = 1;
PVE.Utils.cleanEmptyObjectKeys(queryDict);
var url = '/?' + Ext.Object.toQueryString(queryDict);
box.load(url);
},
},
});
me.callParent();
me.on('afterrender', function() {
me.focus();
});
},
reload: function() {
// reload IFrame content to forcibly reconnect VNC/xterm.js to VM
var box = this.down('[itemid=vncconsole]');
box.getWin().location.reload();
},
});
Ext.define('PVE.button.ConsoleButton', {
extend: 'Ext.button.Split',
alias: 'widget.pveConsoleButton',
consoleType: 'shell', // one of 'shell', 'kvm', 'lxc', 'upgrade', 'cmd'
cmd: undefined,
consoleName: undefined,
iconCls: 'fa fa-terminal',
enableSpice: true,
enableXtermjs: true,
nodename: undefined,
vmid: 0,
text: gettext('Console'),
setEnableSpice: function(enable) {
var me = this;
me.enableSpice = enable;
me.down('#spicemenu').setDisabled(!enable);
},
setEnableXtermJS: function(enable) {
var me = this;
me.enableXtermjs = enable;
me.down('#xtermjs').setDisabled(!enable);
},
handler: function() { // main, general, handler
let me = this;
PVE.Utils.openDefaultConsoleWindow(
{
spice: me.enableSpice,
xtermjs: me.enableXtermjs,
},
me.consoleType,
me.vmid,
me.nodename,
me.consoleName,
me.cmd,
);
},
openConsole: function(types) { // used by split-menu buttons
let me = this;
PVE.Utils.openConsoleWindow(
types,
me.consoleType,
me.vmid,
me.nodename,
me.consoleName,
me.cmd,
);
},
menu: [
{
xtype: 'menuitem',
text: 'noVNC',
iconCls: 'pve-itype-icon-novnc',
type: 'html5',
handler: function(button) {
let view = this.up('button');
view.openConsole(button.type);
},
},
{
xterm: 'menuitem',
itemId: 'spicemenu',
text: 'SPICE',
type: 'vv',
iconCls: 'pve-itype-icon-virt-viewer',
handler: function(button) {
let view = this.up('button');
view.openConsole(button.type);
},
},
{
text: 'xterm.js',
itemId: 'xtermjs',
iconCls: 'pve-itype-icon-xtermjs',
type: 'xtermjs',
handler: function(button) {
let view = this.up('button');
view.openConsole(button.type);
},
},
],
initComponent: function() {
let me = this;
if (!me.nodename) {
throw "no node name specified";
}
me.callParent();
},
});
Ext.define('PVE.button.PendingRevert', {
extend: 'Proxmox.button.Button',
alias: 'widget.pvePendingRevertButton',
text: gettext('Revert'),
disabled: true,
config: {
pendingGrid: null,
apiurl: undefined,
},
handler: function() {
if (!this.pendingGrid) {
this.pendingGrid = this.up('proxmoxPendingObjectGrid');
if (!this.pendingGrid) throw "revert button requires a pendingGrid";
}
let view = this.pendingGrid;
let rec = view.getSelectionModel().getSelection()[0];
if (!rec) return;
let rowdef = view.rows[rec.data.key] || {};
let keys = rowdef.multiKey || [rec.data.key];
Proxmox.Utils.API2Request({
url: this.apiurl || view.editorConfig.url,
waitMsgTarget: view,
selModel: view.getSelectionModel(),
method: 'PUT',
params: {
'revert': keys.join(','),
},
callback: () => view.reload(),
failure: (response) => Ext.Msg.alert('Error', response.htmlStatus),
});
},
});
/* Button features:
* - observe selection changes to enable/disable the button using enableFn()
* - pop up confirmation dialog using confirmMsg()
*
* does this for the button and every menu item
*/
Ext.define('PVE.button.Split', {
extend: 'Ext.button.Split',
alias: 'widget.pveSplitButton',
// the selection model to observe
selModel: undefined,
// if 'false' handler will not be called (button disabled)
enableFn: function(record) {
// do nothing
},
// function(record) or text
confirmMsg: false,
// take special care in confirm box (select no as default).
dangerous: false,
handlerWrapper: function(button, event) {
var me = this;
var rec, msg;
if (me.selModel) {
rec = me.selModel.getSelection()[0];
if (!rec || me.enableFn(rec) === false) {
return;
}
}
if (me.confirmMsg) {
msg = me.confirmMsg;
// confirMsg can be boolean or function
if (Ext.isFunction(me.confirmMsg)) {
msg = me.confirmMsg(rec);
}
Ext.MessageBox.defaultButton = me.dangerous ? 2 : 1;
Ext.Msg.show({
title: gettext('Confirm'),
icon: me.dangerous ? Ext.Msg.WARNING : Ext.Msg.QUESTION,
msg: msg,
buttons: Ext.Msg.YESNO,
callback: function(btn) {
if (btn !== 'yes') {
return;
}
me.realHandler(button, event, rec);
},
});
} else {
me.realHandler(button, event, rec);
}
},
initComponent: function() {
var me = this;
if (me.handler) {
me.realHandler = me.handler;
me.handler = me.handlerWrapper;
}
if (me.menu && me.menu.items) {
me.menu.items.forEach(function(item) {
if (item.handler) {
item.realHandler = item.handler;
item.handler = me.handlerWrapper;
}
if (item.selModel) {
me.mon(item.selModel, "selectionchange", function() {
var rec = item.selModel.getSelection()[0];
if (!rec || item.enableFn(rec) === false) {
item.setDisabled(true);
} else {
item.setDisabled(false);
}
});
}
});
}
me.callParent();
if (me.selModel) {
me.mon(me.selModel, "selectionchange", function() {
var rec = me.selModel.getSelection()[0];
if (!rec || me.enableFn(rec) === false) {
me.setDisabled(true);
} else {
me.setDisabled(false);
}
});
}
},
});
Ext.define('PVE.controller.StorageEdit', {
extend: 'Ext.app.ViewController',
alias: 'controller.storageEdit',
control: {
'field[name=content]': {
change: function(field, value) {
const hasImages = Ext.Array.contains(value, 'images');
const prealloc = field.up('form').getForm().findField('preallocation');
if (prealloc) {
prealloc.setDisabled(!hasImages);
}
var hasBackups = Ext.Array.contains(value, 'backup');
var maxfiles = this.lookupReference('maxfiles');
if (!maxfiles) {
return;
}
if (!hasBackups) {
// clear values which will never be submitted
maxfiles.reset();
}
maxfiles.setDisabled(!hasBackups);
},
},
},
});
Ext.define('PVE.data.PermPathStore', {
extend: 'Ext.data.Store',
alias: 'store.pvePermPath',
fields: ['value'],
autoLoad: false,
data: [
{ 'value': '/' },
{ 'value': '/access' },
{ 'value': '/access/groups' },
{ 'value': '/access/realm' },
{ 'value': '/mapping' },
{ 'value': '/mapping/notifications' },
{ 'value': '/mapping/pci' },
{ 'value': '/mapping/usb' },
{ 'value': '/nodes' },
{ 'value': '/pool' },
{ 'value': '/sdn/zones' },
{ 'value': '/storage' },
{ 'value': '/vms' },
],
constructor: function(config) {
var me = this;
config = config || {};
me.callParent([config]);
let donePaths = {};
me.suspendEvents();
PVE.data.ResourceStore.each(function(record) {
let path;
switch (record.get('type')) {
case 'node': path = '/nodes/' + record.get('text');
break;
case 'qemu': path = '/vms/' + record.get('vmid');
break;
case 'lxc': path = '/vms/' + record.get('vmid');
break;
case 'sdn': path = '/sdn/zones/' + record.get('sdn');
break;
case 'storage': path = '/storage/' + record.get('storage');
break;
case 'pool': path = '/pool/' + record.get('pool');
break;
}
if (path !== undefined && !donePaths[path]) {
me.add({ value: path });
donePaths[path] = 1;
}
});
me.resumeEvents();
me.fireEvent('refresh', me);
me.fireEvent('datachanged', me);
me.sort({
property: 'value',
direction: 'ASC',
});
},
});
Ext.define('PVE.data.ResourceStore', {
extend: 'Proxmox.data.UpdateStore',
singleton: true,
findVMID: function(vmid) {
let me = this;
return me.findExact('vmid', parseInt(vmid, 10)) >= 0;
},
// returns the cached data from all nodes
getNodes: function() {
let me = this;
let nodes = [];
me.each(function(record) {
if (record.get('type') === "node") {
nodes.push(record.getData());
}
});
return nodes;
},
storageIsShared: function(storage_path) {
let me = this;
let index = me.findExact('id', storage_path);
if (index >= 0) {
return me.getAt(index).data.shared;
} else {
return undefined;
}
},
guestNode: function(vmid) {
let me = this;
let index = me.findExact('vmid', parseInt(vmid, 10));
return me.getAt(index).data.node;
},
guestName: function(vmid) {
let me = this;
let index = me.findExact('vmid', parseInt(vmid, 10));
if (index < 0) {
return '-';
}
let rec = me.getAt(index).data;
if ('name' in rec) {
return rec.name;
}
return '';
},
refresh: function() {
let me = this;
// can only refresh if we're loaded at least once and are not currently loading
if (!me.isLoading() && me.isLoaded()) {
let records = (me.getData().getSource() || me.getData()).getRange();
me.fireEvent('load', me, records);
}
},
constructor: function(config) {
let me = this;
config = config || {};
let field_defaults = {
type: {
header: gettext('Type'),
type: 'string',
renderer: PVE.Utils.render_resource_type,
sortable: true,
hideable: false,
width: 100,
},
id: {
header: 'ID',
type: 'string',
hidden: true,
sortable: true,
width: 80,
},
running: {
header: gettext('Online'),
type: 'boolean',
renderer: Proxmox.Utils.format_boolean,
hidden: true,
convert: function(value, record) {
var info = record.data;
return Ext.isNumeric(info.uptime) && info.uptime > 0;
},
},
text: {
header: gettext('Description'),
type: 'string',
sortable: true,
width: 200,
convert: function(value, record) {
if (value) {
return value;
}
let info = record.data, text;
if (Ext.isNumeric(info.vmid) && info.vmid > 0) {
text = String(info.vmid);
if (info.name) {
text += " (" + info.name + ')';
}
} else { // node, pool, storage
text = info[info.type] || info.id;
if (info.node && info.type !== 'node') {
text += " (" + info.node + ")";
}
}
return text;
},
},
vmid: {
header: 'VMID',
type: 'integer',
hidden: true,
sortable: true,
width: 80,
},
name: {
header: gettext('Name'),
hidden: true,
sortable: true,
type: 'string',
},
disk: {
header: gettext('Disk usage'),
type: 'integer',
renderer: PVE.Utils.render_disk_usage,
sortable: true,
width: 100,
hidden: true,
},
diskuse: {
header: gettext('Disk usage') + " %",
type: 'number',
sortable: true,
renderer: PVE.Utils.render_disk_usage_percent,
width: 100,
calculate: PVE.Utils.calculate_disk_usage,
sortType: 'asFloat',
},
maxdisk: {
header: gettext('Disk size'),
type: 'integer',
renderer: Proxmox.Utils.render_size,
sortable: true,
hidden: true,
width: 100,
},
mem: {
header: gettext('Memory usage'),
type: 'integer',
renderer: PVE.Utils.render_mem_usage,
sortable: true,
hidden: true,
width: 100,
},
memuse: {
header: gettext('Memory usage') + " %",
type: 'number',
renderer: PVE.Utils.render_mem_usage_percent,
calculate: PVE.Utils.calculate_mem_usage,
sortType: 'asFloat',
sortable: true,
width: 100,
},
maxmem: {
header: gettext('Memory size'),
type: 'integer',
renderer: Proxmox.Utils.render_size,
hidden: true,
sortable: true,
width: 100,
},
cpu: {
header: gettext('CPU usage'),
type: 'float',
renderer: Proxmox.Utils.render_cpu,
sortable: true,
width: 100,
},
maxcpu: {
header: gettext('maxcpu'),
type: 'integer',
hidden: true,
sortable: true,
width: 60,
},
diskread: {
header: gettext('Total Disk Read'),
type: 'integer',
hidden: true,
sortable: true,
renderer: Proxmox.Utils.format_size,
width: 100,
},
diskwrite: {
header: gettext('Total Disk Write'),
type: 'integer',
hidden: true,
sortable: true,
renderer: Proxmox.Utils.format_size,
width: 100,
},
netin: {
header: gettext('Total NetIn'),
type: 'integer',
hidden: true,
sortable: true,
renderer: Proxmox.Utils.format_size,
width: 100,
},
netout: {
header: gettext('Total NetOut'),
type: 'integer',
hidden: true,
sortable: true,
renderer: Proxmox.Utils.format_size,
width: 100,
},
template: {
header: gettext('Template'),
type: 'integer',
hidden: true,
sortable: true,
width: 60,
},
uptime: {
header: gettext('Uptime'),
type: 'integer',
renderer: Proxmox.Utils.render_uptime,
sortable: true,
width: 110,
},
node: {
header: gettext('Node'),
type: 'string',
hidden: true,
sortable: true,
width: 110,
},
storage: {
header: gettext('Storage'),
type: 'string',
hidden: true,
sortable: true,
width: 110,
},
pool: {
header: gettext('Pool'),
type: 'string',
hidden: true,
sortable: true,
width: 110,
},
hastate: {
header: gettext('HA State'),
type: 'string',
defaultValue: 'unmanaged',
hidden: true,
sortable: true,
},
status: {
header: gettext('Status'),
type: 'string',
hidden: true,
sortable: true,
width: 110,
},
lock: {
header: gettext('Lock'),
type: 'string',
hidden: true,
sortable: true,
width: 110,
},
hostcpu: {
header: gettext('Host CPU usage'),
type: 'float',
renderer: PVE.Utils.render_hostcpu,
calculate: PVE.Utils.calculate_hostcpu,
sortType: 'asFloat',
sortable: true,
width: 100,
},
hostmemuse: {
header: gettext('Host Memory usage') + " %",
type: 'number',
renderer: PVE.Utils.render_hostmem_usage_percent,
calculate: PVE.Utils.calculate_hostmem_usage,
sortType: 'asFloat',
sortable: true,
width: 100,
},
tags: {
header: gettext('Tags'),
renderer: (value) => PVE.Utils.renderTags(value, PVE.UIOptions.tagOverrides),
type: 'string',
sortable: true,
flex: 1,
},
// note: flex only last column to keep info closer together
};
let fields = [];
let fieldNames = [];
Ext.Object.each(field_defaults, function(key, value) {
var field = { name: key, type: value.type };
if (Ext.isDefined(value.convert)) {
field.convert = value.convert;
}
if (Ext.isDefined(value.calculate)) {
field.calculate = value.calculate;
}
if (Ext.isDefined(value.defaultValue)) {
field.defaultValue = value.defaultValue;
}
fields.push(field);
fieldNames.push(key);
});
Ext.define('PVEResources', {
extend: "Ext.data.Model",
fields: fields,
proxy: {
type: 'proxmox',
url: '/api2/json/cluster/resources',
},
});
Ext.define('PVETree', {
extend: "Ext.data.Model",
fields: fields,
proxy: { type: 'memory' },
});
Ext.apply(config, {
storeid: 'PVEResources',
model: 'PVEResources',
defaultColumns: function() {
let res = [];
Ext.Object.each(field_defaults, function(field, info) {
let fieldInfo = Ext.apply({ dataIndex: field }, info);
res.push(fieldInfo);
});
return res;
},
fieldNames: fieldNames,
});
me.callParent([config]);
},
});
Ext.define('pve-rrd-node', {
extend: 'Ext.data.Model',
fields: [
{
name: 'cpu',
// percentage
convert: function(value) {
return value*100;
},
},
{
name: 'iowait',
// percentage
convert: function(value) {
return value*100;
},
},
'loadavg',
'maxcpu',
'memtotal',
'memused',
'netin',
'netout',
'roottotal',
'rootused',
'swaptotal',
'swapused',
{ type: 'date', dateFormat: 'timestamp', name: 'time' },
],
});
Ext.define('pve-rrd-guest', {
extend: 'Ext.data.Model',
fields: [
{
name: 'cpu',
// percentage
convert: function(value) {
return value*100;
},
},
'maxcpu',
'netin',
'netout',
'mem',
'maxmem',
'disk',
'maxdisk',
'diskread',
'diskwrite',
{ type: 'date', dateFormat: 'timestamp', name: 'time' },
],
});
Ext.define('pve-rrd-storage', {
extend: 'Ext.data.Model',
fields: [
'used',
'total',
{ type: 'date', dateFormat: 'timestamp', name: 'time' },
],
});
// This is a container intended to show a field on the first column and one on the second column.
// One can set a ratio for the field sizes.
//
// Works around a limitation of our input panel column1/2 handling that entries are not vertically
// aligned when one of them has wrapping text (like it happens sometimes with such longer
// descriptions)
Ext.define('PVE.container.TwoColumnContainer', {
extend: 'Ext.container.Container',
alias: 'widget.pveTwoColumnContainer',
layout: {
type: 'hbox',
align: 'begin',
},
// The default ratio of the start widget. It an be an integer or a floating point number
startFlex: 1,
// The default ratio of the end widget. It an be an integer or a floating point number
endFlex: 1,
// the padding between the two columns
columnPadding: 20,
// the config of the first widget
startColumn: undefined,
// the config of the second widget
endColumn: undefined,
// same as fields in a panel
padding: '0 0 5 0',
initComponent: function() {
let me = this;
if (!me.startColumn) {
throw "no start widget configured";
}
if (!me.endColumn) {
throw "no end widget configured";
}
Ext.apply(me, {
items: [
Ext.applyIf({ flex: me.startFlex }, me.startColumn),
{
xtype: 'box',
width: me.columnPadding,
},
Ext.applyIf({ flex: me.endFlex }, me.endColumn),
],
});
me.callParent();
},
});
Ext.define('pve-acme-challenges', {
extend: 'Ext.data.Model',
fields: ['id', 'type', 'schema'],
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/acme/challenge-schema",
},
idProperty: 'id',
});
Ext.define('PVE.form.ACMEApiSelector', {
extend: 'Ext.form.field.ComboBox',
alias: 'widget.pveACMEApiSelector',
fieldLabel: gettext('DNS API'),
displayField: 'name',
valueField: 'id',
store: {
model: 'pve-acme-challenges',
autoLoad: true,
},
triggerAction: 'all',
queryMode: 'local',
allowBlank: false,
editable: true,
forceSelection: true,
anyMatch: true,
selectOnFocus: true,
getSchema: function() {
let me = this;
let val = me.getValue();
if (val) {
let record = me.getStore().findRecord('id', val, 0, false, true, true);
if (record) {
return record.data.schema;
}
}
return {};
},
});
Ext.define('PVE.form.ACMEAccountSelector', {
extend: 'Ext.form.field.ComboBox',
alias: 'widget.pveACMEAccountSelector',
displayField: 'name',
valueField: 'name',
store: {
model: 'pve-acme-accounts',
autoLoad: true,
},
triggerAction: 'all',
queryMode: 'local',
allowBlank: false,
editable: false,
forceSelection: true,
isEmpty: function() {
return this.getStore().getData().length === 0;
},
});
Ext.define('PVE.form.ACMEPluginSelector', {
extend: 'Ext.form.field.ComboBox',
alias: 'widget.pveACMEPluginSelector',
fieldLabel: gettext('Plugin'),
displayField: 'plugin',
valueField: 'plugin',
store: {
model: 'pve-acme-plugins',
autoLoad: true,
filters: item => item.data.type === 'dns',
},
triggerAction: 'all',
queryMode: 'local',
allowBlank: false,
editable: false,
});
Ext.define('PVE.form.AgentFeatureSelector', {
extend: 'Proxmox.panel.InputPanel',
alias: ['widget.pveAgentFeatureSelector'],
viewModel: {},
items: [
{
xtype: 'proxmoxcheckbox',
boxLabel: Ext.String.format(gettext('Use {0}'), 'QEMU Guest Agent'),
name: 'enabled',
reference: 'enabled',
uncheckedValue: 0,
},
{
xtype: 'proxmoxcheckbox',
boxLabel: gettext('Run guest-trim after a disk move or VM migration'),
name: 'fstrim_cloned_disks',
bind: {
disabled: '{!enabled.checked}',
},
disabled: true,
},
{
xtype: 'proxmoxcheckbox',
boxLabel: gettext('Freeze/thaw guest filesystems on backup for consistency'),
name: 'freeze-fs-on-backup',
reference: 'freeze_fs_on_backup',
bind: {
disabled: '{!enabled.checked}',
},
disabled: true,
uncheckedValue: '0',
defaultValue: '1',
},
{
xtype: 'displayfield',
userCls: 'pmx-hint',
value: gettext('Freeze/thaw for guest filesystems disabled. This can lead to inconsistent disk backups.'),
bind: {
hidden: '{freeze_fs_on_backup.checked}',
},
},
{
xtype: 'displayfield',
userCls: 'pmx-hint',
value: gettext('Make sure the QEMU Guest Agent is installed in the VM'),
bind: {
hidden: '{!enabled.checked}',
},
},
],
advancedItems: [
{
xtype: 'proxmoxKVComboBox',
name: 'type',
value: '__default__',
deleteEmpty: false,
fieldLabel: 'Type',
comboItems: [
['__default__', Proxmox.Utils.defaultText + " (VirtIO)"],
['virtio', 'VirtIO'],
['isa', 'ISA'],
],
},
],
onGetValues: function(values) {
if (PVE.Parser.parseBoolean(values['freeze-fs-on-backup'])) {
delete values['freeze-fs-on-backup'];
}
const agentstr = PVE.Parser.printPropertyString(values, 'enabled');
return { agent: agentstr };
},
setValues: function(values) {
let res = PVE.Parser.parsePropertyString(values.agent, 'enabled');
if (!Ext.isDefined(res['freeze-fs-on-backup'])) {
res['freeze-fs-on-backup'] = 1;
}
this.callParent([res]);
},
});
Ext.define('PVE.form.BackupCompressionSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: ['widget.pveBackupCompressionSelector'],
comboItems: [
['0', Proxmox.Utils.noneText],
['lzo', 'LZO (' + gettext('fast') + ')'],
['gzip', 'GZIP (' + gettext('good') + ')'],
['zstd', 'ZSTD (' + gettext('fast and good') + ')'],
],
});
Ext.define('PVE.form.BackupModeSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: ['widget.pveBackupModeSelector'],
comboItems: [
['snapshot', gettext('Snapshot')],
['suspend', gettext('Suspend')],
['stop', gettext('Stop')],
],
});
Ext.define('PVE.form.SizeField', {
extend: 'Ext.form.FieldContainer',
alias: 'widget.pveSizeField',
mixins: ['Proxmox.Mixin.CBind'],
viewModel: {
data: {
unit: 'MiB',
unitPostfix: '',
},
formulas: {
unitlabel: (get) => get('unit') + get('unitPostfix'),
},
},
emptyText: '',
layout: 'hbox',
defaults: {
hideLabel: true,
},
units: {
'B': 1,
'KiB': 1024,
'MiB': 1024*1024,
'GiB': 1024*1024*1024,
'TiB': 1024*1024*1024*1024,
'KB': 1000,
'MB': 1000*1000,
'GB': 1000*1000*1000,
'TB': 1000*1000*1000*1000,
},
// display unit (TODO: make (optionally) selectable)
unit: 'MiB',
unitPostfix: '',
// use this if the backend saves values in a unit other than bytes, e.g.,
// for KiB set it to 'KiB'
backendUnit: undefined,
// allow setting 0 and using it as a submit value
allowZero: false,
emptyValue: null,
items: [
{
xtype: 'numberfield',
cbind: {
name: '{name}',
emptyText: '{emptyText}',
allowZero: '{allowZero}',
emptyValue: '{emptyValue}',
},
minValue: 0,
step: 1,
submitLocaleSeparator: false,
fieldStyle: 'text-align: right',
flex: 1,
enableKeyEvents: true,
setValue: function(v) {
if (!this._transformed && v !== null) {
let fieldContainer = this.up('fieldcontainer');
let vm = fieldContainer.getViewModel();
let unit = vm.get('unit');
v /= fieldContainer.units[unit];
v *= fieldContainer.backendFactor;
this._transformed = true;
}
if (Number(v) === 0 && !this.allowZero) {
v = undefined;
}
return Ext.form.field.Text.prototype.setValue.call(this, v);
},
getSubmitValue: function() {
let v = this.processRawValue(this.getRawValue());
v = v.replace(this.decimalSeparator, '.');
if (v === undefined || v === '') {
return this.emptyValue;
}
if (Number(v) === 0) {
return this.allowZero ? 0 : null;
}
let fieldContainer = this.up('fieldcontainer');
let vm = fieldContainer.getViewModel();
let unit = vm.get('unit');
v = parseFloat(v) * fieldContainer.units[unit];
v /= fieldContainer.backendFactor;
return String(Math.floor(v));
},
listeners: {
// our setValue gets only called if we have a value, avoid
// transformation of the first user-entered value
keydown: function() { this._transformed = true; },
},
},
{
xtype: 'displayfield',
name: 'unit',
submitValue: false,
padding: '0 0 0 10',
bind: {
value: '{unitlabel}',
},
listeners: {
change: (f, v) => {
f.originalValue = v;
},
},
width: 40,
},
],
initComponent: function() {
let me = this;
me.unit = me.unit || 'MiB';
if (!(me.unit in me.units)) {
throw "unknown unit: " + me.unit;
}
me.backendFactor = 1;
if (me.backendUnit !== undefined) {
if (!(me.unit in me.units)) {
throw "unknown backend unit: " + me.backendUnit;
}
me.backendFactor = me.units[me.backendUnit];
}
me.callParent(arguments);
me.getViewModel().set('unit', me.unit);
me.getViewModel().set('unitPostfix', me.unitPostfix);
},
});
Ext.define('PVE.form.BandwidthField', {
extend: 'PVE.form.SizeField',
alias: 'widget.pveBandwidthField',
unitPostfix: '/s',
});
Ext.define('PVE.form.BridgeSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.PVE.form.BridgeSelector'],
bridgeType: 'any_bridge', // bridge, OVSBridge or any_bridge
store: {
fields: ['iface', 'active', 'type'],
filterOnLoad: true,
sorters: [
{
property: 'iface',
direction: 'ASC',
},
],
},
valueField: 'iface',
displayField: 'iface',
listConfig: {
columns: [
{
header: gettext('Bridge'),
dataIndex: 'iface',
hideable: false,
width: 100,
},
{
header: gettext('Active'),
width: 60,
dataIndex: 'active',
renderer: Proxmox.Utils.format_boolean,
},
{
header: gettext('Comment'),
dataIndex: 'comments',
renderer: Ext.String.htmlEncode,
flex: 1,
},
],
},
setNodename: function(nodename) {
var me = this;
if (!nodename || me.nodename === nodename) {
return;
}
me.nodename = nodename;
me.store.setProxy({
type: 'proxmox',
url: '/api2/json/nodes/' + me.nodename + '/network?type=' +
me.bridgeType,
});
me.store.load();
},
initComponent: function() {
var me = this;
var nodename = me.nodename;
me.nodename = undefined;
me.callParent();
me.setNodename(nodename);
},
});
Ext.define('PVE.form.BusTypeSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: 'widget.pveBusSelector',
withVirtIO: true,
withUnused: false,
initComponent: function() {
var me = this;
me.comboItems = [['ide', 'IDE'], ['sata', 'SATA']];
if (me.withVirtIO) {
me.comboItems.push(['virtio', 'VirtIO Block']);
}
me.comboItems.push(['scsi', 'SCSI']);
if (me.withUnused) {
me.comboItems.push(['unused', 'Unused']);
}
me.callParent();
},
});
Ext.define('PVE.data.CPUModel', {
extend: 'Ext.data.Model',
fields: [
{ name: 'name' },
{ name: 'vendor' },
{ name: 'custom' },
{ name: 'displayname' },
],
});
Ext.define('PVE.form.CPUModelSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.CPUModelSelector'],
valueField: 'name',
displayField: 'displayname',
emptyText: Proxmox.Utils.defaultText + ' (kvm64)',
allowBlank: true,
editable: true,
anyMatch: true,
forceSelection: true,
autoSelect: false,
deleteEmpty: true,
listConfig: {
columns: [
{
header: gettext('Model'),
dataIndex: 'displayname',
hideable: false,
sortable: true,
flex: 3,
},
{
header: gettext('Vendor'),
dataIndex: 'vendor',
hideable: false,
sortable: true,
flex: 2,
},
],
width: 360,
},
store: {
autoLoad: true,
model: 'PVE.data.CPUModel',
proxy: {
type: 'proxmox',
url: '/api2/json/nodes/localhost/capabilities/qemu/cpu',
},
sorters: [
{
sorterFn: function(recordA, recordB) {
let a = recordA.data;
let b = recordB.data;
let vendorOrder = PVE.Utils.cpu_vendor_order;
let orderA = vendorOrder[a.vendor] || vendorOrder._default_;
let orderB = vendorOrder[b.vendor] || vendorOrder._default_;
if (orderA > orderB) {
return 1;
} else if (orderA < orderB) {
return -1;
}
// Within same vendor, sort alphabetically
return a.name.localeCompare(b.name);
},
direction: 'ASC',
},
],
listeners: {
load: function(store, records, success) {
if (success) {
records.forEach(rec => {
rec.data.displayname = rec.data.name.replace(/^custom-/, '');
let vendor = rec.data.vendor;
if (rec.data.name === 'host') {
vendor = 'Host';
}
// We receive vendor names as given to QEMU as CPUID
vendor = PVE.Utils.cpu_vendor_map[vendor] || vendor;
if (rec.data.custom) {
vendor = gettext('Custom') + ` (${vendor})`;
}
rec.data.vendor = vendor;
});
store.sort();
}
},
},
},
});
Ext.define('PVE.form.CacheTypeSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: ['widget.CacheTypeSelector'],
comboItems: [
['__default__', Proxmox.Utils.defaultText + " (" + gettext('No cache') + ")"],
['directsync', 'Direct sync'],
['writethrough', 'Write through'],
['writeback', 'Write back'],
['unsafe', 'Write back (' + gettext('unsafe') + ')'],
['none', gettext('No cache')],
],
});
Ext.define('PVE.form.CalendarEvent', {
extend: 'Ext.form.field.ComboBox',
xtype: 'pveCalendarEvent',
editable: true,
emptyText: gettext('Editable'), // FIXME: better way to convey that to confused users?
valueField: 'value',
queryMode: 'local',
matchFieldWidth: false,
listConfig: {
maxWidth: 450,
},
store: {
field: ['value', 'text'],
data: [
{ value: '*/30', text: Ext.String.format(gettext("Every {0} minutes"), 30) },
{ value: '*/2:00', text: gettext("Every two hours") },
{ value: '21:00', text: gettext("Every day") + " 21:00" },
{ value: '2,22:30', text: gettext("Every day") + " 02:30, 22:30" },
{ value: 'mon..fri 00:00', text: gettext("Monday to Friday") + " 00:00" },
{ value: 'mon..fri */1:00', text: gettext("Monday to Friday") + ': ' + gettext("hourly") },
{
value: 'mon..fri 7..18:00/15',
text: gettext("Monday to Friday") + ', '
+ Ext.String.format(gettext('{0} to {1}'), '07:00', '18:45') + ': '
+ Ext.String.format(gettext("Every {0} minutes"), 15),
},
{ value: 'sun 01:00', text: gettext("Sunday") + " 01:00" },
{ value: 'monthly', text: gettext("Every first day of the Month") + " 00:00" },
{ value: 'sat *-1..7 15:00', text: gettext("First Saturday each month") + " 15:00" },
{ value: 'yearly', text: gettext("First day of the year") + " 00:00" },
],
},
tpl: [
'<ul class="x-list-plain"><tpl for=".">',
'<li role="option" class="x-boundlist-item">{text}</li>',
'</tpl></ul>',
],
displayTpl: [
'<tpl for=".">',
'{value}',
'</tpl>',
],
});
Ext.define('PVE.form.CephPoolSelector', {
extend: 'Ext.form.field.ComboBox',
alias: 'widget.pveCephPoolSelector',
allowBlank: false,
valueField: 'pool_name',
displayField: 'pool_name',
editable: false,
queryMode: 'local',
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no nodename given";
}
let onlyRBDPools = ({ data }) =>
!data?.application_metadata || !!data?.application_metadata?.rbd;
var store = Ext.create('Ext.data.Store', {
fields: ['name'],
sorters: 'name',
filters: [
onlyRBDPools,
],
proxy: {
type: 'proxmox',
url: '/api2/json/nodes/' + me.nodename + '/ceph/pool',
},
});
Ext.apply(me, {
store: store,
});
me.callParent();
store.load({
callback: function(rec, op, success) {
let filteredRec = rec.filter(onlyRBDPools);
if (success && filteredRec.length > 0) {
me.select(filteredRec[0]);
}
},
});
},
});
Ext.define('PVE.form.CephFSSelector', {
extend: 'Ext.form.field.ComboBox',
alias: 'widget.pveCephFSSelector',
allowBlank: false,
valueField: 'name',
displayField: 'name',
editable: false,
queryMode: 'local',
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no nodename given";
}
var store = Ext.create('Ext.data.Store', {
fields: ['name'],
sorters: 'name',
proxy: {
type: 'proxmox',
url: '/api2/json/nodes/' + me.nodename + '/ceph/fs',
},
});
Ext.apply(me, {
store: store,
});
me.callParent();
store.load({
callback: function(rec, op, success) {
if (success && rec.length > 0) {
me.select(rec[0]);
}
},
});
},
});
Ext.define('PVE.form.ComboBoxSetStoreNode', {
extend: 'Proxmox.form.ComboGrid',
config: {
apiBaseUrl: '/api2/json/nodes/',
apiSuffix: '',
},
showNodeSelector: false,
setNodeName: function(value) {
let me = this;
value ||= Proxmox.NodeName;
me.getStore().getProxy().setUrl(`${me.apiBaseUrl}${value}${me.apiSuffix}`);
me.clearValue();
},
nodeChange: function(_field, value) {
let me = this;
// disable autoSelect if there is already a selection or we have the picker open
if (me.getValue() || me.isExpanded) {
let autoSelect = me.autoSelect;
me.autoSelect = false;
me.store.on('afterload', function() {
me.autoSelect = autoSelect;
}, { single: true });
}
me.setNodeName(value);
me.fireEvent('nodechanged', value);
},
tbarMouseDown: function() {
this.topBarMousePress = true;
},
tbarMouseUp: function() {
let me = this;
delete this.topBarMousePress;
if (me.focusLeft) {
me.focus();
delete me.focusLeft;
}
},
// conditionally prevent the focusLeave handler to continue, preventing collapsing of the picker
onFocusLeave: function() {
let me = this;
me.focusLeft = true;
if (!me.topBarMousePress) {
me.callParent(arguments);
}
return undefined;
},
initComponent: function() {
let me = this;
if (me.showNodeSelector && !PVE.Utils.isStandaloneNode()) {
me.errorHeight = 140;
Ext.apply(me.listConfig ?? {}, {
tbar: {
xtype: 'toolbar',
minHeight: 40,
listeners: {
mousedown: me.tbarMouseDown,
mouseup: me.tbarMouseUp,
element: 'el',
scope: me,
},
items: [
{
xtype: "pveStorageScanNodeSelector",
autoSelect: false,
fieldLabel: gettext('Node to scan'),
listeners: {
change: (field, value) => me.nodeChange(field, value),
},
},
],
},
emptyText: me.listConfig?.emptyText ?? gettext('Nothing found'),
});
}
me.callParent();
},
});
Ext.define('PVE.form.ContentTypeSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: ['widget.pveContentTypeSelector'],
cts: undefined,
initComponent: function() {
var me = this;
me.comboItems = [];
if (me.cts === undefined) {
me.cts = ['images', 'iso', 'vztmpl', 'backup', 'rootdir', 'snippets', 'import'];
}
Ext.Array.each(me.cts, function(ct) {
me.comboItems.push([ct, PVE.Utils.format_content_types(ct)]);
});
me.callParent();
},
});
Ext.define('PVE.form.ControllerSelector', {
extend: 'Ext.form.FieldContainer',
alias: 'widget.pveControllerSelector',
withVirtIO: true,
withUnused: false,
vmconfig: {}, // used to check for existing devices
setToFree: function(controllers, busField, deviceIDField) {
let me = this;
let freeId = PVE.Utils.nextFreeDisk(controllers, me.vmconfig);
if (freeId !== undefined) {
busField?.setValue(freeId.controller);
deviceIDField.setValue(freeId.id);
}
},
updateVMConfig: function(vmconfig) {
let me = this;
me.vmconfig = Ext.apply({}, vmconfig);
me.down('field[name=deviceid]').validate();
},
setVMConfig: function(vmconfig, autoSelect) {
let me = this;
me.vmconfig = Ext.apply({}, vmconfig);
let bussel = me.down('field[name=controller]');
let deviceid = me.down('field[name=deviceid]');
let clist;
if (autoSelect === 'cdrom') {
if (!Ext.isDefined(me.vmconfig.ide2)) {
bussel.setValue('ide');
deviceid.setValue(2);
return;
}
clist = ['ide', 'scsi', 'sata'];
} else {
// in most cases we want to add a disk to the same controller we previously used
clist = PVE.Utils.sortByPreviousUsage(me.vmconfig);
}
me.setToFree(clist, bussel, deviceid);
deviceid.validate();
},
getConfId: function() {
let me = this;
let controller = me.getComponent('controller').getValue() || 'ide';
let id = me.getComponent('deviceid').getValue() || 0;
return `${controller}${id}`;
},
initComponent: function() {
let me = this;
Ext.apply(me, {
fieldLabel: gettext('Bus/Device'),
layout: 'hbox',
defaults: {
hideLabel: true,
},
items: [
{
xtype: 'pveBusSelector',
name: 'controller',
itemId: 'controller',
value: PVE.qemu.OSDefaults.generic.busType,
withVirtIO: me.withVirtIO,
withUnused: me.withUnused,
allowBlank: false,
flex: 2,
listeners: {
change: function(t, value) {
if (!value) {
return;
}
let field = me.down('field[name=deviceid]');
me.setToFree([value], undefined, field);
field.setMaxValue(PVE.Utils.diskControllerMaxIDs[value] - 1);
field.validate();
},
},
},
{
xtype: 'proxmoxintegerfield',
name: 'deviceid',
itemId: 'deviceid',
minValue: 0,
maxValue: PVE.Utils.diskControllerMaxIDs.ide - 1,
value: '0',
flex: 1,
allowBlank: false,
validator: function(value) {
if (!me.rendered) {
return undefined;
}
let controller = me.down('field[name=controller]').getValue();
let confid = controller + value;
if (Ext.isDefined(me.vmconfig[confid])) {
return "This device is already in use.";
}
return true;
},
},
],
});
me.callParent();
if (me.selectFree) {
me.setVMConfig(me.vmconfig);
}
},
});
Ext.define('PVE.form.DayOfWeekSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: ['widget.pveDayOfWeekSelector'],
comboItems: [],
initComponent: function() {
var me = this;
me.comboItems = [
['mon', Ext.util.Format.htmlDecode(Ext.Date.dayNames[1])],
['tue', Ext.util.Format.htmlDecode(Ext.Date.dayNames[2])],
['wed', Ext.util.Format.htmlDecode(Ext.Date.dayNames[3])],
['thu', Ext.util.Format.htmlDecode(Ext.Date.dayNames[4])],
['fri', Ext.util.Format.htmlDecode(Ext.Date.dayNames[5])],
['sat', Ext.util.Format.htmlDecode(Ext.Date.dayNames[6])],
['sun', Ext.util.Format.htmlDecode(Ext.Date.dayNames[0])],
];
this.callParent();
},
});
Ext.define('PVE.form.DiskFormatSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: 'widget.pveDiskFormatSelector',
comboItems: [
['raw', gettext('Raw disk image') + ' (raw)'],
['qcow2', gettext('QEMU image format') + ' (qcow2)'],
['vmdk', gettext('VMware image format') + ' (vmdk)'],
],
});
Ext.define('PVE.form.DiskStorageSelector', {
extend: 'Ext.container.Container',
alias: 'widget.pveDiskStorageSelector',
layout: 'fit',
defaults: {
margin: '0 0 5 0',
},
// the fieldLabel for the storageselector
storageLabel: gettext('Storage'),
// the content to show (e.g., images or rootdir)
storageContent: undefined,
// if true, selects the first available storage
autoSelect: false,
allowBlank: false,
emptyText: '',
// hides the selection field
// this is always hidden on creation,
// and only shown when the storage needs a selection and
// hideSelection is not true
hideSelection: undefined,
// hides the size field (e.g, for the efi disk dialog)
hideSize: false,
// hides the format field (e.g. for TPM state)
hideFormat: false,
// sets the initial size value
// string because else we get a type confusion
defaultSize: '32',
changeStorage: function(f, value) {
var me = this;
var formatsel = me.getComponent('diskformat');
var hdfilesel = me.getComponent('hdimage');
var hdsizesel = me.getComponent('disksize');
// initial store load, and reset/deletion of the storage
if (!value) {
hdfilesel.setDisabled(true);
hdfilesel.setVisible(false);
formatsel.setDisabled(true);
return;
}
var rec = f.store.getById(value);
// if the storage is not defined, or valid,
// we cannot know what to enable/disable
if (!rec) {
return;
}
let validFormats = {};
let selectFormat = 'raw';
if (rec.data.format) {
validFormats = rec.data.format[0]; // 0 is the formats, 1 the default in the backend
delete validFormats.subvol; // we never need subvol in the gui
if (validFormats.qcow2) {
selectFormat = 'qcow2';
} else if (validFormats.raw) {
selectFormat = 'raw';
} else {
selectFormat = rec.data.format[1];
}
}
var select = !!rec.data.select_existing && !me.hideSelection;
formatsel.setDisabled(me.hideFormat || Ext.Object.getSize(validFormats) <= 1);
formatsel.setValue(selectFormat);
hdfilesel.setDisabled(!select);
hdfilesel.setVisible(select);
if (select) {
hdfilesel.setStorage(value);
}
hdsizesel.setDisabled(select || me.hideSize);
hdsizesel.setVisible(!select && !me.hideSize);
},
setNodename: function(nodename) {
var me = this;
var hdstorage = me.getComponent('hdstorage');
var hdfilesel = me.getComponent('hdimage');
hdstorage.setNodename(nodename);
hdfilesel.setNodename(nodename);
},
setDisabled: function(value) {
var me = this;
var hdstorage = me.getComponent('hdstorage');
// reset on disable
if (value) {
hdstorage.setValue();
}
hdstorage.setDisabled(value);
// disabling does not always fire this event and we do not need
// the value of the validity
hdstorage.fireEvent('validitychange');
},
initComponent: function() {
var me = this;
me.items = [
{
xtype: 'pveStorageSelector',
itemId: 'hdstorage',
name: 'hdstorage',
fieldLabel: me.storageLabel,
nodename: me.nodename,
storageContent: me.storageContent,
disabled: me.disabled,
autoSelect: me.autoSelect,
allowBlank: me.allowBlank,
emptyText: me.emptyText,
listeners: {
change: {
fn: me.changeStorage,
scope: me,
},
},
},
{
xtype: 'pveFileSelector',
name: 'hdimage',
itemId: 'hdimage',
fieldLabel: gettext('Disk image'),
nodename: me.nodename,
disabled: true,
hidden: true,
},
{
xtype: 'numberfield',
itemId: 'disksize',
name: 'disksize',
fieldLabel: `${gettext('Disk size')} (${gettext('GiB')})`,
hidden: me.hideSize,
disabled: me.hideSize,
minValue: 0.001,
maxValue: 128*1024,
decimalPrecision: 3,
value: me.defaultSize,
allowBlank: false,
},
{
xtype: 'pveDiskFormatSelector',
itemId: 'diskformat',
name: 'diskformat',
fieldLabel: gettext('Format'),
nodename: me.nodename,
disabled: true,
hidden: me.hideFormat || me.storageContent === 'rootdir',
value: 'qcow2',
allowBlank: false,
},
];
// use it to disable the children but not ourself
me.disabled = false;
me.callParent();
},
});
Ext.define('PVE.form.FileSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: 'widget.pveFileSelector',
editable: true,
anyMatch: true,
forceSelection: true,
listeners: {
afterrender: function() {
var me = this;
if (!me.disabled) {
me.setStorage(me.storage, me.nodename);
}
},
},
setStorage: function(storage, nodename) {
var me = this;
var change = false;
if (storage && me.storage !== storage) {
me.storage = storage;
change = true;
}
if (nodename && me.nodename !== nodename) {
me.nodename = nodename;
change = true;
}
if (!(me.storage && me.nodename && change)) {
return;
}
var url = '/api2/json/nodes/' + me.nodename + '/storage/' + me.storage + '/content';
if (me.storageContent) {
url += '?content=' + me.storageContent;
}
me.store.setProxy({
type: 'proxmox',
url: url,
});
me.store.removeAll();
me.store.load();
},
setNodename: function(nodename) {
this.setStorage(undefined, nodename);
},
store: {
model: 'pve-storage-content',
},
allowBlank: false,
autoSelect: false,
valueField: 'volid',
displayField: 'text',
listConfig: {
width: 600,
columns: [
{
header: gettext('Name'),
dataIndex: 'text',
hideable: false,
flex: 1,
},
{
header: gettext('Format'),
width: 60,
dataIndex: 'format',
},
{
header: gettext('Size'),
width: 100,
dataIndex: 'size',
renderer: Proxmox.Utils.format_size,
},
],
},
});
Ext.define('PVE.form.FirewallPolicySelector', {
extend: 'Proxmox.form.KVComboBox',
alias: ['widget.pveFirewallPolicySelector'],
comboItems: [
['ACCEPT', 'ACCEPT'],
['REJECT', 'REJECT'],
['DROP', 'DROP'],
],
});
/*
* This is a global search field it loads the /cluster/resources on focus and displays the
* result in a floating grid. Filtering and sorting is done in the customFilter function
*
* Accepts key up/down and enter for input, and it opens to CTRL+SHIFT+F and CTRL+SPACE
*/
Ext.define('PVE.form.GlobalSearchField', {
extend: 'Ext.form.field.Text',
alias: 'widget.pveGlobalSearchField',
emptyText: gettext('Search'),
enableKeyEvents: true,
selectOnFocus: true,
padding: '0 5 0 5',
grid: {
xtype: 'gridpanel',
userCls: 'proxmox-tags-full',
focusOnToFront: false,
floating: true,
emptyText: Proxmox.Utils.noneText,
width: 600,
height: 400,
scrollable: {
xtype: 'scroller',
y: true,
x: true,
},
store: {
model: 'PVEResources',
proxy: {
type: 'proxmox',
url: '/api2/extjs/cluster/resources',
},
},
plugins: {
ptype: 'bufferedrenderer',
trailingBufferZone: 20,
leadingBufferZone: 20,
},
hideMe: function() {
var me = this;
if (typeof me.ctxMenu !== 'undefined' && me.ctxMenu.isVisible()) {
return;
}
me.hasFocus = false;
if (!me.textfield.hasFocus) {
me.hide();
}
},
setFocus: function() {
var me = this;
me.hasFocus = true;
},
listeners: {
rowclick: function(grid, record) {
var me = this;
me.textfield.selectAndHide(record.id);
},
itemcontextmenu: function(v, record, item, index, event) {
var me = this;
me.ctxMenu = PVE.Utils.createCmdMenu(v, record, item, index, event);
},
focusleave: 'hideMe',
focusenter: 'setFocus',
},
columns: [
{
text: gettext('Type'),
dataIndex: 'type',
width: 100,
renderer: PVE.Utils.render_resource_type,
},
{
text: gettext('Description'),
flex: 1,
dataIndex: 'text',
renderer: function(value, mD, rec) {
let overrides = PVE.UIOptions.tagOverrides;
let tags = PVE.Utils.renderTags(rec.data.tags, overrides);
return `${value}${tags}`;
},
},
{
text: gettext('Node'),
dataIndex: 'node',
},
{
text: gettext('Pool'),
dataIndex: 'pool',
},
],
},
customFilter: function(item) {
let me = this;
if (me.filterVal === '') {
item.data.relevance = 0;
return true;
}
// different types have different fields to search, e.g., a node will never have a pool
const fieldMap = {
'pool': ['type', 'pool', 'text'],
'node': ['type', 'node', 'text'],
'storage': ['type', 'pool', 'node', 'storage'],
'default': ['name', 'type', 'node', 'pool', 'vmid'],
};
let fields = fieldMap[item.data.type] || fieldMap.default;
let fieldArr = fields.map(field => item.data[field]?.toString().toLowerCase());
if (item.data.tags) {
let tags = item.data.tags.split(/[;, ]/);
fieldArr.push(...tags);
}
let filterWords = me.filterVal.split(/\s+/);
// all text is case insensitive and each split-out word is searched for separately.
// a row gets 1 point for every partial match, and and additional point for every exact match
let match = 0;
for (let fieldValue of fieldArr) {
if (fieldValue === undefined || fieldValue === "") {
continue;
}
for (let filterWord of filterWords) {
if (fieldValue.indexOf(filterWord) !== -1) {
match++; // partial match
if (fieldValue === filterWord) {
match++; // exact match is worth more
}
}
}
}
item.data.relevance = match; // set the row's virtual 'relevance' value for ordering
return match > 0;
},
updateFilter: function(field, newValue, oldValue) {
let me = this;
// parse input and filter store, show grid
me.grid.store.filterVal = newValue.toLowerCase().trim();
me.grid.store.clearFilter(true);
me.grid.store.filterBy(me.customFilter);
me.grid.getSelectionModel().select(0);
},
selectAndHide: function(id) {
var me = this;
me.tree.selectById(id);
me.grid.hide();
me.setValue('');
me.blur();
},
onKey: function(field, e) {
var me = this;
var key = e.getKey();
switch (key) {
case Ext.event.Event.ENTER:
// go to first entry if there is one
if (me.grid.store.getCount() > 0) {
me.selectAndHide(me.grid.getSelection()[0].data.id);
}
break;
case Ext.event.Event.UP:
me.grid.getSelectionModel().selectPrevious();
break;
case Ext.event.Event.DOWN:
me.grid.getSelectionModel().selectNext();
break;
case Ext.event.Event.ESC:
me.grid.hide();
me.blur();
break;
}
},
loadValues: function(field) {
let me = this;
me.hasFocus = true;
me.grid.textfield = me;
me.grid.store.load();
me.grid.showBy(me, 'tl-bl');
},
hideGrid: function() {
let me = this;
me.hasFocus = false;
if (!me.grid.hasFocus) {
me.grid.hide();
}
},
listeners: {
change: {
fn: 'updateFilter',
buffer: 250,
},
specialkey: 'onKey',
focusenter: 'loadValues',
focusleave: {
fn: 'hideGrid',
delay: 100,
},
},
toggleFocus: function() {
let me = this;
if (!me.hasFocus) {
me.focus();
} else {
me.blur();
}
},
initComponent: function() {
let me = this;
if (!me.tree) {
throw "no tree given";
}
me.grid = Ext.create(me.grid);
me.callParent();
// bind CTRL + SHIFT + F and CTRL + SPACE to open/close the search
me.keymap = new Ext.KeyMap({
target: Ext.get(document),
binding: [{
key: 'F',
ctrl: true,
shift: true,
fn: me.toggleFocus,
scope: me,
}, {
key: ' ',
ctrl: true,
fn: me.toggleFocus,
scope: me,
}],
});
// always select first item and sort by relevance after load
me.mon(me.grid.store, 'load', function() {
me.grid.getSelectionModel().select(0);
me.grid.store.sort({
property: 'relevance',
direction: 'DESC',
});
});
},
});
Ext.define('pve-groups', {
extend: 'Ext.data.Model',
fields: ['groupid', 'comment', 'users'],
proxy: {
type: 'proxmox',
url: "/api2/json/access/groups",
},
idProperty: 'groupid',
});
Ext.define('PVE.form.GroupSelector', {
extend: 'Proxmox.form.ComboGrid',
xtype: 'pveGroupSelector',
editable: true,
anyMatch: true,
forceSelection: true,
allowBlank: false,
autoSelect: false,
valueField: 'groupid',
displayField: 'groupid',
listConfig: {
columns: [
{
header: gettext('Group'),
sortable: true,
dataIndex: 'groupid',
flex: 1,
},
{
header: gettext('Comment'),
sortable: false,
dataIndex: 'comment',
renderer: Ext.String.htmlEncode,
flex: 1,
},
{
header: gettext('Users'),
sortable: false,
dataIndex: 'users',
renderer: Ext.String.htmlEncode,
flex: 1,
},
],
},
initComponent: function() {
var me = this;
var store = new Ext.data.Store({
model: 'pve-groups',
sorters: [{
property: 'groupid',
}],
});
Ext.apply(me, {
store: store,
});
me.callParent();
store.load();
},
});
Ext.define('PVE.form.GuestIDSelector', {
extend: 'Ext.form.field.Number',
alias: 'widget.pveGuestIDSelector',
allowBlank: false,
minValue: 100,
maxValue: 999999999,
validateExists: undefined,
loadNextFreeID: false,
guestType: undefined,
validator: function(value) {
var me = this;
if (!Ext.isNumeric(value) ||
value < me.minValue ||
value > me.maxValue) {
// check is done by ExtJS
return true;
}
if (me.validateExists === true && !me.exists) {
return me.unknownID;
}
if (me.validateExists === false && me.exists) {
return me.inUseID;
}
return true;
},
initComponent: function() {
var me = this;
var label = '{0} ID';
var unknownID = gettext('This {0} ID does not exist');
var inUseID = gettext('This {0} ID is already in use');
var type = 'CT/VM';
if (me.guestType === 'lxc') {
type = 'CT';
} else if (me.guestType === 'qemu') {
type = 'VM';
}
me.label = Ext.String.format(label, type);
me.unknownID = Ext.String.format(unknownID, type);
me.inUseID = Ext.String.format(inUseID, type);
Ext.apply(me, {
fieldLabel: me.label,
listeners: {
'change': function(field, newValue, oldValue) {
if (!Ext.isDefined(me.validateExists)) {
return;
}
Proxmox.Utils.API2Request({
params: { vmid: newValue },
url: '/cluster/nextid',
method: 'GET',
success: function(response, opts) {
me.exists = false;
me.validate();
},
failure: function(response, opts) {
me.exists = true;
me.validate();
},
});
},
},
});
me.callParent();
if (me.loadNextFreeID) {
Proxmox.Utils.API2Request({
url: '/cluster/nextid',
method: 'GET',
success: function(response, opts) {
me.setRawValue(response.result.data);
},
});
}
},
});
Ext.define('PVE.form.hashAlgorithmSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: ['widget.pveHashAlgorithmSelector'],
config: {
deleteEmpty: false,
},
comboItems: [
['__default__', 'None'],
['md5', 'MD5'],
['sha1', 'SHA-1'],
['sha224', 'SHA-224'],
['sha256', 'SHA-256'],
['sha384', 'SHA-384'],
['sha512', 'SHA-512'],
],
});
Ext.define('PVE.form.HotplugFeatureSelector', {
extend: 'Ext.form.CheckboxGroup',
alias: 'widget.pveHotplugFeatureSelector',
columns: 1,
vertical: true,
defaults: {
name: 'hotplugCbGroup',
submitValue: false,
},
items: [
{
boxLabel: gettext('Disk'),
inputValue: 'disk',
checked: true,
},
{
boxLabel: gettext('Network'),
inputValue: 'network',
checked: true,
},
{
boxLabel: 'USB',
inputValue: 'usb',
checked: true,
},
{
boxLabel: gettext('Memory'),
inputValue: 'memory',
},
{
boxLabel: gettext('CPU'),
inputValue: 'cpu',
},
],
setValue: function(value) {
var me = this;
var newVal = [];
if (value === '1') {
newVal = ['disk', 'network', 'usb'];
} else if (value !== '0') {
newVal = value.split(',');
}
me.callParent([{ hotplugCbGroup: newVal }]);
},
// override framework function to
// assemble the hotplug value
getSubmitData: function() {
var me = this,
boxes = me.getBoxes(),
data = [];
Ext.Array.forEach(boxes, function(box) {
if (box.getValue()) {
data.push(box.inputValue);
}
});
/* because above is hotplug an array */
if (data.length === 0) {
return { 'hotplug': '0' };
} else {
return { 'hotplug': data.join(',') };
}
},
});
Ext.define('PVE.form.IPProtocolSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.pveIPProtocolSelector'],
valueField: 'p',
displayField: 'p',
listConfig: {
columns: [
{
header: gettext('Protocol'),
dataIndex: 'p',
hideable: false,
sortable: false,
width: 100,
},
{
header: gettext('Number'),
dataIndex: 'n',
hideable: false,
sortable: false,
width: 50,
},
{
header: gettext('Description'),
dataIndex: 'd',
hideable: false,
sortable: false,
flex: 1,
},
],
},
store: {
fields: ['p', 'd', 'n'],
data: [
{ p: 'tcp', n: 6, d: 'Transmission Control Protocol' },
{ p: 'udp', n: 17, d: 'User Datagram Protocol' },
{ p: 'icmp', n: 1, d: 'Internet Control Message Protocol' },
{ p: 'igmp', n: 2, d: 'Internet Group Management' },
{ p: 'ggp', n: 3, d: 'gateway-gateway protocol' },
{ p: 'ipencap', n: 4, d: 'IP encapsulated in IP' },
{ p: 'st', n: 5, d: 'ST datagram mode' },
{ p: 'egp', n: 8, d: 'exterior gateway protocol' },
{ p: 'igp', n: 9, d: 'any private interior gateway (Cisco)' },
{ p: 'pup', n: 12, d: 'PARC universal packet protocol' },
{ p: 'hmp', n: 20, d: 'host monitoring protocol' },
{ p: 'xns-idp', n: 22, d: 'Xerox NS IDP' },
{ p: 'rdp', n: 27, d: '"reliable datagram" protocol' },
{ p: 'iso-tp4', n: 29, d: 'ISO Transport Protocol class 4 [RFC905]' },
{ p: 'dccp', n: 33, d: 'Datagram Congestion Control Prot. [RFC4340]' },
{ p: 'xtp', n: 36, d: 'Xpress Transfer Protocol' },
{ p: 'ddp', n: 37, d: 'Datagram Delivery Protocol' },
{ p: 'idpr-cmtp', n: 38, d: 'IDPR Control Message Transport' },
{ p: 'ipv6', n: 41, d: 'Internet Protocol, version 6' },
{ p: 'ipv6-route', n: 43, d: 'Routing Header for IPv6' },
{ p: 'ipv6-frag', n: 44, d: 'Fragment Header for IPv6' },
{ p: 'idrp', n: 45, d: 'Inter-Domain Routing Protocol' },
{ p: 'rsvp', n: 46, d: 'Reservation Protocol' },
{ p: 'gre', n: 47, d: 'General Routing Encapsulation' },
{ p: 'esp', n: 50, d: 'Encap Security Payload [RFC2406]' },
{ p: 'ah', n: 51, d: 'Authentication Header [RFC2402]' },
{ p: 'skip', n: 57, d: 'SKIP' },
{ p: 'ipv6-icmp', n: 58, d: 'ICMP for IPv6' },
{ p: 'ipv6-nonxt', n: 59, d: 'No Next Header for IPv6' },
{ p: 'ipv6-opts', n: 60, d: 'Destination Options for IPv6' },
{ p: 'vmtp', n: 81, d: 'Versatile Message Transport' },
{ p: 'eigrp', n: 88, d: 'Enhanced Interior Routing Protocol (Cisco)' },
{ p: 'ospf', n: 89, d: 'Open Shortest Path First IGP' },
{ p: 'ax.25', n: 93, d: 'AX.25 frames' },
{ p: 'ipip', n: 94, d: 'IP-within-IP Encapsulation Protocol' },
{ p: 'etherip', n: 97, d: 'Ethernet-within-IP Encapsulation [RFC3378]' },
{ p: 'encap', n: 98, d: 'Yet Another IP encapsulation [RFC1241]' },
{ p: 'pim', n: 103, d: 'Protocol Independent Multicast' },
{ p: 'ipcomp', n: 108, d: 'IP Payload Compression Protocol' },
{ p: 'vrrp', n: 112, d: 'Virtual Router Redundancy Protocol [RFC5798]' },
{ p: 'l2tp', n: 115, d: 'Layer Two Tunneling Protocol [RFC2661]' },
{ p: 'isis', n: 124, d: 'IS-IS over IPv4' },
{ p: 'sctp', n: 132, d: 'Stream Control Transmission Protocol' },
{ p: 'fc', n: 133, d: 'Fibre Channel' },
{ p: 'mobility-header', n: 135, d: 'Mobility Support for IPv6 [RFC3775]' },
{ p: 'udplite', n: 136, d: 'UDP-Lite [RFC3828]' },
{ p: 'mpls-in-ip', n: 137, d: 'MPLS-in-IP [RFC4023]' },
{ p: 'hip', n: 139, d: 'Host Identity Protocol' },
{ p: 'shim6', n: 140, d: 'Shim6 Protocol [RFC5533]' },
{ p: 'wesp', n: 141, d: 'Wrapped Encapsulating Security Payload' },
{ p: 'rohc', n: 142, d: 'Robust Header Compression' },
],
},
});
Ext.define('PVE.form.IPRefSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.pveIPRefSelector'],
base_url: undefined,
preferredValue: '', // hack: else Form sets dirty flag?
ref_type: undefined, // undefined = any [undefined, 'ipset' or 'alias']
valueField: 'scopedref',
displayField: 'ref',
notFoundIsValid: true,
initComponent: function() {
var me = this;
if (!me.base_url) {
throw "no base_url specified";
}
var url = "/api2/json" + me.base_url;
if (me.ref_type) {
url += "?type=" + me.ref_type;
}
var store = Ext.create('Ext.data.Store', {
autoLoad: true,
fields: [
'type',
'name',
'ref',
'comment',
'scope',
{
name: 'scopedref',
calculate: function(v) {
if (v.type === 'alias') {
return `${v.scope}/${v.name}`;
} else if (v.type === 'ipset') {
return `+${v.scope}/${v.name}`;
} else {
return v.ref;
}
},
},
],
idProperty: 'ref',
proxy: {
type: 'proxmox',
url: url,
},
sorters: {
property: 'ref',
direction: 'ASC',
},
});
var columns = [];
if (!me.ref_type) {
columns.push({
header: gettext('Type'),
dataIndex: 'type',
hideable: false,
width: 60,
});
}
let scopes = {
'dc': gettext("Datacenter"),
'guest': gettext("Guest"),
'sdn': gettext("SDN"),
};
columns.push(
{
header: gettext('Name'),
dataIndex: 'ref',
hideable: false,
width: 140,
},
{
header: gettext('Scope'),
dataIndex: 'scope',
hideable: false,
width: 140,
renderer: function(value) {
return scopes[value] ?? "unknown scope";
},
},
{
header: gettext('Comment'),
dataIndex: 'comment',
renderer: Ext.String.htmlEncode,
minWidth: 60,
flex: 1,
},
);
Ext.apply(me, {
store: store,
listConfig: {
columns: columns,
width: 500,
},
});
me.on('beforequery', function(queryPlan) {
return !(queryPlan.query === null || queryPlan.query.match(/^\d/));
});
me.callParent();
},
});
Ext.define('PVE.form.MDevSelector', {
extend: 'Proxmox.form.ComboGrid',
xtype: 'pveMDevSelector',
store: {
fields: ['type', 'available', 'description'],
filterOnLoad: true,
sorters: [
{
property: 'type',
direction: 'ASC',
},
],
},
autoSelect: false,
valueField: 'type',
displayField: 'type',
listConfig: {
width: 550,
columns: [
{
header: gettext('Type'),
dataIndex: 'type',
renderer: function(value, md, rec) {
if (rec.data.name !== undefined) {
return `${rec.data.name} (${value})`;
}
return value;
},
flex: 1,
},
{
header: gettext('Avail'),
dataIndex: 'available',
width: 60,
},
{
header: gettext('Description'),
dataIndex: 'description',
flex: 1,
cellWrap: true,
renderer: function(value) {
if (!value) {
return '';
}
return value.split('\n').join('<br>');
},
},
],
},
setPciIdOrMapping: function(pciIdOrMapping, force) {
var me = this;
if (!force && (!pciIdOrMapping || me.pciIdOrMapping === pciIdOrMapping)) {
return;
}
me.pciIdOrMapping = pciIdOrMapping;
me.updateProxy();
},
setNodename: function(nodename) {
var me = this;
if (!nodename || me.nodename === nodename) {
return;
}
me.nodename = nodename;
me.updateProxy();
},
updateProxy: function() {
var me = this;
me.store.setProxy({
type: 'proxmox',
url: `/api2/json/nodes/${me.nodename}/hardware/pci/${me.pciIdOrMapping}/mdev`,
});
me.store.load();
},
initComponent: function() {
var me = this;
if (!me.nodename) {
throw 'no node name specified';
}
me.callParent();
if (me.pciIdOrMapping) {
me.setPciIdOrMapping(me.pciIdOrMapping, true);
}
},
});
Ext.define('PVE.form.MemoryField', {
extend: 'Ext.form.field.Number',
alias: 'widget.pveMemoryField',
allowBlank: false,
hotplug: false,
minValue: 32,
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;
var current_size = min_size;
var value_up = min_size;
var value_down = min_size;
var value_start = min_size;
var i, j;
for (j = 0; j < 9; j++) {
for (i = 0; i < 32; i++) {
if (value >= current_size && value < current_size + dimm_size) {
value_start = current_size;
value_up = current_size + dimm_size;
value_down = current_size - (i === 0 ? prev_dimm_size : dimm_size);
}
current_size += dimm_size;
}
prev_dimm_size = dimm_size;
dimm_size = dimm_size*2;
}
return { up: value_up, down: value_down, start: value_start };
},
onSpinUp: function() {
var me = this;
if (!me.readOnly) {
var res = me.computeUpDown(me.getValue());
me.setValue(Ext.Number.constrain(res.up, me.minValue, me.maxValue));
}
},
onSpinDown: function() {
var me = this;
if (!me.readOnly) {
var res = me.computeUpDown(me.getValue());
me.setValue(Ext.Number.constrain(res.down, me.minValue, me.maxValue));
}
},
initComponent: function() {
var me = this;
if (me.hotplug) {
me.minValue = 1024;
me.on('blur', function(field) {
var value = me.getValue();
var res = me.computeUpDown(value);
if (value === res.start || value === res.up || value === res.down) {
return;
}
field.setValue(res.up);
});
}
me.callParent();
},
});
Ext.define('PVE.form.MultiPCISelector', {
extend: 'Ext.grid.Panel',
alias: 'widget.pveMultiPCISelector',
emptyText: gettext('No Devices found'),
mixins: {
field: 'Ext.form.field.Field',
},
// will be called after loading finished
onLoadCallBack: Ext.emptyFn,
getValue: function() {
let me = this;
return me.value ?? [];
},
getSubmitData: function() {
let me = this;
let res = {};
res[me.name] = me.getValue();
return res;
},
setValue: function(value) {
let me = this;
value ??= [];
me.updateSelectedDevices(value);
return me.mixins.field.setValue.call(me, value);
},
getErrors: function() {
let me = this;
let errorCls = ['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid'];
if (me.getValue().length < 1) {
let error = gettext("Must choose at least one device");
me.addCls(errorCls);
me.getActionEl()?.dom.setAttribute('data-errorqtip', error);
return [error];
}
me.removeCls(errorCls);
me.getActionEl()?.dom.setAttribute('data-errorqtip', "");
return [];
},
viewConfig: {
getRowClass: function(record) {
if (record.data.disabled === true) {
return 'x-item-disabled';
}
return '';
},
},
updateSelectedDevices: function(value = []) {
let me = this;
let recs = [];
let store = me.getStore();
for (const map of value) {
let parsed = PVE.Parser.parsePropertyString(map);
if (parsed.node !== me.nodename) {
continue;
}
let rec = store.getById(parsed.path);
if (rec) {
recs.push(rec);
}
}
me.suspendEvent('change');
me.setSelection();
me.setSelection(recs);
me.resumeEvent('change');
},
setNodename: function(nodename) {
let me = this;
if (!nodename || me.nodename === nodename) {
return;
}
me.nodename = nodename;
me.getStore().setProxy({
type: 'proxmox',
url: '/api2/json/nodes/' + me.nodename + '/hardware/pci?pci-class-blacklist=',
});
me.setSelection();
me.getStore().load({
callback: (recs, op, success) => me.addSlotRecords(recs, op, success),
});
},
setMdev: function(mdev) {
let me = this;
if (mdev) {
me.getStore().addFilter({
id: 'mdev-filter',
property: 'mdev',
value: '1',
operator: '=',
});
} else {
me.getStore().removeFilter('mdev-filter');
}
me.setSelection();
},
// adds the virtual 'slot' records (e.g. '0000:01:00') to the store
addSlotRecords: function(records, _op, success) {
let me = this;
if (!success) {
return;
}
let slots = {};
records.forEach((rec) => {
let slotname = rec.data.id.slice(0, -2); // remove function
if (slots[slotname] !== undefined) {
slots[slotname].count++;
rec.set('slot', slots[slotname]);
return;
}
slots[slotname] = {
count: 1,
};
rec.set('slot', slots[slotname]);
if (rec.data.id.endsWith('.0')) {
slots[slotname].device = rec.data;
}
});
let store = me.getStore();
for (const [slot, { count, device }] of Object.entries(slots)) {
if (count === 1) {
continue;
}
store.add(Ext.apply({}, {
id: slot,
mdev: undefined,
device_name: gettext('Pass through all functions as one device'),
}, device));
}
me.updateSelectedDevices(me.value);
},
selectionChange: function(_grid, selection) {
let me = this;
let ids = {};
selection
.filter(rec => rec.data.id.indexOf('.') === -1)
.forEach((rec) => { ids[rec.data.id] = true; });
let to_disable = [];
me.getStore().each(rec => {
let id = rec.data.id;
rec.set('disabled', false);
if (id.indexOf('.') === -1) {
return;
}
let slot = id.slice(0, -2); // remove function
if (ids[slot]) {
to_disable.push(rec);
rec.set('disabled', true);
}
});
me.suspendEvent('selectionchange');
me.getSelectionModel().deselect(to_disable);
me.resumeEvent('selectionchange');
me.value = me.getSelection().map((rec) => {
let res = {
path: rec.data.id,
node: me.nodename,
id: `${rec.data.vendor}:${rec.data.device}`.replace(/0x/g, ''),
'subsystem-id': `${rec.data.subsystem_vendor}:${rec.data.subsystem_device}`.replace(/0x/g, ''),
};
if (rec.data.iommugroup !== -1) {
res.iommugroup = rec.data.iommugroup;
}
return PVE.Parser.printPropertyString(res);
});
me.checkChange();
},
selModel: {
type: 'checkboxmodel',
mode: 'SIMPLE',
},
columns: [
{
header: 'ID',
dataIndex: 'id',
renderer: function(value, _md, rec) {
if (value.match(/\.[0-9a-f]/i) && rec.data.slot?.count > 1) {
return `&emsp;${value}`;
}
return value;
},
width: 150,
},
{
header: gettext('IOMMU Group'),
dataIndex: 'iommugroup',
renderer: (v, _md, rec) => rec.data.slot === rec.data.id ? '' : v === -1 ? '-' : v,
width: 50,
},
{
header: gettext('Vendor'),
dataIndex: 'vendor_name',
flex: 3,
},
{
header: gettext('Device'),
dataIndex: 'device_name',
flex: 6,
},
{
header: gettext('Mediated Devices'),
dataIndex: 'mdev',
flex: 1,
renderer: function(val) {
return Proxmox.Utils.format_boolean(!!val);
},
},
],
listeners: {
selectionchange: function() {
this.selectionChange(...arguments);
},
},
store: {
fields: [
'id', 'vendor_name', 'device_name', 'vendor', 'device', 'iommugroup', 'mdev',
'subsystem_vendor', 'subsystem_device', 'disabled',
{
name: 'subsystem-vendor',
calculate: function(data) {
return data.subsystem_vendor;
},
},
{
name: 'subsystem-device',
calculate: function(data) {
return data.subsystem_device;
},
},
],
sorters: [
{
property: 'id',
direction: 'ASC',
},
],
},
initComponent: function() {
let me = this;
let nodename = me.nodename;
me.nodename = undefined;
me.callParent();
me.mon(me.getStore(), 'load', me.onLoadCallBack);
Proxmox.Utils.monStoreErrors(me, me.getStore(), true);
me.setNodename(nodename);
me.initField();
},
});
Ext.define('PVE.form.NetworkCardSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: 'widget.pveNetworkCardSelector',
comboItems: [
['e1000', 'Intel E1000'],
['e1000e', 'Intel E1000E'],
['virtio', 'VirtIO (' + gettext('paravirtualized') + ')'],
['rtl8139', 'Realtek RTL8139'],
['vmxnet3', 'VMware vmxnet3'],
],
});
Ext.define('PVE.form.NodeSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.pveNodeSelector'],
// invalidate nodes which are offline
onlineValidator: false,
selectCurNode: false,
// do not allow those nodes (array)
disallowedNodes: undefined,
// only allow those nodes (array)
allowedNodes: undefined,
valueField: 'node',
displayField: 'node',
store: {
fields: ['node', 'cpu', 'maxcpu', 'mem', 'maxmem', 'uptime'],
proxy: {
type: 'proxmox',
url: '/api2/json/nodes',
},
sorters: [
{
property: 'node',
direction: 'ASC',
},
{
property: 'mem',
direction: 'DESC',
},
],
},
listConfig: {
columns: [
{
header: gettext('Node'),
dataIndex: 'node',
sortable: true,
hideable: false,
flex: 1,
},
{
header: gettext('Memory usage') + " %",
renderer: PVE.Utils.render_mem_usage_percent,
sortable: true,
width: 100,
dataIndex: 'mem',
},
{
header: gettext('CPU usage'),
renderer: Proxmox.Utils.render_cpu,
sortable: true,
width: 100,
dataIndex: 'cpu',
},
],
},
validator: function(value) {
let me = this;
if (!me.onlineValidator || (me.allowBlank && !value)) {
return true;
}
let offline = [], notAllowed = [];
Ext.Array.each(value.split(/\s*,\s*/), function(node) {
let rec = me.store.findRecord(me.valueField, node, 0, false, true, true);
if (!(rec && rec.data) || rec.data.status !== 'online') {
offline.push(node);
} else if (me.allowedNodes && !Ext.Array.contains(me.allowedNodes, node)) {
notAllowed.push(node);
}
});
if (value && notAllowed.length !== 0) {
return "Node " + notAllowed.join(', ') + " is not allowed for this action!";
}
if (value && offline.length !== 0) {
return "Node " + offline.join(', ') + " seems to be offline!";
}
return true;
},
initComponent: function() {
var me = this;
if (me.selectCurNode && PVE.curSelectedNode && PVE.curSelectedNode.data.node) {
me.preferredValue = PVE.curSelectedNode.data.node;
}
me.callParent();
me.getStore().load();
me.getStore().addFilter(new Ext.util.Filter({ // filter out disallowed nodes
filterFn: (item) => !(me.disallowedNodes && me.disallowedNodes.includes(item.data.node)),
}));
me.mon(me.getStore(), 'load', () => me.isValid());
},
});
Ext.define('PVE.form.NotificationModeSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: ['widget.pveNotificationModeSelector'],
comboItems: [
['notification-target', gettext('Target')],
['mailto', gettext('E-Mail')],
],
});
Ext.define('PVE.form.NotificationTargetSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.pveNotificationTargetSelector'],
// set default value to empty array, else it inits it with
// null and after the store load it is an empty array,
// triggering dirtychange
value: [],
valueField: 'name',
displayField: 'name',
deleteEmpty: true,
skipEmptyText: true,
store: {
fields: ['name', 'type', 'comment'],
proxy: {
type: 'proxmox',
url: '/api2/json/cluster/notifications/targets',
},
sorters: [
{
property: 'name',
direction: 'ASC',
},
],
autoLoad: true,
},
listConfig: {
columns: [
{
header: gettext('Target'),
dataIndex: 'name',
sortable: true,
hideable: false,
flex: 1,
},
{
header: gettext('Type'),
dataIndex: 'type',
sortable: true,
hideable: false,
flex: 1,
},
{
header: gettext('Comment'),
dataIndex: 'comment',
sortable: true,
hideable: false,
flex: 2,
},
],
},
});
Ext.define('PVE.form.EmailNotificationSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: ['widget.pveEmailNotificationSelector'],
comboItems: [
['always', gettext('Always')],
['failure', gettext('On failure only')],
],
});
Ext.define('PVE.form.PCISelector', {
extend: 'Proxmox.form.ComboGrid',
xtype: 'pvePCISelector',
store: {
fields: ['id', 'vendor_name', 'device_name', 'vendor', 'device', 'iommugroup', 'mdev'],
filterOnLoad: true,
sorters: [
{
property: 'id',
direction: 'ASC',
},
],
},
autoSelect: false,
valueField: 'id',
displayField: 'id',
// can contain a load callback for the store
// useful to determine the state of the IOMMU
onLoadCallBack: undefined,
listConfig: {
minHeight: 80,
width: 800,
columns: [
{
header: 'ID',
dataIndex: 'id',
width: 100,
},
{
header: gettext('IOMMU Group'),
dataIndex: 'iommugroup',
renderer: v => v === -1 ? '-' : v,
width: 75,
},
{
header: gettext('Vendor'),
dataIndex: 'vendor_name',
flex: 2,
},
{
header: gettext('Device'),
dataIndex: 'device_name',
flex: 6,
},
{
header: gettext('Mediated Devices'),
dataIndex: 'mdev',
flex: 1,
renderer: function(val) {
return Proxmox.Utils.format_boolean(!!val);
},
},
],
},
setNodename: function(nodename) {
var me = this;
if (!nodename || me.nodename === nodename) {
return;
}
me.nodename = nodename;
me.store.setProxy({
type: 'proxmox',
url: '/api2/json/nodes/' + me.nodename + '/hardware/pci',
});
me.store.load();
},
initComponent: function() {
var me = this;
var nodename = me.nodename;
me.nodename = undefined;
me.callParent();
if (me.onLoadCallBack !== undefined) {
me.mon(me.getStore(), 'load', me.onLoadCallBack);
}
me.setNodename(nodename);
},
});
Ext.define('pve-mapped-pci-model', {
extend: 'Ext.data.Model',
fields: ['id', 'path', 'vendor', 'device', 'iommugroup', 'mdev', 'description', 'map'],
idProperty: 'id',
});
Ext.define('PVE.form.PCIMapSelector', {
extend: 'Proxmox.form.ComboGrid',
xtype: 'pvePCIMapSelector',
store: {
model: 'pve-mapped-pci-model',
filterOnLoad: true,
sorters: [
{
property: 'id',
direction: 'ASC',
},
],
},
autoSelect: false,
valueField: 'id',
displayField: 'id',
// can contain a load callback for the store
// useful to determine the state of the IOMMU
onLoadCallBack: undefined,
listConfig: {
width: 800,
columns: [
{
header: gettext('ID'),
dataIndex: 'id',
flex: 1,
},
{
header: gettext('Description'),
dataIndex: 'description',
flex: 1,
renderer: Ext.String.htmlEncode,
},
{
header: gettext('Status'),
dataIndex: 'checks',
renderer: function(value) {
let me = this;
if (!Ext.isArray(value) || !value?.length) {
return `<i class="fa fa-check-circle good"></i> ${gettext('Mapping matches host data')}`;
}
let checks = [];
value.forEach((check) => {
let iconCls;
switch (check?.severity) {
case 'warning':
iconCls = 'fa-exclamation-circle warning';
break;
case 'error':
iconCls = 'fa-times-circle critical';
break;
}
let message = check?.message;
let icon = `<i class="fa ${iconCls}"></i>`;
if (iconCls !== undefined) {
checks.push(`${icon} ${message}`);
}
});
return checks.join('<br>');
},
flex: 3,
},
],
},
setNodename: function(nodename) {
var me = this;
if (!nodename || me.nodename === nodename) {
return;
}
me.nodename = nodename;
me.store.setProxy({
type: 'proxmox',
url: `/api2/json/cluster/mapping/pci?check-node=${nodename}`,
});
me.store.load();
},
initComponent: function() {
var me = this;
var nodename = me.nodename;
me.nodename = undefined;
me.callParent();
if (me.onLoadCallBack !== undefined) {
me.mon(me.getStore(), 'load', me.onLoadCallBack);
}
me.setNodename(nodename);
},
});
Ext.define('PVE.form.PermPathSelector', {
extend: 'Ext.form.field.ComboBox',
xtype: 'pvePermPathSelector',
valueField: 'value',
displayField: 'value',
typeAhead: true,
queryMode: 'local',
width: 380,
store: {
type: 'pvePermPath',
},
});
Ext.define('PVE.form.PoolSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.pvePoolSelector'],
allowBlank: false,
valueField: 'poolid',
displayField: 'poolid',
initComponent: function() {
var me = this;
var store = new Ext.data.Store({
model: 'pve-pools',
sorters: 'poolid',
});
Ext.apply(me, {
store: store,
autoSelect: false,
listConfig: {
columns: [
{
header: gettext('Pool'),
sortable: true,
dataIndex: 'poolid',
flex: 1,
},
{
header: gettext('Comment'),
sortable: false,
dataIndex: 'comment',
renderer: Ext.String.htmlEncode,
flex: 1,
},
],
},
});
me.callParent();
store.load();
},
}, function() {
Ext.define('pve-pools', {
extend: 'Ext.data.Model',
fields: ['poolid', 'comment'],
proxy: {
type: 'proxmox',
url: "/api2/json/pools",
},
idProperty: 'poolid',
});
});
Ext.define('PVE.form.preallocationSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: ['widget.pvePreallocationSelector'],
comboItems: [
['__default__', Proxmox.Utils.defaultText],
['off', 'Off'],
['metadata', 'Metadata'],
['falloc', 'Full (posix_fallocate)'],
['full', 'Full'],
],
});
Ext.define('PVE.form.PrivilegesSelector', {
extend: 'Proxmox.form.KVComboBox',
xtype: 'pvePrivilegesSelector',
multiSelect: true,
initComponent: function() {
let me = this;
me.callParent();
Proxmox.Utils.API2Request({
url: '/access/roles/Administrator',
method: 'GET',
success: function(response, options) {
let data = Object.keys(response.result.data).map(key => [key, key]);
me.store.setData(data);
me.store.sort({
property: 'key',
direction: 'ASC',
});
},
failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
},
});
Ext.define('PVE.form.QemuBiosSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: ['widget.pveQemuBiosSelector'],
initComponent: function() {
var me = this;
me.comboItems = [
['__default__', PVE.Utils.render_qemu_bios('')],
['seabios', PVE.Utils.render_qemu_bios('seabios')],
['ovmf', PVE.Utils.render_qemu_bios('ovmf')],
];
me.callParent();
},
});
Ext.define('PVE.form.SDNControllerSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.pveSDNControllerSelector'],
allowBlank: false,
valueField: 'controller',
displayField: 'controller',
initComponent: function() {
var me = this;
var store = new Ext.data.Store({
model: 'pve-sdn-controller',
sorters: {
property: 'controller',
direction: 'ASC',
},
});
Ext.apply(me, {
store: store,
autoSelect: false,
listConfig: {
columns: [
{
header: gettext('Controller'),
sortable: true,
dataIndex: 'controller',
flex: 1,
},
],
},
});
me.callParent();
store.load();
},
}, function() {
Ext.define('pve-sdn-controller', {
extend: 'Ext.data.Model',
fields: ['controller'],
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/sdn/controllers",
},
idProperty: 'controller',
});
});
Ext.define('PVE.form.SDNZoneSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.pveSDNZoneSelector'],
allowBlank: false,
valueField: 'zone',
displayField: 'zone',
initComponent: function() {
var me = this;
var store = new Ext.data.Store({
model: 'pve-sdn-zone',
sorters: {
property: 'zone',
direction: 'ASC',
},
});
Ext.apply(me, {
store: store,
autoSelect: false,
listConfig: {
columns: [
{
header: gettext('Zone'),
sortable: true,
dataIndex: 'zone',
flex: 1,
},
],
},
});
me.callParent();
store.load();
},
}, function() {
Ext.define('pve-sdn-zone', {
extend: 'Ext.data.Model',
fields: ['zone', 'type'],
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/sdn/zones",
},
idProperty: 'zone',
});
});
Ext.define('PVE.form.SDNVnetSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.pveSDNVnetSelector'],
allowBlank: false,
valueField: 'vnet',
displayField: 'vnet',
initComponent: function() {
var me = this;
var store = new Ext.data.Store({
model: 'pve-sdn-vnet',
sorters: {
property: 'vnet',
direction: 'ASC',
},
});
Ext.apply(me, {
store: store,
autoSelect: false,
listConfig: {
columns: [
{
header: gettext('VNet'),
sortable: true,
dataIndex: 'vnet',
flex: 1,
},
{
header: gettext('Alias'),
flex: 1,
dataIndex: 'alias',
},
{
header: gettext('Tag'),
flex: 1,
dataIndex: 'tag',
},
],
},
});
me.callParent();
store.load();
},
}, function() {
Ext.define('pve-sdn-vnet', {
extend: 'Ext.data.Model',
fields: [
'alias',
'tag',
'type',
'vnet',
'zone',
],
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/sdn/vnets",
},
idProperty: 'vnet',
});
});
Ext.define('PVE.form.SDNIpamSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.pveSDNIpamSelector'],
allowBlank: false,
valueField: 'ipam',
displayField: 'ipam',
initComponent: function() {
var me = this;
var store = new Ext.data.Store({
model: 'pve-sdn-ipam',
sorters: {
property: 'ipam',
direction: 'ASC',
},
});
Ext.apply(me, {
store: store,
autoSelect: false,
listConfig: {
columns: [
{
header: gettext('Ipam'),
sortable: true,
dataIndex: 'ipam',
flex: 1,
},
],
},
});
me.callParent();
store.load();
},
}, function() {
Ext.define('pve-sdn-ipam', {
extend: 'Ext.data.Model',
fields: ['ipam'],
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/sdn/ipams",
},
idProperty: 'ipam',
});
});
Ext.define('PVE.form.SDNDnsSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.pveSDNDnsSelector'],
allowBlank: false,
valueField: 'dns',
displayField: 'dns',
initComponent: function() {
var me = this;
var store = new Ext.data.Store({
model: 'pve-sdn-dns',
sorters: {
property: 'dns',
direction: 'ASC',
},
});
Ext.apply(me, {
store: store,
autoSelect: false,
listConfig: {
columns: [
{
header: gettext('dns'),
sortable: true,
dataIndex: 'dns',
flex: 1,
},
],
},
});
me.callParent();
store.load();
},
}, function() {
Ext.define('pve-sdn-dns', {
extend: 'Ext.data.Model',
fields: ['dns'],
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/sdn/dns",
},
idProperty: 'dns',
});
});
Ext.define('PVE.form.ScsiHwSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: ['widget.pveScsiHwSelector'],
comboItems: [
['__default__', PVE.Utils.render_scsihw('')],
['lsi', PVE.Utils.render_scsihw('lsi')],
['lsi53c810', PVE.Utils.render_scsihw('lsi53c810')],
['megasas', PVE.Utils.render_scsihw('megasas')],
['virtio-scsi-pci', PVE.Utils.render_scsihw('virtio-scsi-pci')],
['virtio-scsi-single', PVE.Utils.render_scsihw('virtio-scsi-single')],
['pvscsi', PVE.Utils.render_scsihw('pvscsi')],
],
});
Ext.define('PVE.form.SecurityGroupsSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.pveSecurityGroupsSelector'],
valueField: 'group',
displayField: 'group',
initComponent: function() {
var me = this;
var store = Ext.create('Ext.data.Store', {
autoLoad: true,
fields: ['group', 'comment'],
idProperty: 'group',
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/firewall/groups",
},
sorters: {
property: 'group',
direction: 'ASC',
},
});
Ext.apply(me, {
store: store,
listConfig: {
columns: [
{
header: gettext('Security Group'),
dataIndex: 'group',
hideable: false,
width: 100,
},
{
header: gettext('Comment'),
dataIndex: 'comment',
renderer: function(value, metaData) {
let comment = Ext.String.htmlEncode(value) || '';
if (comment.length * 12 > metaData.column.cellWidth) {
let qtip = Ext.htmlEncode(comment);
comment = `<span data-qtip="${qtip}">${comment}</span>`;
}
return comment;
},
flex: 1,
},
],
},
});
me.callParent();
},
});
Ext.define('PVE.form.SnapshotSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.PVE.form.SnapshotSelector'],
valueField: 'name',
displayField: 'name',
loadStore: function(nodename, vmid) {
var me = this;
if (!nodename) {
return;
}
me.nodename = nodename;
if (!vmid) {
return;
}
me.vmid = vmid;
me.store.setProxy({
type: 'proxmox',
url: '/api2/json/nodes/' + me.nodename + '/' + me.guestType + '/' + me.vmid +'/snapshot',
});
me.store.load();
},
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (!me.vmid) {
throw "no VM ID specified";
}
if (!me.guestType) {
throw "no guest type specified";
}
var store = Ext.create('Ext.data.Store', {
fields: ['name'],
filterOnLoad: true,
});
Ext.apply(me, {
store: store,
listConfig: {
columns: [
{
header: gettext('Snapshot'),
dataIndex: 'name',
hideable: false,
flex: 1,
},
],
},
});
me.callParent();
me.loadStore(me.nodename, me.vmid);
},
});
Ext.define('PVE.form.SpiceEnhancementSelector', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pveSpiceEnhancementSelector',
viewModel: {},
items: [
{
xtype: 'proxmoxcheckbox',
itemId: 'foldersharing',
name: 'foldersharing',
reference: 'foldersharing',
fieldLabel: 'Folder Sharing',
uncheckedValue: 0,
},
{
xtype: 'proxmoxKVComboBox',
itemId: 'videostreaming',
name: 'videostreaming',
value: 'off',
fieldLabel: 'Video Streaming',
comboItems: [
['off', 'off'],
['all', 'all'],
['filter', 'filter'],
],
},
{
xtype: 'displayfield',
itemId: 'spicehint',
userCls: 'pmx-hint',
value: gettext('To use these features set the display to SPICE in the hardware settings of the VM.'),
hidden: true,
},
{
xtype: 'displayfield',
itemId: 'spicefolderhint',
userCls: 'pmx-hint',
value: gettext('Make sure the SPICE WebDav daemon is installed in the VM.'),
bind: {
hidden: '{!foldersharing.checked}',
},
},
],
onGetValues: function(values) {
var ret = {};
if (values.videostreaming !== "off") {
ret.videostreaming = values.videostreaming;
}
if (values.foldersharing) {
ret.foldersharing = 1;
}
if (Ext.Object.isEmpty(ret)) {
return { 'delete': 'spice_enhancements' };
}
var enhancements = PVE.Parser.printPropertyString(ret);
return { spice_enhancements: enhancements };
},
setValues: function(values) {
var vga = PVE.Parser.parsePropertyString(values.vga, 'type');
if (!/^qxl\d?$/.test(vga.type)) {
this.down('#spicehint').setVisible(true);
}
if (values.spice_enhancements) {
var enhancements = PVE.Parser.parsePropertyString(values.spice_enhancements);
enhancements.foldersharing = PVE.Parser.parseBoolean(enhancements.foldersharing, 0);
this.callParent([enhancements]);
}
},
});
Ext.define('PVE.form.StorageScanNodeSelector', {
extend: 'PVE.form.NodeSelector',
xtype: 'pveStorageScanNodeSelector',
name: 'storageScanNode',
itemId: 'pveStorageScanNodeSelector',
fieldLabel: gettext('Scan node'),
allowBlank: true,
disallowedNodes: undefined,
autoSelect: false,
submitValue: false,
value: null,
autoEl: {
tag: 'div',
'data-qtip': gettext('Scan for available storages on the selected node'),
},
triggers: {
clear: {
handler: function() {
let me = this;
me.setValue(null);
},
},
},
emptyText: Proxmox.NodeName,
setValue: function(value) {
let me = this;
me.callParent([value]);
me.triggers.clear.setVisible(!!value);
},
});
Ext.define('PVE.form.StorageSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: 'widget.pveStorageSelector',
mixins: ['Proxmox.Mixin.CBind'],
cbindData: {
clusterView: false,
},
allowBlank: false,
valueField: 'storage',
displayField: 'storage',
listConfig: {
cbind: {
clusterView: '{clusterView}',
},
width: 450,
columns: [
{
header: gettext('Name'),
dataIndex: 'storage',
hideable: false,
flex: 1,
},
{
header: gettext('Type'),
width: 75,
dataIndex: 'type',
},
{
header: gettext('Avail'),
width: 90,
dataIndex: 'avail',
renderer: Proxmox.Utils.format_size,
cbind: {
hidden: '{clusterView}',
},
},
{
header: gettext('Capacity'),
width: 90,
dataIndex: 'total',
renderer: Proxmox.Utils.format_size,
cbind: {
hidden: '{clusterView}',
},
},
{
header: gettext('Nodes'),
width: 120,
dataIndex: 'nodes',
renderer: (value) => value ? value : '-- ' + gettext('All') + ' --',
cbind: {
hidden: '{!clusterView}',
},
},
{
header: gettext('Shared'),
width: 70,
dataIndex: 'shared',
renderer: Proxmox.Utils.format_boolean,
cbind: {
hidden: '{!clusterView}',
},
},
],
},
reloadStorageList: function() {
let me = this;
if (me.clusterView) {
me.getStore().setProxy({
type: 'proxmox',
url: `/api2/json/storage`,
});
// filter here, back-end does not support it currently
let filters = [(storage) => !storage.data.disable];
if (me.storageContent) {
filters.push(
(storage) => storage.data.content.split(',').includes(me.storageContent),
);
}
if (me.nodename) {
filters.push(
(storage) => !storage.data.nodes || storage.data.nodes.includes(me.nodename),
);
}
me.getStore().clearFilter();
me.getStore().setFilters(filters);
} else {
if (!me.nodename) {
return;
}
let params = {
format: 1,
};
if (me.storageContent) {
params.content = me.storageContent;
}
if (me.targetNode) {
params.target = me.targetNode;
params.enabled = 1; // skip disabled storages
}
me.store.setProxy({
type: 'proxmox',
url: `/api2/json/nodes/${me.nodename}/storage`,
extraParams: params,
});
}
me.store.load(() => me.validate());
},
setTargetNode: function(targetNode) {
var me = this;
if (!targetNode || me.targetNode === targetNode) {
return;
}
if (me.clusterView) {
throw "setting targetNode with clusterView is not implemented";
}
me.targetNode = targetNode;
me.reloadStorageList();
},
setNodename: function(nodename) {
var me = this;
nodename = nodename || '';
if (me.nodename === nodename) {
return;
}
me.nodename = nodename;
me.reloadStorageList();
},
initComponent: function() {
var me = this;
let nodename = me.nodename;
me.nodename = undefined;
var store = Ext.create('Ext.data.Store', {
model: 'pve-storage-status',
sorters: {
property: 'storage',
direction: 'ASC',
},
});
Ext.apply(me, {
store: store,
});
me.callParent();
me.setNodename(nodename);
},
}, function() {
Ext.define('pve-storage-status', {
extend: 'Ext.data.Model',
fields: ['storage', 'active', 'type', 'avail', 'total', 'nodes', 'shared'],
idProperty: 'storage',
});
});
Ext.define('PVE.form.TFASelector', {
extend: 'Ext.container.Container',
xtype: 'pveTFASelector',
mixins: ['Proxmox.Mixin.CBind'],
deleteEmpty: true,
viewModel: {
data: {
type: '__default__',
step: null,
digits: null,
id: null,
key: null,
url: null,
},
formulas: {
isOath: (get) => get('type') === 'oath',
isYubico: (get) => get('type') === 'yubico',
tfavalue: {
get: function(get) {
let val = {
type: get('type'),
};
if (get('isOath')) {
let step = get('step');
let digits = get('digits');
if (step) {
val.step = step;
}
if (digits) {
val.digits = digits;
}
} else if (get('isYubico')) {
let id = get('id');
let key = get('key');
let url = get('url');
val.id = id;
val.key = key;
if (url) {
val.url = url;
}
} else if (val.type === '__default__') {
return "";
}
return PVE.Parser.printPropertyString(val);
},
set: function(value) {
let val = PVE.Parser.parseTfaConfig(value);
this.set(val);
this.notify();
// we need to reset the original values, so that
// we can reliably track the state of the form
let form = this.getView().up('form');
if (form.trackResetOnLoad) {
let fields = this.getView().query('field[name!="tfa"]');
fields.forEach((field) => field.resetOriginalValue());
}
},
},
},
},
items: [
{
xtype: 'proxmoxtextfield',
name: 'tfa',
hidden: true,
submitValue: true,
cbind: {
deleteEmpty: '{deleteEmpty}',
},
bind: {
value: "{tfavalue}",
},
},
{
xtype: 'proxmoxKVComboBox',
value: '__default__',
deleteEmpty: false,
submitValue: false,
fieldLabel: gettext('Require TFA'),
comboItems: [
['__default__', Proxmox.Utils.noneText],
['oath', 'OATH/TOTP'],
['yubico', 'Yubico'],
],
bind: {
value: "{type}",
},
},
{
xtype: 'proxmoxintegerfield',
hidden: true,
minValue: 10,
submitValue: false,
emptyText: Proxmox.Utils.defaultText + ' (30)',
fieldLabel: gettext('Time Step'),
bind: {
value: "{step}",
hidden: "{!isOath}",
disabled: "{!isOath}",
},
},
{
xtype: 'proxmoxintegerfield',
hidden: true,
submitValue: false,
fieldLabel: gettext('Secret Length'),
minValue: 6,
maxValue: 8,
emptyText: Proxmox.Utils.defaultText + ' (6)',
bind: {
value: "{digits}",
hidden: "{!isOath}",
disabled: "{!isOath}",
},
},
{
xtype: 'textfield',
hidden: true,
submitValue: false,
allowBlank: false,
fieldLabel: 'Yubico API Id',
bind: {
value: "{id}",
hidden: "{!isYubico}",
disabled: "{!isYubico}",
},
},
{
xtype: 'textfield',
hidden: true,
submitValue: false,
allowBlank: false,
fieldLabel: 'Yubico API Key',
bind: {
value: "{key}",
hidden: "{!isYubico}",
disabled: "{!isYubico}",
},
},
{
xtype: 'textfield',
hidden: true,
submitValue: false,
fieldLabel: 'Yubico URL',
bind: {
value: "{url}",
hidden: "{!isYubico}",
disabled: "{!isYubico}",
},
},
],
});
Ext.define('PVE.form.TokenSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.pveTokenSelector'],
allowBlank: false,
autoSelect: false,
displayField: 'id',
editable: true,
anyMatch: true,
forceSelection: true,
store: {
model: 'pve-tokens',
autoLoad: true,
proxy: {
type: 'proxmox',
url: 'api2/json/access/users',
extraParams: { 'full': 1 },
},
sorters: 'id',
listeners: {
load: function(store, records, success) {
let tokens = [];
for (const { data: user } of records) {
if (!user.tokens || user.tokens.length === 0) {
continue;
}
for (const token of user.tokens) {
tokens.push({
id: `${user.userid}!${token.tokenid}`,
comment: token.comment,
});
}
}
store.loadData(tokens);
},
},
},
listConfig: {
columns: [
{
header: gettext('API Token'),
sortable: true,
dataIndex: 'id',
renderer: Ext.String.htmlEncode,
flex: 1,
},
{
header: gettext('Comment'),
sortable: false,
dataIndex: 'comment',
renderer: Ext.String.htmlEncode,
flex: 1,
},
],
},
}, function() {
Ext.define('pve-tokens', {
extend: 'Ext.data.Model',
fields: [
'id', 'userid', 'tokenid', 'comment',
{ type: 'boolean', name: 'privsep' },
{ type: 'date', dateFormat: 'timestamp', name: 'expire' },
],
idProperty: 'id',
});
});
Ext.define('PVE.form.USBSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.pveUSBSelector'],
allowBlank: false,
autoSelect: false,
anyMatch: true,
displayField: 'product_and_id',
valueField: 'usbid',
editable: true,
validator: function(value) {
var me = this;
if (!value) {
return true; // handled later by allowEmpty in the getErrors call chain
}
value = me.getValue(); // as the valueField is not the displayfield
if (me.type === 'device') {
return (/^[a-f0-9]{4}:[a-f0-9]{4}$/i).test(value);
} else if (me.type === 'port') {
return (/^[0-9]+-[0-9]+(\.[0-9]+)*$/).test(value);
}
return gettext("Invalid Value");
},
setNodename: function(nodename) {
var me = this;
if (!nodename || me.nodename === nodename) {
return;
}
me.nodename = nodename;
me.store.setProxy({
type: 'proxmox',
url: `/api2/json/nodes/${me.nodename}/hardware/usb`,
});
me.store.load();
},
initComponent: function() {
var me = this;
if (me.pveSelNode) {
me.nodename = me.pveSelNode.data.node;
}
var nodename = me.nodename;
me.nodename = undefined;
if (me.type !== 'device' && me.type !== 'port') {
throw "no valid type specified";
}
let store = new Ext.data.Store({
model: `pve-usb-${me.type}`,
filters: [
({ data }) => !!data.usbpath && !!data.prodid && String(data.class) !== "9",
],
});
let emptyText = '';
if (me.type === 'device') {
emptyText = gettext('Passthrough a specific device');
} else {
emptyText = gettext('Passthrough a full port');
}
Ext.apply(me, {
store: store,
emptyText: emptyText,
listConfig: {
minHeight: 80,
width: 520,
columns: [
{
header: me.type === 'device'?gettext('Device'):gettext('Port'),
sortable: true,
dataIndex: 'usbid',
width: 80,
},
{
header: gettext('Manufacturer'),
sortable: true,
dataIndex: 'manufacturer',
width: 150,
},
{
header: gettext('Product'),
sortable: true,
dataIndex: 'product',
flex: 1,
},
{
header: gettext('Speed'),
width: 75,
sortable: true,
dataIndex: 'speed',
renderer: function(value) {
let speed2Class = {
"10000": "USB 3.1",
"5000": "USB 3.0",
"480": "USB 2.0",
"12": "USB 1.x",
"1.5": "USB 1.x",
};
return speed2Class[value] || value + " Mbps";
},
},
],
},
});
me.callParent();
me.setNodename(nodename);
},
}, function() {
Ext.define('pve-usb-device', {
extend: 'Ext.data.Model',
fields: [
{
name: 'usbid',
convert: function(val, data) {
if (val) {
return val;
}
return data.get('vendid') + ':' + data.get('prodid');
},
},
'speed', 'product', 'manufacturer', 'vendid', 'prodid', 'usbpath',
{ name: 'port', type: 'number' },
{ name: 'level', type: 'number' },
{ name: 'class', type: 'number' },
{ name: 'devnum', type: 'number' },
{ name: 'busnum', type: 'number' },
{
name: 'product_and_id',
type: 'string',
convert: (v, rec) => {
let res = rec.data.product || gettext('Unknown');
res += " (" + rec.data.usbid + ")";
return res;
},
},
],
});
Ext.define('pve-usb-port', {
extend: 'Ext.data.Model',
fields: [
{
name: 'usbid',
convert: function(val, data) {
if (val) {
return val;
}
return data.get('busnum') + '-' + data.get('usbpath');
},
},
'speed', 'product', 'manufacturer', 'vendid', 'prodid', 'usbpath',
{ name: 'port', type: 'number' },
{ name: 'level', type: 'number' },
{ name: 'class', type: 'number' },
{ name: 'devnum', type: 'number' },
{ name: 'busnum', type: 'number' },
{
name: 'product_and_id',
type: 'string',
convert: (v, rec) => {
let res = rec.data.product || gettext('Unplugged');
res += " (" + rec.data.usbid + ")";
return res;
},
},
],
});
});
Ext.define('PVE.form.USBMapSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: 'widget.pveUSBMapSelector',
store: {
fields: ['name', 'vendor', 'device', 'path'],
filterOnLoad: true,
sorters: [
{
property: 'name',
direction: 'ASC',
},
],
},
allowBlank: false,
autoSelect: false,
displayField: 'id',
valueField: 'id',
listConfig: {
width: 800,
columns: [
{
header: gettext('Name'),
dataIndex: 'id',
flex: 1,
},
{
header: gettext('Status'),
dataIndex: 'errors',
flex: 2,
renderer: function(value) {
let me = this;
if (!Ext.isArray(value) || !value?.length) {
return `<i class="fa fa-check-circle good"></i> ${gettext('Mapping matches host data')}`;
}
let errors = [];
value.forEach((error) => {
let iconCls;
switch (error?.severity) {
case 'warning':
iconCls = 'fa-exclamation-circle warning';
break;
case 'error':
iconCls = 'fa-times-circle critical';
break;
}
let message = error?.message;
let icon = `<i class="fa ${iconCls}"></i>`;
if (iconCls !== undefined) {
errors.push(`${icon} ${message}`);
}
});
return errors.join('<br>');
},
},
{
header: gettext('Comment'),
dataIndex: 'description',
flex: 1,
renderer: Ext.String.htmlEncode,
},
],
},
setNodename: function(nodename) {
var me = this;
if (!nodename || me.nodename === nodename) {
return;
}
me.nodename = nodename;
me.store.setProxy({
type: 'proxmox',
url: `/api2/json/cluster/mapping/usb?check-node=${nodename}`,
});
me.store.load();
},
initComponent: function() {
var me = this;
var nodename = me.nodename;
me.nodename = undefined;
me.callParent();
me.setNodename(nodename);
},
});
Ext.define('pmx-users', {
extend: 'Ext.data.Model',
fields: [
'userid', 'firstname', 'lastname', 'email', 'comment',
{ type: 'boolean', name: 'enable' },
{ type: 'date', dateFormat: 'timestamp', name: 'expire' },
],
proxy: {
type: 'proxmox',
url: "/api2/json/access/users?full=1",
},
idProperty: 'userid',
});
Ext.define('PVE.form.VlanField', {
extend: 'Ext.form.field.Number',
alias: ['widget.pveVlanField'],
deleteEmpty: false,
emptyText: gettext('no VLAN'),
fieldLabel: gettext('VLAN Tag'),
allowBlank: true,
getSubmitData: function() {
var me = this,
data = null,
val;
if (!me.disabled && me.submitValue) {
val = me.getSubmitValue();
if (val) {
data = {};
data[me.getName()] = val;
} else if (me.deleteEmpty) {
data = {};
data.delete = me.getName();
}
}
return data;
},
initComponent: function() {
var me = this;
Ext.apply(me, {
minValue: 1,
maxValue: 4094,
});
me.callParent();
},
});
Ext.define('PVE.form.VMCPUFlagSelector', {
extend: 'Ext.grid.Panel',
alias: 'widget.vmcpuflagselector',
mixins: {
field: 'Ext.form.field.Field',
},
disableSelection: true,
columnLines: false,
selectable: false,
hideHeaders: true,
scrollable: 'y',
height: 200,
unkownFlags: [],
store: {
type: 'store',
fields: ['flag', { name: 'state', defaultValue: '=' }, 'desc'],
data: [
// FIXME: let qemu-server host this and autogenerate or get from API call??
{ flag: 'md-clear', desc: 'Required to let the guest OS know if MDS is mitigated correctly' },
{ flag: 'pcid', desc: 'Meltdown fix cost reduction on Westmere, Sandy-, and IvyBridge Intel CPUs' },
{ flag: 'spec-ctrl', desc: 'Allows improved Spectre mitigation with Intel CPUs' },
{ flag: 'ssbd', desc: 'Protection for "Speculative Store Bypass" for Intel models' },
{ flag: 'ibpb', desc: 'Allows improved Spectre mitigation with AMD CPUs' },
{ flag: 'virt-ssbd', desc: 'Basis for "Speculative Store Bypass" protection for AMD models' },
{ flag: 'amd-ssbd', desc: 'Improves Spectre mitigation performance with AMD CPUs, best used with "virt-ssbd"' },
{ flag: 'amd-no-ssb', desc: 'Notifies guest OS that host is not vulnerable for Spectre on AMD CPUs' },
{ flag: 'pdpe1gb', desc: 'Allow guest OS to use 1GB size pages, if host HW supports it' },
{ flag: 'hv-tlbflush', desc: 'Improve performance in overcommitted Windows guests. May lead to guest bluescreens on old CPUs.' },
{ flag: 'hv-evmcs', desc: 'Improve performance for nested virtualization. Only supported on Intel CPUs.' },
{ flag: 'aes', desc: 'Activate AES instruction set for HW acceleration.' },
],
listeners: {
update: function() {
this.commitChanges();
},
},
},
getValue: function() {
var me = this;
var store = me.getStore();
var flags = '';
// ExtJS does not has a nice getAllRecords interface for stores :/
store.queryBy(Ext.returnTrue).each(function(rec) {
var s = rec.get('state');
if (s && s !== '=') {
var f = rec.get('flag');
if (flags === '') {
flags = s + f;
} else {
flags += ';' + s + f;
}
}
});
flags += me.unkownFlags.join(';');
return flags;
},
setValue: function(value) {
var me = this;
var store = me.getStore();
me.value = value || '';
me.unkownFlags = [];
me.getStore().queryBy(Ext.returnTrue).each(function(rec) {
rec.set('state', '=');
});
var flags = value ? value.split(';') : [];
flags.forEach(function(flag) {
var sign = flag.substr(0, 1);
flag = flag.substr(1);
var rec = store.findRecord('flag', flag, 0, false, true, true);
if (rec !== null) {
rec.set('state', sign);
} else {
me.unkownFlags.push(flag);
}
});
store.reload();
var res = me.mixins.field.setValue.call(me, value);
return res;
},
columns: [
{
dataIndex: 'state',
renderer: function(v) {
switch (v) {
case '=': return 'Default';
case '-': return 'Off';
case '+': return 'On';
default: return 'Unknown';
}
},
width: 65,
},
{
xtype: 'widgetcolumn',
dataIndex: 'state',
width: 95,
onWidgetAttach: function(column, widget, record) {
var val = record.get('state') || '=';
widget.down('[inputValue=' + val + ']').setValue(true);
// TODO: disable if selected CPU model and flag are incompatible
},
widget: {
xtype: 'radiogroup',
hideLabel: true,
layout: 'hbox',
validateOnChange: false,
value: '=',
listeners: {
change: function(f, value) {
var v = Object.values(value)[0];
f.getWidgetRecord().set('state', v);
var view = this.up('grid');
view.dirty = view.getValue() !== view.originalValue;
view.checkDirty();
//view.checkChange();
},
},
items: [
{
boxLabel: '-',
boxLabelAlign: 'before',
inputValue: '-',
isFormField: false,
},
{
checked: true,
inputValue: '=',
isFormField: false,
},
{
boxLabel: '+',
inputValue: '+',
isFormField: false,
},
],
},
},
{
dataIndex: 'flag',
width: 100,
},
{
dataIndex: 'desc',
cellWrap: true,
flex: 1,
},
],
initComponent: function() {
var me = this;
// static class store, thus gets not recreated, so ensure defaults are set!
me.getStore().data.forEach(function(v) {
v.state = '=';
});
me.value = me.originalValue = '';
me.callParent(arguments);
},
});
/* filter is a javascript builtin, but extjs calls it also filter */
Ext.define('PVE.form.VMSelector', {
extend: 'Ext.grid.Panel',
alias: 'widget.vmselector',
mixins: {
field: 'Ext.form.field.Field',
},
allowBlank: true,
selectAll: false,
isFormField: true,
plugins: 'gridfilters',
store: {
model: 'PVEResources',
sorters: 'vmid',
},
userCls: 'proxmox-tags-circle',
columnsDeclaration: [
{
header: 'ID',
dataIndex: 'vmid',
width: 80,
filter: {
type: 'number',
},
},
{
header: gettext('Node'),
dataIndex: 'node',
},
{
header: gettext('Status'),
dataIndex: 'status',
filter: {
type: 'list',
},
},
{
header: gettext('Name'),
dataIndex: 'name',
flex: 1,
filter: {
type: 'string',
},
},
{
header: gettext('Pool'),
dataIndex: 'pool',
filter: {
type: 'list',
},
},
{
header: gettext('Type'),
dataIndex: 'type',
width: 120,
renderer: function(value) {
if (value === 'qemu') {
return gettext('Virtual Machine');
} else if (value === 'lxc') {
return gettext('LXC Container');
}
return '';
},
filter: {
type: 'list',
store: {
data: [
{ id: 'qemu', text: gettext('Virtual Machine') },
{ id: 'lxc', text: gettext('LXC Container') },
],
un: function() {
// Due to EXTJS-18711. we have to do a static list via a store but to avoid
// creating an object, we have to have an empty pseudo un function
},
},
},
},
{
header: gettext('Tags'),
dataIndex: 'tags',
renderer: tags => PVE.Utils.renderTags(tags, PVE.UIOptions.tagOverrides),
flex: 1,
},
{
header: 'HA ' + gettext('Status'),
dataIndex: 'hastate',
flex: 1,
filter: {
type: 'list',
},
},
],
// should be a list of 'dataIndex' values, if 'undefined' all declared columns will be included
columnSelection: undefined,
selModel: {
selType: 'checkboxmodel',
mode: 'SIMPLE',
},
checkChangeEvents: [
'selectionchange',
'change',
],
listeners: {
selectionchange: function() {
// to trigger validity and error checks
this.checkChange();
},
},
getValue: function() {
var me = this;
if (me.savedValue !== undefined) {
return me.savedValue;
}
var sm = me.getSelectionModel();
var selection = sm.getSelection();
var values = [];
var store = me.getStore();
selection.forEach(function(item) {
// only add if not filtered
if (store.findExact('vmid', item.data.vmid) !== -1) {
values.push(item.data.vmid);
}
});
return values;
},
setValueSelection: function(value) {
let me = this;
let store = me.getStore();
let notFound = [];
let selection = value.map(item => {
let found = store.findRecord('vmid', item, 0, false, true, true);
if (!found) {
if (Ext.isNumeric(item)) {
notFound.push(item);
} else {
console.warn(`invalid item in vm selection: ${item}`);
}
}
return found;
}).filter(r => r);
for (const vmid of notFound) {
let rec = store.add({
vmid,
node: 'unknown',
});
selection.push(rec[0]);
}
let sm = me.getSelectionModel();
if (selection.length) {
sm.select(selection);
} else {
sm.deselectAll();
}
// to correctly trigger invalid class
me.getErrors();
},
setValue: function(value) {
let me = this;
value ??= [];
if (!Ext.isArray(value)) {
value = value.split(',').filter(v => v !== '');
}
let store = me.getStore();
if (!store.isLoaded()) {
me.savedValue = value;
store.on('load', function() {
me.setValueSelection(value);
delete me.savedValue;
}, { single: true });
} else {
me.setValueSelection(value);
}
return me.mixins.field.setValue.call(me, value);
},
getErrors: function(value) {
let me = this;
if (!me.isDisabled() && me.allowBlank === false &&
me.getValue().length === 0) {
me.addBodyCls(['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']);
return [gettext('No VM selected')];
}
me.removeBodyCls(['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']);
return [];
},
setDisabled: function(disabled) {
let me = this;
let res = me.callParent([disabled]);
me.getErrors();
return res;
},
initComponent: function() {
let me = this;
let columns = me.columnsDeclaration.filter((column) =>
me.columnSelection ? me.columnSelection.indexOf(column.dataIndex) !== -1 : true,
).map((x) => x);
me.columns = columns;
me.callParent();
me.getStore().load({ params: { type: 'vm' } });
if (me.nodename) {
me.getStore().addFilter({
property: 'node',
exactMatch: true,
value: me.nodename,
});
}
// only show the relevant guests by default
if (me.action) {
var statusfilter = '';
switch (me.action) {
case 'startall':
statusfilter = 'stopped';
break;
case 'stopall':
statusfilter = 'running';
break;
}
if (statusfilter !== '') {
me.getStore().addFilter([{
property: 'template',
value: 0,
}, {
id: 'x-gridfilter-status',
operator: 'in',
property: 'status',
value: [statusfilter],
}]);
}
}
if (me.selectAll) {
me.mon(me.getStore(), 'load', function() {
me.getSelectionModel().selectAll(false);
});
}
},
});
Ext.define('PVE.form.VMComboSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: 'widget.vmComboSelector',
valueField: 'vmid',
displayField: 'vmid',
autoSelect: false,
editable: true,
anyMatch: true,
forceSelection: true,
store: {
model: 'PVEResources',
autoLoad: true,
sorters: 'vmid',
filters: [{
property: 'type',
value: /lxc|qemu/,
}],
},
listConfig: {
width: 600,
plugins: 'gridfilters',
columns: [
{
header: 'ID',
dataIndex: 'vmid',
width: 80,
filter: {
type: 'number',
},
},
{
header: gettext('Name'),
dataIndex: 'name',
flex: 1,
filter: {
type: 'string',
},
},
{
header: gettext('Node'),
dataIndex: 'node',
},
{
header: gettext('Status'),
dataIndex: 'status',
filter: {
type: 'list',
},
},
{
header: gettext('Pool'),
dataIndex: 'pool',
hidden: true,
filter: {
type: 'list',
},
},
{
header: gettext('Type'),
dataIndex: 'type',
width: 120,
renderer: function(value) {
if (value === 'qemu') {
return gettext('Virtual Machine');
} else if (value === 'lxc') {
return gettext('LXC Container');
}
return '';
},
filter: {
type: 'list',
store: {
data: [
{ id: 'qemu', text: gettext('Virtual Machine') },
{ id: 'lxc', text: gettext('LXC Container') },
],
un: function() { /* due to EXTJS-18711 */ },
},
},
},
{
header: 'HA ' + gettext('Status'),
dataIndex: 'hastate',
hidden: true,
flex: 1,
filter: {
type: 'list',
},
},
],
},
});
Ext.define('PVE.form.VNCKeyboardSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: ['widget.VNCKeyboardSelector'],
comboItems: Object.entries(PVE.Utils.kvm_keymaps),
});
/*
* Top left combobox, used to select a view of the underneath RessourceTree
*/
Ext.define('PVE.form.ViewSelector', {
extend: 'Ext.form.field.ComboBox',
alias: ['widget.pveViewSelector'],
editable: false,
allowBlank: false,
forceSelection: true,
autoSelect: false,
valueField: 'key',
displayField: 'value',
hideLabel: true,
queryMode: 'local',
initComponent: function() {
let me = this;
let default_views = {
server: {
text: gettext('Server View'),
groups: ['node'],
},
folder: {
text: gettext('Folder View'),
groups: ['type'],
},
pool: {
text: gettext('Pool View'),
groups: ['pool'],
// Pool View only lists VMs and Containers
getFilterFn: () => ({ data }) => data.type === 'qemu' || data.type === 'lxc' || data.type === 'pool',
},
tags: {
text: gettext('Tag View'),
groups: ['tag'],
getFilterFn: () => ({ data }) => ['qemu', 'lxc', 'node', 'storage'].indexOf(data.type) !== -1,
groupRenderer: function(info) {
let tag = PVE.Utils.renderTags(info.tag, PVE.UIOptions.tagOverrides);
return `<span class="proxmox-tags-full">${tag}</span>`;
},
itemMap: function(item) {
let tags = (item.data.tags ?? '').split(/[;, ]/);
if (tags.length === 1 && tags[0] === '') {
return item;
}
let items = [];
for (const tag of tags) {
let id = `${item.data.id}-${tag}`;
let info = Ext.apply({ leaf: true }, item.data);
info.tag = tag;
info.realId = info.id;
info.id = id;
items.push(Ext.create('Ext.data.TreeModel', info));
}
return items;
},
attrMoveChecks: {
tag: (newitem, olditem) => newitem.data.tags !== olditem.data.tags,
},
},
};
let groupdef = Object.entries(default_views).map(([name, config]) => [name, config.text]);
let store = Ext.create('Ext.data.Store', {
model: 'KeyValue',
proxy: {
type: 'memory',
reader: 'array',
},
data: groupdef,
autoload: true,
});
Ext.apply(me, {
store: store,
value: groupdef[0][0],
getViewFilter: function() {
let view = me.getValue();
return Ext.apply({ id: view }, default_views[view] || default_views.server);
},
getState: function() {
return { value: me.getValue() };
},
applyState: function(state, doSelect) {
let view = me.getValue();
if (state && state.value && view !== state.value) {
let record = store.findRecord('key', state.value, 0, false, true, true);
if (record) {
me.setValue(state.value, true);
if (doSelect) {
me.fireEvent('select', me, [record]);
}
}
}
},
stateEvents: ['select'],
stateful: true,
stateId: 'pveview',
id: 'view',
});
me.callParent();
let statechange = function(sp, key, value) {
if (key === me.id) {
me.applyState(value, true);
}
};
let sp = Ext.state.Manager.getProvider();
me.mon(sp, 'statechange', statechange, me);
},
});
Ext.define('PVE.form.iScsiProviderSelector', {
extend: 'Proxmox.form.KVComboBox',
alias: ['widget.pveiScsiProviderSelector'],
comboItems: [
['comstar', 'Comstar'],
['istgt', 'istgt'],
['iet', 'IET'],
['LIO', 'LIO'],
],
});
Ext.define('PVE.form.ColorPicker', {
extend: 'Ext.form.FieldContainer',
alias: 'widget.pveColorPicker',
defaultBindProperty: 'value',
config: {
value: null,
},
height: 24,
layout: {
type: 'hbox',
align: 'stretch',
},
getValue: function() {
return this.realvalue.slice(1);
},
setValue: function(value) {
let me = this;
me.setColor(value);
if (value && value.length === 6) {
me.picker.value = value[0] !== '#' ? `#${value}` : value;
}
},
setColor: function(value) {
let me = this;
let oldValue = me.realvalue;
me.realvalue = value;
let color = value.length === 6 ? `#${value}` : undefined;
me.down('#picker').setStyle('background-color', color);
me.down('#text').setValue(value ?? "");
me.fireEvent('change', me, me.realvalue, oldValue);
},
initComponent: function() {
let me = this;
me.picker = document.createElement('input');
me.picker.type = 'color';
me.picker.style = `opacity: 0; border: 0px; width: 100%; height: ${me.height}px`;
me.picker.value = `${me.value}`;
me.items = [
{
xtype: 'textfield',
itemId: 'text',
minLength: !me.allowBlank ? 6 : undefined,
maxLength: 6,
enforceMaxLength: true,
allowBlank: me.allowBlank,
emptyText: me.allowBlank ? gettext('Automatic') : undefined,
maskRe: /[a-f0-9]/i,
regex: /^[a-f0-9]{6}$/i,
flex: 1,
listeners: {
change: function(field, value) {
me.setValue(value);
},
},
},
{
xtype: 'box',
style: {
'margin-left': '1px',
border: '1px solid #cfcfcf',
},
itemId: 'picker',
width: 24,
contentEl: me.picker,
},
];
me.callParent();
me.picker.oninput = function() {
me.setColor(me.picker.value.slice(1));
};
},
});
Ext.define('PVE.form.TagColorGrid', {
extend: 'Ext.grid.Panel',
alias: 'widget.pveTagColorGrid',
mixins: [
'Ext.form.field.Field',
],
allowBlank: true,
selectAll: false,
isFormField: true,
deleteEmpty: false,
selModel: 'checkboxmodel',
config: {
deleteEmpty: false,
},
emptyText: gettext('No Overrides'),
viewConfig: {
deferEmptyText: false,
},
setValue: function(value) {
let me = this;
let colors;
if (Ext.isObject(value)) {
colors = value.colors;
} else {
colors = value;
}
if (!colors) {
me.getStore().removeAll();
me.checkChange();
return me;
}
let entries = (colors.split(';') || []).map((entry) => {
let [tag, bg, fg] = entry.split(':');
fg = fg || "";
return {
tag,
color: bg,
text: fg,
};
});
me.getStore().setData(entries);
me.checkChange();
return me;
},
getValue: function() {
let me = this;
let values = [];
me.getStore().each((rec) => {
if (rec.data.tag) {
let val = `${rec.data.tag}:${rec.data.color}`;
if (rec.data.text) {
val += `:${rec.data.text}`;
}
values.push(val);
}
});
return values.join(';');
},
getErrors: function(value) {
let me = this;
let emptyTag = false;
let notValidColor = false;
let colorRegex = new RegExp(/^[0-9a-f]{6}$/i);
me.getStore().each((rec) => {
if (!rec.data.tag) {
emptyTag = true;
}
if (!rec.data.color?.match(colorRegex)) {
notValidColor = true;
}
if (rec.data.text && !rec.data.text?.match(colorRegex)) {
notValidColor = true;
}
});
let errors = [];
if (emptyTag) {
errors.push(gettext('Tag must not be empty.'));
}
if (notValidColor) {
errors.push(gettext('Not a valid color.'));
}
return errors;
},
// 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.getView().getStore().add({
tag: '',
color: '',
text: '',
});
},
removeSelection: function() {
let me = this;
let view = me.getView();
let selection = view.getSelection();
if (selection === undefined) {
return;
}
selection.forEach((sel) => {
view.getStore().remove(sel);
});
view.checkChange();
},
tagChange: function(field, newValue, oldValue) {
let me = this;
let rec = field.getWidgetRecord();
if (!rec) {
return;
}
if (newValue && newValue !== oldValue) {
let newrgb = Proxmox.Utils.stringToRGB(newValue);
let newvalue = Proxmox.Utils.rgbToHex(newrgb);
if (!rec.get('color')) {
rec.set('color', newvalue);
} else if (oldValue) {
let oldrgb = Proxmox.Utils.stringToRGB(oldValue);
let oldvalue = Proxmox.Utils.rgbToHex(oldrgb);
if (rec.get('color') === oldvalue) {
rec.set('color', newvalue);
}
}
}
me.fieldChange(field, newValue, oldValue);
},
backgroundChange: function(field, newValue, oldValue) {
let me = this;
let rec = field.getWidgetRecord();
if (!rec) {
return;
}
if (newValue && newValue !== oldValue) {
let newrgb = Proxmox.Utils.hexToRGB(newValue);
let newcls = Proxmox.Utils.getTextContrastClass(newrgb);
let hexvalue = newcls === 'dark' ? '000000' : 'FFFFFF';
if (!rec.get('text')) {
rec.set('text', hexvalue);
} else if (oldValue) {
let oldrgb = Proxmox.Utils.hexToRGB(oldValue);
let oldcls = Proxmox.Utils.getTextContrastClass(oldrgb);
let oldvalue = oldcls === 'dark' ? '000000' : 'FFFFFF';
if (rec.get('text') === oldvalue) {
rec.set('text', hexvalue);
}
}
}
me.fieldChange(field, newValue, oldValue);
},
fieldChange: function(field, newValue, oldValue) {
let me = this;
let view = me.getView();
let rec = field.getWidgetRecord();
if (!rec) {
return;
}
let column = field.getWidgetColumn();
rec.set(column.dataIndex, newValue);
view.checkChange();
},
},
tbar: [
{
text: gettext('Add'),
handler: 'addLine',
},
{
xtype: 'proxmoxButton',
text: gettext('Remove'),
handler: 'removeSelection',
disabled: true,
},
],
columns: [
{
header: 'Tag',
dataIndex: 'tag',
xtype: 'widgetcolumn',
onWidgetAttach: function(col, widget, rec) {
widget.getStore().setData(PVE.UIOptions.tagList.map(v => ({ tag: v })));
},
widget: {
xtype: 'combobox',
isFormField: false,
maskRe: PVE.Utils.tagCharRegex,
allowBlank: false,
queryMode: 'local',
displayField: 'tag',
valueField: 'tag',
store: {},
listeners: {
change: 'tagChange',
},
},
flex: 1,
},
{
header: gettext('Background'),
xtype: 'widgetcolumn',
flex: 1,
dataIndex: 'color',
widget: {
xtype: 'pveColorPicker',
isFormField: false,
listeners: {
change: 'backgroundChange',
},
},
},
{
header: gettext('Text'),
xtype: 'widgetcolumn',
flex: 1,
dataIndex: 'text',
widget: {
xtype: 'pveColorPicker',
allowBlank: true,
isFormField: false,
listeners: {
change: 'fieldChange',
},
},
},
],
store: {
listeners: {
update: function() {
this.commitChanges();
},
},
},
initComponent: function() {
let me = this;
me.callParent();
me.initField();
},
});
Ext.define('PVE.form.ListField', {
extend: 'Ext.container.Container',
alias: 'widget.pveListField',
mixins: [
'Ext.form.field.Field',
],
// override for column header
fieldTitle: gettext('Item'),
// will be applied to the textfields
maskRe: undefined,
allowBlank: true,
selectAll: false,
isFormField: true,
deleteEmpty: false,
config: {
deleteEmpty: 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 => ({ item })));
} else {
store.removeAll();
}
me.checkChange();
return me;
},
getValue: function() {
let me = this;
let values = [];
me.lookup('grid').getStore().each((rec) => {
if (rec.data.item) {
values.push(rec.data.item);
}
});
return values.join(';');
},
getErrors: function(value) {
let me = this;
let empty = false;
me.lookup('grid').getStore().each((rec) => {
if (!rec.data.item) {
empty = true;
}
});
if (empty) {
return [gettext('Tag 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({
item: '',
});
},
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('pveListField');
list.checkChange();
list.validate();
},
control: {
'grid button': {
click: 'removeSelection',
},
},
},
items: [
{
xtype: 'grid',
reference: 'grid',
viewConfig: {
deferEmptyText: false,
},
store: {
listeners: {
update: function() {
this.commitChanges();
},
},
},
},
{
xtype: 'button',
text: gettext('Add'),
iconCls: 'fa fa-plus-circle',
handler: 'addLine',
margin: '5 0 0 0',
},
],
initComponent: function() {
let me = this;
for (const [key, value] of Object.entries(me.gridConfig ?? {})) {
me.items[0][key] = value;
}
me.items[0].columns = [
{
header: me.fieldTtitle,
dataIndex: 'item',
xtype: 'widgetcolumn',
widget: {
xtype: 'textfield',
isFormField: false,
maskRe: me.maskRe,
allowBlank: false,
queryMode: 'local',
listeners: {
change: 'itemChange',
},
},
flex: 1,
},
{
xtype: 'widgetcolumn',
width: 40,
widget: {
xtype: 'button',
iconCls: 'fa fa-trash-o',
},
},
];
me.callParent();
me.initField();
},
});
Ext.define('Proxmox.form.Tag', {
extend: 'Ext.Component',
alias: 'widget.pveTag',
mode: 'editable',
tag: '',
cls: 'pve-edit-tag',
tpl: [
'<i class="handle fa fa-bars"></i>',
'<span>{tag}</span>',
'<i class="action fa fa-minus-square"></i>',
],
focusable: true,
getFocusEl: function() {
return Ext.get(this.tagEl());
},
onFocus: function() {
this.selectText();
},
// contains tags not to show in the picker and not allowing to set
filter: [],
updateFilter: function(tags) {
this.filter = tags;
},
onClick: function(event) {
let me = this;
if (event.target.tagName === 'I' && !event.target.classList.contains('handle')) {
if (me.mode === 'editable') {
me.destroy();
return;
}
} else if (event.target.tagName !== 'SPAN' || me.mode !== 'editable') {
return;
}
me.selectText();
},
selectText: function(collapseToEnd) {
let me = this;
let tagEl = me.tagEl();
tagEl.contentEditable = true;
let range = document.createRange();
range.selectNodeContents(tagEl);
if (collapseToEnd) {
range.collapse(false);
}
let sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
me.showPicker();
},
showPicker: function() {
let me = this;
if (!me.picker) {
me.picker = Ext.widget({
xtype: 'boundlist',
minWidth: 70,
scrollable: true,
floating: true,
hidden: true,
userCls: 'proxmox-tags-full',
displayField: 'tag',
itemTpl: [
'{[Proxmox.Utils.getTagElement(values.tag, PVE.UIOptions.tagOverrides)]}',
],
store: [],
listeners: {
select: function(picker, rec) {
me.tagEl().innerHTML = rec.data.tag;
me.setTag(rec.data.tag, true);
me.selectText(true);
me.setColor(rec.data.tag);
me.picker.hide();
},
},
});
}
me.picker.getStore()?.clearFilter();
let taglist = PVE.UIOptions.tagList.filter(v => !me.filter.includes(v)).map(v => ({ tag: v }));
if (taglist.length < 1) {
return;
}
me.picker.getStore().setData(taglist);
me.picker.showBy(me, 'tl-bl');
me.picker.setMaxHeight(200);
},
setMode: function(mode) {
let me = this;
let tagEl = me.tagEl();
if (tagEl) {
tagEl.contentEditable = mode === 'editable';
}
me.removeCls(me.mode);
me.addCls(mode);
me.mode = mode;
if (me.mode !== 'editable') {
me.picker?.hide();
}
},
onKeyPress: function(event) {
let me = this;
let key = event.browserEvent.key;
switch (key) {
case 'Enter':
case 'Escape':
me.fireEvent('keypress', key);
break;
case 'ArrowLeft':
case 'ArrowRight':
case 'Backspace':
case 'Delete':
return;
default:
if (key.match(PVE.Utils.tagCharRegex)) {
return;
}
me.setTag(me.tagEl().innerHTML);
}
event.browserEvent.preventDefault();
event.browserEvent.stopPropagation();
},
// for pasting text
beforeInput: function(event) {
let me = this;
me.updateLayout();
let tag = event.event.data ?? event.event.dataTransfer?.getData('text/plain');
if (!tag) {
return;
}
if (tag.match(PVE.Utils.tagCharRegex) === null) {
event.event.preventDefault();
event.event.stopPropagation();
}
},
onInput: function(event) {
let me = this;
me.picker.getStore().filter({
property: 'tag',
value: me.tagEl().innerHTML,
anyMatch: true,
});
me.setTag(me.tagEl().innerHTML);
},
lostFocus: function(list, event) {
let me = this;
me.picker?.hide();
window.getSelection().removeAllRanges();
},
setColor: function(tag) {
let me = this;
let rgb = PVE.UIOptions.tagOverrides[tag] ?? Proxmox.Utils.stringToRGB(tag);
let cls = Proxmox.Utils.getTextContrastClass(rgb);
let color = Proxmox.Utils.rgbToCss(rgb);
me.setUserCls(`proxmox-tag-${cls}`);
me.setStyle('background-color', color);
if (rgb.length > 3) {
let fgcolor = Proxmox.Utils.rgbToCss([rgb[3], rgb[4], rgb[5]]);
me.setStyle('color', fgcolor);
} else {
me.setStyle('color');
}
},
setTag: function(tag) {
let me = this;
let oldtag = me.tag;
me.tag = tag;
clearTimeout(me.colorTimeout);
me.colorTimeout = setTimeout(() => me.setColor(tag), 200);
me.updateLayout();
if (oldtag !== tag) {
me.fireEvent('change', me, tag, oldtag);
}
},
tagEl: function() {
return this.el?.dom?.getElementsByTagName('span')?.[0];
},
listeners: {
click: 'onClick',
focusleave: 'lostFocus',
keydown: 'onKeyPress',
beforeInput: 'beforeInput',
input: 'onInput',
element: 'el',
scope: 'this',
},
initComponent: function() {
let me = this;
me.data = {
tag: me.tag,
};
me.setTag(me.tag);
me.setColor(me.tag);
me.setMode(me.mode ?? 'normal');
me.callParent();
},
destroy: function() {
let me = this;
if (me.picker) {
Ext.destroy(me.picker);
}
clearTimeout(me.colorTimeout);
me.callParent();
},
});
Ext.define('PVE.panel.TagEditContainer', {
extend: 'Ext.container.Container',
alias: 'widget.pveTagEditContainer',
layout: {
type: 'hbox',
align: 'middle',
},
// set to false to hide the 'no tags' field and the edit button
canEdit: true,
editOnly: false,
controller: {
xclass: 'Ext.app.ViewController',
loadTags: function(tagstring = '', force = false) {
let me = this;
let view = me.getView();
if (me.oldTags === tagstring && !force) {
return;
}
view.suspendLayout = true;
me.forEachTag((tag) => {
view.remove(tag);
});
me.getViewModel().set('tagCount', 0);
let newtags = tagstring.split(/[;, ]/).filter((t) => !!t) || [];
newtags.forEach((tag) => {
me.addTag(tag);
});
view.suspendLayout = false;
view.updateLayout();
if (!force) {
me.oldTags = tagstring;
}
me.tagsChanged();
},
onRender: function(v) {
let me = this;
let view = me.getView();
view.toggleCls('hide-handles', PVE.UIOptions.shouldSortTags());
view.dragzone = Ext.create('Ext.dd.DragZone', v.getEl(), {
getDragData: function(e) {
let source = e.getTarget('.handle');
if (!source) {
return undefined;
}
let sourceId = source.parentNode.id;
let cmp = Ext.getCmp(sourceId);
let ddel = document.createElement('div');
ddel.classList.add('proxmox-tags-full');
ddel.innerHTML = Proxmox.Utils.getTagElement(cmp.tag, PVE.UIOptions.tagOverrides);
let repairXY = Ext.fly(source).getXY();
cmp.setDisabled(true);
ddel.id = Ext.id();
return {
ddel,
repairXY,
sourceId,
};
},
onMouseUp: function(target, e, id) {
let cmp = Ext.getCmp(this.dragData.sourceId);
if (cmp && !cmp.isDestroyed) {
cmp.setDisabled(false);
}
},
getRepairXY: function() {
return this.dragData.repairXY;
},
beforeInvalidDrop: function(target, e, id) {
let cmp = Ext.getCmp(this.dragData.sourceId);
if (cmp && !cmp.isDestroyed) {
cmp.setDisabled(false);
}
},
});
view.dropzone = Ext.create('Ext.dd.DropZone', v.getEl(), {
getTargetFromEvent: function(e) {
return e.getTarget('.proxmox-tag-dark,.proxmox-tag-light');
},
getIndicator: function() {
if (!view.indicator) {
view.indicator = Ext.create('Ext.Component', {
floating: true,
html: '<i class="fa fa-long-arrow-up"></i>',
hidden: true,
shadow: false,
});
}
return view.indicator;
},
onContainerOver: function() {
this.getIndicator().setVisible(false);
},
notifyOut: function() {
this.getIndicator().setVisible(false);
},
onNodeOver: function(target, dd, e, data) {
let indicator = this.getIndicator();
indicator.setVisible(true);
indicator.alignTo(Ext.getCmp(target.id), 't50-bl', [-1, -2]);
return this.dropAllowed;
},
onNodeDrop: function(target, dd, e, data) {
this.getIndicator().setVisible(false);
let sourceCmp = Ext.getCmp(data.sourceId);
if (!sourceCmp) {
return;
}
sourceCmp.setDisabled(false);
let targetCmp = Ext.getCmp(target.id);
view.remove(sourceCmp, { destroy: false });
view.insert(view.items.indexOf(targetCmp), sourceCmp);
me.tagsChanged();
},
});
},
forEachTag: function(func) {
let me = this;
let view = me.getView();
view.items.each((field) => {
if (field.getXType() === 'pveTag') {
func(field);
}
return true;
});
},
toggleEdit: function(cancel) {
let me = this;
let vm = me.getViewModel();
let view = me.getView();
let editMode = !vm.get('editMode');
vm.set('editMode', editMode);
// get a current tag list for editing
if (editMode) {
PVE.UIOptions.update();
}
me.forEachTag((tag) => {
tag.setMode(editMode ? 'editable' : 'normal');
});
if (!vm.get('editMode')) {
let tags = [];
if (cancel) {
me.loadTags(me.oldTags, true);
} else {
let toRemove = [];
me.forEachTag((cmp) => {
if (cmp.isVisible() && cmp.tag) {
tags.push(cmp.tag);
} else {
toRemove.push(cmp);
}
});
toRemove.forEach(cmp => view.remove(cmp));
tags = tags.join(',');
if (me.oldTags !== tags) {
me.oldTags = tags;
me.loadTags(tags, true);
me.getView().fireEvent('change', tags);
}
}
}
me.getView().updateLayout();
},
tagsChanged: function() {
let me = this;
let tags = [];
me.forEachTag(cmp => {
if (cmp.tag) {
tags.push(cmp.tag);
}
});
me.getViewModel().set('isDirty', me.oldTags !== tags.join(','));
me.forEachTag(cmp => {
cmp.updateFilter(tags);
});
},
addTag: function(tag, isNew) {
let me = this;
let view = me.getView();
let vm = me.getViewModel();
let index = view.items.length - 5;
if (PVE.UIOptions.shouldSortTags() && !isNew) {
index = view.items.findIndexBy(tagField => {
if (tagField.reference === 'noTagsField') {
return false;
}
if (tagField.xtype !== 'pveTag') {
return true;
}
let a = tagField.tag.toLowerCase();
let b = tag.toLowerCase();
return a > b ? true : a < b ? false : tagField.tag.localeCompare(tag) > 0;
}, 1);
}
let tagField = view.insert(index, {
xtype: 'pveTag',
tag,
mode: vm.get('editMode') ? 'editable' : 'normal',
listeners: {
change: 'tagsChanged',
destroy: function() {
vm.set('tagCount', vm.get('tagCount') - 1);
me.tagsChanged();
},
keypress: function(key) {
if (vm.get('hideFinishButtons')) {
return;
}
if (key === 'Enter') {
me.editClick();
} else if (key === 'Escape') {
me.cancelClick();
}
},
},
});
if (isNew) {
me.tagsChanged();
tagField.selectText();
}
vm.set('tagCount', vm.get('tagCount') + 1);
},
addTagClick: function(event) {
let me = this;
me.lookup('noTagsField').setVisible(false);
me.addTag('', true);
},
cancelClick: function() {
this.toggleEdit(true);
},
editClick: function() {
this.toggleEdit(false);
},
init: function(view) {
let me = this;
if (view.tags) {
me.loadTags(view.tags);
}
me.getViewModel().set('canEdit', view.canEdit);
me.getViewModel().set('editOnly', view.editOnly);
me.mon(Ext.GlobalEvents, 'loadedUiOptions', () => {
let vm = me.getViewModel();
view.toggleCls('hide-handles', PVE.UIOptions.shouldSortTags());
me.loadTags(me.oldTags, !vm.get('editMode')); // refresh tag colors and order
});
if (view.editOnly) {
me.toggleEdit();
}
},
},
getTags: function() {
let me =this;
let controller = me.getController();
let tags = [];
controller.forEachTag((cmp) => {
if (cmp.tag.length) {
tags.push(cmp.tag);
}
});
return tags;
},
viewModel: {
data: {
tagCount: 0,
editMode: false,
canEdit: true,
isDirty: false,
editOnly: true,
},
formulas: {
hideNoTags: function(get) {
return get('tagCount') !== 0 || !get('canEdit');
},
hideEditBtn: function(get) {
return get('editMode') || !get('canEdit');
},
hideFinishButtons: function(get) {
return !get('editMode') || get('editOnly');
},
},
},
loadTags: function() {
return this.getController().loadTags(...arguments);
},
items: [
{
xtype: 'box',
reference: 'noTagsField',
bind: {
hidden: '{hideNoTags}',
},
html: gettext('No Tags'),
style: {
opacity: 0.5,
},
},
{
xtype: 'button',
iconCls: 'fa fa-plus',
tooltip: gettext('Add Tag'),
bind: {
hidden: '{!editMode}',
},
hidden: true,
margin: '0 8 0 5',
ui: 'default-toolbar',
handler: 'addTagClick',
},
{
xtype: 'tbseparator',
ui: 'horizontal',
bind: {
hidden: '{hideFinishButtons}',
},
hidden: true,
},
{
xtype: 'button',
iconCls: 'fa fa-times',
tooltip: gettext('Cancel Edit'),
bind: {
hidden: '{hideFinishButtons}',
},
hidden: true,
margin: '0 5 0 0',
ui: 'default-toolbar',
handler: 'cancelClick',
},
{
xtype: 'button',
iconCls: 'fa fa-check',
tooltip: gettext('Finish Edit'),
bind: {
hidden: '{hideFinishButtons}',
disabled: '{!isDirty}',
},
hidden: true,
handler: 'editClick',
},
{
xtype: 'box',
cls: 'pve-tag-inline-button',
html: `<i data-qtip="${gettext('Edit Tags')}" class="fa fa-pencil"></i>`,
bind: {
hidden: '{hideEditBtn}',
},
listeners: {
click: 'editClick',
element: 'el',
},
},
],
listeners: {
render: 'onRender',
},
destroy: function() {
let me = this;
Ext.destroy(me.dragzone);
Ext.destroy(me.dropzone);
Ext.destroy(me.indicator);
me.callParent();
},
});
// mostly copied from ExtJS FileButton, but added 'multiple' at the relevant
// places so we have a file picker where one can select multiple files
// changes are marked with an 'pmx:' comment
Ext.define('PVE.form.MultiFileButton', {
extend: 'Ext.form.field.FileButton',
alias: 'widget.pveMultiFileButton',
afterTpl: [
'<input id="{id}-fileInputEl" data-ref="fileInputEl" class="{childElCls} {inputCls}" ',
'type="file" size="1" name="{inputName}" unselectable="on" multiple ', // pmx: added multiple
'<tpl if="accept != null">accept="{accept}"</tpl>',
'<tpl if="tabIndex != null">tabindex="{tabIndex}"</tpl>',
'>',
],
createFileInput: function(isTemporary) {
var me = this,
fileInputEl, listeners;
fileInputEl = me.fileInputEl = me.el.createChild({
name: me.inputName || me.id,
multiple: true, // pmx: added multiple option
id: !isTemporary ? me.id + '-fileInputEl' : undefined,
cls: me.inputCls + (me.getInherited().rtl ? ' ' + Ext.baseCSSPrefix + 'rtl' : ''),
tag: 'input',
type: 'file',
size: 1,
unselectable: 'on',
}, me.afterInputGuard); // Nothing special happens outside of IE/Edge
// This is our focusEl
fileInputEl.dom.setAttribute('data-componentid', me.id);
if (me.tabIndex !== null) {
me.setTabIndex(me.tabIndex);
}
if (me.accept) {
fileInputEl.dom.setAttribute('accept', me.accept);
}
// We place focus and blur listeners on fileInputEl to activate Button's
// focus and blur style treatment
listeners = {
scope: me,
change: me.fireChange,
mousedown: me.handlePrompt,
keydown: me.handlePrompt,
focus: me.onFileFocus,
blur: me.onFileBlur,
};
if (me.useTabGuards) {
listeners.keydown = me.onFileInputKeydown;
}
fileInputEl.on(listeners);
},
});
Ext.define('PVE.form.TagFieldSet', {
extend: 'Ext.form.FieldSet',
alias: 'widget.pveTagFieldSet',
mixins: ['Ext.form.field.Field'],
title: gettext('Tags'),
padding: '0 5 5 5',
getValue: function() {
let me = this;
let tags = me.down('pveTagEditContainer').getTags().filter(t => t !== '');
return tags.join(';');
},
setValue: function(value) {
let me = this;
value ??= [];
if (!Ext.isArray(value)) {
value = value.split(/[;, ]/).filter(t => t !== '');
}
me.down('pveTagEditContainer').loadTags(value.join(';'));
},
getErrors: function(value) {
value ??= [];
if (!Ext.isArray(value)) {
value = value.split(/[;, ]/).filter(t => t !== '');
}
if (value.some(t => !t.match(PVE.Utils.tagCharRegex))) {
return [gettext("Tags contain invalid characters.")];
}
return [];
},
getSubmitData: function() {
let me = this;
let value = me.getValue();
if (me.disabled || !me.submitValue || value === '') {
return null;
}
let data = {};
data[me.getName()] = value;
return data;
},
layout: 'fit',
items: [
{
xtype: 'pveTagEditContainer',
userCls: 'proxmox-tags-full proxmox-tag-fieldset',
editOnly: true,
allowBlank: true,
layout: 'column',
scrollable: true,
},
],
initComponent: function() {
let me = this;
me.callParent();
me.initField();
},
});
Ext.define('PVE.form.IsoSelector', {
extend: 'Ext.container.Container',
alias: 'widget.pveIsoSelector',
mixins: [
'Ext.form.field.Field',
'Proxmox.Mixin.CBind',
],
layout: {
type: 'vbox',
align: 'stretch',
},
nodename: undefined,
insideWizard: false,
labelWidth: undefined,
labelAlign: 'right',
cbindData: function() {
let me = this;
return {
nodename: me.nodename,
insideWizard: me.insideWizard,
};
},
getValue: function() {
return this.lookup('file').getValue();
},
setValue: function(value) {
let me = this;
if (!value) {
me.lookup('file').reset();
return;
}
var match = value.match(/^([^:]+):/);
if (match) {
me.lookup('storage').setValue(match[1]);
me.lookup('file').setValue(value);
}
},
getErrors: function() {
let me = this;
me.lookup('storage').validate();
let file = me.lookup('file');
file.validate();
let value = file.getValue();
if (!value || !value.length) {
return [""]; // for validation
}
return [];
},
setNodename: function(nodename) {
let me = this;
me.lookup('storage').setNodename(nodename);
me.lookup('file').setStorage(undefined, nodename);
},
setDisabled: function(disabled) {
let me = this;
me.lookup('storage').setDisabled(disabled);
me.lookup('file').setDisabled(disabled);
return me.callParent([disabled]);
},
referenceHolder: true,
items: [
{
xtype: 'pveStorageSelector',
reference: 'storage',
isFormField: false,
fieldLabel: gettext('Storage'),
storageContent: 'iso',
allowBlank: false,
cbind: {
nodename: '{nodename}',
autoSelect: '{insideWizard}',
insideWizard: '{insideWizard}',
disabled: '{disabled}',
labelWidth: '{labelWidth}',
labelAlign: '{labelAlign}',
},
listeners: {
change: function(f, value) {
let me = this;
let selector = me.up('pveIsoSelector');
selector.lookup('file').setStorage(value);
selector.checkChange();
},
},
},
{
xtype: 'pveFileSelector',
reference: 'file',
isFormField: false,
storageContent: 'iso',
fieldLabel: gettext('ISO image'),
labelAlign: 'right',
cbind: {
nodename: '{nodename}',
disabled: '{disabled}',
labelWidth: '{labelWidth}',
labelAlign: '{labelAlign}',
},
allowBlank: false,
listeners: {
change: function() {
this.up('pveIsoSelector').checkChange();
},
},
},
],
});
Ext.define('PVE.grid.BackupView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveBackupView'],
onlineHelp: 'chapter_vzdump',
stateful: true,
stateId: 'grid-guest-backup',
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
var vmid = me.pveSelNode.data.vmid;
if (!vmid) {
throw "no VM ID specified";
}
var vmtype = me.pveSelNode.data.type;
if (!vmtype) {
throw "no VM type specified";
}
var vmtypeFilter;
if (vmtype === 'lxc' || vmtype === 'openvz') {
vmtypeFilter = function(item) {
return PVE.Utils.volume_is_lxc_backup(item.data.volid, item.data.format);
};
} else if (vmtype === 'qemu') {
vmtypeFilter = function(item) {
return PVE.Utils.volume_is_qemu_backup(item.data.volid, item.data.format);
};
} else {
throw "unsupported VM type '" + vmtype + "'";
}
var searchFilter = {
property: 'volid',
value: '',
anyMatch: true,
caseSensitive: false,
};
var vmidFilter = {
property: 'vmid',
value: vmid,
exactMatch: true,
};
me.store = Ext.create('Ext.data.Store', {
model: 'pve-storage-content',
sorters: [
{
property: 'vmid',
direction: 'ASC',
},
{
property: 'vdate',
direction: 'DESC',
},
],
filters: [
vmtypeFilter,
searchFilter,
vmidFilter,
],
});
let updateFilter = function() {
me.store.filter([
vmtypeFilter,
searchFilter,
vmidFilter,
]);
};
const reload = Ext.Function.createBuffered((options) => {
if (me.store) {
me.store.load(options);
}
}, 100);
let isPBS = false;
var setStorage = function(storage) {
var url = '/api2/json/nodes/' + nodename + '/storage/' + storage + '/content';
url += '?content=backup';
me.store.setProxy({
type: 'proxmox',
url: url,
});
Proxmox.Utils.monStoreErrors(me.view, me.store, true);
reload();
};
let file_restore_btn;
var storagesel = Ext.create('PVE.form.StorageSelector', {
nodename: nodename,
fieldLabel: gettext('Storage'),
labelAlign: 'right',
storageContent: 'backup',
allowBlank: false,
listeners: {
change: function(f, value) {
let storage = f.getStore().findRecord('storage', value, 0, false, true, true);
if (storage) {
isPBS = storage.data.type === 'pbs';
me.getColumns().forEach((column) => {
let id = column.dataIndex;
if (id === 'verification' || id === 'encrypted') {
column.setHidden(!isPBS);
}
});
} else {
isPBS = false;
}
setStorage(value);
if (file_restore_btn) {
file_restore_btn.setHidden(!isPBS);
}
},
},
});
var storagefilter = Ext.create('Ext.form.field.Text', {
fieldLabel: gettext('Search'),
labelWidth: 50,
labelAlign: 'right',
enableKeyEvents: true,
value: searchFilter.value,
listeners: {
buffer: 500,
keyup: function(field) {
me.store.clearFilter(true);
searchFilter.value = field.getValue();
updateFilter();
},
},
});
var vmidfilterCB = Ext.create('Ext.form.field.Checkbox', {
boxLabel: gettext('Filter VMID'),
value: '1',
listeners: {
change: function(cb, value) {
vmidFilter.value = value ? vmid : '';
vmidFilter.exactMatch = !!value;
updateFilter();
},
},
});
var sm = Ext.create('Ext.selection.RowModel', {});
var backup_btn = Ext.create('Ext.button.Button', {
text: gettext('Backup now'),
handler: function() {
var win = Ext.create('PVE.window.Backup', {
nodename: nodename,
vmid: vmid,
vmtype: vmtype,
storage: storagesel.getValue(),
listeners: {
close: function() {
reload();
},
},
});
win.show();
},
});
var restore_btn = Ext.create('Proxmox.button.Button', {
text: gettext('Restore'),
disabled: true,
selModel: sm,
enableFn: function(rec) {
return !!rec;
},
handler: function(b, e, rec) {
let win = Ext.create('PVE.window.Restore', {
nodename: nodename,
vmid: vmid,
volid: rec.data.volid,
volidText: PVE.Utils.render_storage_content(rec.data.volid, {}, rec),
vmtype: vmtype,
isPBS: isPBS,
});
win.show();
win.on('destroy', reload);
},
});
let delete_btn = Ext.create('Proxmox.button.StdRemoveButton', {
selModel: sm,
dangerous: true,
delay: 5,
enableFn: rec => !rec?.data?.protected,
confirmMsg: ({ data }) => {
let msg = Ext.String.format(
gettext('Are you sure you want to remove entry {0}'), `'${data.volid}'`);
return msg + " " + gettext('This will permanently erase all data.');
},
getUrl: ({ data }) => `/nodes/${nodename}/storage/${storagesel.getValue()}/content/${data.volid}`,
callback: () => reload(),
});
let config_btn = Ext.create('Proxmox.button.Button', {
text: gettext('Show Configuration'),
disabled: true,
selModel: sm,
enableFn: rec => !!rec,
handler: function(b, e, rec) {
let storage = storagesel.getValue();
if (!storage) {
return;
}
Ext.create('PVE.window.BackupConfig', {
volume: rec.data.volid,
pveSelNode: me.pveSelNode,
autoShow: true,
});
},
});
// declared above so that the storage selector can change this buttons hidden state
file_restore_btn = Ext.create('Proxmox.button.Button', {
text: gettext('File Restore'),
disabled: true,
selModel: sm,
enableFn: rec => !!rec && isPBS,
hidden: !isPBS,
handler: function(b, e, rec) {
let storage = storagesel.getValue();
let isVMArchive = PVE.Utils.volume_is_qemu_backup(rec.data.volid, rec.data.format);
Ext.create('Proxmox.window.FileBrowser', {
title: gettext('File Restore') + " - " + rec.data.text,
listURL: `/api2/json/nodes/localhost/storage/${storage}/file-restore/list`,
downloadURL: `/api2/json/nodes/localhost/storage/${storage}/file-restore/download`,
extraParams: {
volume: rec.data.volid,
},
archive: isVMArchive ? 'all' : undefined,
autoShow: true,
});
},
});
Ext.apply(me, {
selModel: sm,
tbar: {
overflowHandler: 'scroller',
items: [
backup_btn,
'-',
restore_btn,
file_restore_btn,
config_btn,
{
xtype: 'proxmoxButton',
text: gettext('Edit Notes'),
disabled: true,
handler: function() {
let volid = sm.getSelection()[0].data.volid;
var storage = storagesel.getValue();
Ext.create('Proxmox.window.Edit', {
autoLoad: true,
width: 600,
height: 400,
resizable: true,
title: gettext('Notes'),
url: `/api2/extjs/nodes/${nodename}/storage/${storage}/content/${volid}`,
layout: 'fit',
items: [
{
xtype: 'textarea',
layout: 'fit',
name: 'notes',
height: '100%',
},
],
listeners: {
destroy: () => reload(),
},
}).show();
},
},
{
xtype: 'proxmoxButton',
text: gettext('Change Protection'),
disabled: true,
handler: function(button, event, record) {
let volid = record.data.volid, storage = storagesel.getValue();
let url = `/api2/extjs/nodes/${nodename}/storage/${storage}/content/${volid}`;
Proxmox.Utils.API2Request({
url: url,
method: 'PUT',
waitMsgTarget: me,
params: {
'protected': record.data.protected ? 0 : 1,
},
failure: (response) => Ext.Msg.alert('Error', response.htmlStatus),
success: () => {
reload({
callback: () => sm.fireEvent('selectionchange', sm, [record]),
});
},
});
},
},
'-',
delete_btn,
'->',
storagesel,
'-',
vmidfilterCB,
storagefilter,
],
},
columns: [
{
header: gettext('Name'),
flex: 2,
sortable: true,
renderer: PVE.Utils.render_storage_content,
dataIndex: 'volid',
},
{
header: gettext('Notes'),
dataIndex: 'notes',
flex: 1,
renderer: Ext.htmlEncode,
},
{
header: `<i class="fa fa-shield"></i>`,
tooltip: gettext('Protected'),
width: 30,
renderer: v => v ? `<i data-qtip="${gettext('Protected')}" class="fa fa-shield"></i>` : '',
sorter: (a, b) => (b.data.protected || 0) - (a.data.protected || 0),
dataIndex: 'protected',
},
{
header: gettext('Date'),
width: 150,
dataIndex: 'vdate',
},
{
header: gettext('Format'),
width: 100,
dataIndex: 'format',
},
{
header: gettext('Size'),
width: 100,
renderer: Proxmox.Utils.format_size,
dataIndex: 'size',
},
{
header: 'VMID',
dataIndex: 'vmid',
hidden: true,
},
{
header: gettext('Encrypted'),
dataIndex: 'encrypted',
renderer: PVE.Utils.render_backup_encryption,
},
{
header: gettext('Verify State'),
dataIndex: 'verification',
renderer: PVE.Utils.render_backup_verification,
},
],
});
me.callParent();
},
});
Ext.define('PVE.FirewallAliasEdit', {
extend: 'Proxmox.window.Edit',
base_url: undefined,
alias_name: undefined,
width: 400,
initComponent: function() {
let me = this;
me.isCreate = me.alias_name === undefined;
if (me.isCreate) {
me.url = '/api2/extjs' + me.base_url;
me.method = 'POST';
} else {
me.url = '/api2/extjs' + me.base_url + '/' + me.alias_name;
me.method = 'PUT';
}
let ipanel = Ext.create('Proxmox.panel.InputPanel', {
isCreate: me.isCreate,
items: [
{
xtype: 'textfield',
name: me.isCreate ? 'name' : 'rename',
fieldLabel: gettext('Name'),
allowBlank: false,
},
{
xtype: 'textfield',
name: 'cidr',
fieldLabel: gettext('IP/CIDR'),
allowBlank: false,
},
{
xtype: 'textfield',
name: 'comment',
fieldLabel: gettext('Comment'),
},
],
});
Ext.apply(me, {
subject: gettext('Alias'),
isAdd: true,
items: [ipanel],
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
let values = response.result.data;
values.rename = values.name;
ipanel.setValues(values);
},
});
}
},
});
Ext.define('pve-fw-aliases', {
extend: 'Ext.data.Model',
fields: ['name', 'cidr', 'comment', 'digest'],
idProperty: 'name',
});
Ext.define('PVE.FirewallAliases', {
extend: 'Ext.grid.Panel',
alias: ['widget.pveFirewallAliases'],
onlineHelp: 'pve_firewall_ip_aliases',
stateful: true,
stateId: 'grid-firewall-aliases',
base_url: undefined,
title: gettext('Alias'),
initComponent: function() {
let me = this;
if (!me.base_url) {
throw "missing base_url configuration";
}
let store = new Ext.data.Store({
model: 'pve-fw-aliases',
proxy: {
type: 'proxmox',
url: "/api2/json" + me.base_url,
},
sorters: {
property: 'name',
direction: 'ASC',
},
});
let sm = Ext.create('Ext.selection.RowModel', {});
let caps = Ext.state.Manager.get('GuiCap');
let canEdit = !!caps.vms['VM.Config.Network'] || !!caps.dc['Sys.Modify'] || !!caps.nodes['Sys.Modify'];
let reload = function() {
let oldrec = sm.getSelection()[0];
store.load(function(records, operation, success) {
if (oldrec) {
var rec = store.findRecord('name', oldrec.data.name, 0, false, true, true);
if (rec) {
sm.select(rec);
}
}
});
};
let run_editor = function() {
let rec = me.getSelectionModel().getSelection()[0];
if (!rec || !canEdit) {
return;
}
let win = Ext.create('PVE.FirewallAliasEdit', {
base_url: me.base_url,
alias_name: rec.data.name,
});
win.show();
win.on('destroy', reload);
};
me.editBtn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
selModel: sm,
enableFn: rec => canEdit,
handler: run_editor,
});
me.addBtn = Ext.create('Ext.Button', {
text: gettext('Add'),
disabled: !caps.vms['VM.Config.Network'] && !caps.dc['Sys.Modify'] && !caps.nodes['Sys.Modify'],
handler: function() {
var win = Ext.create('PVE.FirewallAliasEdit', {
base_url: me.base_url,
});
win.on('destroy', reload);
win.show();
},
});
me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', {
disabled: true,
selModel: sm,
enableFn: rec => !!caps.vms['VM.Config.Network'] || !!caps.dc['Sys.Modify'] || !!caps.nodes['Sys.Modify'],
baseurl: me.base_url + '/',
callback: reload,
});
Ext.apply(me, {
store: store,
tbar: [me.addBtn, me.removeBtn, me.editBtn],
selModel: sm,
columns: [
{
header: gettext('Name'),
dataIndex: 'name',
flex: 1,
},
{
header: gettext('IP/CIDR'),
dataIndex: 'cidr',
flex: 1,
},
{
header: gettext('Comment'),
dataIndex: 'comment',
renderer: Ext.String.htmlEncode,
flex: 3,
},
],
listeners: {
itemdblclick: run_editor,
},
});
me.callParent();
me.on('activate', reload);
},
});
Ext.define('PVE.FirewallOptions', {
extend: 'Proxmox.grid.ObjectGrid',
alias: ['widget.pveFirewallOptions'],
fwtype: undefined, // 'dc', 'node', 'vm' or 'vnet'
base_url: undefined,
initComponent: function() {
var me = this;
if (!['dc', 'node', 'vm', 'vnet'].includes(me.fwtype)) {
throw "unknown firewall option type";
}
if (me.fwtype === 'node') {
me.cwidth1 = 250;
}
let caps = Ext.state.Manager.get('GuiCap');
let canEdit = caps.vms['VM.Config.Network'] || caps.dc['Sys.Modify'] || caps.nodes['Sys.Modify'];
me.rows = {};
var add_boolean_row = function(name, text, defaultValue) {
me.add_boolean_row(name, text, { defaultValue: defaultValue });
};
var add_integer_row = function(name, text, minValue, labelWidth) {
me.add_integer_row(name, text, {
minValue: minValue,
deleteEmpty: true,
labelWidth: labelWidth,
renderer: function(value) {
if (value === undefined) {
return Proxmox.Utils.defaultText;
}
return value;
},
});
};
var add_log_row = function(name, labelWidth) {
me.rows[name] = {
header: name,
required: true,
defaultValue: 'nolog',
editor: {
xtype: 'proxmoxWindowEdit',
subject: name,
fieldDefaults: { labelWidth: labelWidth || 100 },
items: {
xtype: 'pveFirewallLogLevels',
name: name,
fieldLabel: name,
},
},
};
};
if (me.fwtype === 'node') {
me.rows.enable = {
required: true,
defaultValue: 1,
header: gettext('Firewall'),
renderer: Proxmox.Utils.format_boolean,
editor: {
xtype: 'pveFirewallEnableEdit',
defaultValue: 1,
},
};
add_boolean_row('nosmurfs', gettext('SMURFS filter'), 1);
add_boolean_row('tcpflags', gettext('TCP flags filter'), 0);
add_boolean_row('ndp', 'NDP', 1);
add_integer_row('nf_conntrack_max', 'nf_conntrack_max', 32768, 120);
add_integer_row('nf_conntrack_tcp_timeout_established',
'nf_conntrack_tcp_timeout_established', 7875, 250);
add_log_row('log_level_in');
add_log_row('log_level_out');
add_log_row('log_level_forward');
add_log_row('tcp_flags_log_level', 120);
add_log_row('smurf_log_level');
add_boolean_row('nftables', gettext('nftables (tech preview)'), 0);
} else if (me.fwtype === 'vm') {
me.rows.enable = {
required: true,
defaultValue: 0,
header: gettext('Firewall'),
renderer: Proxmox.Utils.format_boolean,
editor: {
xtype: 'pveFirewallEnableEdit',
defaultValue: 0,
},
};
add_boolean_row('dhcp', 'DHCP', 1);
add_boolean_row('ndp', 'NDP', 1);
add_boolean_row('radv', gettext('Router Advertisement'), 0);
add_boolean_row('macfilter', gettext('MAC filter'), 1);
add_boolean_row('ipfilter', gettext('IP filter'), 0);
add_log_row('log_level_in');
add_log_row('log_level_out');
} else if (me.fwtype === 'dc') {
add_boolean_row('enable', gettext('Firewall'), 0);
add_boolean_row('ebtables', 'ebtables', 1);
me.rows.log_ratelimit = {
header: gettext('Log rate limit'),
required: true,
defaultValue: gettext('Default') + ' (enable=1,rate1/second,burst=5)',
editor: {
xtype: 'pveFirewallLograteEdit',
defaultValue: 'enable=1',
},
};
} else if (me.fwtype === 'vnet') {
add_boolean_row('enable', gettext('Firewall'), 0);
add_log_row('log_level_forward');
}
if (me.fwtype === 'dc' || me.fwtype === 'vm') {
me.rows.policy_in = {
header: gettext('Input Policy'),
required: true,
defaultValue: 'DROP',
editor: {
xtype: 'proxmoxWindowEdit',
subject: gettext('Input Policy'),
items: {
xtype: 'pveFirewallPolicySelector',
name: 'policy_in',
value: 'DROP',
fieldLabel: gettext('Input Policy'),
},
},
};
me.rows.policy_out = {
header: gettext('Output Policy'),
required: true,
defaultValue: 'ACCEPT',
editor: {
xtype: 'proxmoxWindowEdit',
subject: gettext('Output Policy'),
items: {
xtype: 'pveFirewallPolicySelector',
name: 'policy_out',
value: 'ACCEPT',
fieldLabel: gettext('Output Policy'),
},
},
};
}
if (me.fwtype === 'vnet' || me.fwtype === 'dc') {
me.rows.policy_forward = {
header: gettext('Forward Policy'),
required: true,
defaultValue: 'ACCEPT',
editor: {
xtype: 'proxmoxWindowEdit',
subject: gettext('Forward Policy'),
items: {
xtype: 'pveFirewallPolicySelector',
name: 'policy_forward',
value: 'ACCEPT',
fieldLabel: gettext('Forward Policy'),
comboItems: [
['ACCEPT', 'ACCEPT'],
['DROP', 'DROP'],
],
},
},
};
}
var edit_btn = new Ext.Button({
text: gettext('Edit'),
disabled: true,
handler: function() { me.run_editor(); },
});
var set_button_status = function() {
var sm = me.getSelectionModel();
var rec = sm.getSelection()[0];
if (!rec) {
edit_btn.disable();
return;
}
var rowdef = me.rows[rec.data.key];
if (canEdit) {
edit_btn.setDisabled(!rowdef.editor);
}
};
Ext.apply(me, {
tbar: [edit_btn],
listeners: {
itemdblclick: () => { if (canEdit) { me.run_editor(); } },
selectionchange: set_button_status,
},
});
if (me.base_url) {
me.applyUrl(me.base_url);
} else {
me.rstore = Ext.create('Proxmox.data.ObjectStore', {
interval: me.interval,
extraParams: me.extraParams,
rows: me.rows,
});
}
me.callParent();
me.on('activate', me.rstore.startUpdate);
me.on('destroy', me.rstore.stopUpdate);
me.on('deactivate', me.rstore.stopUpdate);
},
applyUrl: function(url) {
let me = this;
Ext.apply(me, {
url: "/api2/json" + url,
editorConfig: {
url: '/api2/extjs/' + url,
},
});
},
setBaseUrl: function(url) {
let me = this;
me.base_url = url;
me.applyUrl(url);
me.rstore.getProxy().setConfig('url', `/api2/extjs/${url}`);
me.rstore.reload();
},
});
Ext.define('PVE.FirewallLogLevels', {
extend: 'Proxmox.form.KVComboBox',
alias: ['widget.pveFirewallLogLevels'],
name: 'log',
fieldLabel: gettext('Log level'),
value: 'nolog',
comboItems: [['nolog', 'nolog'], ['emerg', 'emerg'], ['alert', 'alert'],
['crit', 'crit'], ['err', 'err'], ['warning', 'warning'],
['notice', 'notice'], ['info', 'info'], ['debug', 'debug']],
});
Ext.define('PVE.form.FWMacroSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: 'widget.pveFWMacroSelector',
allowBlank: true,
autoSelect: false,
valueField: 'macro',
displayField: 'macro',
listConfig: {
columns: [
{
header: gettext('Macro'),
dataIndex: 'macro',
hideable: false,
width: 100,
},
{
header: gettext('Description'),
renderer: Ext.String.htmlEncode,
flex: 1,
dataIndex: 'descr',
},
],
},
initComponent: function() {
var me = this;
var store = Ext.create('Ext.data.Store', {
autoLoad: true,
fields: ['macro', 'descr'],
idProperty: 'macro',
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/firewall/macros",
},
sorters: {
property: 'macro',
direction: 'ASC',
},
});
Ext.apply(me, {
store: store,
});
me.callParent();
},
});
Ext.define('PVE.form.ICMPTypeSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: 'widget.pveICMPTypeSelector',
allowBlank: true,
autoSelect: false,
valueField: 'name',
displayField: 'name',
listConfig: {
columns: [
{
header: gettext('Type'),
dataIndex: 'type',
hideable: false,
sortable: false,
width: 50,
},
{
header: gettext('Name'),
dataIndex: 'name',
hideable: false,
sortable: false,
flex: 1,
},
],
},
setName: function(value) {
this.name = value;
},
});
let ICMP_TYPE_NAMES_STORE = Ext.create('Ext.data.Store', {
field: ['type', 'name'],
data: [
{ type: 'any', name: 'any' },
{ type: '0', name: 'echo-reply' },
{ type: '3', name: 'destination-unreachable' },
{ type: '3/0', name: 'network-unreachable' },
{ type: '3/1', name: 'host-unreachable' },
{ type: '3/2', name: 'protocol-unreachable' },
{ type: '3/3', name: 'port-unreachable' },
{ type: '3/4', name: 'fragmentation-needed' },
{ type: '3/5', name: 'source-route-failed' },
{ type: '3/6', name: 'network-unknown' },
{ type: '3/7', name: 'host-unknown' },
{ type: '3/9', name: 'network-prohibited' },
{ type: '3/10', name: 'host-prohibited' },
{ type: '3/11', name: 'TOS-network-unreachable' },
{ type: '3/12', name: 'TOS-host-unreachable' },
{ type: '3/13', name: 'communication-prohibited' },
{ type: '3/14', name: 'host-precedence-violation' },
{ type: '3/15', name: 'precedence-cutoff' },
{ type: '4', name: 'source-quench' },
{ type: '5', name: 'redirect' },
{ type: '5/0', name: 'network-redirect' },
{ type: '5/1', name: 'host-redirect' },
{ type: '5/2', name: 'TOS-network-redirect' },
{ type: '5/3', name: 'TOS-host-redirect' },
{ type: '8', name: 'echo-request' },
{ type: '9', name: 'router-advertisement' },
{ type: '10', name: 'router-solicitation' },
{ type: '11', name: 'time-exceeded' },
{ type: '11/0', name: 'ttl-zero-during-transit' },
{ type: '11/1', name: 'ttl-zero-during-reassembly' },
{ type: '12', name: 'parameter-problem' },
{ type: '12/0', name: 'ip-header-bad' },
{ type: '12/1', name: 'required-option-missing' },
{ type: '13', name: 'timestamp-request' },
{ type: '14', name: 'timestamp-reply' },
{ type: '17', name: 'address-mask-request' },
{ type: '18', name: 'address-mask-reply' },
],
});
let ICMPV6_TYPE_NAMES_STORE = Ext.create('Ext.data.Store', {
field: ['type', 'name'],
data: [
{ type: '1', name: 'destination-unreachable' },
{ type: '1/0', name: 'no-route' },
{ type: '1/1', name: 'communication-prohibited' },
{ type: '1/2', name: 'beyond-scope' },
{ type: '1/3', name: 'address-unreachable' },
{ type: '1/4', name: 'port-unreachable' },
{ type: '1/5', name: 'failed-policy' },
{ type: '1/6', name: 'reject-route' },
{ type: '2', name: 'packet-too-big' },
{ type: '3', name: 'time-exceeded' },
{ type: '3/0', name: 'ttl-zero-during-transit' },
{ type: '3/1', name: 'ttl-zero-during-reassembly' },
{ type: '4', name: 'parameter-problem' },
{ type: '4/0', name: 'bad-header' },
{ type: '4/1', name: 'unknown-header-type' },
{ type: '4/2', name: 'unknown-option' },
{ type: '128', name: 'echo-request' },
{ type: '129', name: 'echo-reply' },
{ type: '133', name: 'router-solicitation' },
{ type: '134', name: 'router-advertisement' },
{ type: '135', name: 'neighbour-solicitation' },
{ type: '136', name: 'neighbour-advertisement' },
{ type: '137', name: 'redirect' },
],
});
let DEFAULT_ALLOWED_DIRECTIONS = ['in', 'out'];
let ALLOWED_DIRECTIONS = {
'dc': ['in', 'out', 'forward'],
'node': ['in', 'out', 'forward'],
'group': ['in', 'out', 'forward'],
'vm': ['in', 'out'],
'vnet': ['forward'],
};
let DEFAULT_ALLOWED_ACTIONS = ['ACCEPT', 'REJECT', 'DROP'];
let ALLOWED_ACTIONS = {
'in': ['ACCEPT', 'REJECT', 'DROP'],
'out': ['ACCEPT', 'REJECT', 'DROP'],
'forward': ['ACCEPT', 'DROP'],
};
Ext.define('PVE.FirewallRulePanel', {
extend: 'Proxmox.panel.InputPanel',
allow_iface: false,
list_refs_url: undefined,
firewall_type: undefined,
action_selector: undefined,
forward_warning: undefined,
onGetValues: function(values) {
var me = this;
// hack: editable ComboGrid returns nothing when empty, so we need to set ''
// Also, disabled text fields return nothing, so we need to set ''
Ext.Array.each(['source', 'dest', 'macro', 'proto', 'sport', 'dport', 'icmp-type', 'log'], function(key) {
if (values[key] === undefined) {
values[key] = '';
}
});
delete values.modified_marker;
return values;
},
setValidActions: function(type) {
let me = this;
let allowed_actions = ALLOWED_ACTIONS[type] ?? DEFAULT_ALLOWED_ACTIONS;
me.action_selector.setComboItems(allowed_actions.map((action) => [action, action]));
},
setForwardWarning: function(type) {
let me = this;
me.forward_warning.setHidden(type !== 'forward');
},
onSetValues: function(values) {
let me = this;
if (values.type) {
me.setValidActions(values.type);
me.setForwardWarning(values.type);
}
return values;
},
initComponent: function() {
var me = this;
if (!me.list_refs_url) {
throw "no list_refs_url specified";
}
let allowed_directions = ALLOWED_DIRECTIONS[me.firewall_type] ?? DEFAULT_ALLOWED_DIRECTIONS;
me.action_selector = Ext.create('Proxmox.form.KVComboBox', {
xtype: 'proxmoxKVComboBox',
name: 'action',
value: 'ACCEPT',
comboItems: DEFAULT_ALLOWED_ACTIONS.map((action) => [action, action]),
fieldLabel: gettext('Action'),
allowBlank: false,
});
me.forward_warning = Ext.create('Proxmox.form.field.DisplayEdit', {
userCls: 'pmx-hint',
value: gettext('Forward rules only take effect when the nftables firewall is activated in the host options'),
hidden: true,
});
me.column1 = [
{
// hack: we use this field to mark the form 'dirty' when the
// record has errors- so that the user can safe the unmodified
// form again.
xtype: 'hiddenfield',
name: 'modified_marker',
value: '',
},
{
xtype: 'proxmoxKVComboBox',
name: 'type',
value: allowed_directions[0],
comboItems: allowed_directions.map((dir) => [dir, dir]),
fieldLabel: gettext('Direction'),
allowBlank: false,
listeners: {
change: function(f, value) {
me.setValidActions(value);
me.setForwardWarning(value);
},
},
},
me.action_selector,
];
if (me.allow_iface) {
me.column1.push({
xtype: 'proxmoxtextfield',
name: 'iface',
deleteEmpty: !me.isCreate,
value: '',
fieldLabel: gettext('Interface'),
});
} else {
me.column1.push({
xtype: 'displayfield',
fieldLabel: '',
value: '',
});
}
me.column1.push(
{
xtype: 'displayfield',
fieldLabel: '',
height: 7,
value: '',
},
{
xtype: 'pveIPRefSelector',
name: 'source',
autoSelect: false,
editable: true,
base_url: me.list_refs_url,
fieldLabel: gettext('Source'),
maxLength: 512,
maxLengthText: gettext('Too long, consider using IP sets.'),
},
{
xtype: 'pveIPRefSelector',
name: 'dest',
autoSelect: false,
editable: true,
base_url: me.list_refs_url,
fieldLabel: gettext('Destination'),
maxLength: 512,
maxLengthText: gettext('Too long, consider using IP sets.'),
},
);
me.column2 = [
{
xtype: 'proxmoxcheckbox',
name: 'enable',
checked: false,
uncheckedValue: 0,
fieldLabel: gettext('Enable'),
},
{
xtype: 'pveFWMacroSelector',
name: 'macro',
fieldLabel: gettext('Macro'),
editable: true,
allowBlank: true,
listeners: {
change: function(f, value) {
if (value === null) {
me.down('field[name=proto]').setDisabled(false);
me.down('field[name=sport]').setDisabled(false);
me.down('field[name=dport]').setDisabled(false);
} else {
me.down('field[name=proto]').setDisabled(true);
me.down('field[name=proto]').setValue('');
me.down('field[name=sport]').setDisabled(true);
me.down('field[name=sport]').setValue('');
me.down('field[name=dport]').setDisabled(true);
me.down('field[name=dport]').setValue('');
}
},
},
},
{
xtype: 'pveIPProtocolSelector',
name: 'proto',
autoSelect: false,
editable: true,
value: '',
fieldLabel: gettext('Protocol'),
listeners: {
change: function(f, value) {
if (value === 'icmp' || value === 'icmpv6' || value === 'ipv6-icmp') {
me.down('field[name=dport]').setHidden(true);
me.down('field[name=dport]').setDisabled(true);
if (value === 'icmp') {
me.down('#icmpv4-type').setHidden(false);
me.down('#icmpv4-type').setDisabled(false);
me.down('#icmpv6-type').setHidden(true);
me.down('#icmpv6-type').setDisabled(true);
} else {
me.down('#icmpv6-type').setHidden(false);
me.down('#icmpv6-type').setDisabled(false);
me.down('#icmpv4-type').setHidden(true);
me.down('#icmpv4-type').setDisabled(true);
}
} else {
me.down('#icmpv4-type').setHidden(true);
me.down('#icmpv4-type').setDisabled(true);
me.down('#icmpv6-type').setHidden(true);
me.down('#icmpv6-type').setDisabled(true);
me.down('field[name=dport]').setHidden(false);
me.down('field[name=dport]').setDisabled(false);
}
},
},
},
{
xtype: 'displayfield',
fieldLabel: '',
height: 7,
value: '',
},
{
xtype: 'textfield',
name: 'sport',
value: '',
fieldLabel: gettext('Source port'),
},
{
xtype: 'textfield',
name: 'dport',
value: '',
fieldLabel: gettext('Dest. port'),
},
{
xtype: 'pveICMPTypeSelector',
name: 'icmp-type',
id: 'icmpv4-type',
autoSelect: false,
editable: true,
hidden: true,
disabled: true,
value: '',
fieldLabel: gettext('ICMP type'),
store: ICMP_TYPE_NAMES_STORE,
},
{
xtype: 'pveICMPTypeSelector',
name: 'icmp-type',
id: 'icmpv6-type',
autoSelect: false,
editable: true,
hidden: true,
disabled: true,
value: '',
fieldLabel: gettext('ICMP type'),
store: ICMPV6_TYPE_NAMES_STORE,
},
];
me.advancedColumn1 = [
{
xtype: 'pveFirewallLogLevels',
},
];
me.columnB = [
{
xtype: 'textfield',
name: 'comment',
value: '',
fieldLabel: gettext('Comment'),
},
me.forward_warning,
];
me.callParent();
if (me.isCreate) {
// on create we never change the values, so we need to trigger this
// manually
me.setValidActions(me.getValues().type);
me.setForwardWarning(me.getValues().type);
}
},
});
Ext.define('PVE.FirewallRuleEdit', {
extend: 'Proxmox.window.Edit',
base_url: undefined,
list_refs_url: undefined,
allow_iface: false,
firewall_type: undefined,
initComponent: function() {
var me = this;
if (!me.base_url) {
throw "no base_url specified";
}
if (!me.list_refs_url) {
throw "no list_refs_url specified";
}
me.isCreate = me.rule_pos === undefined;
if (me.isCreate) {
me.url = '/api2/extjs' + me.base_url;
me.method = 'POST';
} else {
me.url = '/api2/extjs' + me.base_url + '/' + me.rule_pos.toString();
me.method = 'PUT';
}
var ipanel = Ext.create('PVE.FirewallRulePanel', {
isCreate: me.isCreate,
list_refs_url: me.list_refs_url,
allow_iface: me.allow_iface,
rule_pos: me.rule_pos,
firewall_type: me.firewall_type,
});
Ext.apply(me, {
subject: gettext('Rule'),
isAdd: true,
items: [ipanel],
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
var values = response.result.data;
ipanel.setValues(values);
// set icmp-type again after protocol has been set
if (values["icmp-type"] !== undefined) {
ipanel.setValues({ "icmp-type": values["icmp-type"] });
}
if (values.errors) {
var field = me.query('[isFormField][name=modified_marker]')[0];
field.setValue(1);
Ext.Function.defer(function() {
var form = ipanel.up('form').getForm();
form.markInvalid(values.errors);
}, 100);
}
},
});
} else if (me.rec) {
ipanel.setValues(me.rec.data);
}
},
});
Ext.define('PVE.FirewallGroupRuleEdit', {
extend: 'Proxmox.window.Edit',
base_url: undefined,
allow_iface: false,
initComponent: function() {
var me = this;
me.isCreate = me.rule_pos === undefined;
if (me.isCreate) {
me.url = '/api2/extjs' + me.base_url;
me.method = 'POST';
} else {
me.url = '/api2/extjs' + me.base_url + '/' + me.rule_pos.toString();
me.method = 'PUT';
}
var column1 = [
{
xtype: 'hiddenfield',
name: 'type',
value: 'group',
},
{
xtype: 'pveSecurityGroupsSelector',
name: 'action',
value: '',
fieldLabel: gettext('Security Group'),
allowBlank: false,
},
];
if (me.allow_iface) {
column1.push({
xtype: 'proxmoxtextfield',
name: 'iface',
deleteEmpty: !me.isCreate,
value: '',
fieldLabel: gettext('Interface'),
});
}
var ipanel = Ext.create('Proxmox.panel.InputPanel', {
isCreate: me.isCreate,
column1: column1,
column2: [
{
xtype: 'proxmoxcheckbox',
name: 'enable',
checked: false,
uncheckedValue: 0,
fieldLabel: gettext('Enable'),
},
],
columnB: [
{
xtype: 'textfield',
name: 'comment',
value: '',
fieldLabel: gettext('Comment'),
},
],
});
Ext.apply(me, {
subject: gettext('Rule'),
isAdd: true,
items: [ipanel],
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
var values = response.result.data;
ipanel.setValues(values);
},
});
}
},
});
Ext.define('PVE.FirewallRules', {
extend: 'Ext.grid.Panel',
alias: 'widget.pveFirewallRules',
onlineHelp: 'chapter_pve_firewall',
emptyText: gettext('No firewall rule configured here.'),
stateful: true,
stateId: 'grid-firewall-rules',
base_url: undefined,
list_refs_url: undefined,
addBtn: undefined,
removeBtn: undefined,
editBtn: undefined,
groupBtn: undefined,
tbar_prefix: undefined,
allow_groups: true,
allow_iface: false,
firewall_type: undefined,
setBaseUrl: function(url) {
var me = this;
me.base_url = url;
if (url === undefined) {
me.addBtn.setDisabled(true);
if (me.groupBtn) {
me.groupBtn.setDisabled(true);
}
me.store.removeAll();
} else {
if (me.canEdit) {
me.addBtn.setDisabled(false);
if (me.groupBtn) {
me.groupBtn.setDisabled(false);
}
}
me.removeBtn.baseurl = url + '/';
me.store.setProxy({
type: 'proxmox',
url: '/api2/json' + url,
});
me.store.load();
}
},
moveRule: function(from, to) {
var me = this;
if (!me.base_url) {
return;
}
Proxmox.Utils.API2Request({
url: me.base_url + "/" + from,
method: 'PUT',
params: { moveto: to },
waitMsgTarget: me,
failure: function(response, options) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
callback: function() {
me.store.load();
},
});
},
updateRule: function(rule) {
var me = this;
if (!me.base_url) {
return;
}
rule.enable = rule.enable ? 1 : 0;
var pos = rule.pos;
delete rule.pos;
delete rule.errors;
Proxmox.Utils.API2Request({
url: me.base_url + '/' + pos.toString(),
method: 'PUT',
params: rule,
waitMsgTarget: me,
failure: function(response, options) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
callback: function() {
me.store.load();
},
});
},
initComponent: function() {
var me = this;
if (!me.list_refs_url) {
throw "no list_refs_url specified";
}
var store = Ext.create('Ext.data.Store', {
model: 'pve-fw-rule',
});
var reload = function() {
store.load();
};
var sm = Ext.create('Ext.selection.RowModel', {});
me.caps = Ext.state.Manager.get('GuiCap');
me.canEdit = !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify'];
var run_editor = function() {
var rec = sm.getSelection()[0];
if (!rec || !me.canEdit) {
return;
}
var type = rec.data.type;
var editor;
if (type === 'in' || type === 'out' || type === 'forward') {
editor = 'PVE.FirewallRuleEdit';
} else if (type === 'group') {
editor = 'PVE.FirewallGroupRuleEdit';
} else {
return;
}
var win = Ext.create(editor, {
firewall_type: me.firewall_type,
digest: rec.data.digest,
allow_iface: me.allow_iface,
base_url: me.base_url,
list_refs_url: me.list_refs_url,
rule_pos: rec.data.pos,
});
win.show();
win.on('destroy', reload);
};
me.editBtn = Ext.create('Proxmox.button.Button', {
text: gettext('Edit'),
disabled: true,
enableFn: rec => me.canEdit,
selModel: sm,
handler: run_editor,
});
me.addBtn = Ext.create('Ext.Button', {
text: gettext('Add'),
disabled: true,
handler: function() {
var win = Ext.create('PVE.FirewallRuleEdit', {
firewall_type: me.firewall_type,
allow_iface: me.allow_iface,
base_url: me.base_url,
list_refs_url: me.list_refs_url,
});
win.on('destroy', reload);
win.show();
},
});
var run_copy_editor = function() {
let rec = sm.getSelection()[0];
if (!rec) {
return;
}
let type = rec.data.type;
if (!(type === 'in' || type === 'out' || type === 'forward')) {
return;
}
let win = Ext.create('PVE.FirewallRuleEdit', {
firewall_type: me.firewall_type,
allow_iface: me.allow_iface,
base_url: me.base_url,
list_refs_url: me.list_refs_url,
rec: rec,
});
win.show();
win.on('destroy', reload);
};
me.copyBtn = Ext.create('Proxmox.button.Button', {
text: gettext('Copy'),
selModel: sm,
enableFn: ({ data }) => (data.type === 'in' || data.type === 'out' || data.type === 'forward') && me.canEdit,
disabled: true,
handler: run_copy_editor,
});
if (me.allow_groups) {
me.groupBtn = Ext.create('Ext.Button', {
text: gettext('Insert') + ': ' +
gettext('Security Group'),
disabled: true,
handler: function() {
var win = Ext.create('PVE.FirewallGroupRuleEdit', {
allow_iface: me.allow_iface,
base_url: me.base_url,
});
win.on('destroy', reload);
win.show();
},
});
}
me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', {
enableFn: rec => me.canEdit,
selModel: sm,
baseurl: me.base_url + '/',
confirmMsg: false,
getRecordName: function(rec) {
var rule = rec.data;
return rule.pos.toString() +
'?digest=' + encodeURIComponent(rule.digest);
},
callback: function() {
me.store.load();
},
});
let tbar = me.tbar_prefix ? [me.tbar_prefix] : [];
tbar.push(me.addBtn, me.copyBtn);
if (me.groupBtn) {
tbar.push(me.groupBtn);
}
tbar.push(me.removeBtn, me.editBtn);
let render_errors = function(name, value, metaData, record) {
let errors = record.data.errors;
if (errors && errors[name]) {
metaData.tdCls = 'proxmox-invalid-row';
let html = Ext.htmlEncode(`<p>${Ext.htmlEncode(errors[name])}`);
metaData.tdAttr = 'data-qwidth=600 data-qtitle="ERROR" data-qtip="' + html + '"';
}
return Ext.htmlEncode(value);
};
let columns = [
{
// similar to xtype: 'rownumberer',
dataIndex: 'pos',
resizable: false,
minWidth: 65,
maxWidth: 83,
flex: 1,
sortable: false,
hideable: false,
menuDisabled: true,
renderer: function(value, metaData, record, rowIdx, colIdx) {
metaData.tdCls = Ext.baseCSSPrefix + 'grid-cell-special';
let dragHandle = "<i class='pve-grid-fa fa fa-fw fa-reorder cursor-move'></i>";
if (value >= 0) {
return dragHandle + value;
}
return dragHandle;
},
},
{
xtype: 'checkcolumn',
header: gettext('On'),
dataIndex: 'enable',
listeners: {
checkchange: function(column, recordIndex, checked) {
var record = me.getStore().getData().items[recordIndex];
record.commit();
var data = {};
Ext.Array.forEach(record.getFields(), function(field) {
data[field.name] = record.get(field.name);
});
if (!me.allow_iface || !data.iface) {
delete data.iface;
}
me.updateRule(data);
},
},
width: 40,
},
{
header: gettext('Type'),
dataIndex: 'type',
renderer: function(value, metaData, record) {
return render_errors('type', value, metaData, record);
},
minWidth: 60,
maxWidth: 80,
flex: 2,
},
{
header: gettext('Action'),
dataIndex: 'action',
renderer: function(value, metaData, record) {
return render_errors('action', value, metaData, record);
},
minWidth: 80,
maxWidth: 200,
flex: 2,
},
{
header: gettext('Macro'),
dataIndex: 'macro',
renderer: function(value, metaData, record) {
return render_errors('macro', value, metaData, record);
},
minWidth: 80,
flex: 2,
},
];
if (me.allow_iface) {
columns.push({
header: gettext('Interface'),
dataIndex: 'iface',
renderer: function(value, metaData, record) {
return render_errors('iface', value, metaData, record);
},
minWidth: 80,
flex: 2,
});
}
columns.push(
{
header: gettext('Protocol'),
dataIndex: 'proto',
renderer: function(value, metaData, record) {
return render_errors('proto', value, metaData, record);
},
width: 75,
},
{
header: gettext('Source'),
dataIndex: 'source',
renderer: function(value, metaData, record) {
return render_errors('source', value, metaData, record);
},
minWidth: 100,
flex: 2,
},
{
header: gettext('S.Port'),
dataIndex: 'sport',
renderer: function(value, metaData, record) {
return render_errors('sport', value, metaData, record);
},
width: 75,
},
{
header: gettext('Destination'),
dataIndex: 'dest',
renderer: function(value, metaData, record) {
return render_errors('dest', value, metaData, record);
},
minWidth: 100,
flex: 2,
},
{
header: gettext('D.Port'),
dataIndex: 'dport',
renderer: function(value, metaData, record) {
return render_errors('dport', value, metaData, record);
},
width: 75,
},
{
header: gettext('Log level'),
dataIndex: 'log',
renderer: function(value, metaData, record) {
return render_errors('log', value, metaData, record);
},
width: 100,
},
{
header: gettext('Comment'),
dataIndex: 'comment',
flex: 10,
minWidth: 75,
renderer: function(value, metaData, record) {
let comment = render_errors('comment', value, metaData, record) || '';
if (comment.length * 12 > metaData.column.cellWidth) {
comment = `<span data-qtip="${Ext.htmlEncode(comment)}">${comment}</span>`;
}
return comment;
},
},
);
Ext.apply(me, {
store: store,
selModel: sm,
tbar: tbar,
viewConfig: {
plugins: [
{
ptype: 'gridviewdragdrop',
dragGroup: 'FWRuleDDGroup',
dropGroup: 'FWRuleDDGroup',
},
],
listeners: {
beforedrop: function(node, data, dropRec, dropPosition) {
if (!dropRec) {
return false; // empty view
}
let moveto = dropRec.get('pos');
if (dropPosition === 'after') {
moveto++;
}
let pos = data.records[0].get('pos');
me.moveRule(pos, moveto);
return 0;
},
itemdblclick: run_editor,
},
},
sortableColumns: false,
columns: columns,
});
me.callParent();
if (me.base_url) {
me.setBaseUrl(me.base_url); // load
}
},
}, function() {
Ext.define('pve-fw-rule', {
extend: 'Ext.data.Model',
fields: [
{ name: 'enable', type: 'boolean' },
'type',
'action',
'macro',
'source',
'dest',
'proto',
'iface',
'dport',
'sport',
'comment',
'pos',
'digest',
'errors',
],
idProperty: 'pos',
});
});
Ext.define('PVE.pool.AddVM', {
extend: 'Proxmox.window.Edit',
width: 640,
height: 480,
isAdd: true,
isCreate: true,
extraRequestParams: {
'allow-move': 1,
},
initComponent: function() {
var me = this;
if (!me.pool) {
throw "no pool specified";
}
me.url = '/pools/';
me.method = 'PUT';
me.extraRequestParams.poolid = me.pool;
var vmsField = Ext.create('Ext.form.field.Text', {
name: 'vms',
hidden: true,
allowBlank: false,
});
var vmStore = Ext.create('Ext.data.Store', {
model: 'PVEResources',
sorters: [
{
property: 'vmid',
direction: 'ASC',
},
],
filters: [
function(item) {
return (item.data.type === 'lxc' || item.data.type === 'qemu') &&item.data.pool !== me.pool;
},
],
});
var vmGrid = Ext.create('widget.grid', {
store: vmStore,
border: true,
height: 360,
scrollable: true,
selModel: {
selType: 'checkboxmodel',
mode: 'SIMPLE',
listeners: {
selectionchange: function(model, selected, opts) {
var selectedVms = [];
selected.forEach(function(vm) {
selectedVms.push(vm.data.vmid);
});
vmsField.setValue(selectedVms);
},
},
},
columns: [
{
header: 'ID',
dataIndex: 'vmid',
width: 60,
},
{
header: gettext('Node'),
dataIndex: 'node',
},
{
header: gettext('Current Pool'),
dataIndex: 'pool',
},
{
header: gettext('Status'),
dataIndex: 'uptime',
renderer: v => v ? Proxmox.Utils.runningText : Proxmox.Utils.stoppedText,
},
{
header: gettext('Name'),
dataIndex: 'name',
flex: 1,
},
{
header: gettext('Type'),
dataIndex: 'type',
},
],
});
Ext.apply(me, {
subject: gettext('Virtual Machine'),
items: [
vmsField,
vmGrid,
{
xtype: 'displayfield',
userCls: 'pmx-hint',
value: gettext('Selected guests who are already part of a pool will be removed from it first.'),
},
],
});
me.callParent();
vmStore.load();
},
});
Ext.define('PVE.pool.AddStorage', {
extend: 'Proxmox.window.Edit',
initComponent: function() {
var me = this;
if (!me.pool) {
throw "no pool specified";
}
me.isCreate = true;
me.isAdd = true;
me.url = "/pools/";
me.method = 'PUT';
me.extraRequestParams.poolid = me.pool;
Ext.apply(me, {
subject: gettext('Storage'),
width: 350,
items: [
{
xtype: 'pveStorageSelector',
name: 'storage',
nodename: 'localhost',
autoSelect: false,
value: '',
fieldLabel: gettext("Storage"),
},
],
});
me.callParent();
},
});
Ext.define('PVE.grid.PoolMembers', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pvePoolMembers'],
stateful: true,
stateId: 'grid-pool-members',
initComponent: function() {
var me = this;
if (!me.pool) {
throw "no pool specified";
}
me.rstore = Ext.create('Proxmox.data.UpdateStore', {
interval: 10000,
model: 'PVEResources',
proxy: {
type: 'proxmox',
root: 'data[0].members',
url: `/api2/json/pools/?poolid=${me.pool}`,
},
autoStart: true,
});
let store = Ext.create('Proxmox.data.DiffStore', {
rstore: me.rstore,
sorters: [
{
property: 'type',
direction: 'ASC',
},
],
});
var coldef = PVE.data.ResourceStore.defaultColumns().filter((c) =>
c.dataIndex !== 'tags' && c.dataIndex !== 'lock',
);
var reload = function() {
store.load();
};
var sm = Ext.create('Ext.selection.RowModel', {});
var remove_btn = new Proxmox.button.Button({
text: gettext('Remove'),
disabled: true,
selModel: sm,
confirmMsg: function(rec) {
return Ext.String.format(gettext('Are you sure you want to remove entry {0}'),
"'" + rec.data.id + "'");
},
handler: function(btn, event, rec) {
var params = { 'delete': 1, poolid: me.pool };
if (rec.data.type === 'storage') {
params.storage = rec.data.storage;
} else if (rec.data.type === 'qemu' || rec.data.type === 'lxc' || rec.data.type === 'openvz') {
params.vms = rec.data.vmid;
} else {
throw "unknown resource type";
}
Proxmox.Utils.API2Request({
url: '/pools/',
method: 'PUT',
params: params,
waitMsgTarget: me,
callback: function() {
reload();
},
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
});
},
});
Ext.apply(me, {
store: store,
selModel: sm,
tbar: [
{
text: gettext('Add'),
menu: new Ext.menu.Menu({
items: [
{
text: gettext('Virtual Machine'),
iconCls: 'pve-itype-icon-qemu',
handler: function() {
var win = Ext.create('PVE.pool.AddVM', { pool: me.pool });
win.on('destroy', reload);
win.show();
},
},
{
text: gettext('Storage'),
iconCls: 'pve-itype-icon-storage',
handler: function() {
var win = Ext.create('PVE.pool.AddStorage', { pool: me.pool });
win.on('destroy', reload);
win.show();
},
},
],
}),
},
remove_btn,
],
viewConfig: {
stripeRows: true,
},
columns: coldef,
listeners: {
itemcontextmenu: PVE.Utils.createCmdMenu,
itemdblclick: function(v, record) {
var ws = me.up('pveStdWorkspace');
ws.selectById(record.data.id);
},
activate: reload,
destroy: () => me.rstore.stopUpdate(),
},
});
me.callParent();
},
});
Ext.define('PVE.window.ReplicaEdit', {
extend: 'Proxmox.window.Edit',
xtype: 'pveReplicaEdit',
subject: gettext('Replication Job'),
url: '/cluster/replication',
method: 'POST',
initComponent: function() {
var me = this;
var vmid = me.pveSelNode.data.vmid;
var nodename = me.pveSelNode.data.node;
var items = [];
items.push({
xtype: me.isCreate && !vmid?'pveGuestIDSelector':'displayfield',
name: 'guest',
fieldLabel: 'CT/VM ID',
value: vmid || '',
});
items.push(
{
xtype: me.isCreate ? 'pveNodeSelector':'displayfield',
name: 'target',
disallowedNodes: [nodename],
allowBlank: false,
onlineValidator: true,
fieldLabel: gettext("Target"),
},
{
xtype: 'pveCalendarEvent',
fieldLabel: gettext('Schedule'),
emptyText: '*/15 - ' + Ext.String.format(gettext('Every {0} minutes'), 15),
name: 'schedule',
},
{
xtype: 'numberfield',
fieldLabel: gettext('Rate limit') + ' (MB/s)',
step: 1,
minValue: 1,
emptyText: gettext('unlimited'),
name: 'rate',
},
{
xtype: 'textfield',
fieldLabel: gettext('Comment'),
name: 'comment',
},
{
xtype: 'proxmoxcheckbox',
name: 'enabled',
defaultValue: 'on',
checked: true,
fieldLabel: gettext('Enabled'),
},
);
me.items = [
{
xtype: 'inputpanel',
itemId: 'ipanel',
onlineHelp: 'pvesr_schedule_time_format',
onGetValues: function(values) {
let win = this.up('window');
values.disable = values.enabled ? 0 : 1;
delete values.enabled;
PVE.Utils.delete_if_default(values, 'rate', '', win.isCreate);
PVE.Utils.delete_if_default(values, 'disable', 0, win.isCreate);
PVE.Utils.delete_if_default(values, 'schedule', '*/15', win.isCreate);
PVE.Utils.delete_if_default(values, 'comment', '', win.isCreate);
if (win.isCreate) {
values.type = 'local';
let vm = vmid || values.guest;
let id = -1;
if (win.highestids[vm] !== undefined) {
id = win.highestids[vm];
}
id++;
values.id = vm + '-' + id.toString();
delete values.guest;
}
return values;
},
items: items,
},
];
me.callParent();
if (me.isCreate) {
me.load({
success: function(response) {
var jobs = response.result.data;
var highestids = {};
Ext.Array.forEach(jobs, function(job) {
var match = /^([0-9]+)-([0-9]+)$/.exec(job.id);
if (match) {
let jobVMID = parseInt(match[1], 10);
let id = parseInt(match[2], 10);
if (highestids[jobVMID] === undefined || highestids[jobVMID] < id) {
highestids[jobVMID] = id;
}
}
});
me.highestids = highestids;
},
});
} else {
me.load({
success: function(response, options) {
response.result.data.enabled = !response.result.data.disable;
me.setValues(response.result.data);
me.digest = response.result.data.digest;
},
});
}
},
});
/* callback is a function and string */
Ext.define('PVE.grid.ReplicaView', {
extend: 'Ext.grid.Panel',
xtype: 'pveReplicaView',
onlineHelp: 'chapter_pvesr',
stateful: true,
stateId: 'grid-pve-replication-status',
controller: {
xclass: 'Ext.app.ViewController',
addJob: function(button, event, rec) {
let me = this;
let view = me.getView();
Ext.create('PVE.window.ReplicaEdit', {
isCreate: true,
method: 'POST',
pveSelNode: view.pveSelNode,
listeners: {
destroy: () => me.reload(),
},
autoShow: true,
});
},
editJob: function(button, event, { data }) {
let me = this;
let view = me.getView();
Ext.create('PVE.window.ReplicaEdit', {
url: `/cluster/replication/${data.id}`,
method: 'PUT',
pveSelNode: view.pveSelNode,
listeners: {
destroy: () => me.reload(),
},
autoShow: true,
});
},
scheduleJobNow: function(button, event, rec) {
let me = this;
let view = me.getView();
Proxmox.Utils.API2Request({
url: `/api2/extjs/nodes/${view.nodename}/replication/${rec.data.id}/schedule_now`,
method: 'POST',
waitMsgTarget: view,
callback: () => me.reload(),
failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
},
showLog: function(button, event, rec) {
let me = this;
let view = this.getView();
let logView = Ext.create('Proxmox.panel.LogView', {
border: false,
url: `/api2/extjs/nodes/${view.nodename}/replication/${rec.data.id}/log`,
});
let task = Ext.TaskManager.newTask({
run: () => logView.requestUpdate(),
interval: 1000,
});
let win = Ext.create('Ext.window.Window', {
items: [logView],
layout: 'fit',
width: 800,
height: 400,
modal: true,
title: gettext("Replication Log"),
listeners: {
destroy: function() {
task.stop();
me.reload();
},
},
});
task.start();
win.show();
},
reload: function() {
this.getView().rstore.load();
},
dblClick: function(grid, record, item) {
this.editJob(undefined, undefined, record);
},
// currently replication is for cluster only, so disable the whole component for non-cluster
checkPrerequisites: function() {
let view = this.getView();
if (PVE.Utils.isStandaloneNode()) {
view.mask(gettext("Replication needs at least two nodes"), ['pve-static-mask']);
}
},
control: {
'#': {
itemdblclick: 'dblClick',
afterlayout: 'checkPrerequisites',
},
},
},
tbar: [
{
text: gettext('Add'),
itemId: 'addButton',
handler: 'addJob',
},
{
xtype: 'proxmoxButton',
text: gettext('Edit'),
itemId: 'editButton',
handler: 'editJob',
disabled: true,
},
{
xtype: 'proxmoxStdRemoveButton',
itemId: 'removeButton',
baseurl: '/api2/extjs/cluster/replication/',
dangerous: true,
callback: 'reload',
},
{
xtype: 'proxmoxButton',
text: gettext('Log'),
itemId: 'logButton',
handler: 'showLog',
disabled: true,
},
{
xtype: 'proxmoxButton',
text: gettext('Schedule now'),
itemId: 'scheduleNowButton',
handler: 'scheduleJobNow',
disabled: true,
},
],
initComponent: function() {
var me = this;
var mode = '';
var url = '/cluster/replication';
me.nodename = me.pveSelNode.data.node;
me.vmid = me.pveSelNode.data.vmid;
me.columns = [
{
header: gettext('Enabled'),
width: 80,
dataIndex: 'enabled',
align: 'center',
renderer: Proxmox.Utils.renderEnabledIcon,
sortable: true,
},
{
text: 'ID',
dataIndex: 'id',
width: 60,
hidden: true,
},
{
text: gettext('Guest'),
dataIndex: 'guest',
width: 75,
},
{
text: gettext('Job'),
dataIndex: 'jobnum',
width: 60,
},
{
text: gettext('Target'),
dataIndex: 'target',
},
];
if (!me.nodename) {
mode = 'dc';
me.stateId = 'grid-pve-replication-dc';
} else if (!me.vmid) {
mode = 'node';
url = `/nodes/${me.nodename}/replication`;
} else {
mode = 'vm';
url = `/nodes/${me.nodename}/replication?guest=${me.vmid}`;
}
if (mode !== 'dc') {
me.columns.push(
{
text: gettext('Status'),
dataIndex: 'state',
minWidth: 160,
flex: 1,
renderer: function(value, metadata, record) {
if (record.data.pid) {
metadata.tdCls = 'x-grid-row-loading';
return '';
}
let icons = [], states = [];
if (record.data.remove_job) {
icons.push('<i class="fa fa-ban warning" title="'
+ gettext("Removal Scheduled") + '"></i>');
states.push(gettext("Removal Scheduled"));
}
if (record.data.error) {
icons.push('<i class="fa fa-times critical" title="'
+ gettext("Error") + '"></i>');
states.push(record.data.error);
}
if (icons.length === 0) {
icons.push('<i class="fa fa-check good"></i>');
states.push(gettext('OK'));
}
return icons.join(',') + ' ' + states.join(',');
},
},
{
text: gettext('Last Sync'),
dataIndex: 'last_sync',
width: 150,
renderer: function(value, metadata, record) {
if (!value) {
return '-';
}
if (record.data.pid) {
return gettext('syncing');
}
return Proxmox.Utils.render_timestamp(value);
},
},
{
text: gettext('Duration'),
dataIndex: 'duration',
width: 60,
renderer: Proxmox.Utils.render_duration,
},
{
text: gettext('Next Sync'),
dataIndex: 'next_sync',
width: 150,
renderer: function(value) {
if (!value) {
return '-';
}
let now = new Date(), next = new Date(value * 1000);
if (next < now) {
return gettext('pending');
}
return Proxmox.Utils.render_timestamp(value);
},
},
);
}
me.columns.push(
{
text: gettext('Schedule'),
width: 75,
dataIndex: 'schedule',
},
{
text: gettext('Rate limit'),
dataIndex: 'rate',
renderer: function(value) {
if (!value) {
return gettext('unlimited');
}
return value.toString() + ' MB/s';
},
hidden: true,
},
{
text: gettext('Comment'),
dataIndex: 'comment',
renderer: Ext.htmlEncode,
},
);
me.rstore = Ext.create('Proxmox.data.UpdateStore', {
storeid: 'pve-replica-' + me.nodename + me.vmid,
model: mode === 'dc'? 'pve-replication' : 'pve-replication-state',
interval: 3000,
proxy: {
type: 'proxmox',
url: "/api2/json" + url,
},
});
me.store = Ext.create('Proxmox.data.DiffStore', {
rstore: me.rstore,
sorters: [
{
property: 'guest',
},
{
property: 'jobnum',
},
],
});
me.callParent();
// we cannot access the log and scheduleNow button
// in the datacenter, because
// we do not know where/if the jobs runs
if (mode === 'dc') {
me.down('#logButton').setHidden(true);
me.down('#scheduleNowButton').setHidden(true);
}
// if we set the warning mask, we do not want to load
// or set the mask on store errors
if (PVE.Utils.isStandaloneNode()) {
return;
}
Proxmox.Utils.monStoreErrors(me, me.rstore);
me.on('destroy', me.rstore.stopUpdate);
me.rstore.startUpdate();
},
}, function() {
Ext.define('pve-replication', {
extend: 'Ext.data.Model',
fields: [
'id', 'target', 'comment', 'rate', 'type',
{ name: 'guest', type: 'integer' },
{ name: 'jobnum', type: 'integer' },
{ name: 'schedule', defaultValue: '*/15' },
{ name: 'disable', defaultValue: '' },
{ name: 'enabled', calculate: function(data) { return !data.disable; } },
],
});
Ext.define('pve-replication-state', {
extend: 'pve-replication',
fields: [
'last_sync', 'next_sync', 'error', 'duration', 'state',
'fail_count', 'remove_job', 'pid',
],
});
});
Ext.define('PVE.grid.ResourceGrid', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveResourceGrid'],
border: false,
defaultSorter: {
property: 'type',
direction: 'ASC',
},
userCls: 'proxmox-tags-full',
initComponent: function() {
let me = this;
let rstore = PVE.data.ResourceStore;
let store = Ext.create('Ext.data.Store', {
model: 'PVEResources',
sorters: me.defaultSorter,
proxy: {
type: 'memory',
},
});
let textfilter = '';
let textfilterMatch = function(item) {
for (const field of ['name', 'storage', 'node', 'type', 'text']) {
let v = item.data[field];
if (v && v.toLowerCase().indexOf(textfilter) >= 0) {
return true;
}
}
return false;
};
let updateGrid = function() {
var filterfn = me.viewFilter ? me.viewFilter.filterfn : null;
store.suspendEvents();
let nodeidx = {};
let gather_child_nodes;
gather_child_nodes = function(node) {
if (!node || !node.childNodes) {
return;
}
for (let child of node.childNodes) {
let orgNode = rstore.data.get(child.data.realId ?? child.data.id);
if (orgNode) {
if ((!filterfn || filterfn(child)) && (!textfilter || textfilterMatch(child))) {
nodeidx[child.data.id] = orgNode;
}
}
gather_child_nodes(child);
}
};
gather_child_nodes(me.pveSelNode);
// remove vanished items
let rmlist = [];
store.each(olditem => {
if (!nodeidx[olditem.data.id]) {
rmlist.push(olditem);
}
});
if (rmlist.length) {
store.remove(rmlist);
}
// add new items
let addlist = [];
for (const [_key, item] of Object.entries(nodeidx)) {
// getById() use find(), which is slow (ExtJS4 DP5)
let olditem = store.data.get(item.data.id);
if (!olditem) {
addlist.push(item);
continue;
}
let changes = false;
for (let field of PVE.data.ResourceStore.fieldNames) {
if (field !== 'id' && item.data[field] !== olditem.data[field]) {
changes = true;
olditem.beginEdit();
olditem.set(field, item.data[field]);
}
}
if (changes) {
olditem.endEdit(true);
olditem.commit(true);
}
}
if (addlist.length) {
store.add(addlist);
}
store.sort();
store.resumeEvents();
store.fireEvent('refresh', store);
};
Ext.apply(me, {
store: store,
stateful: true,
stateId: 'grid-resource',
tbar: [
'->',
gettext('Search') + ':', ' ',
{
xtype: 'textfield',
width: 200,
value: textfilter,
enableKeyEvents: true,
listeners: {
buffer: 500,
keyup: function(field, e) {
textfilter = field.getValue().toLowerCase();
updateGrid();
},
},
},
],
viewConfig: {
stripeRows: true,
},
listeners: {
itemcontextmenu: PVE.Utils.createCmdMenu,
itemdblclick: function(v, record) {
var ws = me.up('pveStdWorkspace');
ws.selectById(record.data.id);
},
afterrender: function() {
updateGrid();
},
},
columns: rstore.defaultColumns(),
});
me.callParent();
me.mon(rstore, 'load', () => updateGrid());
},
});
/*
* Base class for all the multitab config panels
*
* How to use this:
*
* You create a subclass of this, and then define your wanted tabs
* as items like this:
*
* items: [{
* title: "myTitle",
* xytpe: "somextype",
* iconCls: 'fa fa-icon',
* groups: ['somegroup'],
* expandedOnInit: true,
* itemId: 'someId'
* }]
*
* this has to be in the declarative syntax, else we
* cannot save them for later
* (so no Ext.create or Ext.apply of an item in the subclass)
*
* the groups array expects the itemids of the items
* which are the parents, which have to come before they
* are used
*
* if you want following the tree:
*
* Option1
* Option2
* -> SubOption1
* -> SubSubOption1
*
* the suboption1 group array has to look like this:
* groups: ['itemid-of-option2']
*
* and of subsuboption1:
* groups: ['itemid-of-option2', 'itemid-of-suboption1']
*
* setting the expandedOnInit determines if the item/group is expanded
* initially (false by default)
*/
Ext.define('PVE.panel.Config', {
extend: 'Ext.panel.Panel',
alias: 'widget.pvePanelConfig',
showSearch: true, // add a resource grid with a search button as first tab
viewFilter: undefined, // a filter to pass to that resource grid
tbarSpacing: true, // if true, adds a spacer after the title in tbar
dockedItems: [{
// this is needed for the overflow handler
xtype: 'toolbar',
overflowHandler: 'scroller',
dock: 'left',
style: {
padding: 0,
margin: 0,
},
cls: 'pve-toolbar-bg',
items: {
xtype: 'treelist',
itemId: 'menu',
ui: 'pve-nav',
expanderOnly: true,
expanderFirst: false,
animation: false,
singleExpand: false,
listeners: {
selectionchange: function(treeList, selection) {
if (!selection) {
return;
}
let view = this.up('panel');
view.suspendLayout = true;
view.activateCard(selection.data.id);
view.suspendLayout = false;
view.updateLayout();
},
itemclick: function(treelist, info) {
var olditem = treelist.getSelection();
var newitem = info.node;
// when clicking on the expand arrow, we don't select items, but still want the original behaviour
if (info.select === false) {
return;
}
// click on a different, open item then leave it open, else toggle the clicked item
if (olditem.data.id !== newitem.data.id &&
newitem.data.expanded === true) {
info.toggle = false;
} else {
info.toggle = true;
}
},
},
},
},
{
xtype: 'toolbar',
itemId: 'toolbar',
dock: 'top',
height: 36,
overflowHandler: 'scroller',
}],
firstItem: '',
layout: 'card',
border: 0,
// used for automated test
selectById: function(cardid) {
var me = this;
var root = me.store.getRoot();
var selection = root.findChild('id', cardid, true);
if (selection) {
selection.expand();
var menu = me.down('#menu');
menu.setSelection(selection);
return cardid;
}
return '';
},
activateCard: function(cardid) {
var me = this;
if (me.savedItems[cardid]) {
var curcard = me.getLayout().getActiveItem();
var newcard = me.add(me.savedItems[cardid]);
me.helpButton.setOnlineHelp(newcard.onlineHelp || me.onlineHelp);
if (curcard) {
me.setActiveItem(cardid);
me.remove(curcard, true);
// trigger state change
var ncard = cardid;
// Note: '' is alias for first tab.
// First tab can be 'search' or something else
if (cardid === me.firstItem) {
ncard = '';
}
if (me.hstateid) {
me.sp.set(me.hstateid, { value: ncard });
}
}
}
},
initComponent: function() {
var me = this;
var stateid = me.hstateid;
me.sp = Ext.state.Manager.getProvider();
var activeTab; // leaving this undefined means items[0] will be the default tab
if (stateid) {
let state = me.sp.get(stateid);
if (state && state.value) {
// if this tab does not exist, it chooses the first
activeTab = state.value;
}
}
// get title
var title = me.title || me.pveSelNode.data.text;
me.title = undefined;
// create toolbar
var tbar = me.tbar || [];
me.tbar = undefined;
if (!me.onlineHelp) {
// use the onlineHelp property indirection to enforce checking reference validity
let typeToOnlineHelp = {
'type/lxc': { onlineHelp: 'chapter_pct' },
'type/node': { onlineHelp: 'chapter_system_administration' },
'type/pool': { onlineHelp: 'pveum_pools' },
'type/qemu': { onlineHelp: 'chapter_virtual_machines' },
'type/sdn': { onlineHelp: 'chapter_pvesdn' },
'type/storage': { onlineHelp: 'chapter_storage' },
};
me.onlineHelp = typeToOnlineHelp[me.pveSelNode.data.id]?.onlineHelp;
}
if (me.tbarSpacing) {
tbar.unshift('->');
}
tbar.unshift({
xtype: 'tbtext',
text: title,
baseCls: 'x-panel-header-text',
});
me.helpButton = Ext.create('Proxmox.button.Help', {
hidden: false,
listenToGlobalEvent: false,
onlineHelp: me.onlineHelp || undefined,
});
tbar.push(me.helpButton);
me.dockedItems[1].items = tbar;
// include search tab
me.items = me.items || [];
if (me.showSearch) {
me.items.unshift({
xtype: 'pveResourceGrid',
itemId: 'search',
title: gettext('Search'),
iconCls: 'fa fa-search',
pveSelNode: me.pveSelNode,
});
}
me.savedItems = {};
if (me.items[0]) {
me.firstItem = me.items[0].itemId;
}
me.store = Ext.create('Ext.data.TreeStore', {
root: {
expanded: true,
},
});
var root = me.store.getRoot();
me.insertNodes(me.items);
delete me.items;
me.defaults = me.defaults || {};
Ext.apply(me.defaults, {
pveSelNode: me.pveSelNode,
viewFilter: me.viewFilter,
workspace: me.workspace,
border: 0,
});
me.callParent();
var menu = me.down('#menu');
var selection = root.findChild('id', activeTab, true) || root.firstChild;
var node = selection;
while (node !== root) {
node.expand();
node = node.parentNode;
}
menu.setStore(me.store);
menu.setSelection(selection);
// on a state change,
// select the new item
var statechange = function(sp, key, state) {
// it the state change is for this panel
if (stateid && key === stateid && state) {
// get active item
var acard = me.getLayout().getActiveItem().itemId;
// get the itemid of the new value
var ncard = state.value || me.firstItem;
if (ncard && acard !== ncard) {
// select the chosen item
menu.setSelection(root.findChild('id', ncard, true) || root.firstChild);
}
}
};
if (stateid) {
me.mon(me.sp, 'statechange', statechange);
}
},
insertNodes: function(items) {
var me = this;
var root = me.store.getRoot();
items.forEach(function(item) {
var treeitem = Ext.create('Ext.data.TreeModel', {
id: item.itemId,
text: item.title,
iconCls: item.iconCls,
leaf: true,
expanded: item.expandedOnInit,
});
item.header = false;
if (me.savedItems[item.itemId] !== undefined) {
throw "itemId already exists, please use another";
}
me.savedItems[item.itemId] = item;
var group;
var curnode = root;
// get/create the group items
while (Ext.isArray(item.groups) && item.groups.length > 0) {
group = item.groups.shift();
var child = curnode.findChild('id', group);
if (child === null) {
// did not find the group item
// so add it where we are
break;
}
curnode = child;
}
// insert the item
// lets see if it already exists
var node = curnode.findChild('id', item.itemId);
if (node === null) {
curnode.appendChild(treeitem);
} else {
// should not happen!
throw "id already exists";
}
});
},
});
/*
* Input panel for advanced backup options intended to be used as part of an edit/create window.
*/
Ext.define('PVE.panel.BackupAdvancedOptions', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveBackupAdvancedOptionsPanel',
mixins: ['Proxmox.Mixin.CBind'],
cbindData: function() {
let me = this;
me.isCreate = !!me.isCreate;
return {};
},
viewModel: {
data: {},
},
controller: {
xclass: 'Ext.app.ViewController',
toggleFleecing: function(cb, value) {
let me = this;
me.lookup('fleecingStorage').setDisabled(!value);
},
control: {
'proxmoxcheckbox[reference=fleecingEnabled]': {
change: 'toggleFleecing',
},
},
},
onGetValues: function(formValues) {
let me = this;
if (me.needMask) { // isMasked() may not yet be true if not rendered once
return {};
}
if (!formValues.id && me.isCreate) {
formValues.id = 'backup-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13);
}
let options = {};
if (!me.isCreate) {
options.delete = []; // to avoid having to check this all the time
}
const deletePropertyOnEdit = me.isCreate
? () => { /* no-op on create */ }
: key => options.delete.push(key);
let fleecing = {}, fleecingOptions = ['fleecing-enabled', 'fleecing-storage'];
let performance = {}, performanceOptions = ['max-workers', 'pbs-entries-max'];
for (const [key, value] of Object.entries(formValues)) {
if (performanceOptions.includes(key)) {
performance[key] = value;
// deleteEmpty is not currently implemented for pveBandwidthField
} else if (key === 'bwlimit' && value === '') {
deletePropertyOnEdit('bwlimit');
} else if (key === 'delete') {
if (Array.isArray(value)) {
value.filter(opt => !performanceOptions.includes(opt)).forEach(
opt => deletePropertyOnEdit(opt),
);
} else if (!performanceOptions.includes(formValues.delete)) {
deletePropertyOnEdit(value);
}
} else if (fleecingOptions.includes(key)) {
let fleecingKey = key.slice('fleecing-'.length);
fleecing[fleecingKey] = value;
} else {
options[key] = value;
}
}
if (Object.keys(performance).length > 0) {
options.performance = PVE.Parser.printPropertyString(performance);
} else {
deletePropertyOnEdit('performance');
}
if (Object.keys(fleecing).length > 0) {
options.fleecing = PVE.Parser.printPropertyString(fleecing);
} else {
deletePropertyOnEdit('fleecing');
}
if (me.isCreate) {
delete options.delete;
}
return options;
},
onSetValues: function(values) {
if (values.fleecing) {
for (const [key, value] of Object.entries(values.fleecing)) {
values[`fleecing-${key}`] = value;
}
delete values.fleecing;
}
if (values["pbs-change-detection-mode"] === '__default__') {
delete values["pbs-change-detection-mode"];
}
return values;
},
updateCompression: function(value, disabled) {
this.lookup('zstdThreadCount').setDisabled(disabled || value !== 'zstd');
},
items: [
{
xtype: 'pveTwoColumnContainer',
startColumn: {
xtype: 'pmxDisplayEditField',
vtype: 'ConfigId',
fieldLabel: gettext('Job ID'),
emptyText: gettext('Autogenerate'),
name: 'id',
allowBlank: true,
cbind: {
editable: '{isCreate}',
},
},
endFlex: 2,
endColumn: {
xtype: 'displayfield',
value: gettext('Can be used in notification matchers to match this job.'),
},
},
{
xtype: 'pveTwoColumnContainer',
startColumn: {
xtype: 'pveBandwidthField',
name: 'bwlimit',
fieldLabel: gettext('Bandwidth Limit'),
emptyText: gettext('Fallback'),
backendUnit: 'KiB',
allowZero: true,
emptyValue: '',
autoEl: {
tag: 'div',
'data-qtip': Ext.String.format(gettext('Use {0} for unlimited'), 0),
},
},
endFlex: 2,
endColumn: {
xtype: 'displayfield',
value: `${gettext('Limit I/O bandwidth.')} ${Ext.String.format(gettext("Schema default: {0}"), 0)}`,
},
},
{
xtype: 'pveTwoColumnContainer',
startColumn: {
xtype: 'proxmoxintegerfield',
name: 'zstd',
reference: 'zstdThreadCount',
fieldLabel: Ext.String.format(gettext('{0} Threads'), 'Zstd'),
fieldStyle: 'text-align: right',
emptyText: gettext('Fallback'),
minValue: 0,
cbind: {
deleteEmpty: '{!isCreate}',
},
autoEl: {
tag: 'div',
'data-qtip': gettext('With 0, half of the available cores are used'),
},
},
endFlex: 2,
endColumn: {
xtype: 'displayfield',
value: `${gettext('Threads used for zstd compression (non-PBS).')} ${Ext.String.format(gettext("Schema default: {0}"), 1)}`,
},
},
{
xtype: 'pveTwoColumnContainer',
startColumn: {
xtype: 'proxmoxintegerfield',
name: 'max-workers',
minValue: 1,
maxValue: 256,
fieldLabel: gettext('IO-Workers'),
fieldStyle: 'text-align: right',
emptyText: gettext('Fallback'),
cbind: {
deleteEmpty: '{!isCreate}',
},
},
endFlex: 2,
endColumn: {
xtype: 'displayfield',
value: `${gettext('I/O workers in the QEMU process (VMs only).')} ${Ext.String.format(gettext("Schema default: {0}"), 16)}`,
},
},
{
xtype: 'pveTwoColumnContainer',
startColumn: {
xtype: 'proxmoxcheckbox',
name: 'fleecing-enabled',
reference: 'fleecingEnabled',
fieldLabel: gettext('Fleecing'),
uncheckedValue: 0,
value: 0,
},
endFlex: 2,
endColumn: {
xtype: 'displayfield',
value: gettext('Backup write cache that can reduce IO pressure inside guests (VMs only).'),
},
},
{
xtype: 'pveTwoColumnContainer',
startColumn: {
xtype: 'pveStorageSelector',
name: 'fleecing-storage',
fieldLabel: gettext('Fleecing Storage'),
reference: 'fleecingStorage',
clusterView: true,
storageContent: 'images',
allowBlank: false,
disabled: true,
},
endFlex: 2,
endColumn: {
xtype: 'displayfield',
value: gettext('Prefer a fast and local storage, ideally with support for discard and thin-provisioning or sparse files.'),
},
},
{
// It's part of the 'performance' property string, so have a field to preserve the
// value, but don't expose it. It's a rather niche setting and difficult to
// convey/understand what it does.
xtype: 'proxmoxintegerfield',
name: 'pbs-entries-max',
hidden: true,
fieldLabel: 'TODO',
fieldStyle: 'text-align: right',
emptyText: 'TODO',
cbind: {
deleteEmpty: '{!isCreate}',
},
},
{
xtype: 'pveTwoColumnContainer',
startColumn: {
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Repeat missed'),
name: 'repeat-missed',
uncheckedValue: 0,
defaultValue: 0,
cbind: {
deleteDefaultValue: '{!isCreate}',
},
},
endFlex: 2,
endColumn: {
xtype: 'displayfield',
value: gettext("Run jobs as soon as possible if they couldn't start on schedule, for example, due to the node being offline."),
},
},
{
xtype: 'pveTwoColumnContainer',
startColumn: {
xtype: 'proxmoxKVComboBox',
fieldLabel: gettext('PBS change detection mode'),
name: 'pbs-change-detection-mode',
deleteEmpty: true,
value: '__default__',
comboItems: [
['__default__', "Default"],
['data', "Data"],
['metadata', "Metadata"],
],
},
endFlex: 2,
endColumn: {
xtype: 'displayfield',
value: gettext("Mode to detect file changes and switch archive encoding format for container backups."),
},
},
{
xtype: 'component',
padding: '5 1',
html: `<span class="pmx-hint">${gettext('Note')}</span>: ${
gettext("The node-specific 'vzdump.conf' or, if this is not set, the default from the config schema is used to determine fallback values.")}`,
},
],
});
/*
* Input panel for prune settings with a keep-all option intended to be used as
* part of an edit/create window.
*/
Ext.define('PVE.panel.BackupJobPrune', {
extend: 'Proxmox.panel.PruneInputPanel',
xtype: 'pveBackupJobPrunePanel',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'vzdump_retention',
onGetValues: function(formValues) {
if (this.needMask) { // isMasked() may not yet be true if not rendered once
return {};
} else if (this.isCreate && !this.rendered) {
return this.keepAllDefaultForCreate ? { 'prune-backups': 'keep-all=1' } : {};
}
let options = { 'delete': [] };
if ('max-protected-backups' in formValues) {
options['max-protected-backups'] = formValues['max-protected-backups'];
} else if (this.hasMaxProtected) {
options.delete.push('max-protected-backups');
}
delete formValues['max-protected-backups'];
delete formValues.delete;
let retention = PVE.Parser.printPropertyString(formValues);
if (retention === '') {
options.delete.push('prune-backups');
} else {
options['prune-backups'] = retention;
}
if (!this.isCreate) {
// always delete old 'maxfiles' on edit, we map it to keep-last on window load
options.delete.push('maxfiles');
} else {
delete options.delete;
}
return options;
},
updateComponents: function() {
let me = this;
let keepAll = me.down('proxmoxcheckbox[name=keep-all]').getValue();
let anyValue = false;
me.query('pmxPruneKeepField').forEach(field => {
anyValue = anyValue || field.getValue() !== null;
field.setDisabled(keepAll);
});
me.down('component[name=no-keeps-hint]').setHidden(anyValue || keepAll);
},
listeners: {
afterrender: function(panel) {
if (panel.needMask) {
panel.down('component[name=no-keeps-hint]').setHtml('');
panel.mask(
gettext('Backup content type not available for this storage.'),
);
} else if (panel.isCreate && panel.keepAllDefaultForCreate) {
panel.down('proxmoxcheckbox[name=keep-all]').setValue(true);
}
panel.down('component[name=pbs-hint]').setHidden(!panel.showPBSHint);
let maxProtected = panel.down('proxmoxintegerfield[name=max-protected-backups]');
maxProtected.setDisabled(!panel.hasMaxProtected);
maxProtected.setHidden(!panel.hasMaxProtected);
panel.query('pmxPruneKeepField').forEach(field => {
field.on('change', panel.updateComponents, panel);
});
panel.updateComponents();
},
},
columnT: {
xtype: 'proxmoxcheckbox',
name: 'keep-all',
boxLabel: gettext('Keep all backups'),
listeners: {
change: function(field, newValue) {
let panel = field.up('pveBackupJobPrunePanel');
panel.updateComponents();
},
},
},
columnB: [
{
xtype: 'component',
userCls: 'pmx-hint',
name: 'no-keeps-hint',
hidden: true,
padding: '5 1',
cbind: {
html: '{fallbackHintHtml}',
},
},
{
xtype: 'component',
userCls: 'pmx-hint',
name: 'pbs-hint',
hidden: true,
padding: '5 1',
html: gettext("It's preferred to configure backup retention directly on the Proxmox Backup Server."),
},
{
xtype: 'proxmoxintegerfield',
name: 'max-protected-backups',
fieldLabel: gettext('Maximum Protected'),
minValue: -1,
hidden: true,
disabled: true,
emptyText: 'unlimited with Datastore.Allocate privilege, 5 otherwise',
deleteEmpty: true,
autoEl: {
tag: 'div',
'data-qtip': Ext.String.format(gettext('Use {0} for unlimited'), -1),
},
},
],
});
Ext.define('PVE.widget.HealthWidget', {
extend: 'Ext.Component',
alias: 'widget.pveHealthWidget',
data: {
iconCls: PVE.Utils.get_health_icon(undefined, true),
text: '',
title: '',
},
style: {
'text-align': 'center',
},
tpl: [
'<h3>{title}</h3>',
'<i class="fa fa-5x {iconCls}"></i>',
'<br /><br/>',
'{text}',
],
updateHealth: function(data) {
var me = this;
me.update(Ext.apply(me.data, data));
},
initComponent: function() {
var me = this;
if (me.title) {
me.config.data.title = me.title;
}
me.callParent();
},
});
Ext.define('pve-fw-ipsets', {
extend: 'Ext.data.Model',
fields: ['name', 'comment', 'digest'],
idProperty: 'name',
});
Ext.define('PVE.IPSetList', {
extend: 'Ext.grid.Panel',
alias: 'widget.pveIPSetList',
stateful: true,
stateId: 'grid-firewall-ipsetlist',
ipset_panel: undefined,
base_url: undefined,
addBtn: undefined,
removeBtn: undefined,
editBtn: undefined,
initComponent: function() {
var me = this;
if (typeof me.ipset_panel === 'undefined') {
throw "no rule panel specified";
}
if (typeof me.ipset_panel === 'undefined') {
throw "no base_url specified";
}
var store = new Ext.data.Store({
model: 'pve-fw-ipsets',
proxy: {
type: 'proxmox',
url: "/api2/json" + me.base_url,
},
sorters: {
property: 'name',
direction: 'ASC',
},
});
var caps = Ext.state.Manager.get('GuiCap');
let canEdit = !!caps.vms['VM.Config.Network'] || !!caps.dc['Sys.Modify'] || !!caps.nodes['Sys.Modify'];
var sm = Ext.create('Ext.selection.RowModel', {});
var reload = function() {
var oldrec = sm.getSelection()[0];
store.load(function(records, operation, success) {
if (oldrec) {
var rec = store.findRecord('name', oldrec.data.name, 0, false, true, true);
if (rec) {
sm.select(rec);
}
}
});
};
var run_editor = function() {
var rec = sm.getSelection()[0];
if (!rec || !canEdit) {
return;
}
var win = Ext.create('Proxmox.window.Edit', {
subject: "IPSet '" + rec.data.name + "'",
url: me.base_url,
method: 'POST',
digest: rec.data.digest,
items: [
{
xtype: 'hiddenfield',
name: 'rename',
value: rec.data.name,
},
{
xtype: 'textfield',
name: 'name',
value: rec.data.name,
fieldLabel: gettext('Name'),
allowBlank: false,
},
{
xtype: 'textfield',
name: 'comment',
value: rec.data.comment,
fieldLabel: gettext('Comment'),
},
],
});
win.show();
win.on('destroy', reload);
};
me.editBtn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
enableFn: rec => canEdit,
selModel: sm,
handler: run_editor,
});
me.addBtn = new Proxmox.button.Button({
text: gettext('Create'),
handler: function() {
sm.deselectAll();
var win = Ext.create('Proxmox.window.Edit', {
subject: 'IPSet',
url: me.base_url,
method: 'POST',
items: [
{
xtype: 'textfield',
name: 'name',
value: '',
fieldLabel: gettext('Name'),
allowBlank: false,
},
{
xtype: 'textfield',
name: 'comment',
value: '',
fieldLabel: gettext('Comment'),
},
],
});
win.show();
win.on('destroy', reload);
},
});
me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', {
enableFn: rec => canEdit,
selModel: sm,
baseurl: me.base_url + '/',
callback: reload,
});
Ext.apply(me, {
store: store,
tbar: ['<b>IPSet:</b>', me.addBtn, me.removeBtn, me.editBtn],
selModel: sm,
columns: [
{
header: 'IPSet',
dataIndex: 'name',
minWidth: 150,
flex: 1,
},
{
header: gettext('Comment'),
dataIndex: 'comment',
renderer: Ext.String.htmlEncode,
flex: 4,
},
],
listeners: {
itemdblclick: run_editor,
select: function(_, rec) {
var url = me.base_url + '/' + rec.data.name;
me.ipset_panel.setBaseUrl(url);
},
deselect: function() {
me.ipset_panel.setBaseUrl(undefined);
},
show: reload,
},
});
if (!canEdit) {
me.addBtn.setDisabled(true);
}
me.callParent();
store.load();
},
});
Ext.define('PVE.IPSetCidrEdit', {
extend: 'Proxmox.window.Edit',
cidr: undefined,
initComponent: function() {
var me = this;
me.isCreate = me.cidr === undefined;
if (me.isCreate) {
me.url = '/api2/extjs' + me.base_url;
me.method = 'POST';
} else {
me.url = '/api2/extjs' + me.base_url + '/' + me.cidr;
me.method = 'PUT';
}
var column1 = [];
if (me.isCreate) {
if (!me.list_refs_url) {
throw "no alias_base_url specified";
}
column1.push({
xtype: 'pveIPRefSelector',
name: 'cidr',
ref_type: 'alias',
autoSelect: false,
editable: true,
base_url: me.list_refs_url,
allowBlank: false,
fieldLabel: gettext('IP/CIDR'),
});
} else {
column1.push({
xtype: 'displayfield',
name: 'cidr',
value: '',
fieldLabel: gettext('IP/CIDR'),
});
}
var ipanel = Ext.create('Proxmox.panel.InputPanel', {
isCreate: me.isCreate,
column1: column1,
column2: [
{
xtype: 'proxmoxcheckbox',
name: 'nomatch',
checked: false,
uncheckedValue: 0,
fieldLabel: 'nomatch',
},
],
columnB: [
{
xtype: 'textfield',
name: 'comment',
value: '',
fieldLabel: gettext('Comment'),
},
],
});
Ext.apply(me, {
subject: gettext('IP/CIDR'),
items: [ipanel],
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
var values = response.result.data;
ipanel.setValues(values);
},
});
}
},
});
Ext.define('PVE.IPSetGrid', {
extend: 'Ext.grid.Panel',
alias: 'widget.pveIPSetGrid',
stateful: true,
stateId: 'grid-firewall-ipsets',
base_url: undefined,
list_refs_url: undefined,
addBtn: undefined,
removeBtn: undefined,
editBtn: undefined,
setBaseUrl: function(url) {
var me = this;
me.base_url = url;
if (url === undefined) {
me.addBtn.setDisabled(true);
me.store.removeAll();
} else {
if (me.canEdit) {
me.addBtn.setDisabled(false);
}
me.removeBtn.baseurl = url + '/';
me.store.setProxy({
type: 'proxmox',
url: '/api2/json' + url,
});
me.store.load();
}
},
initComponent: function() {
var me = this;
if (!me.list_refs_url) {
throw "no1 list_refs_url specified";
}
var store = new Ext.data.Store({
model: 'pve-ipset',
});
var reload = function() {
store.load();
};
var sm = Ext.create('Ext.selection.RowModel', {});
me.caps = Ext.state.Manager.get('GuiCap');
me.canEdit = !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify'];
var run_editor = function() {
var rec = sm.getSelection()[0];
if (!rec || !me.canEdit) {
return;
}
var win = Ext.create('PVE.IPSetCidrEdit', {
base_url: me.base_url,
cidr: rec.data.cidr,
});
win.show();
win.on('destroy', reload);
};
me.editBtn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
enableFn: rec => me.canEdit,
selModel: sm,
handler: run_editor,
});
me.addBtn = new Proxmox.button.Button({
text: gettext('Add'),
disabled: true,
enableFn: rec => me.canEdit,
handler: function() {
if (!me.base_url) {
return;
}
var win = Ext.create('PVE.IPSetCidrEdit', {
base_url: me.base_url,
list_refs_url: me.list_refs_url,
});
win.show();
win.on('destroy', reload);
},
});
me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', {
disabled: true,
enableFn: rec => me.canEdit,
selModel: sm,
baseurl: me.base_url + '/',
callback: reload,
});
var render_errors = function(value, metaData, record) {
var errors = record.data.errors;
if (errors) {
var msg = errors.cidr || errors.nomatch;
if (msg) {
metaData.tdCls = 'proxmox-invalid-row';
var html = Ext.htmlEncode(`<p>${Ext.htmlEncode(msg)}</p>`);
metaData.tdAttr = `data-qwidth=600 data-qtitle="ERROR" data-qtip="${html}"`;
}
}
return Ext.htmlEncode(value);
};
Ext.apply(me, {
tbar: ['<b>IP/CIDR:</b>', me.addBtn, me.removeBtn, me.editBtn],
store: store,
selModel: sm,
listeners: {
itemdblclick: run_editor,
},
columns: [
{
xtype: 'rownumberer',
// cannot use width on instantiation as rownumberer hard-wires that in the
// constructor to avoid being overridden by applyDefaults
minWidth: 40,
},
{
header: gettext('IP/CIDR'),
dataIndex: 'cidr',
minWidth: 150,
flex: 1,
renderer: function(value, metaData, record) {
value = render_errors(value, metaData, record);
if (record.data.nomatch) {
return '<b>! </b>' + value;
}
return value;
},
},
{
header: gettext('Comment'),
dataIndex: 'comment',
flex: 3,
renderer: function(value) {
return Ext.util.Format.htmlEncode(value);
},
},
],
});
me.callParent();
if (me.base_url) {
me.setBaseUrl(me.base_url); // load
}
},
}, function() {
Ext.define('pve-ipset', {
extend: 'Ext.data.Model',
fields: [{ name: 'nomatch', type: 'boolean' },
'cidr', 'comment', 'errors'],
idProperty: 'cidr',
});
});
Ext.define('PVE.IPSet', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveIPSet',
title: 'IPSet',
onlineHelp: 'pve_firewall_ip_sets',
list_refs_url: undefined,
initComponent: function() {
var me = this;
if (!me.list_refs_url) {
throw "no list_refs_url specified";
}
var ipset_panel = Ext.createWidget('pveIPSetGrid', {
region: 'center',
list_refs_url: me.list_refs_url,
border: false,
});
var ipset_list = Ext.createWidget('pveIPSetList', {
region: 'west',
ipset_panel: ipset_panel,
base_url: me.base_url,
width: '50%',
border: false,
split: true,
});
Ext.apply(me, {
layout: 'border',
items: [ipset_list, ipset_panel],
listeners: {
show: function() {
ipset_list.fireEvent('show', ipset_list);
},
},
});
me.callParent();
},
});
/*
* This is a running chart widget you add time datapoints to it, and we only
* show the last x of it used for ceph performance charts
*/
Ext.define('PVE.widget.RunningChart', {
extend: 'Ext.container.Container',
alias: 'widget.pveRunningChart',
layout: {
type: 'hbox',
align: 'center',
},
items: [
{
width: 80,
xtype: 'box',
itemId: 'title',
data: {
title: '',
},
tpl: '<h3>{title}:</h3>',
},
{
flex: 1,
xtype: 'cartesian',
height: '100%',
itemId: 'chart',
border: false,
axes: [
{
type: 'numeric',
position: 'left',
hidden: true,
minimum: 0,
},
{
type: 'numeric',
position: 'bottom',
hidden: true,
},
],
store: {
trackRemoved: false,
data: {},
},
sprites: [{
id: 'valueSprite',
type: 'text',
text: '0 B/s',
textAlign: 'end',
textBaseline: 'middle',
fontSize: 14,
}],
series: [{
type: 'line',
xField: 'time',
yField: 'val',
fill: 'true',
colors: ['#cfcfcf'],
tooltip: {
trackMouse: true,
renderer: function(tooltip, record, ctx) {
if (!record || !record.data) return;
const view = this.getChart();
const date = new Date(record.data.time);
const value = view.up().renderer(record.data.val);
const line1 = `${view.up().title}: ${value}`;
const line2 = Ext.Date.format(date, 'H:i:s');
tooltip.setHtml(`${line1}<br />${line2}`);
},
},
style: {
lineWidth: 1.5,
opacity: 0.60,
},
marker: {
opacity: 0,
scaling: 0.01,
fx: {
duration: 200,
easing: 'easeOut',
},
},
highlightCfg: {
opacity: 1,
scaling: 1.5,
},
}],
},
],
// the renderer for the tooltip and last value, default just the value
renderer: Ext.identityFn,
// show the last x seconds default is 5 minutes
timeFrame: 5*60,
checkThemeColors: function() {
let me = this;
let rootStyle = getComputedStyle(document.documentElement);
// get color
let background = rootStyle.getPropertyValue("--pwt-panel-background").trim() || "#ffffff";
let text = rootStyle.getPropertyValue("--pwt-text-color").trim() || "#000000";
// set the colors
me.chart.setBackground(background);
me.chart.valuesprite.setAttributes({ fillStyle: text }, true);
me.chart.redraw();
},
addDataPoint: function(value, time) {
let view = this.chart;
let panel = view.up();
let now = new Date().getTime();
let begin = new Date(now - 1000 * panel.timeFrame).getTime();
view.store.add({
time: time || now,
val: value || 0,
});
// delete all old records when we have 20 times more datapoints
// than seconds in our timeframe (so even a subsecond graph does
// not trigger this often)
//
// records in the store do not take much space, but like this,
// we prevent a memory leak when someone has the site open for a long time
// with minimal graphical glitches
if (view.store.count() > panel.timeFrame * 20) {
var oldData = view.store.getData().createFiltered(function(item) {
return item.data.time < begin;
});
view.store.remove(oldData.getRange());
}
view.timeaxis.setMinimum(begin);
view.timeaxis.setMaximum(now);
view.valuesprite.setText(panel.renderer(value || 0).toString());
view.valuesprite.setAttributes({
x: view.getWidth() - 15,
y: view.getHeight()/2,
}, true);
view.redraw();
},
setTitle: function(title) {
this.title = title;
let titlebox = this.getComponent('title');
titlebox.update({ title: title });
},
initComponent: function() {
var me = this;
me.callParent();
if (me.title) {
me.getComponent('title').update({ title: me.title });
}
me.chart = me.getComponent('chart');
me.chart.timeaxis = me.chart.getAxes()[1];
me.chart.valuesprite = me.chart.getSurface('chart').get('valueSprite');
if (me.color) {
me.chart.series[0].setStyle({
fill: me.color,
stroke: me.color,
});
}
me.checkThemeColors();
// switch colors on media query changes
me.mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
me.themeListener = (e) => { me.checkThemeColors(); };
me.mediaQueryList.addEventListener("change", me.themeListener);
},
doDestroy: function() {
let me = this;
me.mediaQueryList.removeEventListener("change", me.themeListener);
me.callParent();
},
});
/*
* This class describes the bottom panel
*/
Ext.define('PVE.panel.StatusPanel', {
extend: 'Ext.tab.Panel',
alias: 'widget.pveStatusPanel',
//title: "Logs",
//tabPosition: 'bottom',
initComponent: function() {
var me = this;
var stateid = 'ltab';
var sp = Ext.state.Manager.getProvider();
var state = sp.get(stateid);
if (state && state.value) {
me.activeTab = state.value;
}
Ext.apply(me, {
listeners: {
tabchange: function() {
var atab = me.getActiveTab().itemId;
let tabstate = { value: atab };
sp.set(stateid, tabstate);
},
},
items: [
{
itemId: 'tasks',
title: gettext('Tasks'),
xtype: 'pveClusterTasks',
},
{
itemId: 'clog',
title: gettext('Cluster log'),
xtype: 'pveClusterLog',
},
],
});
me.callParent();
me.items.get(0).fireEvent('show', me.items.get(0));
var statechange = function(_, key, newstate) {
if (key === stateid) {
var atab = me.getActiveTab().itemId;
let ntab = newstate.value;
if (newstate && ntab && atab !== ntab) {
me.setActiveTab(ntab);
}
}
};
sp.on('statechange', statechange);
me.on('destroy', function() {
sp.un('statechange', statechange);
});
},
});
Ext.define('PVE.panel.GuestStatusView', {
extend: 'Proxmox.panel.StatusView',
alias: 'widget.pveGuestStatusView',
mixins: ['Proxmox.Mixin.CBind'],
cbindData: function(initialConfig) {
var me = this;
return {
isQemu: me.pveSelNode.data.type === 'qemu',
isLxc: me.pveSelNode.data.type === 'lxc',
};
},
controller: {
xclass: 'Ext.app.ViewController',
init: function(view) {
if (view.pveSelNode.data.type !== 'lxc') {
return;
}
const nodename = view.pveSelNode.data.node;
const vmid = view.pveSelNode.data.vmid;
Proxmox.Utils.API2Request({
url: `/api2/extjs/nodes/${nodename}/lxc/${vmid}/config`,
waitMsgTargetView: view,
method: 'GET',
success: ({ result }) => {
view.down('#unprivileged').updateValue(
Proxmox.Utils.format_boolean(result.data.unprivileged));
view.ostype = Ext.htmlEncode(result.data.ostype);
},
});
},
},
layout: {
type: 'vbox',
align: 'stretch',
},
defaults: {
xtype: 'pmxInfoWidget',
padding: '2 25',
},
items: [
{
xtype: 'box',
height: 20,
},
{
itemId: 'status',
title: gettext('Status'),
iconCls: 'fa fa-info fa-fw',
printBar: false,
multiField: true,
renderer: function(record) {
var me = this;
var text = record.data.status;
var qmpstatus = record.data.qmpstatus;
if (qmpstatus && qmpstatus !== record.data.status) {
text += ' (' + qmpstatus + ')';
}
return text;
},
},
{
itemId: 'hamanaged',
iconCls: 'fa fa-heartbeat fa-fw',
title: gettext('HA State'),
printBar: false,
textField: 'ha',
renderer: PVE.Utils.format_ha,
},
{
itemId: 'node',
iconCls: 'fa fa-building fa-fw',
title: gettext('Node'),
cbind: {
text: '{pveSelNode.data.node}',
},
printBar: false,
},
{
itemId: 'unprivileged',
iconCls: 'fa fa-lock fa-fw',
title: gettext('Unprivileged'),
printBar: false,
cbind: {
hidden: '{isQemu}',
},
},
{
xtype: 'box',
height: 15,
},
{
itemId: 'cpu',
iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon',
title: gettext('CPU usage'),
valueField: 'cpu',
maxField: 'cpus',
renderer: Proxmox.Utils.render_cpu_usage,
// in this specific api call
// we already have the correct value for the usage
calculate: Ext.identityFn,
},
{
itemId: 'memory',
iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon',
title: gettext('Memory usage'),
valueField: 'mem',
maxField: 'maxmem',
},
{
itemId: 'swap',
iconCls: 'fa fa-refresh fa-fw',
title: gettext('SWAP usage'),
valueField: 'swap',
maxField: 'maxswap',
cbind: {
hidden: '{isQemu}',
disabled: '{isQemu}',
},
},
{
itemId: 'rootfs',
iconCls: 'fa fa-hdd-o fa-fw',
title: gettext('Bootdisk size'),
valueField: 'disk',
maxField: 'maxdisk',
printBar: false,
renderer: function(used, max) {
var me = this;
me.setPrintBar(used > 0);
if (used === 0) {
return Proxmox.Utils.render_size(max);
} else {
return Proxmox.Utils.render_size_usage(used, max);
}
},
},
{
xtype: 'box',
height: 15,
},
{
itemId: 'ips',
xtype: 'pveAgentIPView',
cbind: {
rstore: '{rstore}',
pveSelNode: '{pveSelNode}',
hidden: '{isLxc}',
disabled: '{isLxc}',
},
},
],
updateTitle: function() {
var me = this;
var uptime = me.getRecordValue('uptime');
var text = "";
if (Number(uptime) > 0) {
text = " (" + gettext('Uptime') + ': ' + Proxmox.Utils.format_duration_long(uptime)
+ ')';
}
let title = `<div class="left-aligned">${me.getRecordValue('name') + text}</div>`;
if (me.pveSelNode.data.type === 'lxc' && me.ostype && me.ostype !== 'unmanaged') {
// Manual mappings for distros with special casing
const namemap = {
'archlinux': 'Arch Linux',
'nixos': 'NixOS',
'opensuse': 'openSUSE',
'centos': 'CentOS',
};
const distro = namemap[me.ostype] ?? Ext.String.capitalize(me.ostype);
title += `<div class="right-aligned">
<i class="fl-${me.ostype} fl-fw"></i>&nbsp;${distro}</div>`;
}
me.setTitle(title);
},
});
Ext.define('PVE.guest.Summary', {
extend: 'Ext.panel.Panel',
xtype: 'pveGuestSummary',
scrollable: true,
bodyPadding: 5,
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
var vmid = me.pveSelNode.data.vmid;
if (!vmid) {
throw "no VM ID specified";
}
if (!me.workspace) {
throw "no workspace specified";
}
if (!me.statusStore) {
throw "no status storage specified";
}
var type = me.pveSelNode.data.type;
var template = !!me.pveSelNode.data.template;
var rstore = me.statusStore;
var items = [
{
xtype: template ? 'pveTemplateStatusView' : 'pveGuestStatusView',
flex: 1,
padding: template ? '5' : '0 5 0 0',
itemId: 'gueststatus',
pveSelNode: me.pveSelNode,
rstore: rstore,
},
{
xtype: 'pmxNotesView',
flex: 1,
padding: template ? '5' : '0 0 0 5',
itemId: 'notesview',
pveSelNode: me.pveSelNode,
},
];
var rrdstore;
if (!template) {
// in non-template mode put the two panels always together
items = [
{
xtype: 'container',
height: 300,
layout: {
type: 'hbox',
align: 'stretch',
},
items: items,
},
];
rrdstore = Ext.create('Proxmox.data.RRDStore', {
rrdurl: `/api2/json/nodes/${nodename}/${type}/${vmid}/rrddata`,
model: 'pve-rrd-guest',
});
items.push(
{
xtype: 'proxmoxRRDChart',
title: gettext('CPU usage'),
pveSelNode: me.pveSelNode,
fields: ['cpu'],
fieldTitles: [gettext('CPU usage')],
unit: 'percent',
store: rrdstore,
},
{
xtype: 'proxmoxRRDChart',
title: gettext('Memory usage'),
pveSelNode: me.pveSelNode,
fields: ['maxmem', 'mem'],
fieldTitles: [gettext('Total'), gettext('RAM usage')],
unit: 'bytes',
powerOfTwo: true,
store: rrdstore,
},
{
xtype: 'proxmoxRRDChart',
title: gettext('Network traffic'),
pveSelNode: me.pveSelNode,
fields: ['netin', 'netout'],
store: rrdstore,
},
{
xtype: 'proxmoxRRDChart',
title: gettext('Disk IO'),
pveSelNode: me.pveSelNode,
fields: ['diskread', 'diskwrite'],
store: rrdstore,
},
);
}
Ext.apply(me, {
tbar: ['->', { xtype: 'proxmoxRRDTypeSelector' }],
items: [
{
xtype: 'container',
itemId: 'itemcontainer',
layout: {
type: 'column',
},
minWidth: 700,
defaults: {
minHeight: 330,
padding: 5,
},
items: items,
listeners: {
resize: function(container) {
Proxmox.Utils.updateColumns(container);
},
},
},
],
});
me.callParent();
if (!template) {
rrdstore.startUpdate();
me.on('destroy', rrdstore.stopUpdate);
}
let sp = Ext.state.Manager.getProvider();
me.mon(sp, 'statechange', function(provider, key, value) {
if (key !== 'summarycolumns') {
return;
}
Proxmox.Utils.updateColumns(me.getComponent('itemcontainer'));
});
},
});
Ext.define('PVE.panel.TemplateStatusView', {
extend: 'Proxmox.panel.StatusView',
alias: 'widget.pveTemplateStatusView',
layout: {
type: 'vbox',
align: 'stretch',
},
defaults: {
xtype: 'pmxInfoWidget',
printBar: false,
padding: '2 25',
},
items: [
{
xtype: 'box',
height: 20,
},
{
itemId: 'hamanaged',
iconCls: 'fa fa-heartbeat fa-fw',
title: gettext('HA State'),
printBar: false,
textField: 'ha',
renderer: PVE.Utils.format_ha,
},
{
itemId: 'node',
iconCls: 'fa fa-fw fa-building',
title: gettext('Node'),
},
{
xtype: 'box',
height: 20,
},
{
itemId: 'cpus',
iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon',
title: gettext('Processors'),
textField: 'cpus',
},
{
itemId: 'memory',
iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon',
title: gettext('Memory'),
textField: 'maxmem',
renderer: Proxmox.Utils.render_size,
},
{
itemId: 'swap',
iconCls: 'fa fa-refresh fa-fw',
title: gettext('Swap'),
textField: 'maxswap',
renderer: Proxmox.Utils.render_size,
},
{
itemId: 'disk',
iconCls: 'fa fa-hdd-o fa-fw',
title: gettext('Bootdisk size'),
textField: 'maxdisk',
renderer: Proxmox.Utils.render_size,
},
{
xtype: 'box',
height: 20,
},
],
initComponent: function() {
var me = this;
var name = me.pveSelNode.data.name;
if (!name) {
throw "no name specified";
}
me.title = name;
me.callParent();
if (me.pveSelNode.data.type !== 'lxc') {
me.remove(me.getComponent('swap'));
}
me.getComponent('node').updateValue(me.pveSelNode.data.node);
},
});
Ext.define('PVE.panel.MultiDiskPanel', {
extend: 'Ext.panel.Panel',
setNodename: function(nodename) {
this.items.each((panel) => panel.setNodename(nodename));
},
border: false,
bodyBorder: false,
layout: 'card',
controller: {
xclass: 'Ext.app.ViewController',
vmconfig: {},
onAdd: function() {
let me = this;
me.lookup('addButton').setDisabled(true);
me.addDisk();
let count = me.lookup('grid').getStore().getCount() + 1; // +1 is from ide2
me.lookup('addButton').setDisabled(count >= me.maxCount);
},
getNextFreeDisk: function(vmconfig) {
throw "implement in subclass";
},
addPanel: function(itemId, vmconfig, nextFreeDisk) {
throw "implement in subclass";
},
// define in subclass
diskSorter: undefined,
addDisk: function() {
let me = this;
let grid = me.lookup('grid');
let store = grid.getStore();
// get free disk id
let vmconfig = me.getVMConfig(true);
let nextFreeDisk = me.getNextFreeDisk(vmconfig);
if (!nextFreeDisk) {
return;
}
// add store entry + panel
let itemId = 'disk-card-' + ++Ext.idSeed;
let rec = store.add({
name: nextFreeDisk.confid,
itemId,
})[0];
let panel = me.addPanel(itemId, vmconfig, nextFreeDisk);
panel.updateVMConfig(vmconfig);
// we need to setup a validitychange handler, so that we can show
// that a disk has invalid fields
let fields = panel.query('field');
fields.forEach((el) => el.on('validitychange', () => {
let valid = fields.every((field) => field.isValid());
rec.set('valid', valid);
me.checkValidity();
}));
store.sort(me.diskSorter);
// select if the panel added is the only one
if (store.getCount() === 1) {
grid.getSelectionModel().select(0, false);
}
},
getBaseVMConfig: function() {
throw "implement in subclass";
},
getVMConfig: function(all) {
let me = this;
let vmconfig = me.getBaseVMConfig();
me.lookup('grid').getStore().each((rec) => {
if (all || rec.get('valid')) {
vmconfig[rec.get('name')] = rec.get('itemId');
}
});
return vmconfig;
},
checkValidity: function() {
let me = this;
let valid = me.lookup('grid').getStore().findExact('valid', false) === -1;
me.lookup('validationfield').setValue(valid);
},
updateVMConfig: function() {
let me = this;
let view = me.getView();
let grid = me.lookup('grid');
let store = grid.getStore();
let vmconfig = me.getVMConfig();
let valid = true;
store.each((rec) => {
let itemId = rec.get('itemId');
let name = rec.get('name');
let panel = view.getComponent(itemId);
if (!panel) {
throw "unexpected missing panel";
}
// copy config for each panel and remote its own id
let panel_vmconfig = Ext.apply({}, vmconfig);
if (panel_vmconfig[name] === itemId) {
delete panel_vmconfig[name];
}
if (!rec.get('valid')) {
valid = false;
}
panel.updateVMConfig(panel_vmconfig);
});
me.lookup('validationfield').setValue(valid);
return vmconfig;
},
onChange: function(panel, newVal) {
let me = this;
let store = me.lookup('grid').getStore();
let el = store.findRecord('itemId', panel.itemId, 0, false, true, true);
if (el.get('name') === newVal) {
// do not update if there was no change
return;
}
el.set('name', newVal);
el.commit();
store.sort(me.diskSorter);
// so that it happens after the layouting
setTimeout(function() {
me.updateVMConfig();
}, 10);
},
onRemove: function(tableview, rowIndex, colIndex, item, event, record) {
let me = this;
let grid = me.lookup('grid');
let store = grid.getStore();
let removed_idx = store.indexOf(record);
let selection = grid.getSelection()[0];
let selected_idx = store.indexOf(selection);
if (selected_idx === removed_idx) {
let newidx = store.getCount() > removed_idx + 1 ? removed_idx + 1: removed_idx - 1;
grid.getSelectionModel().select(newidx, false);
}
store.remove(record);
me.getView().remove(record.get('itemId'));
me.lookup('addButton').setDisabled(false);
me.updateVMConfig();
me.checkValidity();
},
onSelectionChange: function(grid, selection) {
let me = this;
if (!selection || selection.length < 1) {
return;
}
me.getView().setActiveItem(selection[0].data.itemId);
},
control: {
'inputpanel': {
diskidchange: 'onChange',
},
'grid[reference=grid]': {
selectionchange: 'onSelectionChange',
},
},
init: function(view) {
let me = this;
me.onAdd();
me.lookup('grid').getSelectionModel().select(0, false);
},
},
dockedItems: [
{
xtype: 'container',
layout: {
type: 'vbox',
align: 'stretch',
},
dock: 'left',
border: false,
width: 130,
items: [
{
xtype: 'grid',
hideHeaders: true,
reference: 'grid',
flex: 1,
emptyText: gettext('No Disks'),
margin: '0 0 5 0',
store: {
fields: ['name', 'itemId', 'valid'],
data: [],
},
columns: [
{
dataIndex: 'name',
renderer: function(val, md, rec) {
let warn = '';
if (!rec.get('valid')) {
warn = ' <i class="fa warning fa-warning"></i>';
}
return val + warn;
},
flex: 1,
},
{
xtype: 'actioncolumn',
width: 30,
align: 'center',
menuDisabled: true,
items: [
{
iconCls: 'x-fa fa-trash critical',
tooltip: 'Delete',
handler: 'onRemove',
isActionDisabled: 'deleteDisabled',
},
],
},
],
},
{
xtype: 'button',
reference: 'addButton',
text: gettext('Add'),
iconCls: 'fa fa-plus-circle',
handler: 'onAdd',
},
{
// dummy field to control wizard validation
xtype: 'textfield',
hidden: true,
reference: 'validationfield',
submitValue: false,
value: true,
validator: (val) => !!val,
},
],
},
],
});
Ext.define('PVE.panel.TagConfig', {
extend: 'PVE.panel.Config',
alias: 'widget.pveTagConfig',
//onlineHelp: 'gui_tags', // TODO: use this one once available
onlineHelp: 'chapter_gui',
});
/*
* Left Treepanel, containing all the resources we manage in this datacenter: server nodes, server storages, VMs and Containers
*/
Ext.define('PVE.tree.ResourceTree', {
extend: 'Ext.tree.TreePanel',
alias: ['widget.pveResourceTree'],
userCls: 'proxmox-tags-circle',
statics: {
typeDefaults: {
node: {
iconCls: 'fa fa-building',
text: gettext('Nodes'),
},
pool: {
iconCls: 'fa fa-tags',
text: gettext('Resource Pool'),
},
storage: {
iconCls: 'fa fa-database',
text: gettext('Storage'),
},
sdn: {
iconCls: 'fa fa-th',
text: gettext('SDN'),
},
qemu: {
iconCls: 'fa fa-desktop',
text: gettext('Virtual Machine'),
},
lxc: {
//iconCls: 'x-tree-node-lxc',
iconCls: 'fa fa-cube',
text: gettext('LXC Container'),
},
template: {
iconCls: 'fa fa-file-o',
},
tag: {
iconCls: 'fa fa-tag',
},
},
},
useArrows: true,
// private
getTypeOrder: function(type) {
switch (type) {
case 'lxc': return 0;
case 'qemu': return 1;
case 'node': return 2;
case 'sdn': return 3;
case 'storage': return 4;
default: return 9;
}
},
// private
nodeSortFn: function(node1, node2) {
let me = this;
let n1 = node1.data, n2 = node2.data;
if (!n1.groupbyid === !n2.groupbyid) {
let n1IsGuest = n1.type === 'qemu' || n1.type === 'lxc';
let n2IsGuest = n2.type === 'qemu' || n2.type === 'lxc';
if (me['group-guest-types'] || !n1IsGuest || !n2IsGuest) {
// first sort (group) by type
let res = me.getTypeOrder(n1.type) - me.getTypeOrder(n2.type);
if (res !== 0) {
return res;
}
}
// then sort (group) by ID
if (n1IsGuest) {
if (me['group-templates'] && (!n1.template !== !n2.template)) {
return n1.template ? 1 : -1; // sort templates after regular VMs
}
if (me['sort-field'] === 'vmid') {
if (n1.vmid > n2.vmid) { // prefer VMID as metric for guests
return 1;
} else if (n1.vmid < n2.vmid) {
return -1;
}
} else {
return n1.name.localeCompare(n2.name);
}
}
// same types but not a guest
return n1.id > n2.id ? 1 : n1.id < n2.id ? -1 : 0;
} else if (n1.groupbyid) {
return -1;
} else if (n2.groupbyid) {
return 1;
}
return 0; // should not happen
},
// private: fast binary search
findInsertIndex: function(node, child, start, end) {
let me = this;
let diff = end - start;
if (diff <= 0) {
return start;
}
let mid = start + (diff >> 1);
let res = me.nodeSortFn(child, node.childNodes[mid]);
if (res <= 0) {
return me.findInsertIndex(node, child, start, mid);
} else {
return me.findInsertIndex(node, child, mid + 1, end);
}
},
setIconCls: function(info) {
let cls = PVE.Utils.get_object_icon_class(info.type, info);
if (cls !== '') {
info.iconCls = cls;
}
},
setText: function(info) {
let me = this;
let status = '';
if (info.type === 'storage') {
let usage = info.disk / info.maxdisk;
if (usage >= 0.0 && usage <= 1.0) {
let barHeight = (usage * 100).toFixed(0);
let remainingHeight = (100 - barHeight).toFixed(0);
status = '<div class="usage-wrapper">';
status += `<div class="usage-negative" style="height: ${remainingHeight}%"></div>`;
status += `<div class="usage" style="height: ${barHeight}%"></div>`;
status += '</div> ';
}
}
if (Ext.isNumeric(info.vmid) && info.vmid > 0) {
if (PVE.UIOptions.getTreeSortingValue('sort-field') !== 'vmid') {
info.text = `${info.name} (${String(info.vmid)})`;
}
}
info.text = `<span>${status}${info.text}</span>`;
info.text += PVE.Utils.renderTags(info.tags, PVE.UIOptions.tagOverrides);
},
getToolTip: function(info) {
let qtips = [];
if (info.qmpstatus || info.status) {
qtips.push(Ext.String.format(gettext('Status: {0}'), info.qmpstatus || info.status));
}
if (info.lock) {
qtips.push(Ext.String.format(gettext('Config locked ({0})'), info.lock));
}
if (info.hastate !== 'unmanaged') {
qtips.push(Ext.String.format(gettext('HA State: {0}'), info.hastate));
}
if (info.type === 'storage') {
let usage = info.disk / info.maxdisk;
if (usage >= 0.0 && usage <= 1.0) {
qtips.push(Ext.String.format(gettext("Usage: {0}%"), (usage*100).toFixed(2)));
}
}
if (qtips.length === 0) {
return undefined;
}
let tip = qtips.join(', ');
info.tip = tip;
return tip;
},
// private
addChildSorted: function(node, info) {
let me = this;
me.setIconCls(info);
me.setText(info);
if (info.groupbyid) {
if (me.viewFilter.groupRenderer) {
info.text = me.viewFilter.groupRenderer(info);
} else if (info.type === 'type') {
let defaults = PVE.tree.ResourceTree.typeDefaults[info.groupbyid];
if (defaults && defaults.text) {
info.text = defaults.text;
}
} else {
info.text = info.groupbyid;
}
}
let child = Ext.create('PVETree', info);
if (node.childNodes) {
let pos = me.findInsertIndex(node, child, 0, node.childNodes.length);
node.insertBefore(child, node.childNodes[pos]);
} else {
node.insertBefore(child);
}
return child;
},
// private
groupChild: function(node, info, groups, level) {
let me = this;
let groupBy = groups[level];
let v = info[groupBy];
if (v) {
let group = node.findChild('groupbyid', v);
if (!group) {
let groupinfo;
if (info.type === groupBy) {
groupinfo = info;
} else {
groupinfo = {
type: groupBy,
id: groupBy + "/" + v,
};
if (groupBy !== 'type') {
groupinfo[groupBy] = v;
}
}
groupinfo.leaf = false;
groupinfo.groupbyid = v;
group = me.addChildSorted(node, groupinfo);
}
if (info.type === groupBy) {
return group;
}
if (group) {
return me.groupChild(group, info, groups, level + 1);
}
}
return me.addChildSorted(node, info);
},
saveSortingOptions: function() {
let me = this;
let changed = false;
for (const key of ['sort-field', 'group-templates', 'group-guest-types']) {
let newValue = PVE.UIOptions.getTreeSortingValue(key);
if (me[key] !== newValue) {
me[key] = newValue;
changed = true;
}
}
return changed;
},
initComponent: function() {
let me = this;
me.saveSortingOptions();
let rstore = PVE.data.ResourceStore;
let sp = Ext.state.Manager.getProvider();
if (!me.viewFilter) {
me.viewFilter = {};
}
let pdata = {
dataIndex: {},
updateCount: 0,
};
let store = Ext.create('Ext.data.TreeStore', {
model: 'PVETree',
root: {
expanded: true,
id: 'root',
text: gettext('Datacenter'),
iconCls: 'fa fa-server',
},
});
let stateid = 'rid';
const changedFields = [
'text', 'running', 'template', 'status', 'qmpstatus', 'hastate', 'lock', 'tags',
];
// special case ids from the tag view, since they change the id in the state
let idMapFn = function(id) {
if (!id) {
return undefined;
}
if (id.startsWith('qemu') || id.startsWith('lxc')) {
let [realId, _tag] = id.split('-');
return realId;
}
return id;
};
let findNode = function(rootNode, id) {
if (!id) {
return undefined;
}
let node = rootNode.findChild('id', id, true);
if (!node) {
node = rootNode.findChildBy((r) => idMapFn(r.data.id) === idMapFn(id), undefined, true);
}
return node;
};
let updateTree = function() {
store.suspendEvents();
let rootnode = me.store.getRootNode();
// remember selected node (and all parents)
let sm = me.getSelectionModel();
let lastsel = sm.getSelection()[0];
let parents = [];
let sorting_changed = me.saveSortingOptions();
for (let node = lastsel; node; node = node.parentNode) {
parents.push(node);
}
let groups = me.viewFilter.groups || [];
// explicitly check for node/template, as those are not always grouping attributes
let attrMoveChecks = me.viewFilter.attrMoveChecks ?? {};
// also check for name for when the tree is sorted by name
let moveCheckAttrs = groups.concat(['node', 'template', 'name']);
let filterFn = me.viewFilter.getFilterFn ? me.viewFilter.getFilterFn() : Ext.identityFn;
let reselect = false; // for disappeared nodes
let index = pdata.dataIndex;
// remove vanished or moved items and update changed items in-place
for (const [key, olditem] of Object.entries(index)) {
// getById() use find(), which is slow (ExtJS4 DP5)
let oldid = olditem.data.id;
let id = idMapFn(olditem.data.id);
let item = rstore.data.get(id);
let changed = sorting_changed, moved = sorting_changed;
if (item) {
// test if any grouping attributes changed, catches migrated tree-nodes in server view too
for (const attr of moveCheckAttrs) {
if (attrMoveChecks[attr]) {
if (attrMoveChecks[attr](olditem, item)) {
moved = true;
break;
}
} else if (item.data[attr] !== olditem.data[attr]) {
moved = true;
break;
}
}
// tree item has been updated
for (const field of changedFields) {
if (item.data[field] !== olditem.data[field]) {
changed = true;
break;
}
}
// FIXME: also test filterfn()?
}
if (changed) {
olditem.beginEdit();
let info = olditem.data;
Ext.apply(info, item.data);
if (info.id !== oldid) {
info.id = oldid;
}
me.setIconCls(info);
me.setText(info);
olditem.commit();
}
if ((!item || moved) && olditem.isLeaf()) {
delete index[key];
let parentNode = olditem.parentNode;
// a selected item moved (migration) or disappeared (destroyed), so deselect that
// node now and try to reselect the moved (or its parent) node later
if (lastsel && olditem.data.id === lastsel.data.id) {
reselect = true;
sm.deselect(olditem);
}
// store events are suspended, so remove the item manually
store.remove(olditem);
parentNode.removeChild(olditem, true);
if (parentNode.childNodes.length < 1 && parentNode.parentNode) {
let grandParent = parentNode.parentNode;
grandParent.removeChild(parentNode, true);
}
}
}
let items = rstore.getData().items.flatMap(me.viewFilter.itemMap ?? Ext.identityFn);
items.forEach(function(item) { // add new items
let olditem = index[item.data.id];
if (olditem) {
return;
}
if (filterFn && !filterFn(item)) {
return;
}
let info = Ext.apply({ leaf: true }, item.data);
let child = me.groupChild(rootnode, info, groups, 0);
if (child) {
index[item.data.id] = child;
}
});
store.resumeEvents();
store.fireEvent('refresh', store);
let foundChild = findNode(rootnode, lastsel?.data.id);
// select parent node if original selected node vanished
if (lastsel && !foundChild) {
lastsel = rootnode;
for (const node of parents) {
if (rootnode.findChild('id', node.data.id, true)) {
lastsel = node;
break;
}
}
me.selectById(lastsel.data.id);
} else if (lastsel && reselect) {
me.selectById(lastsel.data.id);
}
// on first tree load set the selection from the stateful provider
if (!pdata.updateCount) {
rootnode.expand();
me.applyState(sp.get(stateid));
}
pdata.updateCount++;
};
sp.on('statechange', (_sp, key, value) => {
if (key === stateid) {
me.applyState(value);
}
});
Ext.apply(me, {
allowSelection: true,
store: store,
viewConfig: {
animate: false, // note: animate cause problems with applyState
},
listeners: {
itemcontextmenu: PVE.Utils.createCmdMenu,
destroy: function() {
rstore.un("load", updateTree);
},
beforecellmousedown: function(tree, td, cellIndex, record, tr, rowIndex, ev) {
let sm = me.getSelectionModel();
// disable selection when right clicking except if the record is already selected
me.allowSelection = ev.button !== 2 || sm.isSelected(record);
},
beforeselect: function(tree, record, index, eopts) {
let allow = me.allowSelection;
me.allowSelection = true;
return allow;
},
itemdblclick: PVE.Utils.openTreeConsole,
afterrender: function() {
if (me.tip) {
return;
}
let selectors = [
'.x-tree-node-text > span:not(.proxmox-tag-dark):not(.proxmox-tag-light)',
'.x-tree-icon',
];
me.tip = Ext.create('Ext.tip.ToolTip', {
target: me.el,
delegate: selectors.join(', '),
trackMouse: true,
renderTo: Ext.getBody(),
listeners: {
beforeshow: function(tip) {
let rec = me.getView().getRecord(tip.triggerElement);
let tipText = me.getToolTip(rec.data);
if (tipText) {
tip.update(tipText);
return true;
}
return false;
},
},
});
},
},
setViewFilter: function(view) {
me.viewFilter = view;
me.clearTree();
updateTree();
},
setDatacenterText: function(clustername) {
let rootnode = me.store.getRootNode();
let rnodeText = gettext('Datacenter');
if (clustername !== undefined) {
rnodeText += ' (' + clustername + ')';
}
rootnode.beginEdit();
rootnode.data.text = rnodeText;
rootnode.commit();
},
clearTree: function() {
pdata.updateCount = 0;
let rootnode = me.store.getRootNode();
rootnode.collapse();
rootnode.removeAll();
pdata.dataIndex = {};
me.getSelectionModel().deselectAll();
},
selectExpand: function(node) {
let sm = me.getSelectionModel();
if (!sm.isSelected(node)) {
sm.select(node);
for (let iter = node; iter; iter = iter.parentNode) {
if (!iter.isExpanded()) {
iter.expand();
}
}
me.getView().focusRow(node);
}
},
selectById: function(nodeid) {
let rootnode = me.store.getRootNode();
let node;
if (nodeid === 'root') {
node = rootnode;
} else {
node = findNode(rootnode, nodeid);
}
if (node) {
me.selectExpand(node);
}
return node;
},
applyState: function(state) {
if (state && state.value) {
me.selectById(state.value);
} else {
me.getSelectionModel().deselectAll();
}
},
});
me.callParent();
me.getSelectionModel().on('select', (_sm, n) => sp.set(stateid, { value: n.data.id }));
rstore.on("load", updateTree);
rstore.startUpdate();
me.mon(Ext.GlobalEvents, 'loadedUiOptions', () => {
me.store.getRootNode().cascadeBy({
before: function(node) {
if (node.data.groupbyid) {
node.beginEdit();
let info = node.data;
me.setIconCls(info);
me.setText(info);
if (me.viewFilter.groupRenderer) {
info.text = me.viewFilter.groupRenderer(info);
}
node.commit();
}
return true;
},
});
});
},
});
Ext.define('PVE.guest.SnapshotTree', {
extend: 'Ext.tree.Panel',
xtype: 'pveGuestSnapshotTree',
stateful: true,
stateId: 'grid-snapshots',
viewModel: {
data: {
// should be 'qemu' or 'lxc'
type: undefined,
nodename: undefined,
vmid: undefined,
snapshotAllowed: false,
rollbackAllowed: false,
snapshotFeature: false,
running: false,
selected: '',
load_delay: 3000,
},
formulas: {
canSnapshot: (get) => get('snapshotAllowed') && get('snapshotFeature'),
canRollback: (get) => get('rollbackAllowed') && get('isSnapshot'),
canRemove: (get) => get('snapshotAllowed') && get('isSnapshot'),
isSnapshot: (get) => get('selected') && get('selected') !== 'current',
buttonText: (get) => get('snapshotAllowed') ? gettext('Edit') : gettext('View'),
showMemory: (get) => get('type') === 'qemu',
},
},
controller: {
xclass: 'Ext.app.ViewController',
newSnapshot: function() {
this.run_editor(false);
},
editSnapshot: function() {
this.run_editor(true);
},
run_editor: function(edit) {
let me = this;
let vm = me.getViewModel();
let snapname;
if (edit) {
snapname = vm.get('selected');
if (!snapname || snapname === 'current') { return; }
}
let win = Ext.create('PVE.window.Snapshot', {
nodename: vm.get('nodename'),
vmid: vm.get('vmid'),
viewonly: !vm.get('snapshotAllowed'),
type: vm.get('type'),
isCreate: !edit,
submitText: !edit ? gettext('Take Snapshot') : undefined,
snapname: snapname,
running: vm.get('running'),
});
win.show();
me.mon(win, 'destroy', me.reload, me);
},
snapshotAction: function(action, method) {
let me = this;
let view = me.getView();
let vm = me.getViewModel();
let snapname = vm.get('selected');
if (!snapname) { return; }
let nodename = vm.get('nodename');
let type = vm.get('type');
let vmid = vm.get('vmid');
Proxmox.Utils.API2Request({
url: `/nodes/${nodename}/${type}/${vmid}/snapshot/${snapname}/${action}`,
method: method,
waitMsgTarget: view,
callback: function() {
me.reload();
},
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
success: function(response, options) {
var upid = response.result.data;
var win = Ext.create('Proxmox.window.TaskProgress', { upid: upid });
win.show();
},
});
},
rollback: function() {
this.snapshotAction('rollback', 'POST');
},
remove: function() {
this.snapshotAction('', 'DELETE');
},
cancel: function() {
this.load_task.cancel();
},
reload: function() {
let me = this;
let view = me.getView();
let vm = me.getViewModel();
let nodename = vm.get('nodename');
let vmid = vm.get('vmid');
let type = vm.get('type');
let load_delay = vm.get('load_delay');
Proxmox.Utils.API2Request({
url: `/nodes/${nodename}/${type}/${vmid}/snapshot`,
method: 'GET',
failure: function(response, opts) {
if (me.destroyed) return;
Proxmox.Utils.setErrorMask(view, response.htmlStatus);
me.load_task.delay(load_delay);
},
success: function(response, opts) {
if (me.destroyed) {
// this is in a delayed task, avoid dragons if view has
// been destroyed already and go home.
return;
}
Proxmox.Utils.setErrorMask(view, false);
var digest = 'invalid';
var idhash = {};
var root = { name: '__root', expanded: true, children: [] };
Ext.Array.each(response.result.data, function(item) {
item.leaf = true;
item.children = [];
if (item.name === 'current') {
vm.set('running', !!item.running);
digest = item.digest + item.running;
item.iconCls = PVE.Utils.get_object_icon_class(vm.get('type'), item);
} else {
item.iconCls = 'fa fa-fw fa-history x-fa-tree';
}
idhash[item.name] = item;
});
if (digest !== me.old_digest) {
me.old_digest = digest;
Ext.Array.each(response.result.data, function(item) {
if (item.parent && idhash[item.parent]) {
var parent_item = idhash[item.parent];
parent_item.children.push(item);
parent_item.leaf = false;
parent_item.expanded = true;
parent_item.expandable = false;
} else {
root.children.push(item);
}
});
me.getView().setRootNode(root);
}
me.load_task.delay(load_delay);
},
});
// if we do not have the permissions, we don't have to check
// if we can create a snapshot, since the butten stays disabled
if (!vm.get('snapshotAllowed')) {
return;
}
Proxmox.Utils.API2Request({
url: `/nodes/${nodename}/${type}/${vmid}/feature`,
params: { feature: 'snapshot' },
method: 'GET',
success: function(response, options) {
if (me.destroyed) {
// this is in a delayed task, the current view could been
// destroyed already; then we mustn't do viemodel set
return;
}
let res = response.result.data;
vm.set('snapshotFeature', !!res.hasFeature);
},
});
},
select: function(grid, val) {
let vm = this.getViewModel();
if (val.length < 1) {
vm.set('selected', '');
return;
}
vm.set('selected', val[0].data.name);
},
init: function(view) {
let me = this;
let vm = me.getViewModel();
me.load_task = new Ext.util.DelayedTask(me.reload, me);
if (!view.type) {
throw 'guest type not set';
}
vm.set('type', view.type);
if (!view.pveSelNode.data.node) {
throw "no node name specified";
}
vm.set('nodename', view.pveSelNode.data.node);
if (!view.pveSelNode.data.vmid) {
throw "no VM ID specified";
}
vm.set('vmid', view.pveSelNode.data.vmid);
let caps = Ext.state.Manager.get('GuiCap');
vm.set('snapshotAllowed', !!caps.vms['VM.Snapshot']);
vm.set('rollbackAllowed', !!caps.vms['VM.Snapshot.Rollback']);
view.getStore().sorters.add({
property: 'order',
direction: 'ASC',
});
me.reload();
},
},
listeners: {
selectionchange: 'select',
itemdblclick: 'editSnapshot',
beforedestroy: 'cancel',
},
layout: 'fit',
rootVisible: false,
animate: false,
sortableColumns: false,
tbar: [
{
xtype: 'proxmoxButton',
text: gettext('Take Snapshot'),
disabled: true,
bind: {
disabled: "{!canSnapshot}",
},
handler: 'newSnapshot',
},
'-',
{
xtype: 'proxmoxButton',
text: gettext('Rollback'),
disabled: true,
bind: {
disabled: '{!canRollback}',
},
confirmMsg: function() {
let view = this.up('treepanel');
let rec = view.getSelection()[0];
let vmid = view.getViewModel().get('vmid');
return Proxmox.Utils.format_task_description('qmrollback', vmid) +
` '${rec.data.name}'? ${gettext("Current state will be lost.")}`;
},
handler: 'rollback',
},
'-',
{
xtype: 'proxmoxButton',
text: gettext('Edit'),
bind: {
text: '{buttonText}',
disabled: '{!isSnapshot}',
},
disabled: true,
edit: true,
handler: 'editSnapshot',
},
{
xtype: 'proxmoxButton',
text: gettext('Remove'),
disabled: true,
dangerous: true,
bind: {
disabled: '{!canRemove}',
},
confirmMsg: function() {
let view = this.up('treepanel');
let { data } = view.getSelection()[0];
return Ext.String.format(gettext('Are you sure you want to remove entry {0}'), `'${data.name}'`);
},
handler: 'remove',
},
{
xtype: 'label',
text: gettext("The current guest configuration does not support taking new snapshots"),
hidden: true,
bind: {
hidden: "{canSnapshot}",
},
},
],
columnLines: true,
fields: [
'name',
'description',
'snapstate',
'vmstate',
'running',
{ name: 'snaptime', type: 'date', dateFormat: 'timestamp' },
{
name: 'order',
calculate: function(data) {
return data.snaptime || (data.name === 'current' ? 'ZZZ' : data.snapstate);
},
},
],
columns: [
{
xtype: 'treecolumn',
text: gettext('Name'),
dataIndex: 'name',
width: 200,
renderer: (value, _, { data }) => data.name !== 'current' ? value : gettext('NOW'),
},
{
text: gettext('RAM'),
hidden: true,
bind: {
hidden: '{!showMemory}',
},
align: 'center',
resizable: false,
dataIndex: 'vmstate',
width: 50,
renderer: (value, _, { data }) => data.name !== 'current' ? Proxmox.Utils.format_boolean(value) : '',
},
{
text: gettext('Date') + "/" + gettext("Status"),
dataIndex: 'snaptime',
width: 150,
renderer: function(value, metaData, record) {
if (record.data.snapstate) {
return record.data.snapstate;
} else if (value) {
return Ext.Date.format(value, 'Y-m-d H:i:s');
}
return '';
},
},
{
text: gettext('Description'),
dataIndex: 'description',
flex: 1,
renderer: function(value, metaData, record) {
if (record.data.name === 'current') {
return gettext("You are here!");
} else {
return Ext.String.htmlEncode(value);
}
},
},
],
});
Ext.define('PVE.tree.ResourceMapTree', {
extend: 'Ext.tree.Panel',
alias: 'widget.pveResourceMapTree',
mixins: ['Proxmox.Mixin.CBind'],
rootVisible: false,
emptyText: gettext('No Mapping found'),
// will be opened on edit
editWindowClass: undefined,
// The base url of the resource
baseUrl: undefined,
// icon class to show on the entries
mapIconCls: undefined,
// if given, should be a function that takes a nodename and returns
// the url for getting the data to check the status
getStatusCheckUrl: undefined,
// the result of above api call and the nodename is passed and can set the status
checkValidity: undefined,
// the property that denotes a single map entry for a node
entryIdProperty: undefined,
cbindData: function(initialConfig) {
let me = this;
const caps = Ext.state.Manager.get('GuiCap');
me.canConfigure = !!caps.mapping['Mapping.Modify'];
return {};
},
controller: {
xclass: 'Ext.app.ViewController',
addMapping: function() {
let me = this;
let view = me.getView();
Ext.create(view.editWindowClass, {
url: view.baseUrl,
autoShow: true,
listeners: {
destroy: () => me.load(),
},
});
},
add: function(_grid, _rI, _cI, _item, _e, rec) {
let me = this;
if (rec.data.type !== 'entry') {
return;
}
me.openMapEditWindow(rec.data.name);
},
editDblClick: function() {
let me = this;
let view = me.getView();
let selection = view.getSelection();
if (!selection || selection.length < 1) {
return;
}
me.edit(selection[0]);
},
editAction: function(_grid, _rI, _cI, _item, _e, rec) {
this.edit(rec);
},
edit: function(rec) {
let me = this;
if (rec.data.type === 'map') {
return;
}
me.openMapEditWindow(rec.data.name, rec.data.node, rec.data.type === 'entry');
},
openMapEditWindow: function(name, nodename, entryOnly) {
let me = this;
let view = me.getView();
Ext.create(view.editWindowClass, {
url: `${view.baseUrl}/${name}`,
autoShow: true,
autoLoad: true,
entryOnly,
nodename,
name,
listeners: {
destroy: () => me.load(),
},
});
},
remove: function(_grid, _rI, _cI, _item, _e, rec) {
let me = this;
let msg, id;
let view = me.getView();
let confirmMsg;
switch (rec.data.type) {
case 'entry':
msg = gettext("Are you sure you want to remove '{0}'");
confirmMsg = Ext.String.format(msg, rec.data.name);
break;
case 'node':
msg = gettext("Are you sure you want to remove '{0}' entries for '{1}'");
confirmMsg = Ext.String.format(msg, rec.data.node, rec.data.name);
break;
case 'map':
msg = gettext("Are you sure you want to remove '{0}' on '{1}' for '{2}'");
id = rec.data[view.entryIdProperty];
confirmMsg = Ext.String.format(msg, id, rec.data.node, rec.data.name);
break;
default:
throw "invalid type";
}
Ext.Msg.confirm(gettext('Confirm'), confirmMsg, function(btn) {
if (btn === 'yes') {
me.executeRemove(rec.data);
}
});
},
executeRemove: function(data) {
let me = this;
let view = me.getView();
let url = `${view.baseUrl}/${data.name}`;
let method = 'PUT';
let params = {
digest: me.lookup[data.name].digest,
};
let map = me.lookup[data.name].map;
switch (data.type) {
case 'entry':
method = 'DELETE';
params = undefined;
break;
case 'node':
params.map = PVE.Parser.filterPropertyStringList(map, (e) => e.node !== data.node);
break;
case 'map':
params.map = PVE.Parser.filterPropertyStringList(map, (e) =>
Object.entries(e).some(([key, value]) => data[key] !== value));
break;
default:
throw "invalid type";
}
if (!params?.map.length) {
method = 'DELETE';
params = undefined;
}
Proxmox.Utils.API2Request({
url,
method,
params,
success: function() {
me.load();
},
});
},
load: function() {
let me = this;
let view = me.getView();
Proxmox.Utils.API2Request({
url: view.baseUrl,
method: 'GET',
failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
success: function({ result: { data } }) {
let lookup = {};
data.forEach((entry) => {
lookup[entry.id] = Ext.apply({}, entry);
entry.iconCls = 'fa fa-fw fa-folder-o';
entry.name = entry.id;
entry.text = entry.id;
entry.type = 'entry';
let nodes = {};
for (const map of entry.map) {
let parsed = PVE.Parser.parsePropertyString(map);
parsed.iconCls = view.mapIconCls;
parsed.leaf = true;
parsed.name = entry.id;
parsed.text = parsed[view.entryIdProperty];
parsed.type = 'map';
if (nodes[parsed.node] === undefined) {
nodes[parsed.node] = {
children: [],
expanded: true,
iconCls: 'fa fa-fw fa-building-o',
leaf: false,
name: entry.id,
node: parsed.node,
text: parsed.node,
type: 'node',
};
}
nodes[parsed.node].children.push(parsed);
}
delete entry.id;
entry.children = Object.values(nodes);
entry.leaf = entry.children.length === 0;
});
me.lookup = lookup;
if (view.getStatusCheckUrl !== undefined && view.checkValidity !== undefined) {
me.loadStatusData();
}
view.setRootNode({
children: data,
});
let root = view.getRootNode();
root.expand();
root.childNodes.forEach(node => node.expand());
},
});
},
nodeLoadingState: {},
loadStatusData: function() {
let me = this;
let view = me.getView();
PVE.data.ResourceStore.getNodes().forEach(({ node }) => {
me.nodeLoadingState[node] = true;
let url = view.getStatusCheckUrl(node);
Proxmox.Utils.API2Request({
url,
method: 'GET',
failure: function(response) {
me.nodeLoadingState[node] = false;
view.getRootNode()?.cascade(function(rec) {
if (rec.data.node !== node) {
return;
}
rec.set('valid', 0);
rec.set('errmsg', response.htmlStatus);
rec.commit();
});
},
success: function({ result: { data } }) {
me.nodeLoadingState[node] = false;
view.checkValidity(data, node);
},
});
});
},
renderStatus: function(value, _metadata, record) {
let me = this;
if (record.data.type !== 'map') {
return '';
}
let iconCls;
let status;
if (value === undefined) {
if (me.nodeLoadingState[record.data.node]) {
iconCls = 'fa-spinner fa-spin';
status = gettext('Loading...');
} else {
iconCls = 'fa-question-circle';
status = gettext('Unknown Node');
}
} else {
let state = value ? 'good' : 'critical';
iconCls = PVE.Utils.get_health_icon(state, true);
status = value ? gettext("Mapping matches host data") : record.data.errmsg || Proxmox.Utils.unknownText;
}
return `<i class="fa ${iconCls}"></i> ${status}`;
},
getAddClass: function(v, mD, rec) {
let cls = 'fa fa-plus-circle';
if (rec.data.type !== 'entry' || rec.data.children?.length >= PVE.data.ResourceStore.getNodes().length) {
cls += ' pmx-action-hidden';
}
return cls;
},
isAddDisabled: function(v, r, c, i, rec) {
return rec.data.type !== 'entry' || rec.data.children?.length >= PVE.data.ResourceStore.getNodes().length;
},
init: function(view) {
let me = this;
['editWindowClass', 'baseUrl', 'mapIconCls', 'entryIdProperty'].forEach((property) => {
if (view[property] === undefined) {
throw `No ${property} defined`;
}
});
me.load();
},
},
store: {
sorters: 'text',
data: {},
},
tbar: [
{
text: gettext('Add'),
handler: 'addMapping',
cbind: {
disabled: '{!canConfigure}',
},
},
],
listeners: {
itemdblclick: 'editDblClick',
},
initComponent: function() {
let me = this;
let columns = [...me.columns];
columns.splice(1, 0, {
xtype: 'actioncolumn',
text: gettext('Actions'),
width: 80,
items: [
{
getTip: (v, m, { data }) =>
Ext.String.format(gettext("Add new host mapping for '{0}'"), data.name),
getClass: 'getAddClass',
isActionDisabled: 'isAddDisabled',
handler: 'add',
},
{
iconCls: 'fa fa-pencil',
getTip: (v, m, { data }) => data.type === 'entry'
? Ext.String.format(gettext("Edit Mapping '{0}'"), data.name)
: Ext.String.format(gettext("Edit Mapping '{0}' for '{1}'"), data.name, data.node),
getClass: (v, m, { data }) => data.type !== 'map' ? 'fa fa-pencil' : 'pmx-hidden',
isActionDisabled: (v, r, c, i, rec) => rec.data.type === 'map',
handler: 'editAction',
},
{
iconCls: 'fa fa-trash-o',
getTip: (v, m, { data }) => data.type === 'entry'
? Ext.String.format(gettext("Remove '{0}'"), data.name)
: data.type === 'node'
? Ext.String.format(gettext("Remove mapping for '{0}'"), data.node)
: Ext.String.format(gettext("Remove mapping '{0}'"), data.path),
handler: 'remove',
},
],
});
me.columns = columns;
me.callParent();
},
});
Ext.define('PVE.sdn.DhcpTree', {
extend: 'Ext.tree.Panel',
xtype: 'pveDhcpTree',
layout: 'fit',
rootVisible: false,
animate: false,
store: {
sorters: ['ip', 'name'],
},
controller: {
xclass: 'Ext.app.ViewController',
reload: function() {
let me = this;
Proxmox.Utils.API2Request({
url: `/cluster/sdn/ipams/pve/status`,
method: 'GET',
success: function(response, opts) {
let root = {
name: '__root',
expanded: true,
children: [],
};
let zones = {};
let vnets = {};
let subnets = {};
response.result.data.forEach((element) => {
element.leaf = true;
if (!(element.zone in zones)) {
let zone = {
name: element.zone,
type: 'zone',
iconCls: 'fa fa-th',
expanded: true,
children: [],
};
zones[element.zone] = zone;
root.children.push(zone);
}
if (!(element.vnet in vnets)) {
let vnet = {
name: element.vnet,
zone: element.zone,
type: 'vnet',
iconCls: 'fa fa-network-wired x-fa-treepanel',
expanded: true,
children: [],
};
vnets[element.vnet] = vnet;
zones[element.zone].children.push(vnet);
}
if (!(element.subnet in subnets)) {
let subnet = {
name: element.subnet,
zone: element.zone,
vnet: element.vnet,
type: 'subnet',
iconCls: 'x-tree-icon-none',
expanded: true,
children: [],
};
subnets[element.subnet] = subnet;
vnets[element.vnet].children.push(subnet);
}
element.type = 'mapping';
element.iconCls = 'x-tree-icon-none';
subnets[element.subnet].children.push(element);
});
me.getView().setRootNode(root);
},
});
},
init: function(view) {
let me = this;
me.reload();
},
onDelete: function(table, rI, cI, item, e, { data }) {
let me = this;
let view = me.getView();
Ext.Msg.show({
title: gettext('Confirm'),
icon: Ext.Msg.WARNING,
message: Ext.String.format(gettext('Are you sure you want to remove DHCP mapping {0}'), `${data.mac} / ${data.ip}`),
buttons: Ext.Msg.YESNO,
defaultFocus: 'no',
callback: function(btn) {
if (btn !== 'yes') {
return;
}
let params = {
zone: data.zone,
mac: data.mac,
ip: data.ip,
};
let encodedParams = Ext.Object.toQueryString(params);
let url = `/cluster/sdn/vnets/${data.vnet}/ips?${encodedParams}`;
Proxmox.Utils.API2Request({
url,
method: 'DELETE',
waitMsgTarget: view,
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
callback: me.reload.bind(me),
});
},
});
},
editAction: function(_grid, _rI, _cI, _item, _e, rec) {
this.edit(rec);
},
editDblClick: function() {
let me = this;
let view = me.getView();
let selection = view.getSelection();
if (!selection || selection.length < 1) {
return;
}
me.edit(selection[0]);
},
edit: function(rec) {
let me = this;
if (rec.data.type === 'mapping' && !rec.data.gateway) {
me.openEditWindow(rec.data);
}
},
openEditWindow: function(data) {
let me = this;
let extraRequestParams = {
mac: data.mac,
zone: data.zone,
vnet: data.vnet,
};
if (data.vmid) {
extraRequestParams.vmid = data.vmid;
}
Ext.create('PVE.sdn.IpamEdit', {
autoShow: true,
mapping: data,
extraRequestParams,
listeners: {
destroy: () => me.reload(),
},
});
},
},
listeners: {
itemdblclick: 'editDblClick',
},
tbar: [
{
xtype: 'proxmoxButton',
text: gettext('Reload'),
handler: 'reload',
},
],
columns: [
{
xtype: 'treecolumn',
text: gettext('Name / VMID'),
dataIndex: 'name',
width: 200,
renderer: function(value, meta, record) {
if (record.get('gateway')) {
return gettext('Gateway');
}
return record.get('name') ?? record.get('vmid') ?? ' ';
},
},
{
text: gettext('IP Address'),
dataIndex: 'ip',
width: 200,
},
{
text: 'MAC',
dataIndex: 'mac',
width: 200,
},
{
text: gettext('Gateway'),
dataIndex: 'gateway',
width: 200,
},
{
header: gettext('Actions'),
xtype: 'actioncolumn',
dataIndex: 'text',
width: 150,
items: [
{
handler: function(table, rI, cI, item, e, { data }) {
let me = this;
Ext.create('PVE.sdn.IpamEdit', {
autoShow: true,
mapping: {},
isCreate: true,
extraRequestParams: {
vnet: data.name,
zone: data.zone,
},
listeners: {
destroy: () => {
me.up('pveDhcpTree').controller.reload();
},
},
});
},
getTip: (v, m, rec) => gettext('Add'),
getClass: (v, m, { data }) => {
if (data.type === 'vnet') {
return 'fa fa-plus-square';
}
return 'pmx-hidden';
},
},
{
handler: 'editAction',
getTip: (v, m, rec) => gettext('Edit'),
getClass: (v, m, { data }) => {
if (data.type === 'mapping' && !data.gateway) {
return 'fa fa-pencil fa-fw';
}
return 'pmx-hidden';
},
},
{
handler: 'onDelete',
getTip: (v, m, rec) => gettext('Delete'),
getClass: (v, m, { data }) => {
if (data.type === 'mapping' && !data.gateway) {
return 'fa critical fa-trash-o';
}
return 'pmx-hidden';
},
},
],
},
],
});
Ext.define('PVE.window.Backup', {
extend: 'Ext.window.Window',
resizable: false,
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (!me.vmid) {
throw "no VM ID specified";
}
if (!me.vmtype) {
throw "no VM type specified";
}
let compressionSelector = Ext.create('PVE.form.BackupCompressionSelector', {
name: 'compress',
value: 'zstd',
fieldLabel: gettext('Compression'),
});
let modeSelector = Ext.create('PVE.form.BackupModeSelector', {
fieldLabel: gettext('Mode'),
value: 'snapshot',
name: 'mode',
});
let mailtoField = Ext.create('Ext.form.field.Text', {
fieldLabel: gettext('Send email to'),
name: 'mailto',
emptyText: Proxmox.Utils.noneText,
});
let notificationModeSelector = Ext.create({
xtype: 'proxmoxKVComboBox',
comboItems: [
['auto', gettext('Auto')],
['legacy-sendmail', gettext('Email (legacy)')],
['notification-system', gettext('Notification system')],
],
fieldLabel: gettext('Notification mode'),
name: 'notification-mode',
value: 'auto',
listeners: {
change: function(field, value) {
mailtoField.setDisabled(value === 'notification-system');
},
},
});
const keepNames = [
['keep-last', gettext('Keep Last')],
['keep-hourly', gettext('Keep Hourly')],
['keep-daily', gettext('Keep Daily')],
['keep-weekly', gettext('Keep Weekly')],
['keep-monthly', gettext('Keep Monthly')],
['keep-yearly', gettext('Keep Yearly')],
];
let pruneSettings = keepNames.map(
name => Ext.create('Ext.form.field.Display', {
name: name[0],
fieldLabel: name[1],
hidden: true,
}),
);
let removeCheckbox = Ext.create('Proxmox.form.Checkbox', {
name: 'remove',
checked: false,
hidden: true,
uncheckedValue: 0,
fieldLabel: gettext('Prune'),
autoEl: {
tag: 'div',
'data-qtip': gettext('Prune older backups afterwards'),
},
handler: function(checkbox, value) {
pruneSettings.forEach(field => field.setHidden(!value));
me.down('label[name="pruneLabel"]').setHidden(!value);
},
});
let initialDefaults = false;
var storagesel = Ext.create('PVE.form.StorageSelector', {
nodename: me.nodename,
name: 'storage',
fieldLabel: gettext('Storage'),
storageContent: 'backup',
allowBlank: false,
listeners: {
change: function(f, v) {
if (!initialDefaults) {
me.setLoading(false);
}
if (v === null || v === undefined || v === '') {
return;
}
let store = f.getStore();
let rec = store.findRecord('storage', v, 0, false, true, true);
if (rec && rec.data && rec.data.type === 'pbs') {
compressionSelector.setValue('zstd');
compressionSelector.setDisabled(true);
} else if (!compressionSelector.getEditable()) {
compressionSelector.setDisabled(false);
}
Proxmox.Utils.API2Request({
url: `/nodes/${me.nodename}/vzdump/defaults`,
method: 'GET',
params: {
storage: v,
},
waitMsgTarget: me,
success: function(response, opts) {
const data = response.result.data;
if (!initialDefaults && data.mailto !== undefined) {
mailtoField.setValue(data.mailto);
}
if (!initialDefaults && data['notification-mode'] !== undefined) {
notificationModeSelector.setValue(data['notification-mode']);
}
if (!initialDefaults && data.mode !== undefined) {
modeSelector.setValue(data.mode);
}
if (!initialDefaults && (data['notes-template'] ?? false)) {
me.down('field[name=notes-template]').setValue(
PVE.Utils.unEscapeNotesTemplate(data['notes-template']),
);
}
initialDefaults = true;
// always update storage dependent properties
if (data['prune-backups'] !== undefined) {
const keepParams = PVE.Parser.parsePropertyString(
data["prune-backups"],
);
if (!keepParams['keep-all']) {
removeCheckbox.setHidden(false);
pruneSettings.forEach(function(field) {
const keep = keepParams[field.name];
if (keep) {
field.setValue(keep);
} else {
field.reset();
}
});
return;
}
}
// no defaults or keep-all=1
removeCheckbox.setHidden(true);
removeCheckbox.setValue(false);
pruneSettings.forEach(field => field.reset());
},
failure: function(response, opts) {
initialDefaults = true;
removeCheckbox.setHidden(true);
removeCheckbox.setValue(false);
pruneSettings.forEach(field => field.reset());
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
});
},
},
});
let protectedCheckbox = Ext.create('Proxmox.form.Checkbox', {
name: 'protected',
checked: false,
uncheckedValue: 0,
fieldLabel: gettext('Protected'),
});
me.formPanel = Ext.create('Proxmox.panel.InputPanel', {
bodyPadding: 10,
border: false,
column1: [
storagesel,
modeSelector,
protectedCheckbox,
],
column2: [
compressionSelector,
notificationModeSelector,
mailtoField,
removeCheckbox,
],
columnB: [
{
xtype: 'textareafield',
name: 'notes-template',
fieldLabel: gettext('Notes'),
anchor: '100%',
value: '{{guestname}}',
},
{
xtype: 'box',
style: {
margin: '8px 0px',
'line-height': '1.5em',
},
html: Ext.String.format(
gettext('Possible template variables are: {0}'),
PVE.Utils.notesTemplateVars.map(v => `<code>{{${v}}}</code>`).join(', '),
),
},
{
xtype: 'label',
name: 'pruneLabel',
text: gettext('Storage Retention Configuration') + ':',
hidden: true,
},
{
layout: 'hbox',
border: false,
defaults: {
border: false,
layout: 'anchor',
flex: 1,
},
items: [
{
padding: '0 10 0 0',
defaults: {
labelWidth: 110,
},
items: [
pruneSettings[0],
pruneSettings[2],
pruneSettings[4],
],
},
{
padding: '0 0 0 10',
defaults: {
labelWidth: 110,
},
items: [
pruneSettings[1],
pruneSettings[3],
pruneSettings[5],
],
},
],
},
],
});
var submitBtn = Ext.create('Ext.Button', {
text: gettext('Backup'),
handler: function() {
var storage = storagesel.getValue();
let values = me.formPanel.getValues();
var params = {
storage: storage,
vmid: me.vmid,
mode: values.mode,
remove: values.remove,
};
if (values.mailto) {
params.mailto = values.mailto;
}
if (values['notification-mode']) {
params['notification-mode'] = values['notification-mode'];
}
if (values.compress) {
params.compress = values.compress;
}
if (values.protected) {
params.protected = values.protected;
}
if (values['notes-template']) {
params['notes-template'] = PVE.Utils.escapeNotesTemplate(
values['notes-template']);
}
Proxmox.Utils.API2Request({
url: '/nodes/' + me.nodename + '/vzdump',
params: params,
method: 'POST',
failure: function(response, opts) {
Ext.Msg.alert('Error', response.htmlStatus);
},
success: function(response, options) {
// close later so we reload the grid
// after the task has completed
me.hide();
var upid = response.result.data;
var win = Ext.create('Proxmox.window.TaskViewer', {
upid: upid,
listeners: {
close: function() {
me.close();
},
},
});
win.show();
},
});
},
});
var helpBtn = Ext.create('Proxmox.button.Help', {
onlineHelp: 'chapter_vzdump',
listenToGlobalEvent: false,
hidden: false,
});
var title = gettext('Backup') + " " +
(me.vmtype === 'lxc' ? "CT" : "VM") +
" " + me.vmid;
Ext.apply(me, {
title: title,
modal: true,
layout: 'auto',
border: false,
width: 600,
items: [me.formPanel],
buttons: [helpBtn, '->', submitBtn],
listeners: {
afterrender: function() {
/// cleared within the storage selector's change listener
me.setLoading(gettext('Please wait...'));
storagesel.setValue(me.storage);
},
},
});
me.callParent();
},
});
Ext.define('PVE.window.BackupConfig', {
extend: 'Ext.window.Window',
title: gettext('Configuration'),
width: 600,
height: 400,
layout: 'fit',
modal: true,
items: {
xtype: 'component',
itemId: 'configtext',
autoScroll: true,
style: {
'white-space': 'pre',
'font-family': 'monospace',
padding: '5px',
},
},
initComponent: function() {
var me = this;
if (!me.volume) {
throw "no volume specified";
}
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
me.callParent();
Proxmox.Utils.API2Request({
url: "/nodes/" + nodename + "/vzdump/extractconfig",
method: 'GET',
params: {
volume: me.volume,
},
failure: function(response, opts) {
me.close();
Ext.Msg.alert('Error', response.htmlStatus);
},
success: function(response, options) {
me.show();
me.down('#configtext').update(Ext.htmlEncode(response.result.data));
},
});
},
});
Ext.define('PVE.window.BulkAction', {
extend: 'Ext.window.Window',
resizable: true,
width: 800,
height: 600,
modal: true,
layout: {
type: 'fit',
},
border: false,
// the action to set, currently there are: `startall`, `migrateall`, `stopall`, `suspendall`
action: undefined,
submit: function(params) {
let me = this;
Proxmox.Utils.API2Request({
params: params,
url: `/nodes/${me.nodename}/${me.action}`,
waitMsgTarget: me,
method: 'POST',
failure: response => Ext.Msg.alert('Error', response.htmlStatus),
success: function({ result }, options) {
Ext.create('Proxmox.window.TaskViewer', {
autoShow: true,
upid: result.data,
listeners: {
destroy: () => me.close(),
},
});
me.hide();
},
});
},
initComponent: function() {
let me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (!me.action) {
throw "no action specified";
}
if (!me.btnText) {
throw "no button text specified";
}
if (!me.title) {
throw "no title specified";
}
let items = [];
if (me.action === 'migrateall') {
items.push(
{
xtype: 'fieldcontainer',
layout: 'hbox',
items: [{
flex: 1,
xtype: 'pveNodeSelector',
name: 'target',
disallowedNodes: [me.nodename],
fieldLabel: gettext('Target node'),
labelWidth: 200,
allowBlank: false,
onlineValidator: true,
padding: '0 10 0 0',
},
{
xtype: 'proxmoxintegerfield',
name: 'maxworkers',
minValue: 1,
maxValue: 100,
value: 1,
fieldLabel: gettext('Parallel jobs'),
allowBlank: false,
flex: 1,
}],
},
{
xtype: 'fieldcontainer',
layout: 'hbox',
items: [{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Allow local disk migration'),
name: 'with-local-disks',
labelWidth: 200,
checked: true,
uncheckedValue: 0,
flex: 1,
padding: '0 10 0 0',
},
{
itemId: 'lxcwarning',
xtype: 'displayfield',
userCls: 'pmx-hint',
value: 'Warning: Running CTs will be migrated in Restart Mode.',
hidden: true, // only visible if running container chosen
flex: 1,
}],
},
);
} else if (me.action === 'startall') {
items.push({
xtype: 'hiddenfield',
name: 'force',
value: 1,
});
} else if (me.action === 'stopall') {
items.push({
xtype: 'fieldcontainer',
layout: 'hbox',
items: [{
xtype: 'proxmoxcheckbox',
name: 'force-stop',
labelWidth: 120,
fieldLabel: gettext('Force Stop'),
boxLabel: gettext('Force stop guest if shutdown times out.'),
checked: true,
uncheckedValue: 0,
flex: 1,
},
{
xtype: 'proxmoxintegerfield',
name: 'timeout',
fieldLabel: gettext('Timeout (s)'),
labelWidth: 120,
emptyText: '180',
minValue: 0,
maxValue: 7200,
allowBlank: true,
flex: 1,
}],
});
}
let refreshLxcWarning = function(vmids, records) {
let showWarning = records.some(
item => vmids.includes(item.data.vmid) && item.data.type === 'lxc' && item.data.status === 'running',
);
me.down('#lxcwarning').setVisible(showWarning);
};
let defaultStatus = me.action === 'migrateall' ? '' : me.action === 'startall' ? 'stopped' : 'running';
let defaultType = me.action === 'suspendall' ? 'qemu' : '';
let statusMap = [];
let poolMap = [];
let haMap = [];
let tagMap = [];
PVE.data.ResourceStore.each((rec) => {
if (['qemu', 'lxc'].indexOf(rec.data.type) !== -1) {
statusMap[rec.data.status] = true;
}
if (rec.data.type === 'pool') {
poolMap[rec.data.pool] = true;
}
if (rec.data.hastate !== "") {
haMap[rec.data.hastate] = true;
}
if (rec.data.tags !== "") {
rec.data.tags.split(/[,; ]/).forEach((tag) => {
if (tag !== '') {
tagMap[tag] = true;
}
});
}
});
let statusList = Object.keys(statusMap).map(key => [key, key]);
statusList.unshift(['', gettext('All')]);
let poolList = Object.keys(poolMap).map(key => [key, key]);
let tagList = Object.keys(tagMap).map(key => ({ value: key }));
let haList = Object.keys(haMap).map(key => [key, key]);
let clearFilters = function() {
me.down('#namefilter').setValue('');
['name', 'status', 'pool', 'type', 'hastate', 'includetag', 'excludetag', 'vmid'].forEach((filter) => {
me.down(`#${filter}filter`).setValue('');
});
};
let filterChange = function() {
let nameValue = me.down('#namefilter').getValue();
let filterCount = 0;
if (nameValue !== '') {
filterCount++;
}
let arrayFiltersData = [];
['pool', 'hastate'].forEach((filter) => {
let selected = me.down(`#${filter}filter`).getValue() ?? [];
if (selected.length) {
filterCount++;
arrayFiltersData.push([filter, [...selected]]);
}
});
let singleFiltersData = [];
['status', 'type'].forEach((filter) => {
let selected = me.down(`#${filter}filter`).getValue() ?? '';
if (selected.length) {
filterCount++;
singleFiltersData.push([filter, selected]);
}
});
let includeTags = me.down('#includetagfilter').getValue() ?? [];
if (includeTags.length) {
filterCount++;
}
let excludeTags = me.down('#excludetagfilter').getValue() ?? [];
if (excludeTags.length) {
filterCount++;
}
let fieldSet = me.down('#filters');
let clearBtn = me.down('#clearBtn');
if (filterCount) {
fieldSet.setTitle(Ext.String.format(gettext('Filters ({0})'), filterCount));
clearBtn.setDisabled(false);
} else {
fieldSet.setTitle(gettext('Filters'));
clearBtn.setDisabled(true);
}
let filterFn = function(value) {
let name = value.data.name.toLowerCase().indexOf(nameValue.toLowerCase()) !== -1;
let arrayFilters = arrayFiltersData.every(([filter, selected]) =>
!selected.length || selected.indexOf(value.data[filter]) !== -1);
let singleFilters = singleFiltersData.every(([filter, selected]) =>
!selected.length || value.data[filter].indexOf(selected) !== -1);
let tags = value.data.tags.split(/[;, ]/).filter(t => !!t);
let includeFilter = !includeTags.length || tags.some(tag => includeTags.indexOf(tag) !== -1);
let excludeFilter = !excludeTags.length || tags.every(tag => excludeTags.indexOf(tag) === -1);
return name && arrayFilters && singleFilters && includeFilter && excludeFilter;
};
let vmselector = me.down('#vms');
vmselector.getStore().setFilters({
id: 'customFilter',
filterFn,
});
vmselector.checkChange();
if (me.action === 'migrateall') {
let records = vmselector.getSelection();
refreshLxcWarning(vmselector.getValue(), records);
}
};
items.push({
xtype: 'fieldset',
itemId: 'filters',
collapsible: true,
title: gettext('Filters'),
layout: 'hbox',
items: [
{
xtype: 'container',
flex: 1,
padding: 5,
layout: {
type: 'vbox',
align: 'stretch',
},
defaults: {
listeners: {
change: filterChange,
},
isFormField: false,
},
items: [
{
fieldLabel: gettext("Name"),
itemId: 'namefilter',
xtype: 'textfield',
},
{
xtype: 'combobox',
itemId: 'statusfilter',
fieldLabel: gettext("Status"),
emptyText: gettext('All'),
editable: false,
value: defaultStatus,
store: statusList,
},
{
xtype: 'combobox',
itemId: 'poolfilter',
fieldLabel: gettext("Pool"),
emptyText: gettext('All'),
editable: false,
multiSelect: true,
store: poolList,
},
],
},
{
xtype: 'container',
layout: {
type: 'vbox',
align: 'stretch',
},
flex: 1,
padding: 5,
defaults: {
listeners: {
change: filterChange,
},
isFormField: false,
},
items: [
{
xtype: 'combobox',
itemId: 'typefilter',
fieldLabel: gettext("Type"),
emptyText: gettext('All'),
editable: false,
value: defaultType,
store: [
['', gettext('All')],
['lxc', gettext('CT')],
['qemu', gettext('VM')],
],
},
{
xtype: 'proxmoxComboGrid',
itemId: 'includetagfilter',
fieldLabel: gettext("Include Tags"),
emptyText: gettext('All'),
editable: false,
multiSelect: true,
valueField: 'value',
displayField: 'value',
listConfig: {
userCls: 'proxmox-tags-full',
columns: [
{
dataIndex: 'value',
flex: 1,
renderer: value =>
PVE.Utils.renderTags(value, PVE.UIOptions.tagOverrides),
},
],
},
store: {
data: tagList,
},
listeners: {
change: filterChange,
},
},
{
xtype: 'proxmoxComboGrid',
itemId: 'excludetagfilter',
fieldLabel: gettext("Exclude Tags"),
emptyText: gettext('None'),
multiSelect: true,
editable: false,
valueField: 'value',
displayField: 'value',
listConfig: {
userCls: 'proxmox-tags-full',
columns: [
{
dataIndex: 'value',
flex: 1,
renderer: value =>
PVE.Utils.renderTags(value, PVE.UIOptions.tagOverrides),
},
],
},
store: {
data: tagList,
},
listeners: {
change: filterChange,
},
},
],
},
{
xtype: 'container',
layout: {
type: 'vbox',
align: 'stretch',
},
flex: 1,
padding: 5,
defaults: {
listeners: {
change: filterChange,
},
isFormField: false,
},
items: [
{
xtype: 'combobox',
itemId: 'hastatefilter',
fieldLabel: gettext("HA status"),
emptyText: gettext('All'),
multiSelect: true,
editable: false,
store: haList,
listeners: {
change: filterChange,
},
},
{
xtype: 'container',
layout: {
type: 'vbox',
align: 'end',
},
items: [
{
xtype: 'button',
itemId: 'clearBtn',
text: gettext('Clear Filters'),
disabled: true,
handler: clearFilters,
},
],
},
],
},
],
});
items.push({
xtype: 'vmselector',
itemId: 'vms',
name: 'vms',
flex: 1,
height: 300,
selectAll: true,
allowBlank: false,
plugins: '',
nodename: me.nodename,
listeners: {
selectionchange: function(vmselector, records) {
if (me.action === 'migrateall') {
let vmids = me.down('#vms').getValue();
refreshLxcWarning(vmids, records);
}
},
},
});
me.formPanel = Ext.create('Ext.form.Panel', {
bodyPadding: 10,
border: false,
layout: {
type: 'vbox',
align: 'stretch',
},
fieldDefaults: {
anchor: '100%',
},
items: items,
});
let form = me.formPanel.getForm();
let submitBtn = Ext.create('Ext.Button', {
text: me.btnText,
handler: function() {
form.isValid();
me.submit(form.getValues());
},
});
Ext.apply(me, {
items: [me.formPanel],
buttons: [submitBtn],
});
me.callParent();
form.on('validitychange', function() {
let valid = form.isValid();
submitBtn.setDisabled(!valid);
});
form.isValid();
filterChange();
},
});
Ext.define('PVE.ceph.Install', {
extend: 'Ext.window.Window',
xtype: 'pveCephInstallWindow',
mixins: ['Proxmox.Mixin.CBind'],
width: 220,
header: false,
resizable: false,
draggable: false,
modal: true,
nodename: undefined,
shadow: false,
border: false,
bodyBorder: false,
closable: false,
cls: 'install-mask',
bodyCls: 'install-mask',
layout: {
align: 'stretch',
pack: 'center',
type: 'vbox',
},
viewModel: {
data: {
isInstalled: false,
},
formulas: {
buttonText: function(get) {
if (get('isInstalled')) {
return gettext('Configure Ceph');
} else {
return gettext('Install Ceph');
}
},
windowText: function(get) {
if (get('isInstalled')) {
return `<p class="install-mask">
${Ext.String.format(gettext('{0} is not initialized.'), 'Ceph')}
${gettext('You need to create an initial config once.')}</p>`;
} else {
return '<p class="install-mask">' +
Ext.String.format(gettext('{0} is not installed on this node.'), 'Ceph') + '<br>' +
gettext('Would you like to install it now?') + '</p>';
}
},
},
},
items: [
{
bind: {
html: '{windowText}',
},
border: false,
padding: 5,
bodyCls: 'install-mask',
},
{
xtype: 'button',
bind: {
text: '{buttonText}',
},
viewModel: {},
cbind: {
nodename: '{nodename}',
},
handler: function() {
let view = this.up('pveCephInstallWindow');
let wizard = Ext.create('PVE.ceph.CephInstallWizard', {
nodename: view.nodename,
});
wizard.getViewModel().set('isInstalled', this.getViewModel().get('isInstalled'));
wizard.show();
view.mon(wizard, 'beforeClose', function() {
view.fireEvent("cephInstallWindowClosed");
view.close();
});
},
},
],
});
Ext.define('PVE.window.Clone', {
extend: 'Ext.window.Window',
resizable: false,
isTemplate: false,
onlineHelp: 'qm_copy_and_clone',
controller: {
xclass: 'Ext.app.ViewController',
control: {
'panel[reference=cloneform]': {
validitychange: 'disableSubmit',
},
},
disableSubmit: function(form) {
this.lookupReference('submitBtn').setDisabled(!form.isValid());
},
},
statics: {
// display a snapshot selector only if needed
wrap: function(nodename, vmid, isTemplate, guestType) {
Proxmox.Utils.API2Request({
url: '/nodes/' + nodename + '/' + guestType + '/' + vmid +'/snapshot',
failure: function(response, opts) {
Ext.Msg.alert('Error', response.htmlStatus);
},
success: function(response, opts) {
var snapshotList = response.result.data;
var hasSnapshots = !(snapshotList.length === 1 &&
snapshotList[0].name === 'current');
Ext.create('PVE.window.Clone', {
nodename: nodename,
guestType: guestType,
vmid: vmid,
isTemplate: isTemplate,
hasSnapshots: hasSnapshots,
}).show();
},
});
},
},
create_clone: function(values) {
var me = this;
var params = { newid: values.newvmid };
if (values.snapname && values.snapname !== 'current') {
params.snapname = values.snapname;
}
if (values.pool) {
params.pool = values.pool;
}
if (values.name) {
if (me.guestType === 'lxc') {
params.hostname = values.name;
} else {
params.name = values.name;
}
}
if (values.target) {
params.target = values.target;
}
if (values.clonemode === 'copy') {
params.full = 1;
if (values.hdstorage) {
params.storage = values.hdstorage;
if (values.diskformat && me.guestType !== 'lxc') {
params.format = values.diskformat;
}
}
}
Proxmox.Utils.API2Request({
params: params,
url: '/nodes/' + me.nodename + '/' + me.guestType + '/' + me.vmid + '/clone',
waitMsgTarget: me,
method: 'POST',
failure: function(response, opts) {
Ext.Msg.alert('Error', response.htmlStatus);
},
success: function(response, options) {
me.close();
},
});
},
// disable the Storage selector when clone mode is linked clone
updateVisibility: function() {
var me = this;
var clonemode = me.lookupReference('clonemodesel').getValue();
var disksel = me.lookup('diskselector');
disksel.setDisabled(clonemode === 'clone');
},
// add to the list of valid nodes each node where
// all the VM disks are available
verifyFeature: function() {
var me = this;
var snapname = me.lookupReference('snapshotsel').getValue();
var clonemode = me.lookupReference('clonemodesel').getValue();
var params = { feature: clonemode };
if (snapname !== 'current') {
params.snapname = snapname;
}
Proxmox.Utils.API2Request({
waitMsgTarget: me,
url: '/nodes/' + me.nodename + '/' + me.guestType + '/' + me.vmid + '/feature',
params: params,
method: 'GET',
failure: function(response, opts) {
me.lookupReference('submitBtn').setDisabled(true);
Ext.Msg.alert('Error', response.htmlStatus);
},
success: function(response, options) {
var res = response.result.data;
me.lookupReference('targetsel').allowedNodes = res.nodes;
me.lookupReference('targetsel').validate();
},
});
},
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (!me.vmid) {
throw "no VM ID specified";
}
if (!me.snapname) {
me.snapname = 'current';
}
if (!me.guestType) {
throw "no Guest Type specified";
}
var titletext = me.guestType === 'lxc' ? 'CT' : 'VM';
if (me.isTemplate) {
titletext += ' Template';
}
me.title = "Clone " + titletext + " " + me.vmid;
var col1 = [];
var col2 = [];
col1.push({
xtype: 'pveNodeSelector',
name: 'target',
reference: 'targetsel',
fieldLabel: gettext('Target node'),
selectCurNode: true,
allowBlank: false,
onlineValidator: true,
listeners: {
change: function(f, value) {
me.lookup('diskselector').getComponent('hdstorage').setTargetNode(value);
},
},
});
var modelist = [['copy', gettext('Full Clone')]];
if (me.isTemplate) {
modelist.push(['clone', gettext('Linked Clone')]);
}
col1.push({
xtype: 'pveGuestIDSelector',
name: 'newvmid',
guestType: me.guestType,
value: '',
loadNextFreeID: true,
validateExists: false,
},
{
xtype: 'textfield',
name: 'name',
vtype: 'DnsName',
allowBlank: true,
fieldLabel: me.guestType === 'lxc' ? gettext('Hostname') : gettext('Name'),
},
{
xtype: 'pvePoolSelector',
fieldLabel: gettext('Resource Pool'),
name: 'pool',
value: '',
allowBlank: true,
},
);
col2.push({
xtype: 'proxmoxKVComboBox',
fieldLabel: gettext('Mode'),
name: 'clonemode',
reference: 'clonemodesel',
allowBlank: false,
hidden: !me.isTemplate,
value: me.isTemplate ? 'clone' : 'copy',
comboItems: modelist,
listeners: {
change: function(t, value) {
me.updateVisibility();
me.verifyFeature();
},
},
},
{
xtype: 'PVE.form.SnapshotSelector',
name: 'snapname',
reference: 'snapshotsel',
fieldLabel: gettext('Snapshot'),
nodename: me.nodename,
guestType: me.guestType,
vmid: me.vmid,
hidden: !!(me.isTemplate || !me.hasSnapshots),
disabled: false,
allowBlank: false,
value: me.snapname,
listeners: {
change: function(f, value) {
me.verifyFeature();
},
},
},
{
xtype: 'pveDiskStorageSelector',
reference: 'diskselector',
nodename: me.nodename,
autoSelect: false,
hideSize: true,
hideSelection: true,
storageLabel: gettext('Target Storage'),
allowBlank: true,
storageContent: me.guestType === 'qemu' ? 'images' : 'rootdir',
emptyText: gettext('Same as source'),
disabled: !!me.isTemplate, // because default mode is clone for templates
});
var formPanel = Ext.create('Ext.form.Panel', {
bodyPadding: 10,
reference: 'cloneform',
border: false,
layout: 'hbox',
defaultType: 'container',
fieldDefaults: {
labelWidth: 100,
anchor: '100%',
},
items: [
{
flex: 1,
padding: '0 10 0 0',
layout: 'anchor',
items: col1,
},
{
flex: 1,
padding: '0 0 0 10',
layout: 'anchor',
items: col2,
},
],
});
Ext.apply(me, {
modal: true,
width: 600,
height: 250,
border: false,
layout: 'fit',
buttons: [{
xtype: 'proxmoxHelpButton',
listenToGlobalEvent: false,
hidden: false,
onlineHelp: me.onlineHelp,
},
'->',
{
reference: 'submitBtn',
text: gettext('Clone'),
disabled: true,
handler: function() {
var cloneForm = me.lookupReference('cloneform');
if (cloneForm.isValid()) {
me.create_clone(cloneForm.getValues());
}
},
}],
items: [formPanel],
});
me.callParent();
me.verifyFeature();
},
});
Ext.define('PVE.FirewallEnableEdit', {
extend: 'Proxmox.window.Edit',
alias: ['widget.pveFirewallEnableEdit'],
mixins: ['Proxmox.Mixin.CBind'],
subject: gettext('Firewall'),
cbindData: {
defaultValue: 0,
},
width: 350,
items: [
{
xtype: 'proxmoxcheckbox',
name: 'enable',
uncheckedValue: 0,
cbind: {
defaultValue: '{defaultValue}',
checked: '{defaultValue}',
},
deleteDefaultValue: false,
fieldLabel: gettext('Firewall'),
},
{
xtype: 'displayfield',
name: 'warning',
userCls: 'pmx-hint',
value: gettext('Warning: Firewall still disabled at datacenter level!'),
hidden: true,
},
],
beforeShow: function() {
var me = this;
Proxmox.Utils.API2Request({
url: '/api2/extjs/cluster/firewall/options',
method: 'GET',
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
success: function(response, opts) {
if (!response.result.data.enable) {
me.down('displayfield[name=warning]').setVisible(true);
}
},
});
},
});
Ext.define('PVE.FirewallLograteInputPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveFirewallLograteInputPanel',
viewModel: {},
items: [
{
xtype: 'proxmoxcheckbox',
name: 'enable',
reference: 'enable',
fieldLabel: gettext('Enable'),
value: true,
},
{
layout: 'hbox',
border: false,
items: [
{
xtype: 'numberfield',
name: 'rate',
fieldLabel: gettext('Log rate limit'),
minValue: 1,
maxValue: 99,
allowBlank: false,
flex: 2,
value: 1,
},
{
xtype: 'box',
html: '<div style="margin: auto; padding: 2.5px;"><b>/</b></div>',
},
{
xtype: 'proxmoxKVComboBox',
name: 'unit',
comboItems: [
['second', 'second'],
['minute', 'minute'],
['hour', 'hour'],
['day', 'day'],
],
allowBlank: false,
flex: 1,
value: 'second',
},
],
},
{
xtype: 'numberfield',
name: 'burst',
fieldLabel: gettext('Log burst limit'),
minValue: 1,
maxValue: 99,
value: 5,
},
],
onGetValues: function(values) {
let me = this;
let cfg = {
enable: values.enable !== undefined ? 1 : 0,
rate: values.rate + '/' + values.unit,
burst: values.burst,
};
let properties = PVE.Parser.printPropertyString(cfg, undefined);
if (properties === '') {
return { 'delete': 'log_ratelimit' };
}
return { log_ratelimit: properties };
},
setValues: function(values) {
let me = this;
let properties = {};
if (values.log_ratelimit !== undefined) {
properties = PVE.Parser.parsePropertyString(values.log_ratelimit, 'enable');
if (properties.rate) {
var matches = properties.rate.match(/^(\d+)\/(second|minute|hour|day)$/);
if (matches) {
properties.rate = matches[1];
properties.unit = matches[2];
}
}
}
me.callParent([properties]);
},
});
Ext.define('PVE.FirewallLograteEdit', {
extend: 'Proxmox.window.Edit',
xtype: 'pveFirewallLograteEdit',
subject: gettext('Log rate limit'),
items: [{
xtype: 'pveFirewallLograteInputPanel',
}],
autoLoad: true,
});
/*global u2f*/
Ext.define('PVE.window.LoginWindow', {
extend: 'Ext.window.Window',
viewModel: {
data: {
openid: false,
},
formulas: {
button_text: function(get) {
if (get("openid") === true) {
return gettext("Login (OpenID redirect)");
} else {
return gettext("Login");
}
},
},
},
controller: {
xclass: 'Ext.app.ViewController',
onLogon: async function() {
var me = this;
var form = this.lookupReference('loginForm');
var unField = this.lookupReference('usernameField');
var saveunField = this.lookupReference('saveunField');
var view = this.getView();
if (!form.isValid()) {
return;
}
let creds = form.getValues();
if (this.getViewModel().data.openid === true) {
const redirectURL = location.origin;
Proxmox.Utils.API2Request({
url: '/api2/extjs/access/openid/auth-url',
params: {
realm: creds.realm,
"redirect-url": redirectURL,
},
method: 'POST',
success: function(resp, opts) {
window.location = resp.result.data;
},
failure: function(resp, opts) {
Proxmox.Utils.authClear();
form.unmask();
Ext.MessageBox.alert(
gettext('Error'),
gettext('OpenID redirect failed.') + `<br>${resp.htmlStatus}`,
);
},
});
return;
}
view.el.mask(gettext('Please wait...'), 'x-mask-loading');
// set or clear username
var sp = Ext.state.Manager.getProvider();
if (saveunField.getValue() === true) {
sp.set(unField.getStateId(), unField.getValue());
} else {
sp.clear(unField.getStateId());
}
sp.set(saveunField.getStateId(), saveunField.getValue());
try {
// Request updated authentication mechanism:
creds['new-format'] = 1;
let resp = await Proxmox.Async.api2({
url: '/api2/extjs/access/ticket',
params: creds,
method: 'POST',
});
let data = resp.result.data;
if (data.ticket.startsWith("PVE:!tfa!")) {
// Store first factor login information first:
data.LoggedOut = true;
Proxmox.Utils.setAuthData(data);
data = await me.performTFAChallenge(data);
// Fill in what we copy over from the 1st factor:
data.CSRFPreventionToken = Proxmox.CSRFPreventionToken;
data.username = Proxmox.UserName;
me.success(data);
} else if (Ext.isDefined(data.NeedTFA)) {
// Store first factor login information first:
data.LoggedOut = true;
Proxmox.Utils.setAuthData(data);
if (Ext.isDefined(data.U2FChallenge)) {
me.perform_u2f(data);
} else {
me.perform_otp();
}
} else {
me.success(data);
}
} catch (error) {
me.failure(error);
}
},
/* START NEW TFA CODE (pbs copy) */
performTFAChallenge: async function(data) {
let me = this;
let userid = data.username;
let ticket = data.ticket;
let challenge = JSON.parse(decodeURIComponent(
ticket.split(':')[1].slice("!tfa!".length),
));
let resp = await new Promise((resolve, reject) => {
Ext.create('Proxmox.window.TfaLoginWindow', {
userid,
ticket,
challenge,
onResolve: value => resolve(value),
onReject: reject,
}).show();
});
return resp.result.data;
},
/* END NEW TFA CODE (pbs copy) */
failure: function(resp) {
var me = this;
var view = me.getView();
view.el.unmask();
var handler = function() {
var uf = me.lookupReference('usernameField');
uf.focus(true, true);
};
let emsg = gettext("Login failed. Please try again");
if (resp.failureType === "connect") {
emsg = gettext("Connection failure. Network error or Proxmox VE services not running?");
}
Ext.MessageBox.alert(gettext('Error'), emsg, handler);
},
success: function(data) {
var me = this;
var view = me.getView();
var handler = view.handler || Ext.emptyFn;
handler.call(me, data);
view.close();
},
perform_otp: function() {
var me = this;
var win = Ext.create('PVE.window.TFALoginWindow', {
onLogin: function(value) {
me.finish_tfa(value);
},
onCancel: function() {
Proxmox.LoggedOut = false;
Proxmox.Utils.authClear();
me.getView().show();
},
});
win.show();
},
perform_u2f: function(data) {
var me = this;
// Show the message:
var msg = Ext.Msg.show({
title: 'U2F: '+gettext('Verification'),
message: gettext('Please press the button on your U2F Device'),
buttons: [],
});
var chlg = data.U2FChallenge;
var key = {
version: chlg.version,
keyHandle: chlg.keyHandle,
};
u2f.sign(chlg.appId, chlg.challenge, [key], function(res) {
msg.close();
if (res.errorCode) {
Proxmox.Utils.authClear();
Ext.Msg.alert(gettext('Error'), PVE.Utils.render_u2f_error(res.errorCode));
return;
}
delete res.errorCode;
me.finish_tfa(JSON.stringify(res));
});
},
finish_tfa: function(res) {
var me = this;
var view = me.getView();
view.el.mask(gettext('Please wait...'), 'x-mask-loading');
Proxmox.Utils.API2Request({
url: '/api2/extjs/access/tfa',
params: {
response: res,
},
method: 'POST',
timeout: 5000, // it'll delay both success & failure
success: function(resp, opts) {
view.el.unmask();
// Fill in what we copy over from the 1st factor:
var data = resp.result.data;
data.CSRFPreventionToken = Proxmox.CSRFPreventionToken;
data.username = Proxmox.UserName;
// Finish logging in:
me.success(data);
},
failure: function(resp, opts) {
Proxmox.Utils.authClear();
me.failure(resp);
},
});
},
control: {
'field[name=username]': {
specialkey: function(f, e) {
if (e.getKey() === e.ENTER) {
var pf = this.lookupReference('passwordField');
if (!pf.getValue()) {
pf.focus(false);
}
}
},
},
'field[name=lang]': {
change: function(f, value) {
var dt = Ext.Date.add(new Date(), Ext.Date.YEAR, 10);
Ext.util.Cookies.set('PVELangCookie', value, dt);
this.getView().mask(gettext('Please wait...'), 'x-mask-loading');
window.location.reload();
},
},
'field[name=realm]': {
change: function(f, value) {
let record = f.store.getById(value);
if (record === undefined) return;
let data = record.data;
this.getViewModel().set("openid", data.type === "openid");
},
},
'button[reference=loginButton]': {
click: 'onLogon',
},
'#': {
show: function() {
var me = this;
var sp = Ext.state.Manager.getProvider();
var checkboxField = this.lookupReference('saveunField');
var unField = this.lookupReference('usernameField');
var checked = sp.get(checkboxField.getStateId());
checkboxField.setValue(checked);
if (checked === true) {
var username = sp.get(unField.getStateId());
unField.setValue(username);
var pwField = this.lookupReference('passwordField');
pwField.focus();
}
let auth = Proxmox.Utils.getOpenIDRedirectionAuthorization();
if (auth !== undefined) {
Proxmox.Utils.authClear();
let loginForm = this.lookupReference('loginForm');
loginForm.mask(gettext('OpenID login - please wait...'), 'x-mask-loading');
const redirectURL = location.origin;
Proxmox.Utils.API2Request({
url: '/api2/extjs/access/openid/login',
params: {
state: auth.state,
code: auth.code,
"redirect-url": redirectURL,
},
method: 'POST',
failure: function(response) {
loginForm.unmask();
let error = response.htmlStatus;
Ext.MessageBox.alert(
gettext('Error'),
gettext('OpenID login failed, please try again') + `<br>${error}`,
() => { window.location = redirectURL; },
);
},
success: function(response, options) {
loginForm.unmask();
let data = response.result.data;
history.replaceState(null, '', redirectURL);
me.success(data);
},
});
}
},
},
},
},
width: 400,
modal: true,
border: false,
draggable: true,
closable: false,
resizable: false,
layout: 'auto',
title: gettext('Proxmox VE Login'),
defaultFocus: 'usernameField',
defaultButton: 'loginButton',
items: [{
xtype: 'form',
layout: 'form',
url: '/api2/extjs/access/ticket',
reference: 'loginForm',
fieldDefaults: {
labelAlign: 'right',
allowBlank: false,
},
items: [
{
xtype: 'textfield',
fieldLabel: gettext('User name'),
name: 'username',
itemId: 'usernameField',
reference: 'usernameField',
stateId: 'login-username',
inputAttrTpl: 'autocomplete=username',
bind: {
visible: "{!openid}",
disabled: "{openid}",
},
},
{
xtype: 'textfield',
inputType: 'password',
fieldLabel: gettext('Password'),
name: 'password',
reference: 'passwordField',
inputAttrTpl: 'autocomplete=current-password',
bind: {
visible: "{!openid}",
disabled: "{openid}",
},
},
{
xtype: 'pmxRealmComboBox',
name: 'realm',
},
{
xtype: 'proxmoxLanguageSelector',
fieldLabel: gettext('Language'),
value: PVE.Utils.getUiLanguage(),
name: 'lang',
reference: 'langField',
submitValue: false,
},
],
buttons: [
{
xtype: 'checkbox',
fieldLabel: gettext('Save User name'),
name: 'saveusername',
reference: 'saveunField',
stateId: 'login-saveusername',
labelWidth: 250,
labelAlign: 'right',
submitValue: false,
bind: {
visible: "{!openid}",
},
},
{
bind: {
text: "{button_text}",
},
reference: 'loginButton',
},
],
}],
});
Ext.define('PVE.window.Migrate', {
extend: 'Ext.window.Window',
vmtype: undefined,
nodename: undefined,
vmid: undefined,
maxHeight: 450,
viewModel: {
data: {
vmid: undefined,
nodename: undefined,
vmtype: undefined,
running: false,
qemu: {
onlineHelp: 'qm_migration',
commonName: 'VM',
},
lxc: {
onlineHelp: 'pct_migration',
commonName: 'CT',
},
migration: {
possible: true,
preconditions: [],
'with-local-disks': 0,
mode: undefined,
allowedNodes: undefined,
overwriteLocalResourceCheck: false,
hasLocalResources: false,
},
},
formulas: {
setMigrationMode: function(get) {
if (get('running')) {
if (get('vmtype') === 'qemu') {
return gettext('Online');
} else {
return gettext('Restart Mode');
}
} else {
return gettext('Offline');
}
},
setStorageselectorHidden: function(get) {
if (get('migration.with-local-disks') && get('running')) {
return false;
} else {
return true;
}
},
setLocalResourceCheckboxHidden: function(get) {
if (get('running') || !get('migration.hasLocalResources') ||
Proxmox.UserName !== 'root@pam') {
return true;
} else {
return false;
}
},
},
},
controller: {
xclass: 'Ext.app.ViewController',
control: {
'panel[reference=formPanel]': {
validityChange: function(panel, isValid) {
this.getViewModel().set('migration.possible', isValid);
this.checkMigratePreconditions();
},
},
},
init: function(view) {
var me = this,
vm = view.getViewModel();
if (!view.nodename) {
throw "missing custom view config: nodename";
}
vm.set('nodename', view.nodename);
if (!view.vmid) {
throw "missing custom view config: vmid";
}
vm.set('vmid', view.vmid);
if (!view.vmtype) {
throw "missing custom view config: vmtype";
}
vm.set('vmtype', view.vmtype);
view.setTitle(
Ext.String.format('{0} {1} {2}', gettext('Migrate'), vm.get(view.vmtype).commonName, view.vmid),
);
me.lookup('proxmoxHelpButton').setHelpConfig({
onlineHelp: vm.get(view.vmtype).onlineHelp,
});
me.lookup('formPanel').isValid();
},
onTargetChange: function(nodeSelector) {
// Always display the storages of the currently selected migration target
this.lookup('pveDiskStorageSelector').setNodename(nodeSelector.value);
this.checkMigratePreconditions();
},
startMigration: function() {
var me = this,
view = me.getView(),
vm = me.getViewModel();
var values = me.lookup('formPanel').getValues();
var params = {
target: values.target,
};
if (vm.get('migration.mode')) {
params[vm.get('migration.mode')] = 1;
}
if (vm.get('migration.with-local-disks')) {
params['with-local-disks'] = 1;
}
//offline migration to a different storage currently might fail at a late stage
//(i.e. after some disks have been moved), so don't expose it yet in the GUI
if (vm.get('migration.with-local-disks') && vm.get('running') && values.targetstorage) {
params.targetstorage = values.targetstorage;
}
if (vm.get('migration.overwriteLocalResourceCheck')) {
params.force = 1;
}
Proxmox.Utils.API2Request({
params: params,
url: '/nodes/' + vm.get('nodename') + '/' + vm.get('vmtype') + '/' + vm.get('vmid') + '/migrate',
waitMsgTarget: view,
method: 'POST',
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
success: function(response, options) {
var upid = response.result.data;
var extraTitle = Ext.String.format(' ({0} ---> {1})', vm.get('nodename'), params.target);
Ext.create('Proxmox.window.TaskViewer', {
upid: upid,
extraTitle: extraTitle,
}).show();
view.close();
},
});
},
checkMigratePreconditions: async function(resetMigrationPossible) {
var me = this,
vm = me.getViewModel();
var vmrec = PVE.data.ResourceStore.findRecord('vmid', vm.get('vmid'),
0, false, false, true);
if (vmrec && vmrec.data && vmrec.data.running) {
vm.set('running', true);
}
me.lookup('pveNodeSelector').disallowedNodes = [vm.get('nodename')];
if (vm.get('vmtype') === 'qemu') {
await me.checkQemuPreconditions(resetMigrationPossible);
} else {
me.checkLxcPreconditions(resetMigrationPossible);
}
// Only allow nodes where the local storage is available in case of offline migration
// where storage migration is not possible
me.lookup('pveNodeSelector').allowedNodes = vm.get('migration.allowedNodes');
me.lookup('formPanel').isValid();
},
checkQemuPreconditions: async function(resetMigrationPossible) {
let me = this,
vm = me.getViewModel(),
migrateStats;
if (vm.get('running')) {
vm.set('migration.mode', 'online');
}
try {
if (me.fetchingNodeMigrateInfo && me.fetchingNodeMigrateInfo === vm.get('nodename')) {
return;
}
me.fetchingNodeMigrateInfo = vm.get('nodename');
let { result } = await Proxmox.Async.api2({
url: `/nodes/${vm.get('nodename')}/${vm.get('vmtype')}/${vm.get('vmid')}/migrate`,
method: 'GET',
});
migrateStats = result.data;
me.fetchingNodeMigrateInfo = false;
} catch (error) {
Ext.Msg.alert(gettext('Error'), error.htmlStatus);
return;
}
if (migrateStats.running) {
vm.set('running', true);
}
// Get migration object from viewmodel to prevent to many bind callbacks
let migration = vm.get('migration');
if (resetMigrationPossible) {
migration.possible = true;
}
migration.preconditions = [];
if (migrateStats.allowed_nodes) {
migration.allowedNodes = migrateStats.allowed_nodes;
let target = me.lookup('pveNodeSelector').value;
if (target.length && !migrateStats.allowed_nodes.includes(target)) {
let disallowed = migrateStats.not_allowed_nodes[target] ?? {};
if (disallowed.unavailable_storages !== undefined) {
let missingStorages = disallowed.unavailable_storages.join(', ');
migration.possible = false;
migration.preconditions.push({
text: 'Storage (' + missingStorages + ') not available on selected target. ' +
'Start VM to use live storage migration or select other target node',
severity: 'error',
});
}
if (disallowed['unavailable-resources'] !== undefined) {
let unavailableResources = disallowed['unavailable-resources'].join(', ');
migration.possible = false;
migration.preconditions.push({
text: 'Mapped Resources (' + unavailableResources + ') not available on selected target. ',
severity: 'error',
});
}
}
}
let blockingResources = [];
let mappedResources = migrateStats['mapped-resources'] ?? [];
for (const res of migrateStats.local_resources) {
if (mappedResources.indexOf(res) === -1) {
blockingResources.push(res);
}
}
if (blockingResources.length) {
migration.hasLocalResources = true;
if (!migration.overwriteLocalResourceCheck || vm.get('running')) {
migration.possible = false;
migration.preconditions.push({
text: Ext.String.format('Can\'t migrate VM with local resources: {0}',
blockingResources.join(', ')),
severity: 'error',
});
} else {
migration.preconditions.push({
text: Ext.String.format('Migrate VM with local resources: {0}. ' +
'This might fail if resources aren\'t available on the target node.',
blockingResources.join(', ')),
severity: 'warning',
});
}
}
if (mappedResources && mappedResources.length) {
if (vm.get('running')) {
migration.possible = false;
migration.preconditions.push({
text: Ext.String.format('Can\'t migrate running VM with mapped resources: {0}',
mappedResources.join(', ')),
severity: 'error',
});
}
}
if (migrateStats.local_disks.length) {
migrateStats.local_disks.forEach(function(disk) {
if (disk.cdrom && disk.cdrom === 1) {
if (!disk.volid.includes('vm-' + vm.get('vmid') + '-cloudinit')) {
migration.possible = false;
migration.preconditions.push({
text: "Can't migrate VM with local CD/DVD",
severity: 'error',
});
}
} else {
let size = disk.size ? '(' + Proxmox.Utils.render_size(disk.size) + ')' : '';
migration['with-local-disks'] = 1;
migration.preconditions.push({
text: Ext.String.format('Migration with local disk might take long: {0} {1}', disk.volid, size),
severity: 'warning',
});
}
});
}
vm.set('migration', migration);
},
checkLxcPreconditions: function(resetMigrationPossible) {
let vm = this.getViewModel();
if (vm.get('running')) {
vm.set('migration.mode', 'restart');
}
},
},
width: 600,
modal: true,
layout: {
type: 'vbox',
align: 'stretch',
},
border: false,
items: [
{
xtype: 'form',
reference: 'formPanel',
bodyPadding: 10,
border: false,
layout: 'hbox',
items: [
{
xtype: 'container',
flex: 1,
items: [{
xtype: 'displayfield',
name: 'source',
fieldLabel: gettext('Source node'),
bind: {
value: '{nodename}',
},
},
{
xtype: 'displayfield',
reference: 'migrationMode',
fieldLabel: gettext('Mode'),
bind: {
value: '{setMigrationMode}',
},
}],
},
{
xtype: 'container',
flex: 1,
items: [{
xtype: 'pveNodeSelector',
reference: 'pveNodeSelector',
name: 'target',
fieldLabel: gettext('Target node'),
allowBlank: false,
disallowedNodes: undefined,
onlineValidator: true,
listeners: {
change: 'onTargetChange',
},
},
{
xtype: 'pveStorageSelector',
reference: 'pveDiskStorageSelector',
name: 'targetstorage',
fieldLabel: gettext('Target storage'),
storageContent: 'images',
allowBlank: true,
autoSelect: false,
emptyText: gettext('Current layout'),
bind: {
hidden: '{setStorageselectorHidden}',
},
},
{
xtype: 'proxmoxcheckbox',
name: 'overwriteLocalResourceCheck',
fieldLabel: gettext('Force'),
autoEl: {
tag: 'div',
'data-qtip': 'Overwrite local resources unavailable check',
},
bind: {
hidden: '{setLocalResourceCheckboxHidden}',
value: '{migration.overwriteLocalResourceCheck}',
},
listeners: {
change: {
fn: 'checkMigratePreconditions',
extraArg: true,
},
},
}],
},
],
},
{
xtype: 'gridpanel',
reference: 'preconditionGrid',
selectable: false,
flex: 1,
columns: [{
text: '',
dataIndex: 'severity',
renderer: function(v) {
switch (v) {
case 'warning':
return '<i class="fa fa-exclamation-triangle warning"></i> ';
case 'error':
return '<i class="fa fa-times critical"></i>';
default:
return v;
}
},
width: 35,
},
{
text: 'Info',
dataIndex: 'text',
cellWrap: true,
flex: 1,
}],
bind: {
hidden: '{!migration.preconditions.length}',
store: {
fields: ['severity', 'text'],
data: '{migration.preconditions}',
sorters: 'text',
},
},
},
],
buttons: [
{
xtype: 'proxmoxHelpButton',
reference: 'proxmoxHelpButton',
onlineHelp: 'pct_migration',
listenToGlobalEvent: false,
hidden: false,
},
'->',
{
xtype: 'button',
reference: 'submitButton',
text: gettext('Migrate'),
handler: 'startMigration',
bind: {
disabled: '{!migration.possible}',
},
},
],
});
Ext.define('pve-prune-list', {
extend: 'Ext.data.Model',
fields: [
'type',
'vmid',
{
name: 'ctime',
type: 'date',
dateFormat: 'timestamp',
},
],
});
Ext.define('PVE.PruneInputPanel', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pvePruneInputPanel',
mixins: ['Proxmox.Mixin.CBind'],
onGetValues: function(values) {
let me = this;
// the API expects a single prune-backups property string
let pruneBackups = PVE.Parser.printPropertyString(values);
values = {
'prune-backups': pruneBackups,
'type': me.backup_type,
'vmid': me.backup_id,
};
return values;
},
controller: {
xclass: 'Ext.app.ViewController',
init: function(view) {
if (!view.url) {
throw "no url specified";
}
if (!view.backup_type) {
throw "no backup_type specified";
}
if (!view.backup_id) {
throw "no backup_id specified";
}
this.reload(); // initial load
},
reload: function() {
let view = this.getView();
// helper to allow showing why a backup is kept
let addKeepReasons = function(backups, params) {
const rules = [
'keep-last',
'keep-hourly',
'keep-daily',
'keep-weekly',
'keep-monthly',
'keep-yearly',
'keep-all', // when all keep options are not set
];
let counter = {};
backups.sort((a, b) => b.ctime - a.ctime);
let ruleIndex = -1;
let nextRule = function() {
let rule;
do {
ruleIndex++;
rule = rules[ruleIndex];
} while (!params[rule] && rule !== 'keep-all');
counter[rule] = 0;
return rule;
};
let rule = nextRule();
for (let backup of backups) {
if (backup.mark === 'keep') {
counter[rule]++;
if (rule !== 'keep-all') {
backup.keepReason = rule + ': ' + counter[rule];
if (counter[rule] >= params[rule]) {
rule = nextRule();
}
} else {
backup.keepReason = rule;
}
}
}
};
let params = view.getValues();
let keepParams = PVE.Parser.parsePropertyString(params["prune-backups"]);
Proxmox.Utils.API2Request({
url: view.url,
method: "GET",
params: params,
callback: function() {
// for easy breakpoint setting
},
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
success: function(response, options) {
var data = response.result.data;
addKeepReasons(data, keepParams);
view.pruneStore.setData(data);
},
});
},
control: {
field: { change: 'reload' },
},
},
column1: [
{
xtype: 'pmxPruneKeepField',
name: 'keep-last',
fieldLabel: gettext('keep-last'),
},
{
xtype: 'pmxPruneKeepField',
name: 'keep-hourly',
fieldLabel: gettext('keep-hourly'),
},
{
xtype: 'pmxPruneKeepField',
name: 'keep-daily',
fieldLabel: gettext('keep-daily'),
},
{
xtype: 'pmxPruneKeepField',
name: 'keep-weekly',
fieldLabel: gettext('keep-weekly'),
},
{
xtype: 'pmxPruneKeepField',
name: 'keep-monthly',
fieldLabel: gettext('keep-monthly'),
},
{
xtype: 'pmxPruneKeepField',
name: 'keep-yearly',
fieldLabel: gettext('keep-yearly'),
},
],
initComponent: function() {
var me = this;
me.pruneStore = Ext.create('Ext.data.Store', {
model: 'pve-prune-list',
sorters: { property: 'ctime', direction: 'DESC' },
});
me.column2 = [
{
xtype: 'grid',
height: 200,
store: me.pruneStore,
columns: [
{
header: gettext('Backup Time'),
sortable: true,
dataIndex: 'ctime',
renderer: function(value, metaData, record) {
let text = Ext.Date.format(value, 'Y-m-d H:i:s');
if (record.data.mark === 'remove') {
return '<div style="text-decoration: line-through;">'+ text +'</div>';
} else {
return text;
}
},
flex: 1,
},
{
text: 'Keep (reason)',
dataIndex: 'mark',
renderer: function(value, metaData, record) {
if (record.data.mark === 'keep') {
return 'true (' + record.data.keepReason + ')';
} else if (record.data.mark === 'protected') {
return 'true (protected)';
} else if (record.data.mark === 'renamed') {
return 'true (renamed)';
} else {
return 'false';
}
},
flex: 1,
},
],
},
];
me.callParent();
},
});
Ext.define('PVE.window.Prune', {
extend: 'Proxmox.window.Edit',
method: 'DELETE',
submitText: gettext("Prune"),
fieldDefaults: { labelWidth: 130 },
isCreate: true,
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no nodename specified";
}
if (!me.storage) {
throw "no storage specified";
}
if (!me.backup_type) {
throw "no backup_type specified";
}
if (me.backup_type !== 'qemu' && me.backup_type !== 'lxc') {
throw "unknown backup type: " + me.backup_type;
}
if (!me.backup_id) {
throw "no backup_id specified";
}
let title = Ext.String.format(
gettext("Prune Backups for '{0}' on Storage '{1}'"),
me.backup_type + '/' + me.backup_id,
me.storage,
);
Ext.apply(me, {
url: '/api2/extjs/nodes/' + me.nodename + '/storage/' + me.storage + "/prunebackups",
title: title,
items: [
{
xtype: 'pvePruneInputPanel',
url: '/api2/extjs/nodes/' + me.nodename + '/storage/' + me.storage + "/prunebackups",
backup_type: me.backup_type,
backup_id: me.backup_id,
storage: me.storage,
},
],
});
me.callParent();
},
});
Ext.define('PVE.window.Restore', {
extend: 'Ext.window.Window', // fixme: Proxmox.window.Edit?
resizable: false,
width: 500,
modal: true,
layout: 'auto',
border: false,
controller: {
xclass: 'Ext.app.ViewController',
control: {
'#liveRestore': {
change: function(el, newVal) {
let liveWarning = this.lookupReference('liveWarning');
liveWarning.setHidden(!newVal);
let start = this.lookupReference('start');
start.setDisabled(newVal);
},
},
'form': {
validitychange: function(f, valid) {
this.lookupReference('doRestoreBtn').setDisabled(!valid);
},
},
},
doRestore: function() {
let me = this;
let view = me.getView();
let values = view.down('form').getForm().getValues();
let params = {
vmid: view.vmid || values.vmid,
force: view.vmid ? 1 : 0,
};
if (values.unique) {
params.unique = 1;
}
if (values.start && !values['live-restore']) {
params.start = 1;
}
if (values['live-restore']) {
params['live-restore'] = 1;
}
if (values.storage) {
params.storage = values.storage;
}
['bwlimit', 'cores', 'name', 'memory', 'sockets'].forEach(opt => {
if ((values[opt] ?? '') !== '') {
params[opt] = values[opt];
}
});
if (params.name && view.vmtype === 'lxc') {
params.hostname = params.name;
delete params.name;
}
let confirmMsg;
if (view.vmtype === 'lxc') {
params.ostemplate = view.volid;
params.restore = 1;
if (values.unprivileged !== 'keep') {
params.unprivileged = values.unprivileged;
}
confirmMsg = Proxmox.Utils.format_task_description('vzrestore', params.vmid);
} else if (view.vmtype === 'qemu') {
params.archive = view.volid;
confirmMsg = Proxmox.Utils.format_task_description('qmrestore', params.vmid);
} else {
throw 'unknown VM type';
}
let executeRestore = () => {
Proxmox.Utils.API2Request({
url: `/nodes/${view.nodename}/${view.vmtype}`,
params: params,
method: 'POST',
waitMsgTarget: view,
failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
success: function(response, options) {
Ext.create('Proxmox.window.TaskViewer', {
autoShow: true,
upid: response.result.data,
});
view.close();
},
});
};
if (view.vmid) {
confirmMsg += `. ${Ext.String.format(
gettext('This will permanently erase current {0} data.'),
view.vmtype === 'lxc' ? 'CT' : 'VM',
)}`;
if (view.vmtype === 'lxc') {
confirmMsg += `<br>${gettext('Mount point volumes are also erased.')}`;
}
Ext.Msg.confirm(gettext('Confirm'), confirmMsg, function(btn) {
if (btn === 'yes') {
executeRestore();
}
});
} else {
executeRestore();
}
},
afterRender: function() {
let view = this.getView();
Proxmox.Utils.API2Request({
url: `/nodes/${view.nodename}/vzdump/extractconfig`,
method: 'GET',
waitMsgTarget: view,
params: {
volume: view.volid,
},
failure: response => Ext.Msg.alert('Error', response.htmlStatus),
success: function(response, options) {
let allStoragesAvailable = true;
response.result.data.split('\n').forEach(line => {
let [_, key, value] = line.match(/^([^:]+):\s*(\S+)\s*$/) ?? [];
if (!key) {
return;
}
if (key === '#qmdump#map') {
let match = value.match(/^(\S+):(\S+):(\S*):(\S*):$/) ?? [];
// if a /dev/XYZ disk was backed up, there is no storage hint
allStoragesAvailable &&= !!match[3] && !!PVE.data.ResourceStore.getById(
`storage/${view.nodename}/${match[3]}`);
} else if (key === 'name' || key === 'hostname') {
view.lookupReference('nameField').setEmptyText(value);
} else if (key === 'memory' || key === 'cores' || key === 'sockets') {
view.lookupReference(`${key}Field`).setEmptyText(value);
}
});
if (!allStoragesAvailable) {
let storagesel = view.down('pveStorageSelector[name=storage]');
storagesel.allowBlank = false;
storagesel.setEmptyText('');
}
},
});
},
},
initComponent: function() {
let me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (!me.volid) {
throw "no volume ID specified";
}
if (!me.vmtype) {
throw "no vmtype specified";
}
let storagesel = Ext.create('PVE.form.StorageSelector', {
nodename: me.nodename,
name: 'storage',
value: '',
fieldLabel: gettext('Storage'),
storageContent: me.vmtype === 'lxc' ? 'rootdir' : 'images',
// when restoring a container without specifying a storage, the backend defaults
// to 'local', which is unintuitive and 'rootdir' might not even be allowed on it
allowBlank: me.vmtype !== 'lxc',
emptyText: me.vmtype === 'lxc' ? '' : gettext('From backup configuration'),
autoSelect: me.vmtype === 'lxc',
});
let items = [
{
xtype: 'displayfield',
value: me.volidText || me.volid,
fieldLabel: gettext('Source'),
},
storagesel,
{
xtype: 'pmxDisplayEditField',
name: 'vmid',
fieldLabel: me.vmtype === 'lxc' ? 'CT' : 'VM',
value: me.vmid,
editable: !me.vmid,
editConfig: {
xtype: 'pveGuestIDSelector',
guestType: me.vmtype,
loadNextFreeID: true,
validateExists: false,
},
},
{
xtype: 'pveBandwidthField',
name: 'bwlimit',
backendUnit: 'KiB',
allowZero: true,
fieldLabel: gettext('Bandwidth Limit'),
emptyText: gettext('Defaults to target storage restore limit'),
autoEl: {
tag: 'div',
'data-qtip': gettext("Use '0' to disable all bandwidth limits."),
},
},
{
xtype: 'fieldcontainer',
layout: 'hbox',
items: [{
xtype: 'proxmoxcheckbox',
name: 'unique',
fieldLabel: gettext('Unique'),
flex: 1,
autoEl: {
tag: 'div',
'data-qtip': gettext('Autogenerate unique properties, e.g., MAC addresses'),
},
checked: false,
},
{
xtype: 'proxmoxcheckbox',
name: 'start',
reference: 'start',
flex: 1,
fieldLabel: gettext('Start after restore'),
labelWidth: 105,
checked: false,
}],
},
];
if (me.vmtype === 'lxc') {
items.push(
{
xtype: 'radiogroup',
fieldLabel: gettext('Privilege Level'),
reference: 'noVNCScalingGroup',
height: '15px', // renders faster with value assigned
layout: {
type: 'hbox',
algin: 'stretch',
},
autoEl: {
tag: 'div',
'data-qtip':
gettext('Choose if you want to keep or override the privilege level of the restored Container.'),
},
items: [
{
xtype: 'radiofield',
name: 'unprivileged',
inputValue: 'keep',
boxLabel: gettext('From Backup'),
flex: 1,
checked: true,
},
{
xtype: 'radiofield',
name: 'unprivileged',
inputValue: '1',
boxLabel: gettext('Unprivileged'),
flex: 1,
},
{
xtype: 'radiofield',
name: 'unprivileged',
inputValue: '0',
boxLabel: gettext('Privileged'),
flex: 1,
//margin: '0 0 0 10',
},
],
},
);
} else if (me.vmtype === 'qemu') {
items.push({
xtype: 'proxmoxcheckbox',
name: 'live-restore',
itemId: 'liveRestore',
flex: 1,
fieldLabel: gettext('Live restore'),
checked: false,
hidden: !me.isPBS,
},
{
xtype: 'displayfield',
reference: 'liveWarning',
// TODO: Remove once more tested/stable?
value: gettext('Note: If anything goes wrong during the live-restore, new data written by the VM may be lost.'),
userCls: 'pmx-hint',
hidden: true,
});
}
items.push({
xtype: 'fieldset',
title: `${gettext('Override Settings')}:`,
layout: 'hbox',
defaults: {
border: false,
layout: 'anchor',
flex: 1,
},
items: [
{
padding: '0 10 0 0',
items: [{
xtype: 'textfield',
fieldLabel: me.vmtype === 'lxc' ? gettext('Hostname') : gettext('Name'),
name: 'name',
vtype: 'DnsName',
reference: 'nameField',
allowBlank: true,
}, {
xtype: 'proxmoxintegerfield',
fieldLabel: gettext('Cores'),
name: 'cores',
reference: 'coresField',
minValue: 1,
maxValue: 128,
allowBlank: true,
}],
},
{
padding: '0 0 0 10',
items: [
{
xtype: 'pveMemoryField',
fieldLabel: gettext('Memory'),
name: 'memory',
reference: 'memoryField',
value: '',
allowBlank: true,
},
{
xtype: 'proxmoxintegerfield',
fieldLabel: gettext('Sockets'),
name: 'sockets',
reference: 'socketsField',
minValue: 1,
maxValue: 4,
allowBlank: true,
hidden: me.vmtype !== 'qemu',
disabled: me.vmtype !== 'qemu',
}],
},
],
});
let title = gettext('Restore') + ": " + (me.vmtype === 'lxc' ? 'CT' : 'VM');
if (me.vmid) {
title = `${gettext('Overwrite')} ${title} ${me.vmid}`;
}
Ext.apply(me, {
title: title,
items: [
{
xtype: 'form',
bodyPadding: 10,
border: false,
fieldDefaults: {
labelWidth: 100,
anchor: '100%',
},
items: items,
},
],
buttons: [
{
text: gettext('Restore'),
reference: 'doRestoreBtn',
handler: 'doRestore',
},
],
});
me.callParent();
},
});
/*
* SafeDestroy window with additional checkboxes for removing guests
*/
Ext.define('PVE.window.SafeDestroyGuest', {
extend: 'Proxmox.window.SafeDestroy',
alias: 'widget.pveSafeDestroyGuest',
additionalItems: [
{
xtype: 'proxmoxcheckbox',
name: 'purge',
reference: 'purgeCheckbox',
boxLabel: gettext('Purge from job configurations'),
checked: false,
autoEl: {
tag: 'div',
'data-qtip': gettext('Remove from replication, HA and backup jobs'),
},
},
{
xtype: 'proxmoxcheckbox',
name: 'destroyUnreferenced',
reference: 'destroyUnreferencedCheckbox',
boxLabel: gettext('Destroy unreferenced disks owned by guest'),
checked: false,
autoEl: {
tag: 'div',
'data-qtip': gettext('Scan all enabled storages for unreferenced disks and delete them.'),
},
},
],
note: gettext('Referenced disks will always be destroyed.'),
getParams: function() {
let me = this;
const purgeCheckbox = me.lookupReference('purgeCheckbox');
me.params.purge = purgeCheckbox.checked ? 1 : 0;
const destroyUnreferencedCheckbox = me.lookupReference('destroyUnreferencedCheckbox');
me.params["destroy-unreferenced-disks"] = destroyUnreferencedCheckbox.checked ? 1 : 0;
return me.callParent();
},
});
/*
* SafeDestroy window with additional checkboxes for removing a storage on the disk level.
*/
Ext.define('PVE.window.SafeDestroyStorage', {
extend: 'Proxmox.window.SafeDestroy',
alias: 'widget.pveSafeDestroyStorage',
showProgress: true,
additionalItems: [
{
xtype: 'proxmoxcheckbox',
name: 'wipeDisks',
reference: 'wipeDisksCheckbox',
boxLabel: gettext('Cleanup Disks'),
checked: true,
autoEl: {
tag: 'div',
'data-qtip': gettext('Wipe labels and other left-overs'),
},
},
{
xtype: 'proxmoxcheckbox',
name: 'cleanupConfig',
reference: 'cleanupConfigCheckbox',
boxLabel: gettext('Cleanup Storage Configuration'),
checked: true,
},
],
getParams: function() {
let me = this;
me.params['cleanup-disks'] = me.lookupReference('wipeDisksCheckbox').checked ? 1 : 0;
me.params['cleanup-config'] = me.lookupReference('cleanupConfigCheckbox').checked ? 1 : 0;
return me.callParent();
},
});
Ext.define('PVE.window.Settings', {
extend: 'Ext.window.Window',
width: '800px',
title: gettext('My Settings'),
iconCls: 'fa fa-gear',
modal: true,
bodyPadding: 10,
resizable: false,
buttons: [
{
xtype: 'proxmoxHelpButton',
onlineHelp: 'gui_my_settings',
hidden: false,
},
'->',
{
text: gettext('Close'),
handler: function() {
this.up('window').close();
},
},
],
layout: 'hbox',
controller: {
xclass: 'Ext.app.ViewController',
init: function(view) {
var me = this;
var sp = Ext.state.Manager.getProvider();
var username = sp.get('login-username') || Proxmox.Utils.noneText;
me.lookupReference('savedUserName').setValue(Ext.String.htmlEncode(username));
var vncMode = sp.get('novnc-scaling') || 'auto';
me.lookupReference('noVNCScalingGroup').setValue({ noVNCScalingField: vncMode });
let summarycolumns = sp.get('summarycolumns', 'auto');
me.lookup('summarycolumns').setValue(summarycolumns);
me.lookup('guestNotesCollapse').setValue(sp.get('guest-notes-collapse', 'never'));
me.lookup('editNotesOnDoubleClick').setValue(sp.get('edit-notes-on-double-click', false));
var settings = ['fontSize', 'fontFamily', 'letterSpacing', 'lineHeight'];
settings.forEach(function(setting) {
var val = localStorage.getItem('pve-xterm-' + setting);
if (val !== undefined && val !== null) {
var field = me.lookup(setting);
field.setValue(val);
field.resetOriginalValue();
}
});
},
set_button_status: function() {
let me = this;
let form = me.lookup('xtermform');
let valid = form.isValid(), dirty = form.isDirty();
let hasValues = Object.values(form.getValues()).some(v => !!v);
me.lookup('xtermsave').setDisabled(!dirty || !valid);
me.lookup('xtermreset').setDisabled(!hasValues);
},
control: {
'#xtermjs form': {
dirtychange: 'set_button_status',
validitychange: 'set_button_status',
},
'#xtermjs button': {
click: function(button) {
var me = this;
var settings = ['fontSize', 'fontFamily', 'letterSpacing', 'lineHeight'];
settings.forEach(function(setting) {
var field = me.lookup(setting);
if (button.reference === 'xtermsave') {
var value = field.getValue();
if (value) {
localStorage.setItem('pve-xterm-' + setting, value);
} else {
localStorage.removeItem('pve-xterm-' + setting);
}
} else if (button.reference === 'xtermreset') {
field.setValue(undefined);
localStorage.removeItem('pve-xterm-' + setting);
}
field.resetOriginalValue();
});
me.set_button_status();
},
},
'button[name=reset]': {
click: function() {
let blacklist = ['GuiCap', 'login-username', 'dash-storages'];
let sp = Ext.state.Manager.getProvider();
for (const state of Object.keys(sp.state)) {
if (!blacklist.includes(state)) {
sp.clear(state);
}
}
window.location.reload();
},
},
'button[name=clear-username]': {
click: function() {
let me = this;
me.lookupReference('savedUserName').setValue(Proxmox.Utils.noneText);
Ext.state.Manager.getProvider().clear('login-username');
},
},
'grid[reference=dashboard-storages]': {
selectionchange: function(grid, selected) {
var me = this;
var sp = Ext.state.Manager.getProvider();
// saves the selected storageids as "id1,id2,id3,..." or clears the variable
if (selected.length > 0) {
sp.set('dash-storages', Ext.Array.pluck(selected, 'id').join(','));
} else {
sp.clear('dash-storages');
}
},
afterrender: function(grid) {
let store = grid.getStore();
let storages = Ext.state.Manager.getProvider().get('dash-storages') || '';
let items = [];
storages.split(',').forEach(storage => {
if (storage !== '') { // we have to get the records to be able to select them
let item = store.getById(storage);
if (item) {
items.push(item);
}
}
});
grid.suspendEvent('selectionchange');
grid.getSelectionModel().select(items);
grid.resumeEvent('selectionchange');
},
},
'field[reference=summarycolumns]': {
change: (el, newValue) => Ext.state.Manager.getProvider().set('summarycolumns', newValue),
},
'field[reference=guestNotesCollapse]': {
change: (e, v) => Ext.state.Manager.getProvider().set('guest-notes-collapse', v),
},
'field[reference=editNotesOnDoubleClick]': {
change: (e, v) => Ext.state.Manager.getProvider().set('edit-notes-on-double-click', v),
},
},
},
items: [{
xtype: 'fieldset',
flex: 1,
title: gettext('Webinterface Settings'),
margin: '5',
layout: {
type: 'vbox',
align: 'left',
},
defaults: {
width: '100%',
margin: '0 0 10 0',
},
items: [
{
xtype: 'displayfield',
fieldLabel: gettext('Dashboard Storages'),
labelAlign: 'left',
labelWidth: '50%',
},
{
xtype: 'grid',
maxHeight: 150,
reference: 'dashboard-storages',
selModel: {
selType: 'checkboxmodel',
},
columns: [{
header: gettext('Name'),
dataIndex: 'storage',
flex: 1,
}, {
header: gettext('Node'),
dataIndex: 'node',
flex: 1,
}],
store: {
type: 'diff',
field: ['type', 'storage', 'id', 'node'],
rstore: PVE.data.ResourceStore,
filters: [{
property: 'type',
value: 'storage',
}],
sorters: ['node', 'storage'],
},
},
{
xtype: 'box',
autoEl: { tag: 'hr' },
},
{
xtype: 'container',
layout: 'hbox',
items: [
{
xtype: 'displayfield',
fieldLabel: gettext('Saved User Name') + ':',
labelWidth: 150,
stateId: 'login-username',
reference: 'savedUserName',
flex: 1,
value: '',
},
{
xtype: 'button',
cls: 'x-btn-default-toolbar-small proxmox-inline-button',
text: gettext('Reset'),
name: 'clear-username',
},
],
},
{
xtype: 'box',
autoEl: { tag: 'hr' },
},
{
xtype: 'container',
layout: 'hbox',
items: [
{
xtype: 'displayfield',
fieldLabel: gettext('Layout') + ':',
flex: 1,
},
{
xtype: 'button',
cls: 'x-btn-default-toolbar-small proxmox-inline-button',
text: gettext('Reset'),
tooltip: gettext('Reset all layout changes (for example, column widths)'),
name: 'reset',
},
],
},
{
xtype: 'box',
autoEl: { tag: 'hr' },
},
{
xtype: 'proxmoxKVComboBox',
fieldLabel: gettext('Summary columns') + ':',
labelWidth: 125,
stateId: 'summarycolumns',
reference: 'summarycolumns',
comboItems: [
['auto', 'auto'],
['1', '1'],
['2', '2'],
['3', '3'],
],
},
{
xtype: 'proxmoxKVComboBox',
fieldLabel: gettext('Guest Notes') + ':',
labelWidth: 125,
stateId: 'guest-notes-collapse',
reference: 'guestNotesCollapse',
comboItems: [
['never', 'Show by default'],
['always', 'Collapse by default'],
['auto', 'auto (Collapse if empty)'],
],
},
{
xtype: 'checkbox',
fieldLabel: gettext('Notes'),
labelWidth: 125,
boxLabel: gettext('Open editor on double-click'),
reference: 'editNotesOnDoubleClick',
inputValue: true,
uncheckedValue: false,
},
],
},
{
xtype: 'container',
layout: 'vbox',
flex: 1,
margin: '5',
defaults: {
width: '100%',
// right margin ensures that the right border of the fieldsets
// is shown
margin: '0 2 10 0',
},
items: [
{
xtype: 'fieldset',
itemId: 'xtermjs',
title: gettext('xterm.js Settings'),
items: [{
xtype: 'form',
reference: 'xtermform',
border: false,
layout: {
type: 'vbox',
algin: 'left',
},
defaults: {
width: '100%',
margin: '0 0 10 0',
},
items: [
{
xtype: 'textfield',
name: 'fontFamily',
reference: 'fontFamily',
emptyText: Proxmox.Utils.defaultText,
fieldLabel: gettext('Font-Family'),
},
{
xtype: 'proxmoxintegerfield',
emptyText: Proxmox.Utils.defaultText,
name: 'fontSize',
reference: 'fontSize',
minValue: 1,
fieldLabel: gettext('Font-Size'),
},
{
xtype: 'numberfield',
name: 'letterSpacing',
reference: 'letterSpacing',
emptyText: Proxmox.Utils.defaultText,
fieldLabel: gettext('Letter Spacing'),
},
{
xtype: 'numberfield',
name: 'lineHeight',
minValue: 0.1,
reference: 'lineHeight',
emptyText: Proxmox.Utils.defaultText,
fieldLabel: gettext('Line Height'),
},
{
xtype: 'container',
layout: {
type: 'hbox',
pack: 'end',
},
defaults: {
margin: '0 0 0 5',
},
items: [
{
xtype: 'button',
reference: 'xtermreset',
disabled: true,
text: gettext('Reset'),
},
{
xtype: 'button',
reference: 'xtermsave',
disabled: true,
text: gettext('Save'),
},
],
},
],
}],
}, {
xtype: 'fieldset',
title: gettext('noVNC Settings'),
items: [
{
xtype: 'radiogroup',
fieldLabel: gettext('Scaling mode'),
reference: 'noVNCScalingGroup',
height: '15px', // renders faster with value assigned
layout: {
type: 'hbox',
},
items: [
{
xtype: 'radiofield',
name: 'noVNCScalingField',
inputValue: 'auto',
boxLabel: 'Auto',
},
{
xtype: 'radiofield',
name: 'noVNCScalingField',
inputValue: 'scale',
boxLabel: 'Local Scaling',
margin: '0 0 0 10',
}, {
xtype: 'radiofield',
name: 'noVNCScalingField',
inputValue: 'off',
boxLabel: 'Off',
margin: '0 0 0 10',
},
],
listeners: {
change: function(el, { noVNCScalingField }) {
let provider = Ext.state.Manager.getProvider();
if (noVNCScalingField === 'auto') {
provider.clear('novnc-scaling');
} else {
provider.set('novnc-scaling', noVNCScalingField);
}
},
},
},
],
},
],
}],
});
Ext.define('PVE.window.Snapshot', {
extend: 'Proxmox.window.Edit',
viewModel: {
data: {
type: undefined,
isCreate: undefined,
running: false,
guestAgentEnabled: false,
},
formulas: {
runningWithoutGuestAgent: (get) => get('type') === 'qemu' && get('running') && !get('guestAgentEnabled'),
shouldWarnAboutFS: (get) => get('isCreate') && get('runningWithoutGuestAgent') && get('!vmstate.checked'),
},
},
onGetValues: function(values) {
let me = this;
if (me.type === 'lxc') {
delete values.vmstate;
}
return values;
},
initComponent: function() {
var me = this;
var vm = me.getViewModel();
if (!me.nodename) {
throw "no node name specified";
}
if (!me.vmid) {
throw "no VM ID specified";
}
if (!me.type) {
throw "no type specified";
}
vm.set('type', me.type);
vm.set('running', me.running);
vm.set('isCreate', me.isCreate);
if (me.type === 'qemu' && me.isCreate) {
Proxmox.Utils.API2Request({
url: `/nodes/${me.nodename}/${me.type}/${me.vmid}/config`,
params: { 'current': '1' },
method: 'GET',
success: function(response, options) {
let res = response.result.data;
let enabled = PVE.Parser.parsePropertyString(res.agent, 'enabled');
vm.set('guestAgentEnabled', !!PVE.Parser.parseBoolean(enabled.enabled));
},
});
}
me.items = [
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'snapname',
value: me.snapname,
fieldLabel: gettext('Name'),
vtype: 'ConfigId',
allowBlank: false,
},
{
xtype: 'displayfield',
hidden: me.isCreate,
disabled: me.isCreate,
name: 'snaptime',
renderer: PVE.Utils.render_timestamp_human_readable,
fieldLabel: gettext('Timestamp'),
},
{
xtype: 'proxmoxcheckbox',
hidden: me.type !== 'qemu' || !me.isCreate || !me.running,
disabled: me.type !== 'qemu' || !me.isCreate || !me.running,
name: 'vmstate',
reference: 'vmstate',
uncheckedValue: 0,
defaultValue: 0,
checked: 1,
fieldLabel: gettext('Include RAM'),
},
{
xtype: 'textareafield',
grow: true,
editable: !me.viewonly,
name: 'description',
fieldLabel: gettext('Description'),
},
{
xtype: 'displayfield',
userCls: 'pmx-hint',
name: 'fswarning',
hidden: true,
value: gettext('It is recommended to either include the RAM or use the QEMU Guest Agent when taking a snapshot of a running VM to avoid inconsistencies.'),
bind: {
hidden: '{!shouldWarnAboutFS}',
},
},
{
title: gettext('Settings'),
hidden: me.isCreate,
xtype: 'grid',
itemId: 'summary',
border: true,
height: 200,
store: {
model: 'KeyValue',
sorters: [
{
property: 'key',
direction: 'ASC',
},
],
},
columns: [
{
header: gettext('Key'),
width: 150,
dataIndex: 'key',
},
{
header: gettext('Value'),
flex: 1,
dataIndex: 'value',
},
],
},
];
me.url = `/nodes/${me.nodename}/${me.type}/${me.vmid}/snapshot`;
let subject;
if (me.isCreate) {
subject = (me.type === 'qemu' ? 'VM' : 'CT') + me.vmid + ' ' + gettext('Snapshot');
me.method = 'POST';
me.showTaskViewer = true;
} else {
subject = `${gettext('Snapshot')} ${me.snapname}`;
me.url += `/${me.snapname}/config`;
}
Ext.apply(me, {
subject: subject,
width: me.isCreate ? 450 : 620,
height: me.isCreate ? undefined : 420,
});
me.callParent();
if (!me.snapname) {
return;
}
me.load({
success: function(response) {
let kvarray = [];
Ext.Object.each(response.result.data, function(key, value) {
if (key === 'description' || key === 'snaptime') {
return;
}
kvarray.push({ key: key, value: value });
});
let summarystore = me.down('#summary').getStore();
summarystore.suspendEvents();
summarystore.add(kvarray);
summarystore.sort();
summarystore.resumeEvents();
summarystore.fireEvent('refresh', summarystore);
me.setValues(response.result.data);
},
});
},
});
Ext.define('PVE.panel.StartupInputPanel', {
extend: 'Proxmox.panel.InputPanel',
onlineHelp: 'qm_startup_and_shutdown',
onGetValues: function(values) {
var me = this;
var res = PVE.Parser.printStartup(values);
if (res === undefined || res === '') {
return { 'delete': 'startup' };
}
return { startup: res };
},
setStartup: function(value) {
var me = this;
var startup = PVE.Parser.parseStartup(value);
if (startup) {
me.setValues(startup);
}
},
initComponent: function() {
var me = this;
me.items = [
{
xtype: 'textfield',
name: 'order',
defaultValue: '',
emptyText: 'any',
fieldLabel: gettext('Start/Shutdown order'),
},
{
xtype: 'textfield',
name: 'up',
defaultValue: '',
emptyText: 'default',
fieldLabel: gettext('Startup delay'),
},
{
xtype: 'textfield',
name: 'down',
defaultValue: '',
emptyText: 'default',
fieldLabel: gettext('Shutdown timeout'),
},
];
me.callParent();
},
});
Ext.define('PVE.window.StartupEdit', {
extend: 'Proxmox.window.Edit',
alias: 'widget.pveWindowStartupEdit',
onlineHelp: undefined,
initComponent: function() {
let me = this;
let ipanelConfig = me.onlineHelp ? { onlineHelp: me.onlineHelp } : {};
let ipanel = Ext.create('PVE.panel.StartupInputPanel', ipanelConfig);
Ext.applyIf(me, {
subject: gettext('Start/Shutdown order'),
fieldDefaults: {
labelWidth: 120,
},
items: [ipanel],
});
me.callParent();
me.load({
success: function(response, options) {
me.vmconfig = response.result.data;
ipanel.setStartup(me.vmconfig.startup);
},
});
},
});
Ext.define('PVE.window.DownloadUrlToStorage', {
extend: 'Proxmox.window.Edit',
alias: 'widget.pveStorageDownloadUrl',
mixins: ['Proxmox.Mixin.CBind'],
isCreate: true,
method: 'POST',
showTaskViewer: true,
title: gettext('Download from URL'),
submitText: gettext('Download'),
cbindData: function(initialConfig) {
var me = this;
return {
nodename: me.nodename,
storage: me.storage,
content: me.content,
};
},
cbind: {
url: '/nodes/{nodename}/storage/{storage}/download-url',
},
viewModel: {
data: {
size: '-',
mimetype: '-',
enableQuery: true,
},
},
controller: {
xclass: 'Ext.app.ViewController',
urlChange: function(field) {
this.resetMetaInfo();
this.setQueryEnabled();
},
setQueryEnabled: function() {
this.getViewModel().set('enableQuery', true);
},
resetMetaInfo: function() {
let vm = this.getViewModel();
vm.set('size', '-');
vm.set('mimetype', '-');
},
urlCheck: function(field) {
let me = this;
let view = me.getView();
const queryParam = view.getValues();
me.getViewModel().set('enableQuery', false);
me.resetMetaInfo();
let urlField = view.down('[name=url]');
Proxmox.Utils.API2Request({
url: `/nodes/${view.nodename}/query-url-metadata`,
method: 'GET',
params: {
url: queryParam.url,
'verify-certificates': queryParam['verify-certificates'],
},
waitMsgTarget: view,
failure: res => {
urlField.setValidation(res.result.message);
urlField.validate();
Ext.MessageBox.alert(gettext('Error'), res.htmlStatus);
// re-enable so one can directly requery, e.g., if it was just a network hiccup
me.setQueryEnabled();
},
success: function(res, opt) {
urlField.setValidation();
urlField.validate();
let data = res.result.data;
let filename = data.filename || "";
let compression = '__default__';
if (view.content === 'iso') {
const matches = filename.match(/^(.+)\.(gz|lzo|zst|bz2)$/i);
if (matches) {
filename = matches[1];
compression = matches[2].toLowerCase();
}
}
view.setValues({
filename,
compression,
size: (data.size && Proxmox.Utils.format_size(data.size)) || gettext("Unknown"),
mimetype: data.mimetype || gettext("Unknown"),
});
},
});
},
hashChange: function(field) {
let checksum = Ext.getCmp('downloadUrlChecksum');
if (field.getValue() === '__default__') {
checksum.setDisabled(true);
checksum.setValue("");
checksum.allowBlank = true;
} else {
checksum.setDisabled(false);
checksum.allowBlank = false;
}
},
},
items: [
{
xtype: 'inputpanel',
border: false,
onGetValues: function(values) {
if (typeof values.checksum === 'string') {
values.checksum = values.checksum.trim();
}
return values;
},
columnT: [
{
xtype: 'fieldcontainer',
layout: 'hbox',
fieldLabel: gettext('URL'),
items: [
{
xtype: 'textfield',
name: 'url',
emptyText: gettext("Enter URL to download"),
allowBlank: false,
flex: 1,
listeners: {
change: 'urlChange',
},
},
{
xtype: 'button',
name: 'check',
text: gettext('Query URL'),
margin: '0 0 0 5',
bind: {
disabled: '{!enableQuery}',
},
listeners: {
click: 'urlCheck',
},
},
],
},
{
xtype: 'textfield',
name: 'filename',
allowBlank: false,
fieldLabel: gettext('File name'),
emptyText: gettext("Please (re-)query URL to get meta information"),
},
],
column1: [
{
xtype: 'displayfield',
name: 'size',
fieldLabel: gettext('File size'),
bind: {
value: '{size}',
},
},
],
column2: [
{
xtype: 'displayfield',
name: 'mimetype',
fieldLabel: gettext('MIME type'),
bind: {
value: '{mimetype}',
},
},
],
advancedColumn1: [
{
xtype: 'pveHashAlgorithmSelector',
name: 'checksum-algorithm',
fieldLabel: gettext('Hash algorithm'),
allowBlank: true,
hasNoneOption: true,
value: '__default__',
listeners: {
change: 'hashChange',
},
},
{
xtype: 'textfield',
name: 'checksum',
fieldLabel: gettext('Checksum'),
allowBlank: true,
disabled: true,
emptyText: gettext('none'),
id: 'downloadUrlChecksum',
},
],
advancedColumn2: [
{
xtype: 'proxmoxcheckbox',
name: 'verify-certificates',
fieldLabel: gettext('Verify certificates'),
uncheckedValue: 0,
checked: true,
listeners: {
change: 'setQueryEnabled',
},
},
{
xtype: 'proxmoxKVComboBox',
name: 'compression',
fieldLabel: gettext('Decompression algorithm'),
allowBlank: true,
hasNoneOption: true,
deleteEmpty: false,
value: '__default__',
comboItems: [
['__default__', Proxmox.Utils.NoneText],
['lzo', 'LZO'],
['gz', 'GZIP'],
['zst', 'ZSTD'],
['bz2', 'BZIP2'],
],
cbind: {
hidden: get => get('content') !== 'iso',
},
},
],
},
{
xtype: 'hiddenfield',
name: 'content',
cbind: {
value: '{content}',
},
},
],
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (!me.storage) {
throw "no storage ID specified";
}
me.callParent();
},
});
Ext.define('PVE.window.UploadToStorage', {
extend: 'Ext.window.Window',
alias: 'widget.pveStorageUpload',
mixins: ['Proxmox.Mixin.CBind'],
resizable: false,
modal: true,
title: gettext('Upload'),
acceptedExtensions: {
'import': ['.ova'],
iso: ['.img', '.iso'],
vztmpl: ['.tar.gz', '.tar.xz', '.tar.zst'],
},
cbindData: function(initialConfig) {
const me = this;
const ext = me.acceptedExtensions[me.content] || [];
me.url = `/nodes/${me.nodename}/storage/${me.storage}/upload`;
return {
extensions: ext.join(', '),
filenameRegex: RegExp('^.*(?:' + ext.join('|').replaceAll('.', '\\.') + ')$', 'i'),
};
},
viewModel: {
data: {
size: '-',
mimetype: '-',
filename: '',
},
},
controller: {
submit: function(button) {
const view = this.getView();
const form = this.lookup('formPanel').getForm();
const abortBtn = this.lookup('abortBtn');
const pbar = this.lookup('progressBar');
const updateProgress = function(per, bytes) {
let text = (per * 100).toFixed(2) + '%';
if (bytes) {
text += " (" + Proxmox.Utils.format_size(bytes) + ')';
}
pbar.updateProgress(per, text);
};
const fd = new FormData();
button.setDisabled(true);
abortBtn.setDisabled(false);
fd.append("content", view.content);
const fileField = form.findField('file');
const file = fileField.fileInputEl.dom.files[0];
fileField.setDisabled(true);
const filenameField = form.findField('filename');
const filename = filenameField.getValue();
filenameField.setDisabled(true);
const algorithmField = form.findField('checksum-algorithm');
algorithmField.setDisabled(true);
if (algorithmField.getValue() !== '__default__') {
fd.append("checksum-algorithm", algorithmField.getValue());
const checksumField = form.findField('checksum');
fd.append("checksum", checksumField.getValue()?.trim());
checksumField.setDisabled(true);
}
fd.append("filename", file, filename);
pbar.setVisible(true);
updateProgress(0);
const xhr = new XMLHttpRequest();
view.xhr = xhr;
xhr.addEventListener("load", function(e) {
if (xhr.status === 200) {
view.hide();
const result = JSON.parse(xhr.response);
const upid = result.data;
Ext.create('Proxmox.window.TaskViewer', {
autoShow: true,
upid: upid,
taskDone: view.taskDone,
listeners: {
destroy: function() {
view.close();
},
},
});
return;
}
const err = Ext.htmlEncode(xhr.statusText);
let msg = `${gettext('Error')} ${xhr.status.toString()}: ${err}`;
if (xhr.responseText !== "") {
const result = Ext.decode(xhr.responseText);
result.message = msg;
msg = Proxmox.Utils.extractRequestError(result, true);
}
Ext.Msg.alert(gettext('Error'), msg, btn => view.close());
}, false);
xhr.addEventListener("error", function(e) {
const err = e.target.status.toString();
const msg = `Error '${err}' occurred while receiving the document.`;
Ext.Msg.alert(gettext('Error'), msg, btn => view.close());
});
xhr.upload.addEventListener("progress", function(evt) {
if (evt.lengthComputable) {
const percentComplete = evt.loaded / evt.total;
updateProgress(percentComplete, evt.loaded);
}
}, false);
xhr.open("POST", `/api2/json${view.url}`, true);
xhr.send(fd);
},
validitychange: function(f, valid) {
const submitBtn = this.lookup('submitBtn');
submitBtn.setDisabled(!valid);
},
fileChange: function(input) {
const vm = this.getViewModel();
const name = input.value.replace(/^.*(\/|\\)/, '');
const fileInput = input.fileInputEl.dom;
vm.set('filename', name);
vm.set('size', (fileInput.files[0] && Proxmox.Utils.format_size(fileInput.files[0].size)) || '-');
vm.set('mimetype', (fileInput.files[0] && fileInput.files[0].type) || '-');
},
hashChange: function(field, value) {
const checksum = this.lookup('downloadUrlChecksum');
if (value === '__default__') {
checksum.setDisabled(true);
checksum.setValue("");
} else {
checksum.setDisabled(false);
}
},
},
items: [
{
xtype: 'form',
reference: 'formPanel',
method: 'POST',
waitMsgTarget: true,
bodyPadding: 10,
border: false,
width: 400,
fieldDefaults: {
labelWidth: 100,
anchor: '100%',
},
items: [
{
xtype: 'filefield',
name: 'file',
buttonText: gettext('Select File'),
allowBlank: false,
fieldLabel: gettext('File'),
cbind: {
accept: '{extensions}',
},
listeners: {
change: 'fileChange',
},
},
{
xtype: 'textfield',
name: 'filename',
allowBlank: false,
fieldLabel: gettext('File name'),
bind: {
value: '{filename}',
},
cbind: {
regex: '{filenameRegex}',
},
regexText: gettext('Wrong file extension'),
},
{
xtype: 'displayfield',
name: 'size',
fieldLabel: gettext('File size'),
bind: {
value: '{size}',
},
},
{
xtype: 'displayfield',
name: 'mimetype',
fieldLabel: gettext('MIME type'),
bind: {
value: '{mimetype}',
},
},
{
xtype: 'pveHashAlgorithmSelector',
name: 'checksum-algorithm',
fieldLabel: gettext('Hash algorithm'),
allowBlank: true,
hasNoneOption: true,
value: '__default__',
listeners: {
change: 'hashChange',
},
},
{
xtype: 'textfield',
name: 'checksum',
fieldLabel: gettext('Checksum'),
allowBlank: false,
disabled: true,
emptyText: gettext('none'),
reference: 'downloadUrlChecksum',
},
{
xtype: 'progressbar',
text: 'Ready',
hidden: true,
reference: 'progressBar',
},
{
xtype: 'hiddenfield',
name: 'content',
cbind: {
value: '{content}',
},
},
],
listeners: {
validitychange: 'validitychange',
},
},
],
buttons: [
{
xtype: 'button',
text: gettext('Abort'),
reference: 'abortBtn',
disabled: true,
handler: function() {
const me = this;
me.up('pveStorageUpload').close();
},
},
{
text: gettext('Upload'),
reference: 'submitBtn',
disabled: true,
handler: 'submit',
},
],
listeners: {
close: function() {
const me = this;
if (me.xhr) {
me.xhr.abort();
delete me.xhr;
}
},
},
initComponent: function() {
const me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (!me.storage) {
throw "no storage ID specified";
}
if (!me.acceptedExtensions[me.content]) {
throw "content type not supported";
}
me.callParent();
},
});
Ext.define('PVE.window.ScheduleSimulator', {
extend: 'Ext.window.Window',
title: gettext('Job Schedule Simulator'),
viewModel: {
data: {
simulatedOnce: false,
},
formulas: {
gridEmptyText: get => get('simulatedOnce') ? Proxmox.Utils.NoneText : gettext('No simulation done'),
},
},
controller: {
xclass: 'Ext.app.ViewController',
close: function() {
this.getView().close();
},
simulate: function() {
let me = this;
let schedule = me.lookup('schedule').getValue();
if (!schedule) {
return;
}
let iterations = me.lookup('iterations').getValue() || 10;
Proxmox.Utils.API2Request({
url: '/cluster/jobs/schedule-analyze',
method: 'GET',
params: {
schedule,
iterations,
},
failure: response => {
me.getViewModel().set('simulatedOnce', true);
me.lookup('grid').getStore().setData([]);
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
success: function(response) {
let schedules = response.result.data;
me.lookup('grid').getStore().setData(schedules);
me.getViewModel().set('simulatedOnce', true);
},
});
},
scheduleChanged: function(field, value) {
this.lookup('simulateBtn').setDisabled(!value);
},
renderDate: function(value) {
let date = new Date(value*1000);
return date.toLocaleDateString();
},
renderTime: function(value) {
let date = new Date(value*1000);
return date.toLocaleTimeString();
},
init: function(view) {
let me = this;
if (view.schedule) {
me.lookup('schedule').setValue(view.schedule);
}
},
},
bodyPadding: 10,
modal: true,
resizable: false,
width: 600,
layout: 'fit',
items: [
{
xtype: 'inputpanel',
column1: [
{
xtype: 'pveCalendarEvent',
reference: 'schedule',
fieldLabel: gettext('Schedule'),
listeners: {
change: 'scheduleChanged',
},
},
{
xtype: 'proxmoxintegerfield',
reference: 'iterations',
fieldLabel: gettext('Iterations'),
minValue: 1,
maxValue: 100,
value: 10,
},
{
xtype: 'container',
layout: 'hbox',
items: [
{
xtype: 'box',
flex: 1,
},
{
xtype: 'button',
reference: 'simulateBtn',
text: gettext('Simulate'),
handler: 'simulate',
disabled: true,
},
],
},
],
column2: [
{
xtype: 'grid',
reference: 'grid',
bind: {
emptyText: '{gridEmptyText}',
},
scrollable: true,
height: 300,
columns: [
{
text: gettext('Date'),
renderer: 'renderDate',
dataIndex: 'timestamp',
flex: 1,
},
{
text: gettext('Time'),
renderer: 'renderTime',
dataIndex: 'timestamp',
align: 'right',
flex: 1,
},
],
store: {
fields: ['timestamp'],
data: [],
sorter: 'timestamp',
},
},
],
},
],
buttons: [
{
text: gettext('Done'),
handler: 'close',
},
],
});
Ext.define('PVE.window.Wizard', {
extend: 'Ext.window.Window',
activeTitle: '', // used for automated testing
width: 720,
height: 540,
modal: true,
border: false,
draggable: true,
closable: true,
resizable: false,
layout: 'border',
getValues: function(dirtyOnly) {
let me = this;
let values = {};
me.down('form').getForm().getFields().each(field => {
if (!field.up('inputpanel') && (!dirtyOnly || field.isDirty())) {
Proxmox.Utils.assemble_field_data(values, field.getSubmitData());
}
});
me.query('inputpanel').forEach(panel => {
Proxmox.Utils.assemble_field_data(values, panel.getValues(dirtyOnly));
});
return values;
},
initComponent: function() {
var me = this;
var tabs = me.items || [];
delete me.items;
/*
* Items may have the following functions:
* validator(): per tab custom validation
* onSubmit(): submit handler
* onGetValues(): overwrite getValues results
*/
Ext.Array.each(tabs, function(tab) {
tab.disabled = true;
});
tabs[0].disabled = false;
let maxidx = 0, curidx = 0;
let check_card = function(card) {
let fields = card.query('field, fieldcontainer');
if (card.isXType('fieldcontainer')) {
fields.unshift(card);
}
let valid = true;
for (const field of fields) {
// Note: not all fielcontainer have isValid()
if (Ext.isFunction(field.isValid) && !field.isValid()) {
valid = false;
}
}
if (Ext.isFunction(card.validator)) {
return card.validator();
}
return valid;
};
let disableTab = function(card) {
let tp = me.down('#wizcontent');
for (let idx = tp.items.indexOf(card); idx < tp.items.getCount(); idx++) {
let tab = tp.items.getAt(idx);
if (tab) {
tab.disable();
}
}
};
let tabchange = function(tp, newcard, oldcard) {
if (newcard.onSubmit) {
me.down('#next').setVisible(false);
me.down('#submit').setVisible(true);
} else {
me.down('#next').setVisible(true);
me.down('#submit').setVisible(false);
}
let valid = check_card(newcard);
me.down('#next').setDisabled(!valid);
me.down('#submit').setDisabled(!valid);
me.down('#back').setDisabled(tp.items.indexOf(newcard) === 0);
let idx = tp.items.indexOf(newcard);
if (idx > maxidx) {
maxidx = idx;
}
curidx = idx;
let ntab = tp.items.getAt(idx + 1);
if (valid && ntab && !newcard.onSubmit) {
ntab.enable();
}
};
if (me.subject && !me.title) {
me.title = Proxmox.Utils.dialog_title(me.subject, true, false);
}
let sp = Ext.state.Manager.getProvider();
let advancedOn = sp.get('proxmox-advanced-cb');
Ext.apply(me, {
items: [
{
xtype: 'form',
region: 'center',
layout: 'fit',
border: false,
margins: '5 5 0 5',
fieldDefaults: {
labelWidth: 100,
anchor: '100%',
},
items: [{
itemId: 'wizcontent',
xtype: 'tabpanel',
activeItem: 0,
bodyPadding: 0,
listeners: {
afterrender: function(tp) {
tabchange(tp, this.getActiveTab());
},
tabchange: function(tp, newcard, oldcard) {
tabchange(tp, newcard, oldcard);
},
},
defaults: {
padding: 10,
},
items: tabs,
}],
},
],
fbar: [
{
xtype: 'proxmoxHelpButton',
itemId: 'help',
},
'->',
{
xtype: 'proxmoxcheckbox',
boxLabelAlign: 'before',
boxLabel: gettext('Advanced'),
value: advancedOn,
listeners: {
change: function(_, value) {
let tp = me.down('#wizcontent');
tp.query('inputpanel').forEach(function(ip) {
ip.setAdvancedVisible(value);
});
sp.set('proxmox-advanced-cb', value);
},
},
},
{
text: gettext('Back'),
disabled: true,
itemId: 'back',
minWidth: 60,
handler: function() {
let tp = me.down('#wizcontent');
let prev = tp.items.indexOf(tp.getActiveTab()) - 1;
if (prev < 0) {
return;
}
let ntab = tp.items.getAt(prev);
if (ntab) {
tp.setActiveTab(ntab);
}
},
},
{
text: gettext('Next'),
disabled: true,
itemId: 'next',
minWidth: 60,
handler: function() {
let tp = me.down('#wizcontent');
let activeTab = tp.getActiveTab();
if (!check_card(activeTab)) {
return;
}
let next = tp.items.indexOf(activeTab) + 1;
let ntab = tp.items.getAt(next);
if (ntab) {
ntab.enable();
tp.setActiveTab(ntab);
}
},
},
{
text: gettext('Finish'),
minWidth: 60,
hidden: true,
itemId: 'submit',
handler: function() {
let tp = me.down('#wizcontent');
tp.getActiveTab().onSubmit();
},
},
],
});
me.callParent();
Ext.Array.each(me.query('inputpanel'), function(panel) {
panel.setAdvancedVisible(advancedOn);
});
Ext.Array.each(me.query('field'), function(field) {
let validcheck = function() {
let tp = me.down('#wizcontent');
// check validity for current to last enabled tab, as local change may affect validity of a later one
for (let i = curidx; i <= maxidx && i < tp.items.getCount(); i++) {
let tab = tp.items.getAt(i);
let valid = check_card(tab);
// only set the buttons on the current panel
if (i === curidx) {
me.down('#next').setDisabled(!valid);
me.down('#submit').setDisabled(!valid);
}
// if a panel is invalid, then disable all following, else enable the next tab
let nextTab = tp.items.getAt(i + 1);
if (!valid) {
disableTab(nextTab);
return;
} else if (nextTab && !tab.onSubmit) {
nextTab.enable();
}
}
};
field.on('change', validcheck);
field.on('validitychange', validcheck);
});
},
});
Ext.define('PVE.window.GuestDiskReassign', {
extend: 'Proxmox.window.Edit',
mixins: ['Proxmox.Mixin.CBind'],
resizable: false,
modal: true,
width: 350,
border: false,
layout: 'fit',
showReset: false,
showProgress: true,
method: 'POST',
viewModel: {
data: {
mpType: '',
},
formulas: {
mpMaxCount: get => get('mpType') === 'mp'
? PVE.Utils.lxc_mp_counts.mps - 1
: PVE.Utils.lxc_mp_counts.unused - 1,
},
},
cbindData: function() {
let me = this;
return {
vmid: me.vmid,
disk: me.disk,
isQemu: me.type === 'qemu',
nodename: me.nodename,
url: () => {
let endpoint = me.type === 'qemu' ? 'move_disk' : 'move_volume';
return `/nodes/${me.nodename}/${me.type}/${me.vmid}/${endpoint}`;
},
};
},
cbind: {
title: get => get('isQemu') ? gettext('Reassign Disk') : gettext('Reassign Volume'),
submitText: get => get('title'),
qemu: '{isQemu}',
url: '{url}',
},
getValues: function() {
let me = this;
let values = me.formPanel.getForm().getValues();
let params = {
vmid: me.vmid,
'target-vmid': values.targetVmid,
};
params[me.qemu ? 'disk' : 'volume'] = me.disk;
if (me.qemu) {
params['target-disk'] = `${values.controller}${values.deviceid}`;
} else {
params['target-volume'] = `${values.mpType}${values.mpId}`;
}
return params;
},
controller: {
xclass: 'Ext.app.ViewController',
initViewModel: function(model) {
let view = this.getView();
let mpTypeValue = view.disk.match(/^unused\d+/) ? 'unused' : 'mp';
model.set('mpType', mpTypeValue);
},
onMpTypeChange: function(value) {
let view = this.getView();
view.getViewModel().set('mpType', value.getValue());
view.lookup('mpIdSelector').validate();
},
onTargetVMChange: function(f, vmid) {
let me = this;
let view = me.getView();
let diskSelector = view.lookup('diskSelector');
if (!vmid) {
diskSelector.setVMConfig(null);
me.VMConfig = null;
return;
}
let url = `/nodes/${view.nodename}/${view.type}/${vmid}/config`;
Proxmox.Utils.API2Request({
url: url,
method: 'GET',
failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
success: function({ result }, options) {
if (view.qemu) {
diskSelector.setVMConfig(result.data);
diskSelector.setDisabled(false);
} else {
let mpIdSelector = view.lookup('mpIdSelector');
let mpType = view.lookup('mpType');
view.VMConfig = result.data;
mpIdSelector.setValue(
PVE.Utils.nextFreeLxcMP(
view.getViewModel().get('mpType'),
view.VMConfig,
).id,
);
mpType.setDisabled(false);
mpIdSelector.setDisabled(false);
mpIdSelector.validate();
}
},
});
},
},
defaultFocus: 'sourceDisk',
items: [
{
xtype: 'displayfield',
name: 'sourceDisk',
fieldLabel: gettext('Source'),
cbind: {
name: get => get('isQemu') ? 'disk' : 'volume',
value: '{disk}',
},
allowBlank: false,
},
{
xtype: 'vmComboSelector',
name: 'targetVmid',
allowBlank: false,
fieldLabel: gettext('Target Guest'),
store: {
model: 'PVEResources',
autoLoad: true,
sorters: 'vmid',
cbind: {}, // for nested cbinds
filters: [
{
property: 'type',
cbind: { value: '{type}' },
},
{
property: 'node',
cbind: { value: '{nodename}' },
},
// FIXME: remove, artificial restriction that doesn't gains us anything..
{
property: 'vmid',
operator: '!=',
cbind: { value: '{vmid}' },
},
{
property: 'template',
value: 0,
},
],
},
listeners: { change: 'onTargetVMChange' },
},
{
xtype: 'pveControllerSelector',
reference: 'diskSelector',
withUnused: true,
disabled: true,
cbind: {
hidden: '{!isQemu}',
},
},
{
xtype: 'container',
layout: 'hbox',
cbind: {
hidden: '{isQemu}',
disabled: '{isQemu}',
},
items: [
{
xtype: 'pmxDisplayEditField',
cbind: {
editable: get => !get('disk').match(/^unused\d+/),
value: get => get('disk').match(/^unused\d+/) ? 'unused' : 'mp',
},
disabled: true,
name: 'mpType',
reference: 'mpType',
fieldLabel: gettext('Add as'),
submitValue: true,
flex: 4,
editConfig: {
xtype: 'proxmoxKVComboBox',
name: 'mpTypeCombo',
deleteEmpty: false,
cbind: {
hidden: '{isQemu}',
},
comboItems: [
['mp', gettext('Mount Point')],
['unused', gettext('Unused')],
],
listeners: { change: 'onMpTypeChange' },
},
},
{
xtype: 'proxmoxintegerfield',
name: 'mpId',
reference: 'mpIdSelector',
minValue: 0,
flex: 1,
allowBlank: false,
validateOnChange: true,
disabled: true,
bind: {
maxValue: '{mpMaxCount}',
},
validator: function(value) {
let view = this.up('window');
let type = view.getViewModel().get('mpType');
if (Ext.isDefined(view.VMConfig[`${type}${value}`])) {
return "Mount point is already in use.";
}
return true;
},
},
],
},
],
initComponent: function() {
let me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (!me.vmid) {
throw "no VM ID specified";
}
if (!me.type) {
throw "no type specified";
}
me.callParent();
},
});
Ext.define('PVE.GuestStop', {
extend: 'Ext.window.MessageBox',
closeAction: 'destroy',
initComponent: function() {
let me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (!me.vm) {
throw "no vm specified";
}
let isQemuVM = me.vm.type === 'qemu';
let overruleTaskType = isQemuVM ? 'qmshutdown' : 'vzshutdown';
me.taskType = isQemuVM ? 'qmstop' : 'vzstop';
me.url = `/nodes/${me.nodename}/${me.vm.type}/${me.vm.vmid}/status/stop`;
let caps = Ext.state.Manager.get('GuiCap');
let hasSysModify = !!caps.nodes['Sys.Modify'];
// offer to overrule if there is at least one matching shutdown task and the guest is not
// HA-enabled. Also allow users to abort tasks started by one of their API tokens.
let activeShutdownTask = Ext.getStore('pve-cluster-tasks')?.findBy(task =>
(hasSysModify || task.data.user === Proxmox.UserName) &&
task.data.id === me.vm.vmid.toString() &&
task.data.status === undefined &&
task.data.type === overruleTaskType,
) !== -1;
let haEnabled = me.vm.hastate && me.vm.hastate !== 'unmanaged';
me.callParent();
// message box has its actual content in a sub-container, the top one is just for layouting
me.promptContainer.add({
xtype: 'proxmoxcheckbox',
name: 'overrule-shutdown',
checked: !haEnabled && activeShutdownTask,
boxLabel: gettext('Overrule active shutdown tasks'),
hidden: !(hasSysModify || activeShutdownTask),
disabled: !(hasSysModify || activeShutdownTask) || haEnabled,
padding: '3 0 0 0',
});
},
handler: function(btn) {
let me = this;
if (btn === 'yes') {
let overruleField = me.promptContainer.down('proxmoxcheckbox[name=overrule-shutdown]');
let params = !overruleField.isDisabled() && overruleField.getSubmitValue()
? { 'overrule-shutdown': 1 }
: undefined;
Proxmox.Utils.API2Request({
url: me.url,
waitMsgTarget: me,
method: 'POST',
params: params,
failure: response => Ext.Msg.alert('Error', response.htmlStatus),
});
}
},
show: function() {
let me = this;
let cfg = {
title: gettext('Confirm'),
icon: Ext.Msg.WARNING,
msg: PVE.Utils.formatGuestTaskConfirmation(me.taskType, me.vm.vmid, me.vm.name),
buttons: Ext.Msg.YESNO,
callback: btn => me.handler(btn),
};
me.callParent([cfg]);
},
});
Ext.define('PVE.window.TreeSettingsEdit', {
extend: 'Proxmox.window.Edit',
alias: 'widget.pveTreeSettingsEdit',
title: gettext('Tree Settings'),
isCreate: false,
url: '#', // ignored as submit() gets overridden here, but the parent class requires it
width: 450,
fieldDefaults: {
labelWidth: 150,
},
items: [
{
xtype: 'inputpanel',
items: [
{
xtype: 'proxmoxKVComboBox',
name: 'sort-field',
fieldLabel: gettext('Sort Key'),
comboItems: [
['__default__', `${Proxmox.Utils.defaultText} (VMID)`],
['vmid', 'VMID'],
['name', gettext('Name')],
],
defaultValue: '__default__',
value: '__default__',
deleteEmpty: false,
},
{
xtype: 'proxmoxKVComboBox',
name: 'group-templates',
fieldLabel: gettext('Group Templates'),
comboItems: [
['__default__', `${Proxmox.Utils.defaultText} (${gettext("Yes")})`],
[1, gettext('Yes')],
[0, gettext('No')],
],
defaultValue: '__default__',
value: '__default__',
deleteEmpty: false,
},
{
xtype: 'proxmoxKVComboBox',
name: 'group-guest-types',
fieldLabel: gettext('Group Guest Types'),
comboItems: [
['__default__', `${Proxmox.Utils.defaultText} (${gettext("Yes")})`],
[1, gettext('Yes')],
[0, gettext('No')],
],
defaultValue: '__default__',
value: '__default__',
deleteEmpty: false,
},
{
xtype: 'displayfield',
userCls: 'pmx-hint',
value: gettext('Settings are saved in the local storage of the browser'),
},
],
},
],
submit: function() {
let me = this;
let localStorage = Ext.state.Manager.getProvider();
localStorage.set('pve-tree-sorting', me.down('inputpanel').getValues() || null);
me.apiCallDone();
me.close();
},
initComponent: function() {
let me = this;
me.callParent();
let localStorage = Ext.state.Manager.getProvider();
me.down('inputpanel').setValues(localStorage.get('pve-tree-sorting'));
},
});
Ext.define('PVE.window.PCIMapEditWindow', {
extend: 'Proxmox.window.Edit',
mixins: ['Proxmox.Mixin.CBind'],
width: 800,
subject: gettext('PCI mapping'),
onlineHelp: 'resource_mapping',
method: 'POST',
cbindData: function(initialConfig) {
let me = this;
me.isCreate = (!me.name || !me.nodename) && !me.entryOnly;
me.method = me.name ? 'PUT' : 'POST';
me.hideMapping = !!me.entryOnly;
me.hideComment = me.name && !me.entryOnly;
me.hideNodeSelector = me.nodename || me.entryOnly;
me.hideNode = !me.nodename || !me.hideNodeSelector;
return {
name: me.name,
nodename: me.nodename,
};
},
submitUrl: function(_url, data) {
let me = this;
let name = me.method === 'PUT' ? me.name : '';
return `/cluster/mapping/pci/${name}`;
},
controller: {
xclass: 'Ext.app.ViewController',
onGetValues: function(values) {
let me = this;
let view = me.getView();
if (view.method === "POST") {
delete me.digest;
}
if (values.iommugroup === -1) {
delete values.iommugroup;
}
let nodename = values.node ?? view.nodename;
delete values.node;
if (me.originalMap) {
let otherMaps = PVE.Parser
.filterPropertyStringList(me.originalMap, (e) => e.node !== nodename);
if (otherMaps.length) {
values.map = values.map.concat(otherMaps);
}
}
return values;
},
onSetValues: function(values) {
let me = this;
let view = me.getView();
me.originalMap = [...values.map];
let configuredNodes = [];
values.map = PVE.Parser.filterPropertyStringList(values.map, (e) => {
configuredNodes.push(e.node);
return e.node === view.nodename;
});
me.lookup('nodeselector').disallowedNodes = configuredNodes;
return values;
},
checkIommu: function(store, records, success) {
let me = this;
if (!success || !records.length) {
return;
}
me.lookup('iommu_warning').setVisible(
records.every((val) => val.data.iommugroup === -1),
);
let value = me.lookup('pciselector').getValue();
me.checkIsolated(value);
},
checkIsolated: function(value) {
let me = this;
let store = me.lookup('pciselector').getStore();
let isIsolated = function(entry) {
let isolated = true;
let parsed = PVE.Parser.parsePropertyString(entry);
parsed.iommugroup = parseInt(parsed.iommugroup, 10);
if (!parsed.iommugroup) {
return isolated;
}
store.each(({ data }) => {
let isSubDevice = data.id.startsWith(parsed.path);
if (data.iommugroup === parsed.iommugroup && data.id !== parsed.path && !isSubDevice) {
isolated = false;
return false;
}
return true;
});
return isolated;
};
let showWarning = false;
if (Ext.isArray(value)) {
for (const entry of value) {
if (!isIsolated(entry)) {
showWarning = true;
break;
}
}
} else {
showWarning = isIsolated(value);
}
me.lookup('group_warning').setVisible(showWarning);
},
mdevChange: function(mdevField, value) {
this.lookup('pciselector').setMdev(value);
},
nodeChange: function(field, value) {
if (!field.isDisabled()) {
this.lookup('pciselector').setNodename(value);
}
},
pciChange: function(_field, value) {
let me = this;
me.lookup('multiple_warning').setVisible(Ext.isArray(value) && value.length > 1);
me.checkIsolated(value);
},
control: {
'field[name=mdev]': {
change: 'mdevChange',
},
'pveNodeSelector': {
change: 'nodeChange',
},
'pveMultiPCISelector': {
change: 'pciChange',
},
},
},
items: [
{
xtype: 'inputpanel',
onGetValues: function(values) {
return this.up('window').getController().onGetValues(values);
},
onSetValues: function(values) {
return this.up('window').getController().onSetValues(values);
},
columnT: [
{
xtype: 'displayfield',
reference: 'iommu_warning',
hidden: true,
columnWidth: 1,
padding: '0 0 10 0',
value: gettext('No IOMMU detected, please activate it. See Documentation for further information.'),
userCls: 'pmx-hint',
},
{
xtype: 'displayfield',
reference: 'multiple_warning',
hidden: true,
columnWidth: 1,
padding: '0 0 10 0',
value: gettext('When multiple devices are selected, the first free one will be chosen on guest start.'),
userCls: 'pmx-hint',
},
{
xtype: 'displayfield',
reference: 'group_warning',
hidden: true,
columnWidth: 1,
padding: '0 0 10 0',
itemId: 'iommuwarning',
value: gettext('A selected device is not in a separate IOMMU group, make sure this is intended.'),
userCls: 'pmx-hint',
},
],
column1: [
{
xtype: 'pmxDisplayEditField',
fieldLabel: gettext('Name'),
labelWidth: 120,
cbind: {
editable: '{!name}',
value: '{name}',
submitValue: '{isCreate}',
},
name: 'id',
allowBlank: false,
},
{
xtype: 'displayfield',
fieldLabel: gettext('Mapping on Node'),
labelWidth: 120,
name: 'node',
cbind: {
value: '{nodename}',
disabled: '{hideNode}',
hidden: '{hideNode}',
},
allowBlank: false,
},
{
xtype: 'pveNodeSelector',
reference: 'nodeselector',
fieldLabel: gettext('Mapping on Node'),
labelWidth: 120,
name: 'node',
cbind: {
disabled: '{hideNodeSelector}',
hidden: '{hideNodeSelector}',
},
allowBlank: false,
},
],
column2: [
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Use with Mediated Devices'),
labelWidth: 200,
reference: 'mdev',
name: 'mdev',
cbind: {
deleteEmpty: '{!isCreate}',
disabled: '{hideComment}',
},
},
],
columnB: [
{
xtype: 'pveMultiPCISelector',
fieldLabel: gettext('Device'),
labelWidth: 120,
height: 300,
reference: 'pciselector',
name: 'map',
cbind: {
nodename: '{nodename}',
disabled: '{hideMapping}',
hidden: '{hideMapping}',
},
allowBlank: false,
onLoadCallBack: 'checkIommu',
margin: '0 0 10 0',
},
{
xtype: 'proxmoxtextfield',
fieldLabel: gettext('Comment'),
labelWidth: 120,
submitValue: true,
name: 'description',
cbind: {
deleteEmpty: '{!isCreate}',
disabled: '{hideComment}',
hidden: '{hideComment}',
},
},
],
},
],
});
Ext.define('PVE.window.USBMapEditWindow', {
extend: 'Proxmox.window.Edit',
mixins: ['Proxmox.Mixin.CBind'],
cbindData: function(initialConfig) {
let me = this;
me.isCreate = !me.name;
me.method = me.isCreate ? 'POST' : 'PUT';
me.hideMapping = !!me.entryOnly;
me.hideComment = me.name && !me.entryOnly;
me.hideNodeSelector = me.nodename || me.entryOnly;
me.hideNode = !me.nodename || !me.hideNodeSelector;
return {
name: me.name,
nodename: me.nodename,
};
},
submitUrl: function(_url, data) {
let me = this;
let name = me.isCreate ? '' : me.name;
return `/cluster/mapping/usb/${name}`;
},
title: gettext('Add USB mapping'),
onlineHelp: 'resource_mapping',
method: 'POST',
controller: {
xclass: 'Ext.app.ViewController',
onGetValues: function(values) {
let me = this;
let view = me.getView();
values.node ??= view.nodename;
let type = me.getView().down('radiofield').getGroupValue();
let name = values.name;
let description = values.description;
delete values.description;
delete values.name;
if (type === 'path') {
let usbsel = me.lookup(type);
let usbDev = usbsel.getStore().findRecord('usbid', values[type], 0, false, true, true);
if (!usbDev) {
return {};
}
values.id = `${usbDev.data.vendid}:${usbDev.data.prodid}`;
}
let map = [];
if (me.originalMap) {
map = PVE.Parser.filterPropertyStringList(me.originalMap, (e) => e.node !== values.node);
}
if (values.id) {
map.push(PVE.Parser.printPropertyString(values));
}
values = { map };
if (description) {
values.description = description;
}
if (view.isCreate) {
values.id = name;
}
return values;
},
onSetValues: function(values) {
let me = this;
let view = me.getView();
me.originalMap = [...values.map];
let configuredNodes = [];
PVE.Parser.filterPropertyStringList(values.map, (e) => {
configuredNodes.push(e.node);
if (e.node === view.nodename) {
values = e;
}
return false;
});
me.lookup('nodeselector').disallowedNodes = configuredNodes;
if (values.path) {
values.usb = 'path';
}
return values;
},
modeChange: function(field, value) {
let me = this;
let type = field.inputValue;
let usbsel = me.lookup(type);
usbsel.setDisabled(!value);
},
nodeChange: function(field, value) {
if (!field.isDisabled()) {
this.lookup('id').setNodename(value);
this.lookup('path').setNodename(value);
}
},
init: function(view) {
let me = this;
if (!view.nodename) {
//throw "no nodename given";
}
},
control: {
'radiofield': {
change: 'modeChange',
},
'pveNodeSelector': {
change: 'nodeChange',
},
},
},
items: [
{
xtype: 'inputpanel',
onGetValues: function(values) {
return this.up('window').getController().onGetValues(values);
},
onSetValues: function(values) {
return this.up('window').getController().onSetValues(values);
},
column1: [
{
xtype: 'pmxDisplayEditField',
fieldLabel: gettext('Name'),
cbind: {
editable: '{!name}',
value: '{name}',
submitValue: '{isCreate}',
},
name: 'name',
allowBlank: false,
},
{
xtype: 'displayfield',
fieldLabel: gettext('Mapping on Node'),
labelWidth: 120,
name: 'node',
cbind: {
value: '{nodename}',
disabled: '{hideNode}',
hidden: '{hideNode}',
},
allowBlank: false,
},
{
xtype: 'pveNodeSelector',
reference: 'nodeselector',
fieldLabel: gettext('Mapping on Node'),
labelWidth: 120,
name: 'node',
cbind: {
disabled: '{hideNodeSelector}',
hidden: '{hideNodeSelector}',
},
allowBlank: false,
},
],
column2: [
{
xtype: 'fieldcontainer',
defaultType: 'radiofield',
layout: 'fit',
cbind: {
disabled: '{hideMapping}',
hidden: '{hideMapping}',
},
items: [
{
name: 'usb',
inputValue: 'id',
checked: true,
boxLabel: gettext('Use USB Vendor/Device ID'),
submitValue: false,
},
{
xtype: 'pveUSBSelector',
type: 'device',
reference: 'id',
name: 'id',
cbind: {
nodename: '{nodename}',
disabled: '{hideMapping}',
},
editable: true,
allowBlank: false,
fieldLabel: gettext('Choose Device'),
labelAlign: 'right',
},
{
name: 'usb',
inputValue: 'path',
boxLabel: gettext('Use USB Port'),
submitValue: false,
},
{
xtype: 'pveUSBSelector',
disabled: true,
name: 'path',
reference: 'path',
cbind: {
nodename: '{nodename}',
},
editable: true,
type: 'port',
allowBlank: false,
fieldLabel: gettext('Choose Port'),
labelAlign: 'right',
},
],
},
],
columnB: [
{
xtype: 'proxmoxtextfield',
fieldLabel: gettext('Comment'),
submitValue: true,
name: 'description',
cbind: {
disabled: '{hideComment}',
hidden: '{hideComment}',
},
},
],
},
],
});
Ext.define('PVE.window.GuestImport', {
extend: 'Proxmox.window.Edit', // fixme: Proxmox.window.Edit?
alias: 'widget.pveGuestImportWindow',
title: gettext('Import Guest'),
onlineHelp: 'qm_import_virtual_machines',
width: 720,
bodyPadding: 0,
submitUrl: function() {
let me = this;
return `/nodes/${me.nodename}/qemu`;
},
isAdd: true,
isCreate: true,
submitText: gettext('Import'),
showTaskViewer: true,
method: 'POST',
loadUrl: function(_url, { storage, nodename, volumeName }) {
let args = Ext.Object.toQueryString({ volume: volumeName });
return `/nodes/${nodename}/storage/${storage}/import-metadata?${args}`;
},
controller: {
xclass: 'Ext.app.ViewController',
setNodename: function(_column, widget) {
let me = this;
let view = me.getView();
widget.setNodename(view.nodename);
},
diskStorageChange: function(storageSelector, value) {
let me = this;
let grid = me.lookup('diskGrid');
let rec = storageSelector.getWidgetRecord();
let validFormats = storageSelector.store.getById(value)?.data.format;
grid.query('pveDiskFormatSelector').some((selector) => {
if (selector.getWidgetRecord().data.id !== rec.data.id) {
return false;
}
if (validFormats?.[0]?.qcow2) {
selector.setDisabled(false);
selector.setValue('qcow2');
} else {
selector.setValue('raw');
selector.setDisabled(true);
}
return true;
});
},
isoStorageChange: function(storageSelector, value) {
let me = this;
let grid = me.lookup('cdGrid');
let rec = storageSelector.getWidgetRecord();
grid.query('pveFileSelector').some((selector) => {
if (selector.getWidgetRecord().data.id !== rec.data.id) {
return false;
}
selector.setStorage(value);
if (!value) {
selector.setValue('');
}
return true;
});
},
onOSBaseChange: function(_field, value) {
let me = this;
let ostype = me.lookup('ostype');
let store = ostype.getStore();
store.setData(PVE.Utils.kvm_ostypes[value]);
let old_val = ostype.getValue();
if (old_val && store.find('val', old_val) !== -1) {
ostype.setValue(old_val);
} else {
ostype.setValue(store.getAt(0));
}
},
calculateConfig: function() {
let me = this;
let inputPanel = me.lookup('mainInputPanel');
let summaryGrid = me.lookup('summaryGrid');
let values = inputPanel.getValues();
summaryGrid.getStore().setData(Object.entries(values).map(([key, value]) => ({ key, value })));
},
calculateAdditionalCDIdx: function() {
let me = this;
let maxIde = me.getMaxControllerId('ide');
let maxSata = me.getMaxControllerId('sata');
// only ide0 and ide2 can be used reliably for isos (e.g. for q35)
if (maxIde < 0) {
return 'ide0';
}
if (maxIde < 2) {
return 'ide2';
}
if (maxSata < PVE.Utils.diskControllerMaxIDs.sata - 1) {
return `sata${maxSata+1}`;
}
return '';
},
// assume assigned sata disks indices are continuous, so without holes
getMaxControllerId: function(controller) {
let me = this;
let view = me.getView();
if (!controller) {
return -1;
}
let max = view[`max${controller}`];
if (max !== undefined) {
return max;
}
max = -1;
for (const key of Object.keys(me.getView().vmConfig)) {
if (!key.toLowerCase().startsWith(controller)) {
continue;
}
let idx = parseInt(key.slice(controller.length), 10);
if (idx > max) {
max = idx;
}
}
me.lookup('diskGrid').getStore().each(rec => {
if (!rec.data.id.toLowerCase().startsWith(controller)) {
return;
}
let idx = parseInt(rec.data.id.slice(controller.length), 10);
if (idx > max) {
max = idx;
}
});
me.lookup('cdGrid').getStore().each(rec => {
if (!rec.data.id.toLowerCase().startsWith(controller) || rec.data.hidden) {
return;
}
let idx = parseInt(rec.data.id.slice(controller.length), 10);
if (idx > max) {
max = idx;
}
});
view[`max${controller}`] = max;
return max;
},
renderDisk: function(value, metaData, record, rowIndex, colIndex, store, tableView) {
let diskGrid = tableView.grid ?? this.lookup('diskGrid');
if (diskGrid.diskMap) {
let mappedID = diskGrid.diskMap[value];
if (mappedID) {
let prefix = '';
if (mappedID === value) { // mapped to the same value means we ran out of IDs
let warning = gettext('Too many disks, could not map to SATA.');
prefix = `<i data-qtip="${warning}" class="fa fa-exclamation-triangle warning"></i> `;
}
return `${prefix}${mappedID}`;
}
}
return value;
},
refreshGrids: function() {
this.lookup('diskGrid').reconfigure();
this.lookup('cdGrid').reconfigure();
this.lookup('netGrid').reconfigure();
},
onOSTypeChange: function(_cb, value) {
let me = this;
if (!value) {
return;
}
let store = me.lookup('cdGrid').getStore();
let collection = store.getData().getSource() ?? store.getData();
let rec = collection.find('autogenerated', true);
let isWindows = (value ?? '').startsWith('w');
if (rec) {
rec.set('hidden', !isWindows);
rec.commit();
}
let prepareVirtio = me.lookup('prepareForVirtIO').getValue();
let defaultScsiHw = me.getView().vmConfig.scsihw ?? '__default__';
me.lookup('scsihw').setValue(prepareVirtio && isWindows ? 'virtio-scsi-single' : defaultScsiHw);
me.refreshGrids();
},
onPrepareVirtioChange: function(_cb, value) {
let me = this;
let view = me.getView();
let diskGrid = me.lookup('diskGrid');
diskGrid.diskMap = {};
if (value) {
const hasAdditionalSataCDROM =
me.getViewModel().get('isWindows') && view.additionalCdIdx?.startsWith('sata');
diskGrid.getStore().each(rec => {
let diskID = rec.data.id;
if (!diskID.toLowerCase().startsWith('scsi')) {
return; // continue
}
let offset = parseInt(diskID.slice(4), 10);
let newIdx = offset + me.getMaxControllerId('sata') + 1;
if (hasAdditionalSataCDROM) {
newIdx++;
}
let mappedID = `sata${newIdx}`;
if (newIdx >= PVE.Utils.diskControllerMaxIDs.sata) {
mappedID = diskID; // map to self so that the renderer can detect that we're out of IDs
}
diskGrid.diskMap[diskID] = mappedID;
});
}
let scsihw = me.lookup('scsihw');
scsihw.suspendEvents();
scsihw.setValue(value ? 'virtio-scsi-single' : me.getView().vmConfig.scsihw);
scsihw.resumeEvents();
me.refreshGrids();
},
onScsiHwChange: function(_field, value) {
let me = this;
me.getView().vmConfig.scsihw = value;
},
onUniqueMACChange: function(_cb, value) {
let me = this;
me.getViewModel().set('uniqueMACAdresses', value);
me.lookup('netGrid').reconfigure();
},
renderMacAddress: function(value, metaData, record, rowIndex, colIndex, store, view) {
let me = this;
let vm = me.getViewModel();
return !vm.get('uniqueMACAdresses') && value ? value : 'auto';
},
control: {
'grid field': {
// update records from widgetcolumns
change: function(widget, value) {
let rec = widget.getWidgetRecord();
rec.set(widget.name, value);
rec.commit();
},
},
'grid[reference=diskGrid] pveStorageSelector': {
change: 'diskStorageChange',
},
'grid[reference=cdGrid] pveStorageSelector': {
change: 'isoStorageChange',
},
'field[name=osbase]': {
change: 'onOSBaseChange',
},
'panel[reference=summaryTab]': {
activate: 'calculateConfig',
},
'proxmoxcheckbox[reference=prepareForVirtIO]': {
change: 'onPrepareVirtioChange',
},
'combobox[name=ostype]': {
change: 'onOSTypeChange',
},
'pveScsiHwSelector': {
change: 'onScsiHwChange',
},
'proxmoxcheckbox[name=uniqueMACs]': {
change: 'onUniqueMACChange',
},
},
},
viewModel: {
data: {
coreCount: 1,
socketCount: 1,
liveImport: false,
os: 'l26',
maxCdDrives: false,
uniqueMACAdresses: false,
isOva: false,
warnings: [],
},
formulas: {
totalCoreCount: get => get('socketCount') * get('coreCount'),
hideWarnings: get => get('warnings').length === 0,
warningsText: get => '<ul style="margin: 0; padding-left: 20px;">'
+ get('warnings').map(w => `<li>${w}</li>`).join('') + '</ul>',
liveImportNote: get => !get('liveImport') ? ''
: gettext('Note: If anything goes wrong during the live-import, new data written by the VM may be lost.'),
isWindows: get => (get('os') ?? '').startsWith('w'),
liveImportText: get => get('isOva') ? gettext('Starts a VM and imports the disks in the background')
: gettext('Starts a previously stopped VM on Proxmox VE and imports the disks in the background.'),
},
},
items: [{
xtype: 'tabpanel',
defaults: {
bodyPadding: 10,
},
items: [
{
title: gettext('General'),
xtype: 'inputpanel',
reference: 'mainInputPanel',
onGetValues: function(values) {
let me = this;
let view = me.up('pveGuestImportWindow');
let vm = view.getViewModel();
let diskGrid = view.lookup('diskGrid');
// from pveDiskStorageSelector
let defaultStorage = values.hdstorage;
let defaultFormat = values.diskformat;
delete values.hdstorage;
delete values.diskformat;
let defaultBridge = values.defaultBridge;
delete values.defaultBridge;
let config = { ...view.vmConfig };
Ext.apply(config, values);
if (config.scsi0) {
config.scsi0 = config.scsi0.replace('local:0,', 'local:0,format=qcow2,');
}
let parsedBoot = PVE.Parser.parsePropertyString(config.boot ?? '');
if (parsedBoot.order) {
parsedBoot.order = parsedBoot.order.split(';');
}
let diskMap = diskGrid.diskMap ?? {};
diskGrid.getStore().each(rec => {
if (!rec.data.enable) {
return;
}
let id = diskMap[rec.data.id] ?? rec.data.id;
if (id !== rec.data.id && parsedBoot?.order) {
let idx = parsedBoot.order.indexOf(rec.data.id);
if (idx !== -1) {
parsedBoot.order[idx] = id;
}
}
let data = {
...rec.data,
};
delete data.enable;
delete data.id;
delete data.size;
if (!data.file) {
data.file = defaultStorage;
data.format = defaultFormat;
}
data.file += ':0'; // for our special api format
if (id === 'efidisk0') {
delete data['import-from'];
}
config[id] = PVE.Parser.printQemuDrive(data);
});
if (parsedBoot.order) {
parsedBoot.order = parsedBoot.order.join(';');
}
config.boot = PVE.Parser.printPropertyString(parsedBoot);
view.lookup('netGrid').getStore().each((rec) => {
if (!rec.data.enable) {
return;
}
let id = rec.data.id;
let data = {
...rec.data,
};
delete data.enable;
delete data.id;
if (!data.bridge) {
data.bridge = defaultBridge;
}
if (vm.get('uniqueMACAdresses')) {
data.macaddr = undefined;
}
config[id] = PVE.Parser.printQemuNetwork(data);
});
view.lookup('cdGrid').getStore().each((rec) => {
if (!rec.data.enable) {
return;
}
let id = rec.data.id;
let cd = {
media: 'cdrom',
file: rec.data.file ? rec.data.file : 'none',
};
config[id] = PVE.Parser.printPropertyString(cd);
});
config.scsihw = view.lookup('scsihw').getValue();
if (view.lookup('liveimport').getValue()) {
config['live-restore'] = 1;
}
// remove __default__ values
for (const [key, value] of Object.entries(config)) {
if (value === '__default__') {
delete config[key];
}
}
if (config['import-working-storage'] === '') {
delete config['import-working-storage'];
}
return config;
},
column1: [
{
xtype: 'pveGuestIDSelector',
name: 'vmid',
fieldLabel: 'VM',
guestType: 'qemu',
loadNextFreeID: true,
validateExists: false,
},
{
xtype: 'proxmoxintegerfield',
fieldLabel: gettext('Sockets'),
name: 'sockets',
reference: 'socketsField',
value: 1,
minValue: 1,
maxValue: 128,
allowBlank: true,
bind: {
value: '{socketCount}',
},
},
{
xtype: 'proxmoxintegerfield',
fieldLabel: gettext('Cores'),
name: 'cores',
reference: 'coresField',
value: 1,
minValue: 1,
maxValue: 1024,
allowBlank: true,
bind: {
value: '{coreCount}',
},
},
{
xtype: 'pveMemoryField',
fieldLabel: gettext('Memory') + ' (MiB)',
name: 'memory',
reference: 'memoryField',
value: 512,
allowBlank: true,
},
{ xtype: 'displayfield' }, // spacer
{ xtype: 'displayfield' }, // spacer
{
xtype: 'pveDiskStorageSelector',
reference: 'defaultStorage',
storageLabel: gettext('Default Storage'),
storageContent: 'images',
autoSelect: true,
hideSize: true,
name: 'defaultStorage',
},
],
column2: [
{
xtype: 'textfield',
fieldLabel: gettext('Name'),
name: 'name',
vtype: 'DnsName',
reference: 'nameField',
allowBlank: true,
},
{
xtype: 'CPUModelSelector',
name: 'cpu',
reference: 'cputype',
value: 'x86-64-v2-AES',
fieldLabel: gettext('CPU Type'),
},
{
xtype: 'displayfield',
fieldLabel: gettext('Total cores'),
name: 'totalcores',
isFormField: false,
bind: {
value: '{totalCoreCount}',
},
},
{
xtype: 'combobox',
submitValue: false,
name: 'osbase',
fieldLabel: gettext('OS Type'),
editable: false,
queryMode: 'local',
value: 'Linux',
store: Object.keys(PVE.Utils.kvm_ostypes),
},
{
xtype: 'combobox',
name: 'ostype',
reference: 'ostype',
fieldLabel: gettext('Version'),
value: 'l26',
allowBlank: false,
editable: false,
queryMode: 'local',
valueField: 'val',
displayField: 'desc',
bind: {
value: '{os}',
},
store: {
fields: ['desc', 'val'],
data: PVE.Utils.kvm_ostypes.Linux,
},
},
{ xtype: 'displayfield' }, // spacer
{
xtype: 'PVE.form.BridgeSelector',
reference: 'defaultBridge',
name: 'defaultBridge',
allowBlank: false,
fieldLabel: gettext('Default Bridge'),
},
{
xtype: 'pveStorageSelector',
reference: 'extractionStorage',
fieldLabel: gettext('Import Working Storage'),
storageContent: 'images',
emptyText: gettext('Source Storage'),
autoSelect: false,
name: 'import-working-storage',
disabled: true,
hidden: true,
allowBlank: true,
bind: {
disabled: '{!isOva}',
hidden: '{!isOva}',
},
},
],
columnB: [
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Live Import'),
reference: 'liveimport',
isFormField: false,
bind: {
value: '{liveImport}',
boxLabel: '{liveImportText}',
},
},
{
xtype: 'displayfield',
userCls: 'pmx-hint black',
value: gettext('Note: If anything goes wrong during the live-import, new data written by the VM may be lost.'),
bind: {
hidden: '{!liveImport}',
},
},
{
xtype: 'displayfield',
fieldLabel: gettext('Warnings'),
labelWidth: 200,
hidden: true,
bind: {
hidden: '{hideWarnings}',
},
},
{
xtype: 'displayfield',
reference: 'warningText',
userCls: 'pmx-hint',
hidden: true,
bind: {
hidden: '{hideWarnings}',
value: '{warningsText}',
},
},
],
},
{
title: gettext('Advanced'),
xtype: 'inputpanel',
// the first inputpanel handles all values, so prevent value leakage here
onGetValues: () => ({}),
columnT: [
{
xtype: 'displayfield',
fieldLabel: gettext('Disks'),
labelWidth: 200,
},
{
xtype: 'grid',
reference: 'diskGrid',
minHeight: 60,
maxHeight: 150,
store: {
data: [],
sorters: [
'id',
],
},
columns: [
{
xtype: 'checkcolumn',
header: gettext('Use'),
width: 50,
dataIndex: 'enable',
listeners: {
checkchange: function(_column, _rowIndex, _checked, record) {
record.commit();
},
},
},
{
text: gettext('Disk'),
dataIndex: 'id',
renderer: 'renderDisk',
},
{
text: gettext('Source'),
dataIndex: 'import-from',
flex: 1,
renderer: function(value) {
return value.replace(/^.*\//, '');
},
},
{
text: gettext('Size'),
dataIndex: 'size',
renderer: (value) => {
if (Ext.isNumeric(value)) {
return Proxmox.Utils.render_size(value);
}
return value ?? Proxmox.Utils.unknownText;
},
},
{
text: gettext('Storage'),
dataIndex: 'file',
xtype: 'widgetcolumn',
width: 150,
widget: {
xtype: 'pveStorageSelector',
isFormField: false,
autoSelect: false,
allowBlank: true,
emptyText: gettext('From Default'),
name: 'file',
storageContent: 'images',
},
onWidgetAttach: 'setNodename',
},
{
text: gettext('Format'),
dataIndex: 'format',
xtype: 'widgetcolumn',
width: 150,
widget: {
xtype: 'pveDiskFormatSelector',
name: 'format',
disabled: true,
isFormField: false,
matchFieldWidth: false,
},
},
],
},
],
column1: [
{
xtype: 'proxmoxcheckbox',
boxLabel: gettext('Prepare for VirtIO-SCSI'),
reference: 'prepareForVirtIO',
name: 'prepareForVirtIO',
submitValue: false,
disabled: true,
bind: {
disabled: '{!isWindows}',
},
autoEl: {
tag: 'div',
'data-qtip': gettext('Maps SCSI disks to SATA and changes the SCSI Controller. Useful for a quicker switch to VirtIO-SCSI attached disks'),
},
},
],
column2: [
{
xtype: 'pveScsiHwSelector',
reference: 'scsihw',
name: 'scsihw',
value: '__default__',
submitValue: false,
fieldLabel: gettext('SCSI Controller'),
},
],
columnB: [
{
xtype: 'displayfield',
fieldLabel: gettext('CD/DVD Drives'),
labelWidth: 200,
},
{
xtype: 'grid',
reference: 'cdGrid',
minHeight: 60,
maxHeight: 150,
store: {
data: [],
sorters: [
'id',
],
filters: [
function(rec) {
return !rec.data.hidden;
},
],
},
columns: [
{
xtype: 'checkcolumn',
header: gettext('Use'),
width: 50,
dataIndex: 'enable',
listeners: {
checkchange: function(_column, _rowIndex, _checked, record) {
record.commit();
},
},
},
{
text: gettext('Slot'),
dataIndex: 'id',
sorted: true,
},
{
text: gettext('Storage'),
xtype: 'widgetcolumn',
width: 150,
widget: {
xtype: 'pveStorageSelector',
isFormField: false,
autoSelect: false,
allowBlank: true,
emptyText: Proxmox.Utils.noneText,
storageContent: 'iso',
},
onWidgetAttach: 'setNodename',
},
{
text: gettext('ISO'),
dataIndex: 'file',
xtype: 'widgetcolumn',
flex: 1,
widget: {
xtype: 'pveFileSelector',
name: 'file',
isFormField: false,
allowBlank: true,
emptyText: Proxmox.Utils.noneText,
storageContent: 'iso',
},
onWidgetAttach: 'setNodename',
},
],
},
{
xtype: 'displayfield',
fieldLabel: gettext('Network Interfaces'),
labelWidth: 200,
style: {
paddingTop: '10px',
},
},
{
xtype: 'grid',
minHeight: 58,
maxHeight: 150,
reference: 'netGrid',
store: {
data: [],
sorters: [
'id',
],
},
columns: [
{
xtype: 'checkcolumn',
header: gettext('Use'),
width: 50,
dataIndex: 'enable',
listeners: {
checkchange: function(_column, _rowIndex, _checked, record) {
record.commit();
},
},
},
{
text: gettext('ID'),
dataIndex: 'id',
},
{
text: gettext('MAC address'),
flex: 7,
dataIndex: 'macaddr',
renderer: 'renderMacAddress',
},
{
text: gettext('Model'),
flex: 7,
dataIndex: 'model',
xtype: 'widgetcolumn',
widget: {
xtype: 'pveNetworkCardSelector',
name: 'model',
isFormField: false,
allowBlank: false,
},
},
{
text: gettext('Bridge'),
dataIndex: 'bridge',
xtype: 'widgetcolumn',
flex: 6,
widget: {
xtype: 'PVE.form.BridgeSelector',
name: 'bridge',
isFormField: false,
autoSelect: false,
allowBlank: true,
emptyText: gettext('From Default'),
},
onWidgetAttach: 'setNodename',
},
{
text: gettext('VLAN Tag'),
dataIndex: 'tag',
xtype: 'widgetcolumn',
flex: 5,
widget: {
xtype: 'pveVlanField',
fieldLabel: undefined,
name: 'tag',
isFormField: false,
allowBlank: true,
},
},
],
},
{
xtype: 'proxmoxcheckbox',
name: 'uniqueMACs',
boxLabel: gettext('Unique MAC addresses'),
uncheckedValue: false,
value: false,
},
],
},
{
title: gettext('Resulting Config'),
reference: 'summaryTab',
items: [
{
xtype: 'grid',
reference: 'summaryGrid',
maxHeight: 400,
scrollable: true,
store: {
model: 'KeyValue',
sorters: [{
property: 'key',
direction: 'ASC',
}],
},
columns: [
{ header: 'Key', width: 150, dataIndex: 'key' },
{ header: 'Value', flex: 1, dataIndex: 'value' },
],
},
],
},
],
}],
initComponent: function() {
let me = this;
if (!me.volumeName) {
throw "no volumeName given";
}
if (!me.storage) {
throw "no storage given";
}
if (!me.nodename) {
throw "no nodename given";
}
me.callParent();
me.setTitle(Ext.String.format(gettext('Import Guest - {0}'), `${me.storage}:${me.volumeName}`));
me.lookup('defaultStorage').setNodename(me.nodename);
me.lookup('defaultBridge').setNodename(me.nodename);
me.lookup('extractionStorage').setNodename(me.nodename);
let renderWarning = w => {
const warningsCatalogue = {
'cdrom-image-ignored': gettext("CD-ROM images cannot get imported, if required you can reconfigure the '{0}' drive in the 'Advanced' tab."),
'nvme-unsupported': gettext("NVMe disks are currently not supported, '{0}' will get attached as SCSI"),
'ovmf-with-lsi-unsupported': gettext("OVMF is built without LSI drivers, scsi hardware was set to '{1}'"),
'serial-port-socket-only': gettext("Serial socket '{0}' will be mapped to a socket"),
'guest-is-running': gettext('Virtual guest seems to be running on source host. Import might fail or have inconsistent state!'),
'efi-state-lost': Ext.String.format(
gettext('EFI state cannot be imported, you may need to reconfigure the boot order (see {0})'),
'<a href="https://pve.proxmox.com/wiki/OVMF/UEFI_Boot_Entries">OVMF/UEFI Boot Entries</a>',
),
'ova-needs-extracting': gettext('Importing an OVA temporarily requires extra space on the working storage while extracting the contained disks for further processing.'),
};
let message = warningsCatalogue[w.type];
if (!w.type || !message) {
return w.message ?? w.type ?? gettext('Unknown warning');
}
return Ext.String.format(message, w.key ?? 'unknown', w.value ?? 'unknown');
};
me.load({
success: function(response) {
let data = response.result.data;
me.vmConfig = data['create-args'];
let disks = [];
for (const [id, value] of Object.entries(data.disks ?? {})) {
let volid = Ext.htmlEncode('<none>');
let size = 'auto';
if (Ext.isObject(value)) {
volid = value.volid;
size = value.size;
}
disks.push({
id,
enable: true,
size,
'import-from': volid,
format: 'raw',
});
}
let nets = [];
for (const [id, parsed] of Object.entries(data.net ?? {})) {
parsed.id = id;
parsed.enable = true;
nets.push(parsed);
}
let cdroms = [];
for (const [id, value] of Object.entries(me.vmConfig)) {
if (!Ext.isString(value) || !value.match(/media=cdrom/)) {
continue;
}
cdroms.push({
enable: true,
hidden: false,
id,
});
delete me.vmConfig[id];
}
me.lookup('diskGrid').getStore().setData(disks);
me.lookup('netGrid').getStore().setData(nets);
me.lookup('cdGrid').getStore().setData(cdroms);
let additionalCdIdx = me.getController().calculateAdditionalCDIdx();
if (additionalCdIdx === '') {
me.getViewModel().set('maxCdDrives', true);
} else if (cdroms.length === 0) {
me.additionalCdIdx = additionalCdIdx;
me.lookup('cdGrid').getStore().add({
enable: true,
hidden: !(me.vmConfig.ostype ?? '').startsWith('w'),
id: additionalCdIdx,
autogenerated: true,
});
}
me.getViewModel().set('warnings', data.warnings.map(w => renderWarning(w)));
me.getViewModel().set('isOva', data.warnings.map(w => w.type).indexOf('ova-needs-extracting') !== -1);
let osinfo = PVE.Utils.get_kvm_osinfo(me.vmConfig.ostype ?? '');
let prepareForVirtIO = (me.vmConfig.ostype ?? '').startsWith('w') && (me.vmConfig.bios ?? '').indexOf('ovmf') !== -1;
me.setValues({
osbase: osinfo.base,
...me.vmConfig,
});
me.lookup('prepareForVirtIO').setValue(prepareForVirtIO);
},
});
},
});
Ext.define('PVE.ha.FencingView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveFencingView'],
onlineHelp: 'ha_manager_fencing',
initComponent: function() {
var me = this;
var store = new Ext.data.Store({
model: 'pve-ha-fencing',
data: [],
});
Ext.apply(me, {
store: store,
stateful: false,
viewConfig: {
trackOver: false,
deferEmptyText: false,
emptyText: gettext('Use watchdog based fencing.'),
},
columns: [
{
header: gettext('Node'),
width: 100,
sortable: true,
dataIndex: 'node',
},
{
header: gettext('Command'),
flex: 1,
dataIndex: 'command',
},
],
});
me.callParent();
},
}, function() {
Ext.define('pve-ha-fencing', {
extend: 'Ext.data.Model',
fields: [
'node', 'command', 'digest',
],
});
});
Ext.define('PVE.ha.GroupInputPanel', {
extend: 'Proxmox.panel.InputPanel',
onlineHelp: 'ha_manager_groups',
groupId: undefined,
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.type = 'group';
}
return values;
},
initComponent: function() {
var me = this;
let update_nodefield, update_node_selection;
let sm = Ext.create('Ext.selection.CheckboxModel', {
mode: 'SIMPLE',
listeners: {
selectionchange: function(model, selected) {
update_nodefield(selected);
},
},
});
let store = Ext.create('Ext.data.Store', {
fields: ['node', 'mem', 'cpu', 'priority'],
data: PVE.data.ResourceStore.getNodes(), // use already cached data to avoid an API call
proxy: {
type: 'memory',
reader: { type: 'json' },
},
sorters: [
{
property: 'node',
direction: 'ASC',
},
],
});
var nodegrid = Ext.createWidget('grid', {
store: store,
border: true,
height: 300,
selModel: sm,
columns: [
{
header: gettext('Node'),
flex: 1,
dataIndex: 'node',
},
{
header: gettext('Memory usage') + " %",
renderer: PVE.Utils.render_mem_usage_percent,
sortable: true,
width: 150,
dataIndex: 'mem',
},
{
header: gettext('CPU usage'),
renderer: Proxmox.Utils.render_cpu,
sortable: true,
width: 150,
dataIndex: 'cpu',
},
{
header: gettext('Priority'),
xtype: 'widgetcolumn',
dataIndex: 'priority',
sortable: true,
stopSelection: true,
widget: {
xtype: 'proxmoxintegerfield',
minValue: 0,
maxValue: 1000,
isFormField: false,
listeners: {
change: function(numberfield, value, old_value) {
let record = numberfield.getWidgetRecord();
record.set('priority', value);
update_nodefield(sm.getSelection());
record.commit();
},
},
},
},
],
});
let nodefield = Ext.create('Ext.form.field.Hidden', {
name: 'nodes',
value: '',
listeners: {
change: function(field, value) {
update_node_selection(value);
},
},
isValid: function() {
let value = this.getValue();
return value && value.length !== 0;
},
});
update_node_selection = function(string) {
sm.deselectAll(true);
string.split(',').forEach(function(e, idx, array) {
let [node, priority] = e.split(':');
store.each(function(record) {
if (record.get('node') === node) {
sm.select(record, true);
record.set('priority', priority);
record.commit();
}
});
});
nodegrid.reconfigure(store);
};
update_nodefield = function(selected) {
let nodes = selected
.map(({ data }) => data.node + (data.priority ? `:${data.priority}` : ''))
.join(',');
// nodefield change listener calls us again, which results in a
// endless recursion, suspend the event temporary to avoid this
nodefield.suspendEvent('change');
nodefield.setValue(nodes);
nodefield.resumeEvent('change');
};
me.column1 = [
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'group',
value: me.groupId || '',
fieldLabel: 'ID',
vtype: 'StorageId',
allowBlank: false,
},
nodefield,
];
me.column2 = [
{
xtype: 'proxmoxcheckbox',
name: 'restricted',
uncheckedValue: 0,
fieldLabel: 'restricted',
},
{
xtype: 'proxmoxcheckbox',
name: 'nofailback',
uncheckedValue: 0,
fieldLabel: 'nofailback',
},
];
me.columnB = [
{
xtype: 'textfield',
name: 'comment',
fieldLabel: gettext('Comment'),
},
nodegrid,
];
me.callParent();
},
});
Ext.define('PVE.ha.GroupEdit', {
extend: 'Proxmox.window.Edit',
groupId: undefined,
initComponent: function() {
var me = this;
me.isCreate = !me.groupId;
if (me.isCreate) {
me.url = '/api2/extjs/cluster/ha/groups';
me.method = 'POST';
} else {
me.url = '/api2/extjs/cluster/ha/groups/' + me.groupId;
me.method = 'PUT';
}
var ipanel = Ext.create('PVE.ha.GroupInputPanel', {
isCreate: me.isCreate,
groupId: me.groupId,
});
Ext.apply(me, {
subject: gettext('HA Group'),
items: [ipanel],
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
var values = response.result.data;
ipanel.setValues(values);
},
});
}
},
});
Ext.define('PVE.ha.GroupSelector', {
extend: 'Proxmox.form.ComboGrid',
alias: ['widget.pveHAGroupSelector'],
autoSelect: false,
valueField: 'group',
displayField: 'group',
listConfig: {
columns: [
{
header: gettext('Group'),
width: 100,
sortable: true,
dataIndex: 'group',
},
{
header: gettext('Nodes'),
width: 100,
sortable: false,
dataIndex: 'nodes',
},
{
header: gettext('Comment'),
flex: 1,
dataIndex: 'comment',
renderer: Ext.String.htmlEncode,
},
],
},
store: {
model: 'pve-ha-groups',
sorters: {
property: 'group',
direction: 'ASC',
},
},
initComponent: function() {
var me = this;
me.callParent();
me.getStore().load();
},
}, function() {
Ext.define('pve-ha-groups', {
extend: 'Ext.data.Model',
fields: [
'group', 'type', 'digest', 'nodes', 'comment',
{
name: 'restricted',
type: 'boolean',
},
{
name: 'nofailback',
type: 'boolean',
},
],
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/ha/groups",
},
idProperty: 'group',
});
});
Ext.define('PVE.ha.GroupsView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveHAGroupsView'],
onlineHelp: 'ha_manager_groups',
stateful: true,
stateId: 'grid-ha-groups',
initComponent: function() {
var me = this;
var caps = Ext.state.Manager.get('GuiCap');
var store = new Ext.data.Store({
model: 'pve-ha-groups',
sorters: {
property: 'group',
direction: 'ASC',
},
});
var reload = function() {
store.load();
};
var sm = Ext.create('Ext.selection.RowModel', {});
let run_editor = function() {
let rec = sm.getSelection()[0];
Ext.create('PVE.ha.GroupEdit', {
groupId: rec.data.group,
listeners: {
destroy: () => store.load(),
},
autoShow: true,
});
};
let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
selModel: sm,
baseurl: '/cluster/ha/groups/',
callback: () => store.load(),
});
let edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
selModel: sm,
handler: run_editor,
});
Ext.apply(me, {
store: store,
selModel: sm,
viewConfig: {
trackOver: false,
},
tbar: [
{
text: gettext('Create'),
disabled: !caps.nodes['Sys.Console'],
handler: function() {
Ext.create('PVE.ha.GroupEdit', {
listeners: {
destroy: () => store.load(),
},
autoShow: true,
});
},
},
edit_btn,
remove_btn,
],
columns: [
{
header: gettext('Group'),
width: 150,
sortable: true,
dataIndex: 'group',
},
{
header: 'restricted',
width: 100,
sortable: true,
renderer: Proxmox.Utils.format_boolean,
dataIndex: 'restricted',
},
{
header: 'nofailback',
width: 100,
sortable: true,
renderer: Proxmox.Utils.format_boolean,
dataIndex: 'nofailback',
},
{
header: gettext('Nodes'),
flex: 1,
sortable: false,
dataIndex: 'nodes',
},
{
header: gettext('Comment'),
flex: 1,
renderer: Ext.String.htmlEncode,
dataIndex: 'comment',
},
],
listeners: {
activate: reload,
beforeselect: (grid, record, index, eOpts) => caps.nodes['Sys.Console'],
itemdblclick: run_editor,
},
});
me.callParent();
},
});
Ext.define('PVE.ha.VMResourceInputPanel', {
extend: 'Proxmox.panel.InputPanel',
onlineHelp: 'ha_manager_resource_config',
vmid: undefined,
onGetValues: function(values) {
var me = this;
if (values.vmid) {
values.sid = values.vmid;
}
delete values.vmid;
PVE.Utils.delete_if_default(values, 'group', '', me.isCreate);
PVE.Utils.delete_if_default(values, 'max_restart', '1', me.isCreate);
PVE.Utils.delete_if_default(values, 'max_relocate', '1', me.isCreate);
return values;
},
initComponent: function() {
var me = this;
var MIN_QUORUM_VOTES = 3;
var disabledHint = Ext.createWidget({
xtype: 'displayfield', // won't get submitted by default
userCls: 'pmx-hint',
value: 'Disabling the resource will stop the guest system. ' +
'See the online help for details.',
hidden: true,
});
var fewVotesHint = Ext.createWidget({
itemId: 'fewVotesHint',
xtype: 'displayfield',
userCls: 'pmx-hint',
value: 'At least three quorum votes are recommended for reliable HA.',
hidden: true,
});
Proxmox.Utils.API2Request({
url: '/cluster/config/nodes',
method: 'GET',
failure: function(response) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
success: function(response) {
var nodes = response.result.data;
var votes = 0;
Ext.Array.forEach(nodes, function(node) {
var vote = parseInt(node.quorum_votes, 10); // parse as base 10
votes += vote || 0; // parseInt might return NaN, which is false
});
if (votes < MIN_QUORUM_VOTES) {
fewVotesHint.setVisible(true);
}
},
});
var vmidStore = me.vmid ? {} : {
model: 'PVEResources',
autoLoad: true,
sorters: 'vmid',
filters: [
{
property: 'type',
value: /lxc|qemu/,
},
{
property: 'hastate',
value: /unmanaged/,
},
],
};
// value is a string above, but a number below
me.column1 = [
{
xtype: me.vmid ? 'displayfield' : 'vmComboSelector',
submitValue: me.isCreate,
name: 'vmid',
fieldLabel: me.vmid && me.guestType === 'ct' ? 'CT' : 'VM',
value: me.vmid,
store: vmidStore,
validateExists: true,
},
{
xtype: 'proxmoxintegerfield',
name: 'max_restart',
fieldLabel: gettext('Max. Restart'),
value: 1,
minValue: 0,
maxValue: 10,
allowBlank: false,
},
{
xtype: 'proxmoxintegerfield',
name: 'max_relocate',
fieldLabel: gettext('Max. Relocate'),
value: 1,
minValue: 0,
maxValue: 10,
allowBlank: false,
},
];
me.column2 = [
{
xtype: 'pveHAGroupSelector',
name: 'group',
fieldLabel: gettext('Group'),
},
{
xtype: 'proxmoxKVComboBox',
name: 'state',
value: 'started',
fieldLabel: gettext('Request State'),
comboItems: [
['started', 'started'],
['stopped', 'stopped'],
['ignored', 'ignored'],
['disabled', 'disabled'],
],
listeners: {
'change': function(field, newValue) {
if (newValue === 'disabled') {
disabledHint.setVisible(true);
} else if (disabledHint.isVisible()) {
disabledHint.setVisible(false);
}
},
},
},
disabledHint,
];
me.columnB = [
{
xtype: 'textfield',
name: 'comment',
fieldLabel: gettext('Comment'),
},
fewVotesHint,
];
me.callParent();
},
});
Ext.define('PVE.ha.VMResourceEdit', {
extend: 'Proxmox.window.Edit',
vmid: undefined,
guestType: undefined,
isCreate: undefined,
initComponent: function() {
var me = this;
if (me.isCreate === undefined) {
me.isCreate = !me.vmid;
}
if (me.isCreate) {
me.url = '/api2/extjs/cluster/ha/resources';
me.method = 'POST';
} else {
me.url = '/api2/extjs/cluster/ha/resources/' + me.vmid;
me.method = 'PUT';
}
var ipanel = Ext.create('PVE.ha.VMResourceInputPanel', {
isCreate: me.isCreate,
vmid: me.vmid,
guestType: me.guestType,
});
Ext.apply(me, {
subject: gettext('Resource') + ': ' + gettext('Container') +
'/' + gettext('Virtual Machine'),
isAdd: true,
items: [ipanel],
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
var values = response.result.data;
var regex = /^(\S+):(\S+)$/;
var res = regex.exec(values.sid);
if (res[1] !== 'vm' && res[1] !== 'ct') {
throw "got unexpected resource type";
}
values.vmid = res[2];
ipanel.setValues(values);
},
});
}
},
});
Ext.define('PVE.ha.ResourcesView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveHAResourcesView'],
onlineHelp: 'ha_manager_resources',
stateful: true,
stateId: 'grid-ha-resources',
initComponent: function() {
let me = this;
if (!me.rstore) {
throw "no store given";
}
Proxmox.Utils.monStoreErrors(me, me.rstore);
let store = Ext.create('Proxmox.data.DiffStore', {
rstore: me.rstore,
filters: {
property: 'type',
value: 'service',
},
});
let sm = Ext.create('Ext.selection.RowModel', {});
let run_editor = function() {
let rec = sm.getSelection()[0];
let sid = rec.data.sid;
let res = sid.match(/^(\S+):(\S+)$/);
if (!res || (res[1] !== 'vm' && res[1] !== 'ct')) {
console.warn(`unknown HA service ID type ${sid}`);
return;
}
let [, guestType, vmid] = res;
Ext.create('PVE.ha.VMResourceEdit', {
guestType: guestType,
vmid: vmid,
listeners: {
destroy: () => me.rstore.load(),
},
autoShow: true,
});
};
let caps = Ext.state.Manager.get('GuiCap');
Ext.apply(me, {
store: store,
selModel: sm,
viewConfig: {
trackOver: false,
},
tbar: [
{
text: gettext('Add'),
disabled: !caps.nodes['Sys.Console'],
handler: function() {
Ext.create('PVE.ha.VMResourceEdit', {
listeners: {
destroy: () => me.rstore.load(),
},
autoShow: true,
});
},
},
{
xtype: 'proxmoxButton',
text: gettext('Edit'),
disabled: true,
selModel: sm,
handler: run_editor,
},
{
xtype: 'proxmoxStdRemoveButton',
selModel: sm,
getUrl: function(rec) {
return `/cluster/ha/resources/${rec.get('sid')}`;
},
callback: () => me.rstore.load(),
},
],
columns: [
{
header: 'ID',
width: 100,
sortable: true,
dataIndex: 'sid',
},
{
header: gettext('State'),
width: 100,
sortable: true,
dataIndex: 'state',
},
{
header: gettext('Node'),
width: 100,
sortable: true,
dataIndex: 'node',
},
{
header: gettext('Request State'),
width: 100,
hidden: true,
sortable: true,
renderer: v => v || 'started',
dataIndex: 'request_state',
},
{
header: gettext('CRM State'),
width: 100,
hidden: true,
sortable: true,
dataIndex: 'crm_state',
},
{
header: gettext('Name'),
width: 100,
sortable: true,
dataIndex: 'vname',
},
{
header: gettext('Max. Restart'),
width: 100,
sortable: true,
renderer: (v) => v === undefined ? '1' : v,
dataIndex: 'max_restart',
},
{
header: gettext('Max. Relocate'),
width: 100,
sortable: true,
renderer: (v) => v === undefined ? '1' : v,
dataIndex: 'max_relocate',
},
{
header: gettext('Group'),
width: 200,
sortable: true,
renderer: function(value, metaData, { data }) {
if (data.errors && data.errors.group) {
metaData.tdCls = 'proxmox-invalid-row';
let html = Ext.htmlEncode(`<p>${Ext.htmlEncode(data.errors.group)}</p>`);
metaData.tdAttr = 'data-qwidth=600 data-qtitle="ERROR" data-qtip="' + html + '"';
}
return value;
},
dataIndex: 'group',
},
{
header: gettext('Description'),
flex: 1,
renderer: Ext.String.htmlEncode,
dataIndex: 'comment',
},
],
listeners: {
beforeselect: (grid, record, index, eOpts) => caps.nodes['Sys.Console'],
itemdblclick: run_editor,
},
});
me.callParent();
},
});
Ext.define('PVE.ha.Status', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveHAStatus',
onlineHelp: 'chapter_ha_manager',
layout: {
type: 'vbox',
align: 'stretch',
},
initComponent: function() {
var me = this;
me.rstore = Ext.create('Proxmox.data.ObjectStore', {
interval: me.interval,
model: 'pve-ha-status',
storeid: 'pve-store-' + ++Ext.idSeed,
groupField: 'type',
proxy: {
type: 'proxmox',
url: '/api2/json/cluster/ha/status/current',
},
});
me.items = [{
xtype: 'pveHAStatusView',
title: gettext('Status'),
rstore: me.rstore,
border: 0,
collapsible: true,
padding: '0 0 20 0',
}, {
xtype: 'pveHAResourcesView',
flex: 1,
collapsible: true,
title: gettext('Resources'),
border: 0,
rstore: me.rstore,
}];
me.callParent();
me.on('activate', me.rstore.startUpdate);
},
});
Ext.define('PVE.ha.StatusView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveHAStatusView'],
onlineHelp: 'chapter_ha_manager',
sortPriority: {
quorum: 1,
master: 2,
lrm: 3,
service: 4,
},
initComponent: function() {
var me = this;
if (!me.rstore) {
throw "no rstore given";
}
Proxmox.Utils.monStoreErrors(me, me.rstore);
var store = Ext.create('Proxmox.data.DiffStore', {
rstore: me.rstore,
sortAfterUpdate: true,
sorters: [{
sorterFn: function(rec1, rec2) {
var p1 = me.sortPriority[rec1.data.type];
var p2 = me.sortPriority[rec2.data.type];
return p1 !== p2 ? p1 > p2 ? 1 : -1 : 0;
},
}],
filters: {
property: 'type',
value: 'service',
operator: '!=',
},
});
Ext.apply(me, {
store: store,
stateful: false,
viewConfig: {
trackOver: false,
},
columns: [
{
header: gettext('Type'),
width: 80,
dataIndex: 'type',
},
{
header: gettext('Status'),
width: 80,
flex: 1,
dataIndex: 'status',
},
],
});
me.callParent();
me.on('activate', me.rstore.startUpdate);
me.on('destroy', me.rstore.stopUpdate);
},
}, function() {
Ext.define('pve-ha-status', {
extend: 'Ext.data.Model',
fields: [
'id', 'type', 'node', 'status', 'sid',
'state', 'group', 'comment',
'max_restart', 'max_relocate', 'type',
'crm_state', 'request_state',
{
name: 'vname',
convert: function(value, record) {
let sid = record.data.sid;
if (!sid) return '';
let res = sid.match(/^(\S+):(\S+)$/);
if (res[1] !== 'vm' && res[1] !== 'ct') {
return '-';
}
let vmid = res[2];
return PVE.data.ResourceStore.guestName(vmid);
},
},
],
idProperty: 'id',
});
});
Ext.define('PVE.dc.ACLAdd', {
extend: 'Proxmox.window.Edit',
alias: ['widget.pveACLAdd'],
url: '/access/acl',
method: 'PUT',
isAdd: true,
isCreate: true,
width: 400,
initComponent: function() {
let me = this;
let items = [
{
xtype: me.path ? 'hiddenfield' : 'pvePermPathSelector',
name: 'path',
value: me.path,
allowBlank: false,
fieldLabel: gettext('Path'),
},
];
if (me.aclType === 'group') {
me.subject = gettext("Group Permission");
items.push({
xtype: 'pveGroupSelector',
name: 'groups',
fieldLabel: gettext('Group'),
});
} else if (me.aclType === 'user') {
me.subject = gettext("User Permission");
items.push({
xtype: 'pmxUserSelector',
name: 'users',
fieldLabel: gettext('User'),
});
} else if (me.aclType === 'token') {
me.subject = gettext("API Token Permission");
items.push({
xtype: 'pveTokenSelector',
name: 'tokens',
fieldLabel: gettext('API Token'),
});
} else {
throw "unknown ACL type";
}
items.push({
xtype: 'pmxRoleSelector',
name: 'roles',
value: 'NoAccess',
fieldLabel: gettext('Role'),
});
if (!me.path) {
items.push({
xtype: 'proxmoxcheckbox',
name: 'propagate',
checked: true,
uncheckedValue: 0,
fieldLabel: gettext('Propagate'),
});
}
let ipanel = Ext.create('Proxmox.panel.InputPanel', {
items: items,
onlineHelp: 'pveum_permission_management',
});
Ext.apply(me, {
items: [ipanel],
});
me.callParent();
},
});
Ext.define('PVE.dc.ACLView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveACLView'],
onlineHelp: 'chapter_user_management',
stateful: true,
stateId: 'grid-acls',
// use fixed path
path: undefined,
initComponent: function() {
let me = this;
let store = Ext.create('Ext.data.Store', {
model: 'pve-acl',
proxy: {
type: 'proxmox',
url: "/api2/json/access/acl",
},
sorters: {
property: 'path',
direction: 'ASC',
},
});
if (me.path) {
store.addFilter(Ext.create('Ext.util.Filter', {
filterFn: item => item.data.path === me.path,
}));
}
let render_ugid = function(ugid, metaData, record) {
if (record.data.type === 'group') {
return '@' + ugid;
}
return Ext.String.htmlEncode(ugid);
};
let columns = [
{
header: gettext('User') + '/' + gettext('Group') + '/' + gettext('API Token'),
flex: 1,
sortable: true,
renderer: render_ugid,
dataIndex: 'ugid',
},
{
header: gettext('Role'),
flex: 1,
sortable: true,
dataIndex: 'roleid',
},
];
if (!me.path) {
columns.unshift({
header: gettext('Path'),
flex: 1,
sortable: true,
dataIndex: 'path',
});
columns.push({
header: gettext('Propagate'),
width: 80,
sortable: true,
dataIndex: 'propagate',
});
}
let sm = Ext.create('Ext.selection.RowModel', {});
let remove_btn = new Proxmox.button.Button({
text: gettext('Remove'),
disabled: true,
selModel: sm,
confirmMsg: gettext('Are you sure you want to remove this entry'),
handler: function(btn, event, rec) {
var params = {
'delete': 1,
path: rec.data.path,
roles: rec.data.roleid,
};
if (rec.data.type === 'group') {
params.groups = rec.data.ugid;
} else if (rec.data.type === 'user') {
params.users = rec.data.ugid;
} else if (rec.data.type === 'token') {
params.tokens = rec.data.ugid;
} else {
throw 'unknown data type';
}
Proxmox.Utils.API2Request({
url: '/access/acl',
params: params,
method: 'PUT',
waitMsgTarget: me,
callback: () => store.load(),
failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
},
});
Proxmox.Utils.monStoreErrors(me, store);
Ext.apply(me, {
store: store,
selModel: sm,
tbar: [
{
text: gettext('Add'),
menu: {
xtype: 'menu',
items: [
{
text: gettext('Group Permission'),
iconCls: 'fa fa-fw fa-group',
handler: function() {
var win = Ext.create('PVE.dc.ACLAdd', {
aclType: 'group',
path: me.path,
});
win.on('destroy', () => store.load());
win.show();
},
},
{
text: gettext('User Permission'),
iconCls: 'fa fa-fw fa-user',
handler: function() {
var win = Ext.create('PVE.dc.ACLAdd', {
aclType: 'user',
path: me.path,
});
win.on('destroy', () => store.load());
win.show();
},
},
{
text: gettext('API Token Permission'),
iconCls: 'fa fa-fw fa-user-o',
handler: function() {
let win = Ext.create('PVE.dc.ACLAdd', {
aclType: 'token',
path: me.path,
});
win.on('destroy', () => store.load());
win.show();
},
},
],
},
},
remove_btn,
],
viewConfig: {
trackOver: false,
},
columns: columns,
listeners: {
activate: () => store.load(),
},
});
me.callParent();
},
}, function() {
Ext.define('pve-acl', {
extend: 'Ext.data.Model',
fields: [
'path', 'type', 'ugid', 'roleid',
{
name: 'propagate',
type: 'boolean',
},
],
});
});
Ext.define('pve-acme-accounts', {
extend: 'Ext.data.Model',
fields: ['name'],
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/acme/account",
},
idProperty: 'name',
});
Ext.define('pve-acme-plugins', {
extend: 'Ext.data.Model',
fields: ['type', 'plugin', 'api'],
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/acme/plugins",
},
idProperty: 'plugin',
});
Ext.define('PVE.dc.ACMEClusterView', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveACMEClusterView',
onlineHelp: 'sysadmin_certificate_management',
items: [
{
region: 'north',
border: false,
xtype: 'pmxACMEAccounts',
acmeUrl: '/cluster/acme',
},
{
region: 'center',
border: false,
xtype: 'pmxACMEPluginView',
acmeUrl: '/cluster/acme',
},
],
});
Ext.define('PVE.panel.AuthBase', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveAuthBasePanel',
type: '',
onGetValues: function(values) {
let me = this;
if (!values.port) {
if (!me.isCreate) {
Proxmox.Utils.assemble_field_data(values, { 'delete': 'port' });
}
delete values.port;
}
if (me.isCreate) {
values.type = me.type;
}
return values;
},
initComponent: function() {
let me = this;
let options = PVE.Utils.authSchema[me.type];
if (!me.column1) { me.column1 = []; }
if (!me.column2) { me.column2 = []; }
if (!me.columnB) { me.columnB = []; }
// first field is name
me.column1.unshift({
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'realm',
fieldLabel: gettext('Realm'),
value: me.realm,
allowBlank: false,
});
// last field is default'
me.column1.push({
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Default'),
name: 'default',
uncheckedValue: 0,
});
if (options.tfa) {
// last field of column2is tfa
me.column2.push({
xtype: 'pveTFASelector',
deleteEmpty: !me.isCreate,
});
}
me.columnB.push({
xtype: 'textfield',
name: 'comment',
fieldLabel: gettext('Comment'),
});
me.callParent();
},
});
Ext.define('PVE.dc.AuthEditBase', {
extend: 'Proxmox.window.Edit',
onlineHelp: 'pveum_authentication_realms',
isAdd: true,
fieldDefaults: {
labelWidth: 120,
},
initComponent: function() {
var me = this;
me.isCreate = !me.realm;
if (me.isCreate) {
me.url = '/api2/extjs/access/domains';
me.method = 'POST';
} else {
me.url = '/api2/extjs/access/domains/' + me.realm;
me.method = 'PUT';
}
let authConfig = PVE.Utils.authSchema[me.authType];
if (!authConfig) {
throw 'unknown auth type';
} else if (!authConfig.add && me.isCreate) {
throw 'trying to add non addable realm';
}
me.subject = authConfig.name;
let items;
let bodyPadding;
if (authConfig.syncipanel) {
bodyPadding = 0;
items = {
xtype: 'tabpanel',
region: 'center',
layout: 'fit',
bodyPadding: 10,
items: [
{
title: gettext('General'),
realm: me.realm,
xtype: authConfig.ipanel,
isCreate: me.isCreate,
type: me.authType,
},
{
title: gettext('Sync Options'),
realm: me.realm,
xtype: authConfig.syncipanel,
isCreate: me.isCreate,
type: me.authType,
},
],
};
} else {
items = [{
realm: me.realm,
xtype: authConfig.ipanel,
isCreate: me.isCreate,
type: me.authType,
}];
}
Ext.apply(me, {
items,
bodyPadding,
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
var data = response.result.data || {};
// just to be sure (should not happen)
if (data.type !== me.authType) {
me.close();
throw "got wrong auth type";
}
me.setValues(data);
},
});
}
},
});
Ext.define('PVE.panel.ADInputPanel', {
extend: 'PVE.panel.AuthBase',
xtype: 'pveAuthADPanel',
initComponent: function() {
let me = this;
if (me.type !== 'ad') {
throw 'invalid type';
}
me.column1 = [
{
xtype: 'textfield',
name: 'domain',
fieldLabel: gettext('Domain'),
emptyText: 'company.net',
allowBlank: false,
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Case-Sensitive'),
name: 'case-sensitive',
uncheckedValue: 0,
checked: true,
},
];
me.column2 = [
{
xtype: 'textfield',
fieldLabel: gettext('Server'),
name: 'server1',
allowBlank: false,
},
{
xtype: 'proxmoxtextfield',
fieldLabel: gettext('Fallback Server'),
deleteEmpty: !me.isCreate,
name: 'server2',
},
{
xtype: 'proxmoxintegerfield',
name: 'port',
fieldLabel: gettext('Port'),
minValue: 1,
maxValue: 65535,
emptyText: gettext('Default'),
submitEmptyText: false,
},
{
xtype: 'proxmoxKVComboBox',
name: 'mode',
fieldLabel: gettext('Mode'),
editable: false,
comboItems: [
['__default__', Proxmox.Utils.defaultText + ' (LDAP)'],
['ldap', 'LDAP'],
['ldap+starttls', 'STARTTLS'],
['ldaps', 'LDAPS'],
],
value: '__default__',
deleteEmpty: !me.isCreate,
listeners: {
change: function(field, newValue) {
let verifyCheckbox = field.nextSibling('proxmoxcheckbox[name=verify]');
if (newValue === 'ldap' || newValue === '__default__') {
verifyCheckbox.disable();
verifyCheckbox.setValue(0);
} else {
verifyCheckbox.enable();
}
},
},
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Verify Certificate'),
name: 'verify',
uncheckedValue: 0,
disabled: true,
checked: false,
autoEl: {
tag: 'div',
'data-qtip': gettext('Verify TLS certificate of the server'),
},
},
];
me.advancedItems = [
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Check connection'),
name: 'check-connection',
uncheckedValue: 0,
checked: true,
autoEl: {
tag: 'div',
'data-qtip':
gettext('Verify connection parameters and bind credentials on save'),
},
},
];
me.callParent();
},
onGetValues: function(values) {
let me = this;
if (!values.verify) {
if (!me.isCreate) {
Proxmox.Utils.assemble_field_data(values, { 'delete': 'verify' });
}
delete values.verify;
}
if (!me.isCreate) {
// Delete old `secure` parameter. It has been deprecated in favor to the
// `mode` parameter. Migration happens automatically in `onSetValues`.
Proxmox.Utils.assemble_field_data(values, { 'delete': 'secure' });
}
return me.callParent([values]);
},
onSetValues(values) {
let me = this;
if (values.secure !== undefined && !values.mode) {
// If `secure` is set, use it to determine the correct setting for `mode`
// `secure` is later deleted by `onSetValues` .
// In case *both* are set, we simply ignore `secure` and use
// whatever `mode` is set to.
values.mode = values.secure ? 'ldaps' : 'ldap';
}
return me.callParent([values]);
},
});
Ext.define('PVE.panel.LDAPInputPanel', {
extend: 'PVE.panel.AuthBase',
xtype: 'pveAuthLDAPPanel',
initComponent: function() {
let me = this;
if (me.type !== 'ldap') {
throw 'invalid type';
}
me.column1 = [
{
xtype: 'textfield',
name: 'base_dn',
fieldLabel: gettext('Base Domain Name'),
emptyText: 'CN=Users,DC=Company,DC=net',
allowBlank: false,
},
{
xtype: 'textfield',
name: 'user_attr',
emptyText: 'uid / sAMAccountName',
fieldLabel: gettext('User Attribute Name'),
allowBlank: false,
},
];
me.column2 = [
{
xtype: 'textfield',
fieldLabel: gettext('Server'),
name: 'server1',
allowBlank: false,
},
{
xtype: 'proxmoxtextfield',
fieldLabel: gettext('Fallback Server'),
deleteEmpty: !me.isCreate,
name: 'server2',
},
{
xtype: 'proxmoxintegerfield',
name: 'port',
fieldLabel: gettext('Port'),
minValue: 1,
maxValue: 65535,
emptyText: gettext('Default'),
submitEmptyText: false,
},
{
xtype: 'proxmoxKVComboBox',
name: 'mode',
fieldLabel: gettext('Mode'),
editable: false,
comboItems: [
['__default__', Proxmox.Utils.defaultText + ' (LDAP)'],
['ldap', 'LDAP'],
['ldap+starttls', 'STARTTLS'],
['ldaps', 'LDAPS'],
],
value: '__default__',
deleteEmpty: !me.isCreate,
listeners: {
change: function(field, newValue) {
let verifyCheckbox = field.nextSibling('proxmoxcheckbox[name=verify]');
if (newValue === 'ldap' || newValue === '__default__') {
verifyCheckbox.disable();
verifyCheckbox.setValue(0);
} else {
verifyCheckbox.enable();
}
},
},
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Verify Certificate'),
name: 'verify',
uncheckedValue: 0,
disabled: true,
checked: false,
autoEl: {
tag: 'div',
'data-qtip': gettext('Verify TLS certificate of the server'),
},
},
];
me.advancedItems = [
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Check connection'),
name: 'check-connection',
uncheckedValue: 0,
checked: true,
autoEl: {
tag: 'div',
'data-qtip':
gettext('Verify connection parameters and bind credentials on save'),
},
},
];
me.callParent();
},
onGetValues: function(values) {
let me = this;
if (!values.verify) {
if (!me.isCreate) {
Proxmox.Utils.assemble_field_data(values, { 'delete': 'verify' });
}
delete values.verify;
}
if (!me.isCreate) {
// Delete old `secure` parameter. It has been deprecated in favor to the
// `mode` parameter. Migration happens automatically in `onSetValues`.
Proxmox.Utils.assemble_field_data(values, { 'delete': 'secure' });
}
return me.callParent([values]);
},
onSetValues(values) {
let me = this;
if (values.secure !== undefined && !values.mode) {
// If `secure` is set, use it to determine the correct setting for `mode`
// `secure` is later deleted by `onSetValues` .
// In case *both* are set, we simply ignore `secure` and use
// whatever `mode` is set to.
values.mode = values.secure ? 'ldaps' : 'ldap';
}
return me.callParent([values]);
},
});
Ext.define('PVE.panel.LDAPSyncInputPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveAuthLDAPSyncPanel',
editableAttributes: ['email'],
editableDefaults: ['scope', 'enable-new'],
default_opts: {},
sync_attributes: {},
// (de)construct the sync-attributes from the list above,
// not touching all others
onGetValues: function(values) {
let me = this;
me.editableDefaults.forEach((attr) => {
if (values[attr]) {
me.default_opts[attr] = values[attr];
delete values[attr];
} else {
delete me.default_opts[attr];
}
});
let vanished_opts = [];
['acl', 'entry', 'properties'].forEach((prop) => {
if (values[`remove-vanished-${prop}`]) {
vanished_opts.push(prop);
}
delete values[`remove-vanished-${prop}`];
});
me.default_opts['remove-vanished'] = vanished_opts.join(';');
values['sync-defaults-options'] = PVE.Parser.printPropertyString(me.default_opts);
me.editableAttributes.forEach((attr) => {
if (values[attr]) {
me.sync_attributes[attr] = values[attr];
delete values[attr];
} else {
delete me.sync_attributes[attr];
}
});
values.sync_attributes = PVE.Parser.printPropertyString(me.sync_attributes);
PVE.Utils.delete_if_default(values, 'sync-defaults-options');
PVE.Utils.delete_if_default(values, 'sync_attributes');
// Force values.delete to be an array
if (typeof values.delete === 'string') {
values.delete = values.delete.split(',');
}
if (me.isCreate) {
delete values.delete; // on create we cannot delete values
}
return values;
},
setValues: function(values) {
let me = this;
if (values.sync_attributes) {
me.sync_attributes = PVE.Parser.parsePropertyString(values.sync_attributes);
delete values.sync_attributes;
me.editableAttributes.forEach((attr) => {
if (me.sync_attributes[attr]) {
values[attr] = me.sync_attributes[attr];
}
});
}
if (values['sync-defaults-options']) {
me.default_opts = PVE.Parser.parsePropertyString(values['sync-defaults-options']);
delete values.default_opts;
me.editableDefaults.forEach((attr) => {
if (me.default_opts[attr]) {
values[attr] = me.default_opts[attr];
}
});
if (me.default_opts['remove-vanished']) {
let opts = me.default_opts['remove-vanished'].split(';');
for (const opt of opts) {
values[`remove-vanished-${opt}`] = 1;
}
}
}
return me.callParent([values]);
},
column1: [
{
xtype: 'proxmoxtextfield',
name: 'bind_dn',
deleteEmpty: true,
emptyText: Proxmox.Utils.noneText,
fieldLabel: gettext('Bind User'),
},
{
xtype: 'proxmoxtextfield',
inputType: 'password',
name: 'password',
emptyText: gettext('Unchanged'),
fieldLabel: gettext('Bind Password'),
},
{
xtype: 'proxmoxtextfield',
name: 'email',
fieldLabel: gettext('E-Mail attribute'),
},
{
xtype: 'proxmoxtextfield',
name: 'group_name_attr',
deleteEmpty: true,
fieldLabel: gettext('Groupname attr.'),
},
{
xtype: 'displayfield',
value: gettext('Default Sync Options'),
},
{
xtype: 'proxmoxKVComboBox',
name: 'scope',
emptyText: Proxmox.Utils.NoneText,
fieldLabel: gettext('Scope'),
value: '__default__',
deleteEmpty: false,
comboItems: [
['__default__', Proxmox.Utils.NoneText],
['users', gettext('Users')],
['groups', gettext('Groups')],
['both', gettext('Users and Groups')],
],
},
],
column2: [
{
xtype: 'proxmoxtextfield',
name: 'user_classes',
fieldLabel: gettext('User classes'),
deleteEmpty: true,
emptyText: 'inetorgperson, posixaccount, person, user',
},
{
xtype: 'proxmoxtextfield',
name: 'group_classes',
fieldLabel: gettext('Group classes'),
deleteEmpty: true,
emptyText: 'groupOfNames, group, univentionGroup, ipausergroup',
},
{
xtype: 'proxmoxtextfield',
name: 'filter',
fieldLabel: gettext('User Filter'),
deleteEmpty: true,
},
{
xtype: 'proxmoxtextfield',
name: 'group_filter',
fieldLabel: gettext('Group Filter'),
deleteEmpty: true,
},
{
// fake for spacing
xtype: 'displayfield',
value: ' ',
},
{
xtype: 'proxmoxKVComboBox',
value: '__default__',
deleteEmpty: false,
comboItems: [
[
'__default__',
Ext.String.format(
gettext("{0} ({1})"),
Proxmox.Utils.yesText,
Proxmox.Utils.defaultText,
),
],
['1', Proxmox.Utils.yesText],
['0', Proxmox.Utils.noText],
],
name: 'enable-new',
fieldLabel: gettext('Enable new users'),
},
],
columnB: [
{
xtype: 'fieldset',
title: gettext('Remove Vanished Options'),
items: [
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('ACL'),
name: 'remove-vanished-acl',
boxLabel: gettext('Remove ACLs of vanished users and groups.'),
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Entry'),
name: 'remove-vanished-entry',
boxLabel: gettext('Remove vanished user and group entries.'),
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Properties'),
name: 'remove-vanished-properties',
boxLabel: gettext('Remove vanished properties from synced users.'),
},
],
},
],
});
Ext.define('PVE.panel.OpenIDInputPanel', {
extend: 'PVE.panel.AuthBase',
xtype: 'pveAuthOpenIDPanel',
mixins: ['Proxmox.Mixin.CBind'],
onGetValues: function(values) {
let me = this;
if (!values.verify) {
if (!me.isCreate) {
Proxmox.Utils.assemble_field_data(values, { 'delete': 'verify' });
}
delete values.verify;
}
return me.callParent([values]);
},
columnT: [
{
xtype: 'textfield',
name: 'issuer-url',
fieldLabel: gettext('Issuer URL'),
allowBlank: false,
},
],
column1: [
{
xtype: 'proxmoxtextfield',
fieldLabel: gettext('Client ID'),
name: 'client-id',
allowBlank: false,
},
{
xtype: 'proxmoxtextfield',
fieldLabel: gettext('Client Key'),
cbind: {
deleteEmpty: '{!isCreate}',
},
name: 'client-key',
},
],
column2: [
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Autocreate Users'),
name: 'autocreate',
value: 0,
cbind: {
deleteEmpty: '{!isCreate}',
},
},
{
xtype: 'pmxDisplayEditField',
name: 'username-claim',
fieldLabel: gettext('Username Claim'),
editConfig: {
xtype: 'proxmoxKVComboBox',
editable: true,
comboItems: [
['__default__', Proxmox.Utils.defaultText],
['subject', 'subject'],
['username', 'username'],
['email', 'email'],
],
},
cbind: {
value: get => get('isCreate') ? '__default__' : Proxmox.Utils.defaultText,
deleteEmpty: '{!isCreate}',
editable: '{isCreate}',
},
},
{
xtype: 'proxmoxtextfield',
name: 'scopes',
fieldLabel: gettext('Scopes'),
emptyText: `${Proxmox.Utils.defaultText} (email profile)`,
submitEmpty: false,
cbind: {
deleteEmpty: '{!isCreate}',
},
},
{
xtype: 'proxmoxKVComboBox',
name: 'prompt',
fieldLabel: gettext('Prompt'),
editable: true,
emptyText: gettext('Auth-Provider Default'),
comboItems: [
['__default__', gettext('Auth-Provider Default')],
['none', 'none'],
['login', 'login'],
['consent', 'consent'],
['select_account', 'select_account'],
],
cbind: {
deleteEmpty: '{!isCreate}',
},
},
],
advancedColumnB: [
{
xtype: 'proxmoxtextfield',
name: 'acr-values',
fieldLabel: gettext('ACR Values'),
submitEmpty: false,
cbind: {
deleteEmpty: '{!isCreate}',
},
},
],
initComponent: function() {
let me = this;
if (me.type !== 'openid') {
throw 'invalid type';
}
me.callParent();
},
});
Ext.define('PVE.dc.AuthView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveAuthView'],
onlineHelp: 'pveum_authentication_realms',
stateful: true,
stateId: 'grid-authrealms',
viewConfig: {
trackOver: false,
},
columns: [
{
header: gettext('Realm'),
width: 100,
sortable: true,
dataIndex: 'realm',
},
{
header: gettext('Type'),
width: 100,
sortable: true,
dataIndex: 'type',
},
{
header: gettext('TFA'),
width: 100,
sortable: true,
dataIndex: 'tfa',
},
{
header: gettext('Comment'),
sortable: false,
dataIndex: 'comment',
renderer: Ext.String.htmlEncode,
flex: 1,
},
],
store: {
model: 'pmx-domains',
sorters: {
property: 'realm',
direction: 'ASC',
},
},
openEditWindow: function(authType, realm) {
let me = this;
Ext.create('PVE.dc.AuthEditBase', {
authType,
realm,
listeners: {
destroy: () => me.reload(),
},
}).show();
},
reload: function() {
let me = this;
me.getStore().load();
},
run_editor: function() {
let me = this;
let rec = me.getSelection()[0];
if (!rec) {
return;
}
me.openEditWindow(rec.data.type, rec.data.realm);
},
open_sync_window: function() {
let me = this;
let rec = me.getSelection()[0];
if (!rec) {
return;
}
Ext.create('PVE.dc.SyncWindow', {
realm: rec.data.realm,
listeners: {
destroy: () => me.reload(),
},
}).show();
},
initComponent: function() {
var me = this;
let items = [];
for (const [authType, config] of Object.entries(PVE.Utils.authSchema)) {
if (!config.add) { continue; }
items.push({
text: config.name,
iconCls: 'fa fa-fw ' + (config.iconCls || 'fa-address-book-o'),
handler: () => me.openEditWindow(authType),
});
}
Ext.apply(me, {
tbar: [
{
text: gettext('Add'),
menu: {
items: items,
},
},
{
xtype: 'proxmoxButton',
text: gettext('Edit'),
disabled: true,
handler: () => me.run_editor(),
},
{
xtype: 'proxmoxStdRemoveButton',
baseurl: '/access/domains/',
enableFn: (rec) => PVE.Utils.authSchema[rec.data.type].add,
callback: () => me.reload(),
},
'-',
{
xtype: 'proxmoxButton',
text: gettext('Sync'),
disabled: true,
enableFn: (rec) => Boolean(PVE.Utils.authSchema[rec.data.type].syncipanel),
handler: () => me.open_sync_window(),
},
],
listeners: {
itemdblclick: () => me.run_editor(),
},
});
me.callParent();
me.reload();
},
});
Ext.define('PVE.dc.BackupDiskTree', {
extend: 'Ext.tree.Panel',
alias: 'widget.pveBackupDiskTree',
folderSort: true,
rootVisible: false,
store: {
sorters: 'id',
data: {},
},
tools: [
{
type: 'expand',
tooltip: gettext('Expand All'),
callback: panel => panel.expandAll(),
},
{
type: 'collapse',
tooltip: gettext('Collapse All'),
callback: panel => panel.collapseAll(),
},
],
columns: [
{
xtype: 'treecolumn',
text: gettext('Guest Image'),
renderer: function(value, meta, record) {
if (record.data.type) {
// guest level
let ret = value;
if (record.data.name) {
ret += " (" + record.data.name + ")";
}
return ret;
} else {
// extJS needs unique IDs but we only want to show the volumes key from "vmid:key"
return value.split(':')[1] + " - " + record.data.name;
}
},
dataIndex: 'id',
flex: 6,
},
{
text: gettext('Type'),
dataIndex: 'type',
flex: 1,
},
{
text: gettext('Backup Job'),
renderer: PVE.Utils.render_backup_status,
dataIndex: 'included',
flex: 3,
},
],
reload: function() {
let me = this;
let sm = me.getSelectionModel();
Proxmox.Utils.API2Request({
url: `/cluster/backup/${me.jobid}/included_volumes`,
waitMsgTarget: me,
method: 'GET',
failure: function(response, opts) {
Proxmox.Utils.setErrorMask(me, response.htmlStatus);
},
success: function(response, opts) {
sm.deselectAll();
me.setRootNode(response.result.data);
me.expandAll();
},
});
},
initComponent: function() {
var me = this;
if (!me.jobid) {
throw "no job id specified";
}
var sm = Ext.create('Ext.selection.TreeModel', {});
Ext.apply(me, {
selModel: sm,
fields: ['id', 'type',
{
type: 'string',
name: 'iconCls',
calculate: function(data) {
var txt = 'fa x-fa-tree fa-';
if (data.leaf && !data.type) {
return txt + 'hdd-o';
} else if (data.type === 'qemu') {
return txt + 'desktop';
} else if (data.type === 'lxc') {
return txt + 'cube';
} else {
return txt + 'question-circle';
}
},
},
],
header: {
items: [{
xtype: 'textfield',
fieldLabel: gettext('Search'),
labelWidth: 50,
emptyText: 'Name, VMID, Type',
width: 200,
padding: '0 5 0 0',
enableKeyEvents: true,
listeners: {
buffer: 500,
keyup: function(field) {
let searchValue = field.getValue().toLowerCase();
me.store.clearFilter(true);
me.store.filterBy(function(record) {
let data = {};
if (record.data.depth === 0) {
return true;
} else if (record.data.depth === 1) {
data = record.data;
} else if (record.data.depth === 2) {
data = record.parentNode.data;
}
for (const property of ['name', 'id', 'type']) {
if (!data[property]) {
continue;
}
let v = data[property].toString();
if (v !== undefined) {
v = v.toLowerCase();
if (v.includes(searchValue)) {
return true;
}
}
}
return false;
});
},
},
}],
},
});
me.callParent();
me.reload();
},
});
Ext.define('PVE.dc.BackupInfo', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pveBackupInfo',
viewModel: {
data: {
retentionType: 'none',
},
formulas: {
hasRetention: (get) => get('retentionType') !== 'none',
retentionKeepAll: (get) => get('retentionType') === 'all',
},
},
padding: '5 0 5 10',
column1: [
{
xtype: 'displayfield',
name: 'node',
fieldLabel: gettext('Node'),
renderer: value => value || `-- ${gettext('All')} --`,
},
{
xtype: 'displayfield',
name: 'storage',
fieldLabel: gettext('Storage'),
},
{
xtype: 'displayfield',
name: 'schedule',
fieldLabel: gettext('Schedule'),
},
{
xtype: 'displayfield',
name: 'next-run',
fieldLabel: gettext('Next Run'),
renderer: PVE.Utils.render_next_event,
},
{
xtype: 'displayfield',
name: 'selMode',
fieldLabel: gettext('Selection mode'),
},
],
column2: [
{
xtype: 'displayfield',
name: 'notification-policy',
fieldLabel: gettext('Notification'),
renderer: function(value) {
let record = this.up('pveBackupInfo')?.record;
// Fall back to old value, in case this option is not migrated yet.
let policy = value || record?.mailnotification || 'always';
let when = gettext('Always');
if (policy === 'failure') {
when = gettext('On failure only');
} else if (policy === 'never') {
when = gettext('Never');
}
// Notification-target takes precedence
let target = record?.['notification-target'] ||
record?.mailto ||
gettext('No target configured');
return `${when} (${target})`;
},
},
{
xtype: 'displayfield',
name: 'compress',
fieldLabel: gettext('Compression'),
},
{
xtype: 'displayfield',
name: 'mode',
fieldLabel: gettext('Mode'),
renderer: function(value) {
const modeToDisplay = {
snapshot: gettext('Snapshot'),
stop: gettext('Stop'),
suspend: gettext('Suspend'),
};
return modeToDisplay[value] ?? gettext('Unknown');
},
},
{
xtype: 'displayfield',
name: 'enabled',
fieldLabel: gettext('Enabled'),
renderer: v => PVE.Parser.parseBoolean(v.toString()) ? gettext('Yes') : gettext('No'),
},
{
xtype: 'displayfield',
name: 'pool',
fieldLabel: gettext('Pool to backup'),
},
],
columnB: [
{
xtype: 'displayfield',
name: 'comment',
fieldLabel: gettext('Comment'),
renderer: Ext.String.htmlEncode,
},
{
xtype: 'fieldset',
title: gettext('Retention Configuration'),
layout: 'hbox',
collapsible: true,
defaults: {
border: false,
layout: 'anchor',
flex: 1,
},
bind: {
hidden: '{!hasRetention}',
},
items: [
{
padding: '0 10 0 0',
defaults: {
labelWidth: 110,
},
items: [{
xtype: 'displayfield',
name: 'keep-all',
fieldLabel: gettext('Keep All'),
renderer: Proxmox.Utils.format_boolean,
bind: {
hidden: '{!retentionKeepAll}',
},
}].concat(
[
['keep-last', gettext('Keep Last')],
['keep-hourly', gettext('Keep Hourly')],
].map(
name => ({
xtype: 'displayfield',
name: name[0],
fieldLabel: name[1],
bind: {
hidden: '{!hasRetention || retentionKeepAll}',
},
}),
),
),
},
{
padding: '0 0 0 10',
defaults: {
labelWidth: 110,
},
items: [
['keep-daily', gettext('Keep Daily')],
['keep-weekly', gettext('Keep Weekly')],
].map(
name => ({
xtype: 'displayfield',
name: name[0],
fieldLabel: name[1],
bind: {
hidden: '{!hasRetention || retentionKeepAll}',
},
}),
),
},
{
padding: '0 0 0 10',
defaults: {
labelWidth: 110,
},
items: [
['keep-monthly', gettext('Keep Monthly')],
['keep-yearly', gettext('Keep Yearly')],
].map(
name => ({
xtype: 'displayfield',
name: name[0],
fieldLabel: name[1],
bind: {
hidden: '{!hasRetention || retentionKeepAll}',
},
}),
),
},
],
},
],
setValues: function(values) {
var me = this;
let vm = me.getViewModel();
Ext.iterate(values, function(fieldId, val) {
let field = me.query('[isFormField][name=' + fieldId + ']')[0];
if (field) {
field.setValue(val);
}
});
if (values['prune-backups'] || values.maxfiles !== undefined) {
let keepValues;
if (values['prune-backups']) {
keepValues = values['prune-backups'];
} else if (values.maxfiles > 0) {
keepValues = { 'keep-last': values.maxfiles };
} else {
keepValues = { 'keep-all': 1 };
}
vm.set('retentionType', keepValues['keep-all'] ? 'all' : 'other');
// set values of all keep-X fields
['all', 'last', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'].forEach(time => {
let name = `keep-${time}`;
me.query(`[isFormField][name=${name}]`)[0]?.setValue(keepValues[name]);
});
} else {
vm.set('retentionType', 'none');
}
// selection Mode depends on the presence/absence of several keys
let selModeField = me.query('[isFormField][name=selMode]')[0];
let selMode = 'none';
if (values.vmid) {
selMode = gettext('Include selected VMs');
}
if (values.all) {
selMode = gettext('All');
}
if (values.exclude) {
selMode = gettext('Exclude selected VMs');
}
if (values.pool) {
selMode = gettext('Pool based');
}
selModeField.setValue(selMode);
if (!values.pool) {
let poolField = me.query('[isFormField][name=pool]')[0];
poolField.setVisible(0);
}
},
initComponent: function() {
var me = this;
if (!me.record) {
throw "no data provided";
}
me.callParent();
me.setValues(me.record);
},
});
Ext.define('PVE.dc.BackedGuests', {
extend: 'Ext.grid.GridPanel',
alias: 'widget.pveBackedGuests',
stateful: true,
stateId: 'grid-dc-backed-guests',
textfilter: '',
columns: [
{
header: gettext('Type'),
dataIndex: "type",
renderer: PVE.Utils.render_resource_type,
flex: 1,
sortable: true,
},
{
header: 'VMID',
dataIndex: 'vmid',
flex: 1,
sortable: true,
},
{
header: gettext('Name'),
dataIndex: 'name',
flex: 2,
sortable: true,
},
],
viewConfig: {
stripeRows: true,
trackOver: false,
},
initComponent: function() {
let me = this;
me.store.clearFilter(true);
Ext.apply(me, {
tbar: [
'->',
gettext('Search') + ':',
' ',
{
xtype: 'textfield',
width: 200,
emptyText: 'Name, VMID, Type',
enableKeyEvents: true,
listeners: {
buffer: 500,
keyup: function(field) {
let searchValue = field.getValue().toLowerCase();
me.store.clearFilter(true);
me.store.filterBy(function(record) {
let data = record.data;
for (const property of ['name', 'vmid', 'type']) {
if (data[property] === null) {
continue;
}
let v = data[property].toString();
if (v !== undefined) {
if (v.toLowerCase().includes(searchValue)) {
return true;
}
}
}
return false;
});
},
},
},
],
});
me.callParent();
},
});
Ext.define('PVE.dc.BackupEdit', {
extend: 'Proxmox.window.Edit',
alias: ['widget.pveDcBackupEdit'],
mixins: ['Proxmox.Mixin.CBind'],
defaultFocus: undefined,
subject: gettext("Backup Job"),
width: 720,
bodyPadding: 0,
url: '/api2/extjs/cluster/backup',
method: 'POST',
isCreate: true,
cbindData: function() {
let me = this;
if (me.jobid) {
me.isCreate = false;
me.method = 'PUT';
me.url += `/${me.jobid}`;
}
return {};
},
controller: {
xclass: 'Ext.app.ViewController',
onGetValues: function(values) {
let me = this;
let isCreate = me.getView().isCreate;
if (!values.node) {
if (!isCreate) {
Proxmox.Utils.assemble_field_data(values, { 'delete': 'node' });
}
delete values.node;
}
// Get rid of new-old parameters for notification settings.
// These should only be set for those selected few who ran
// pve-manager from pvetest.
if (!isCreate) {
Proxmox.Utils.assemble_field_data(values, { 'delete': 'notification-policy' });
Proxmox.Utils.assemble_field_data(values, { 'delete': 'notification-target' });
}
let selMode = values.selMode;
delete values.selMode;
if (selMode === 'all') {
values.all = 1;
values.exclude = '';
delete values.vmid;
} else if (selMode === 'exclude') {
values.all = 1;
values.exclude = values.vmid;
delete values.vmid;
} else if (selMode === 'pool') {
delete values.vmid;
}
if (selMode !== 'pool') {
delete values.pool;
}
return values;
},
nodeChange: function(f, value) {
let me = this;
me.lookup('storageSelector').setNodename(value);
let vmgrid = me.lookup('vmgrid');
let store = vmgrid.getStore();
store.clearFilter();
store.filterBy(function(rec) {
return !value || rec.get('node') === value;
});
let mode = me.lookup('modeSelector').getValue();
if (mode === 'all') {
vmgrid.selModel.selectAll(true);
}
if (mode === 'pool') {
me.selectPoolMembers();
}
},
storageChange: function(f, v) {
let me = this;
let rec = f.getStore().findRecord('storage', v, 0, false, true, true);
let compressionSelector = me.lookup('compressionSelector');
if (rec?.data?.type === 'pbs') {
compressionSelector.setValue('zstd');
compressionSelector.setDisabled(true);
} else if (!compressionSelector.getEditable()) {
compressionSelector.setDisabled(false);
}
},
selectPoolMembers: function() {
let me = this;
let mode = me.lookup('modeSelector').getValue();
if (mode !== 'pool') {
return;
}
let vmgrid = me.lookup('vmgrid');
let poolid = me.lookup('poolSelector').getValue();
vmgrid.getSelectionModel().deselectAll(true);
if (!poolid) {
return;
}
vmgrid.getStore().filter([
{
id: 'poolFilter',
property: 'pool',
value: poolid,
},
]);
vmgrid.selModel.selectAll(true);
},
modeChange: function(f, value, oldValue) {
let me = this;
let vmgrid = me.lookup('vmgrid');
vmgrid.getStore().removeFilter('poolFilter');
if (oldValue === 'all' && value !== 'all') {
vmgrid.getSelectionModel().deselectAll(true);
}
if (value === 'all') {
vmgrid.getSelectionModel().selectAll(true);
}
if (value === 'pool') {
me.selectPoolMembers();
}
},
compressionChange: function(f, value, oldValue) {
this.getView().lookup('backupAdvanced').updateCompression(value, f.isDisabled());
},
compressionDisable: function(f) {
this.getView().lookup('backupAdvanced').updateCompression(f.getValue(), true);
},
compressionEnable: function(f) {
this.getView().lookup('backupAdvanced').updateCompression(f.getValue(), false);
},
prepareValues: function(data) {
let me = this;
let viewModel = me.getViewModel();
// Migrate 'new'-old notification-policy back to old-old mailnotification.
// Only should affect users who used pve-manager from pvetest. This was a remnant of
// notifications before the overhaul.
let policy = data['notification-policy'];
if (policy === 'always' || policy === 'failure') {
data.mailnotification = policy;
}
if (data.exclude) {
data.vmid = data.exclude;
data.selMode = 'exclude';
} else if (data.all) {
data.vmid = '';
data.selMode = 'all';
} else if (data.pool) {
data.selMode = 'pool';
data.selPool = data.pool;
} else {
data.selMode = 'include';
}
viewModel.set('selMode', data.selMode);
if (data['prune-backups']) {
Object.assign(data, data['prune-backups']);
delete data['prune-backups'];
} else if (data.maxfiles !== undefined) {
if (data.maxfiles > 0) {
data['keep-last'] = data.maxfiles;
} else {
data['keep-all'] = 1;
}
delete data.maxfiles;
}
if (data['notes-template']) {
data['notes-template'] =
PVE.Utils.unEscapeNotesTemplate(data['notes-template']);
}
if (data.performance) {
Object.assign(data, data.performance);
delete data.performance;
}
return data;
},
init: function(view) {
let me = this;
if (view.isCreate) {
me.lookup('modeSelector').setValue('include');
} else {
view.load({
success: function(response, _options) {
let values = me.prepareValues(response.result.data);
view.setValues(values);
},
});
}
},
},
viewModel: {
data: {
selMode: 'include',
notificationMode: '__default__',
mailto: '',
mailNotification: 'always',
},
formulas: {
poolMode: (get) => get('selMode') === 'pool',
disableVMSelection: (get) => get('selMode') !== 'include' &&
get('selMode') !== 'exclude',
showMailtoFields: (get) =>
['auto', 'legacy-sendmail', '__default__'].includes(get('notificationMode')),
enableMailnotificationField: (get) => {
let mode = get('notificationMode');
let mailto = get('mailto');
return (['auto', '__default__'].includes(mode) && mailto) ||
mode === 'legacy-sendmail';
},
},
},
items: [
{
xtype: 'tabpanel',
region: 'center',
layout: 'fit',
bodyPadding: 10,
items: [
{
xtype: 'container',
title: gettext('General'),
region: 'center',
layout: {
type: 'vbox',
align: 'stretch',
},
items: [
{
xtype: 'inputpanel',
onlineHelp: 'chapter_vzdump',
column1: [
{
xtype: 'pveNodeSelector',
name: 'node',
fieldLabel: gettext('Node'),
allowBlank: true,
editable: true,
autoSelect: false,
emptyText: '-- ' + gettext('All') + ' --',
listeners: {
change: 'nodeChange',
},
},
{
xtype: 'pveStorageSelector',
reference: 'storageSelector',
fieldLabel: gettext('Storage'),
clusterView: true,
storageContent: 'backup',
allowBlank: false,
name: 'storage',
listeners: {
change: 'storageChange',
},
},
{
xtype: 'pveCalendarEvent',
fieldLabel: gettext('Schedule'),
allowBlank: false,
name: 'schedule',
},
{
xtype: 'proxmoxKVComboBox',
reference: 'modeSelector',
comboItems: [
['include', gettext('Include selected VMs')],
['all', gettext('All')],
['exclude', gettext('Exclude selected VMs')],
['pool', gettext('Pool based')],
],
fieldLabel: gettext('Selection mode'),
name: 'selMode',
value: '',
bind: {
value: '{selMode}',
},
listeners: {
change: 'modeChange',
},
},
{
xtype: 'pvePoolSelector',
reference: 'poolSelector',
fieldLabel: gettext('Pool to backup'),
hidden: true,
allowBlank: false,
name: 'pool',
listeners: {
change: 'selectPoolMembers',
},
bind: {
hidden: '{!poolMode}',
disabled: '{!poolMode}',
},
},
],
column2: [
{
xtype: 'proxmoxKVComboBox',
comboItems: [
[
'__default__',
Ext.String.format(
gettext('{0} (Auto)'), Proxmox.Utils.defaultText,
),
],
['auto', gettext('Auto')],
['legacy-sendmail', gettext('Email (legacy)')],
['notification-system', gettext('Notification system')],
],
fieldLabel: gettext('Notification mode'),
name: 'notification-mode',
value: '__default__',
cbind: {
deleteEmpty: '{!isCreate}',
},
bind: {
value: '{notificationMode}',
},
},
{
xtype: 'textfield',
fieldLabel: gettext('Send email to'),
name: 'mailto',
bind: {
hidden: '{!showMailtoFields}',
value: '{mailto}',
},
},
{
xtype: 'pveEmailNotificationSelector',
fieldLabel: gettext('Send email'),
name: 'mailnotification',
cbind: {
value: (get) => get('isCreate') ? 'always' : '',
deleteEmpty: '{!isCreate}',
},
bind: {
hidden: '{!showMailtoFields}',
disabled: '{!enableMailnotificationField}',
value: '{mailNotification}',
},
},
{
xtype: 'pveBackupCompressionSelector',
reference: 'compressionSelector',
fieldLabel: gettext('Compression'),
name: 'compress',
cbind: {
deleteEmpty: '{!isCreate}',
},
value: 'zstd',
listeners: {
change: 'compressionChange',
disable: 'compressionDisable',
enable: 'compressionEnable',
},
},
{
xtype: 'pveBackupModeSelector',
fieldLabel: gettext('Mode'),
value: 'snapshot',
name: 'mode',
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Enable'),
name: 'enabled',
uncheckedValue: 0,
defaultValue: 1,
checked: true,
},
],
columnB: [
{
xtype: 'proxmoxtextfield',
name: 'comment',
fieldLabel: gettext('Job Comment'),
cbind: {
deleteEmpty: '{!isCreate}',
},
autoEl: {
tag: 'div',
'data-qtip': gettext('Description of the job'),
},
},
{
xtype: 'vmselector',
reference: 'vmgrid',
height: 300,
name: 'vmid',
disabled: true,
allowBlank: false,
columnSelection: ['vmid', 'node', 'status', 'name', 'type'],
bind: {
disabled: '{disableVMSelection}',
},
},
],
onGetValues: function(values) {
return this.up('window').getController().onGetValues(values);
},
},
],
},
{
xtype: 'pveBackupJobPrunePanel',
title: gettext('Retention'),
cbind: {
isCreate: '{isCreate}',
},
keepAllDefaultForCreate: false,
showPBSHint: false,
fallbackHintHtml: gettext('Without any keep option, the storage\'s configuration or node\'s vzdump.conf is used as fallback'),
},
{
xtype: 'inputpanel',
title: gettext('Note Template'),
region: 'center',
layout: {
type: 'vbox',
align: 'stretch',
},
onGetValues: function(values) {
if (values['notes-template']) {
values['notes-template'] =
PVE.Utils.escapeNotesTemplate(values['notes-template']);
}
return values;
},
items: [
{
xtype: 'textarea',
name: 'notes-template',
fieldLabel: gettext('Backup Notes'),
height: 100,
maxLength: 512,
cbind: {
deleteEmpty: '{!isCreate}',
value: (get) => get('isCreate') ? '{{guestname}}' : undefined,
},
},
{
xtype: 'box',
style: {
margin: '8px 0px',
'line-height': '1.5em',
},
html: gettext('The notes are added to each backup created by this job.')
+ '<br>'
+ Ext.String.format(
gettext('Possible template variables are: {0}'),
PVE.Utils.notesTemplateVars.map(v => `<code>{{${v}}}</code>`).join(', '),
),
},
],
},
{
xtype: 'pveBackupAdvancedOptionsPanel',
reference: 'backupAdvanced',
title: gettext('Advanced'),
cbind: {
isCreate: '{isCreate}',
},
},
],
},
],
});
Ext.define('PVE.dc.BackupView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveDcBackupView'],
onlineHelp: 'chapter_vzdump',
allText: '-- ' + gettext('All') + ' --',
initComponent: function() {
let me = this;
let store = new Ext.data.Store({
model: 'pve-cluster-backup',
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/backup",
},
});
let not_backed_store = new Ext.data.Store({
sorters: 'vmid',
proxy: {
type: 'proxmox',
url: 'api2/json/cluster/backup-info/not-backed-up',
},
});
let noBackupJobInfoButton;
let reload = function() {
store.load();
not_backed_store.load({
callback: records => noBackupJobInfoButton.setVisible(records.length > 0),
});
};
let sm = Ext.create('Ext.selection.RowModel', {});
let run_editor = function() {
let rec = sm.getSelection()[0];
if (!rec) {
return;
}
Ext.create('PVE.dc.BackupEdit', {
autoShow: true,
jobid: rec.data.id,
listeners: {
destroy: () => reload(),
},
});
};
let run_detail = function() {
let record = sm.getSelection()[0];
if (!record) {
return;
}
Ext.create('Ext.window.Window', {
modal: true,
width: 800,
height: Ext.getBody().getViewSize().height > 1000 ? 800 : 600, // factor out as common infra?
resizable: true,
layout: 'fit',
title: gettext('Backup Details'),
items: [
{
xtype: 'panel',
region: 'center',
layout: {
type: 'vbox',
align: 'stretch',
},
items: [
{
xtype: 'pveBackupInfo',
flex: 0,
layout: 'fit',
record: record.data,
},
{
xtype: 'pveBackupDiskTree',
title: gettext('Included disks'),
flex: 1,
jobid: record.data.id,
},
],
},
],
}).show();
};
let run_backup_now = function(job) {
job = Ext.clone(job);
let jobNode = job.node;
// Remove properties related to scheduling
delete job.enabled;
delete job.starttime;
delete job.dow;
delete job.id;
delete job.schedule;
delete job.type;
delete job.node;
delete job.comment;
delete job['next-run'];
delete job['repeat-missed'];
job.all = job.all === true ? 1 : 0;
['performance', 'prune-backups', 'fleecing'].forEach(key => {
if (job[key]) {
job[key] = PVE.Parser.printPropertyString(job[key]);
}
});
let allNodes = PVE.data.ResourceStore.getNodes();
let nodes = allNodes.filter(node => node.status === 'online').map(node => node.node);
let errors = [];
if (jobNode !== undefined) {
if (!nodes.includes(jobNode)) {
Ext.Msg.alert('Error', "Node '"+ jobNode +"' from backup job isn't online!");
return;
}
nodes = [jobNode];
} else {
let unkownNodes = allNodes.filter(node => node.status !== 'online');
if (unkownNodes.length > 0) {errors.push(unkownNodes.map(node => node.node + ": " + gettext("Node is offline")));}
}
let jobTotalCount = nodes.length, jobsStarted = 0;
Ext.Msg.show({
title: gettext('Please wait...'),
closable: false,
progress: true,
progressText: '0/' + jobTotalCount,
});
let postRequest = function() {
jobsStarted++;
Ext.Msg.updateProgress(jobsStarted / jobTotalCount, jobsStarted + '/' + jobTotalCount);
if (jobsStarted === jobTotalCount) {
Ext.Msg.hide();
if (errors.length > 0) {
Ext.Msg.alert('Error', 'Some errors have been encountered:<br />' + errors.join('<br />'));
}
}
};
nodes.forEach(node => Proxmox.Utils.API2Request({
url: '/nodes/' + node + '/vzdump',
method: 'POST',
params: job,
failure: function(response, opts) {
errors.push(node + ': ' + response.htmlStatus);
postRequest();
},
success: postRequest,
}));
};
var edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
selModel: sm,
handler: run_editor,
});
var run_btn = new Proxmox.button.Button({
text: gettext('Run now'),
disabled: true,
selModel: sm,
handler: function() {
var rec = sm.getSelection()[0];
if (!rec) {
return;
}
Ext.Msg.show({
title: gettext('Confirm'),
icon: Ext.Msg.QUESTION,
msg: gettext('Start the selected backup job now?'),
buttons: Ext.Msg.YESNO,
callback: function(btn) {
if (btn !== 'yes') {
return;
}
run_backup_now(rec.data);
},
});
},
});
var remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
selModel: sm,
baseurl: '/cluster/backup',
callback: function() {
reload();
},
});
var detail_btn = new Proxmox.button.Button({
text: gettext('Job Detail'),
disabled: true,
tooltip: gettext('Show job details and which guests and volumes are affected by the backup job'),
selModel: sm,
handler: run_detail,
});
noBackupJobInfoButton = new Proxmox.button.Button({
text: `${gettext('Show')}: ${gettext('Guests Without Backup Job')}`,
tooltip: gettext('Some guests are not covered by any backup job.'),
iconCls: 'fa fa-fw fa-exclamation-circle',
hidden: true,
handler: () => {
Ext.create('Ext.window.Window', {
autoShow: true,
modal: true,
width: 600,
height: 500,
resizable: true,
layout: 'fit',
title: gettext('Guests Without Backup Job'),
items: [
{
xtype: 'panel',
region: 'center',
layout: {
type: 'vbox',
align: 'stretch',
},
items: [
{
xtype: 'pveBackedGuests',
flex: 1,
layout: 'fit',
store: not_backed_store,
},
],
},
],
});
},
});
Proxmox.Utils.monStoreErrors(me, store);
Ext.apply(me, {
store: store,
selModel: sm,
stateful: true,
stateId: 'grid-dc-backup',
viewConfig: {
trackOver: false,
},
dockedItems: [{
xtype: 'toolbar',
overflowHandler: 'scroller',
dock: 'top',
items: [
{
text: gettext('Add'),
handler: function() {
var win = Ext.create('PVE.dc.BackupEdit', {});
win.on('destroy', reload);
win.show();
},
},
'-',
remove_btn,
edit_btn,
detail_btn,
'-',
run_btn,
'->',
noBackupJobInfoButton,
'-',
{
xtype: 'proxmoxButton',
selModel: null,
text: gettext('Schedule Simulator'),
handler: () => {
let record = sm.getSelection()[0];
let schedule;
if (record) {
schedule = record.data.schedule;
}
Ext.create('PVE.window.ScheduleSimulator', {
autoShow: true,
schedule,
});
},
},
],
}],
columns: [
{
header: gettext('Enabled'),
width: 80,
dataIndex: 'enabled',
align: 'center',
renderer: Proxmox.Utils.renderEnabledIcon,
sortable: true,
},
{
header: gettext('ID'),
dataIndex: 'id',
hidden: true,
},
{
header: gettext('Node'),
width: 100,
sortable: true,
dataIndex: 'node',
renderer: function(value) {
if (value) {
return value;
}
return me.allText;
},
},
{
header: gettext('Schedule'),
width: 150,
dataIndex: 'schedule',
},
{
text: gettext('Next Run'),
dataIndex: 'next-run',
width: 150,
renderer: PVE.Utils.render_next_event,
},
{
header: gettext('Storage'),
width: 100,
sortable: true,
dataIndex: 'storage',
},
{
header: gettext('Comment'),
dataIndex: 'comment',
renderer: Ext.htmlEncode,
sorter: (a, b) => (a.data.comment || '').localeCompare(b.data.comment || ''),
flex: 1,
},
{
header: gettext('Retention'),
dataIndex: 'prune-backups',
renderer: v => v ? PVE.Parser.printPropertyString(v) : gettext('Fallback from storage config'),
flex: 2,
},
{
header: gettext('Selection'),
flex: 4,
sortable: false,
dataIndex: 'vmid',
renderer: PVE.Utils.render_backup_selection,
},
],
listeners: {
activate: reload,
itemdblclick: run_editor,
},
});
me.callParent();
},
}, function() {
Ext.define('pve-cluster-backup', {
extend: 'Ext.data.Model',
fields: [
'id',
'compress',
'dow',
'exclude',
'mailto',
'mode',
'node',
'pool',
'prune-backups',
'starttime',
'storage',
'vmid',
{ name: 'enabled', type: 'boolean' },
{ name: 'all', type: 'boolean' },
],
});
});
Ext.define('pve-cluster-nodes', {
extend: 'Ext.data.Model',
fields: [
'node', { type: 'integer', name: 'nodeid' }, 'ring0_addr', 'ring1_addr',
{ type: 'integer', name: 'quorum_votes' },
],
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/config/nodes",
},
idProperty: 'nodeid',
});
Ext.define('pve-cluster-info', {
extend: 'Ext.data.Model',
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/config/join",
},
});
Ext.define('PVE.ClusterAdministration', {
extend: 'Ext.panel.Panel',
xtype: 'pveClusterAdministration',
title: gettext('Cluster Administration'),
onlineHelp: 'chapter_pvecm',
border: false,
defaults: { border: false },
viewModel: {
parent: null,
data: {
totem: {},
nodelist: [],
preferred_node: {
name: '',
fp: '',
addr: '',
},
isInCluster: false,
nodecount: 0,
},
},
items: [
{
xtype: 'panel',
title: gettext('Cluster Information'),
controller: {
xclass: 'Ext.app.ViewController',
init: function(view) {
view.store = Ext.create('Proxmox.data.UpdateStore', {
autoStart: true,
interval: 15 * 1000,
storeid: 'pve-cluster-info',
model: 'pve-cluster-info',
});
view.store.on('load', this.onLoad, this);
view.on('destroy', view.store.stopUpdate);
},
onLoad: function(store, records, success, operation) {
let vm = this.getViewModel();
let data = records?.[0]?.data;
if (!success || !data || !data.nodelist?.length) {
let error = operation.getError();
if (error) {
let msg = Proxmox.Utils.getResponseErrorMessage(error);
if (error.status !== 424 && !msg.match(/node is not in a cluster/i)) {
// an actual error, not just the "not in a cluster one", so show it!
Proxmox.Utils.setErrorMask(this.getView(), msg);
}
}
vm.set('totem', {});
vm.set('isInCluster', false);
vm.set('nodelist', []);
vm.set('preferred_node', {
name: '',
addr: '',
fp: '',
});
return;
}
vm.set('totem', data.totem);
vm.set('isInCluster', !!data.totem.cluster_name);
vm.set('nodelist', data.nodelist);
let nodeinfo = data.nodelist.find(el => el.name === data.preferred_node);
let links = {};
let ring_addr = [];
PVE.Utils.forEachCorosyncLink(nodeinfo, (num, link) => {
links[num] = link;
ring_addr.push(link);
});
vm.set('preferred_node', {
name: data.preferred_node,
addr: nodeinfo.pve_addr,
peerLinks: links,
ring_addr: ring_addr,
fp: nodeinfo.pve_fp,
});
},
onCreate: function() {
let view = this.getView();
view.store.stopUpdate();
Ext.create('PVE.ClusterCreateWindow', {
autoShow: true,
listeners: {
destroy: function() {
view.store.startUpdate();
},
},
});
},
onClusterInfo: function() {
let vm = this.getViewModel();
Ext.create('PVE.ClusterInfoWindow', {
autoShow: true,
joinInfo: {
ipAddress: vm.get('preferred_node.addr'),
fingerprint: vm.get('preferred_node.fp'),
peerLinks: vm.get('preferred_node.peerLinks'),
ring_addr: vm.get('preferred_node.ring_addr'),
totem: vm.get('totem'),
},
});
},
onJoin: function() {
let view = this.getView();
view.store.stopUpdate();
Ext.create('PVE.ClusterJoinNodeWindow', {
autoShow: true,
listeners: {
destroy: function() {
view.store.startUpdate();
},
},
});
},
},
tbar: [
{
text: gettext('Create Cluster'),
reference: 'createButton',
handler: 'onCreate',
bind: {
disabled: '{isInCluster}',
},
},
{
text: gettext('Join Information'),
reference: 'addButton',
handler: 'onClusterInfo',
bind: {
disabled: '{!isInCluster}',
},
},
{
text: gettext('Join Cluster'),
reference: 'joinButton',
handler: 'onJoin',
bind: {
disabled: '{isInCluster}',
},
},
],
layout: 'hbox',
bodyPadding: 5,
items: [
{
xtype: 'displayfield',
fieldLabel: gettext('Cluster Name'),
bind: {
value: '{totem.cluster_name}',
hidden: '{!isInCluster}',
},
flex: 1,
},
{
xtype: 'displayfield',
fieldLabel: gettext('Config Version'),
bind: {
value: '{totem.config_version}',
hidden: '{!isInCluster}',
},
flex: 1,
},
{
xtype: 'displayfield',
fieldLabel: gettext('Number of Nodes'),
labelWidth: 120,
bind: {
value: '{nodecount}',
hidden: '{!isInCluster}',
},
flex: 1,
},
{
xtype: 'displayfield',
value: gettext('Standalone node - no cluster defined'),
bind: {
hidden: '{isInCluster}',
},
flex: 1,
},
],
},
{
xtype: 'grid',
title: gettext('Cluster Nodes'),
autoScroll: true,
enableColumnHide: false,
controller: {
xclass: 'Ext.app.ViewController',
init: function(view) {
view.rstore = Ext.create('Proxmox.data.UpdateStore', {
autoLoad: true,
xtype: 'update',
interval: 5 * 1000,
autoStart: true,
storeid: 'pve-cluster-nodes',
model: 'pve-cluster-nodes',
});
view.setStore(Ext.create('Proxmox.data.DiffStore', {
rstore: view.rstore,
sorters: {
property: 'nodeid',
direction: 'ASC',
},
}));
Proxmox.Utils.monStoreErrors(view, view.rstore);
view.rstore.on('load', this.onLoad, this);
view.on('destroy', view.rstore.stopUpdate);
},
onLoad: function(store, records, success) {
let view = this.getView();
let vm = this.getViewModel();
if (!success || !records || !records.length) {
vm.set('nodecount', 0);
return;
}
vm.set('nodecount', records.length);
// show/hide columns according to used links
let linkIndex = view.columns.length;
Ext.each(view.columns, (col, i) => {
if (col.linkNumber !== undefined) {
col.setHidden(true);
// save offset at which link columns start, so we can address them directly below
if (i < linkIndex) {
linkIndex = i;
}
}
});
PVE.Utils.forEachCorosyncLink(records[0].data,
(linknum, val) => {
if (linknum > 7) {
return;
}
view.columns[linkIndex + linknum].setHidden(false);
},
);
},
},
columns: {
items: [
{
header: gettext('Nodename'),
hidden: false,
dataIndex: 'name',
},
{
header: gettext('ID'),
minWidth: 100,
width: 100,
flex: 0,
hidden: false,
dataIndex: 'nodeid',
},
{
header: gettext('Votes'),
minWidth: 100,
width: 100,
flex: 0,
hidden: false,
dataIndex: 'quorum_votes',
},
{
header: Ext.String.format(gettext('Link {0}'), 0),
dataIndex: 'ring0_addr',
linkNumber: 0,
},
{
header: Ext.String.format(gettext('Link {0}'), 1),
dataIndex: 'ring1_addr',
linkNumber: 1,
},
{
header: Ext.String.format(gettext('Link {0}'), 2),
dataIndex: 'ring2_addr',
linkNumber: 2,
},
{
header: Ext.String.format(gettext('Link {0}'), 3),
dataIndex: 'ring3_addr',
linkNumber: 3,
},
{
header: Ext.String.format(gettext('Link {0}'), 4),
dataIndex: 'ring4_addr',
linkNumber: 4,
},
{
header: Ext.String.format(gettext('Link {0}'), 5),
dataIndex: 'ring5_addr',
linkNumber: 5,
},
{
header: Ext.String.format(gettext('Link {0}'), 6),
dataIndex: 'ring6_addr',
linkNumber: 6,
},
{
header: Ext.String.format(gettext('Link {0}'), 7),
dataIndex: 'ring7_addr',
linkNumber: 7,
},
],
defaults: {
flex: 1,
hidden: true,
minWidth: 150,
},
},
},
],
});
Ext.define('PVE.ClusterCreateWindow', {
extend: 'Proxmox.window.Edit',
xtype: 'pveClusterCreateWindow',
title: gettext('Create Cluster'),
width: 600,
method: 'POST',
url: '/cluster/config',
isCreate: true,
subject: gettext('Cluster'),
showTaskViewer: true,
onlineHelp: 'pvecm_create_cluster',
items: {
xtype: 'inputpanel',
items: [{
xtype: 'textfield',
fieldLabel: gettext('Cluster Name'),
allowBlank: false,
maxLength: 15,
name: 'clustername',
},
{
xtype: 'fieldcontainer',
fieldLabel: gettext("Cluster Network"),
items: [
{
xtype: 'pveCorosyncLinkEditor',
infoText: gettext("Multiple links are used as failover, lower numbers have higher priority."),
name: 'links',
},
],
}],
},
});
Ext.define('PVE.ClusterInfoWindow', {
extend: 'Ext.window.Window',
xtype: 'pveClusterInfoWindow',
mixins: ['Proxmox.Mixin.CBind'],
width: 800,
modal: true,
resizable: false,
title: gettext('Cluster Join Information'),
joinInfo: {
ipAddress: undefined,
fingerprint: undefined,
totem: {},
},
items: [
{
xtype: 'component',
border: false,
padding: '10 10 10 10',
html: gettext("Copy the Join Information here and use it on the node you want to add."),
},
{
xtype: 'container',
layout: 'form',
border: false,
padding: '0 10 10 10',
items: [
{
xtype: 'textfield',
fieldLabel: gettext('IP Address'),
cbind: {
value: '{joinInfo.ipAddress}',
},
editable: false,
},
{
xtype: 'textfield',
fieldLabel: gettext('Fingerprint'),
cbind: {
value: '{joinInfo.fingerprint}',
},
editable: false,
},
{
xtype: 'textarea',
inputId: 'pveSerializedClusterInfo',
fieldLabel: gettext('Join Information'),
grow: true,
cbind: {
joinInfo: '{joinInfo}',
},
editable: false,
listeners: {
afterrender: function(field) {
if (!field.joinInfo) {
return;
}
var jsons = Ext.JSON.encode(field.joinInfo);
var base64s = Ext.util.Base64.encode(jsons);
field.setValue(base64s);
},
},
},
],
},
],
dockedItems: [{
dock: 'bottom',
xtype: 'toolbar',
items: [{
xtype: 'button',
handler: function(b) {
var el = document.getElementById('pveSerializedClusterInfo');
el.select();
document.execCommand("copy");
},
text: gettext('Copy Information'),
iconCls: 'fa fa-clipboard',
}],
}],
});
Ext.define('PVE.ClusterJoinNodeWindow', {
extend: 'Proxmox.window.Edit',
xtype: 'pveClusterJoinNodeWindow',
title: gettext('Cluster Join'),
width: 800,
method: 'POST',
url: '/cluster/config/join',
defaultFocus: 'textarea[name=serializedinfo]',
isCreate: true,
bind: {
submitText: '{submittxt}',
},
showTaskViewer: true,
onlineHelp: 'pvecm_join_node_to_cluster',
viewModel: {
parent: null,
data: {
info: {
fp: '',
ip: '',
clusterName: '',
},
hasAssistedInfo: false,
},
formulas: {
submittxt: function(get) {
let cn = get('info.clusterName');
if (cn) {
return Ext.String.format(gettext('Join {0}'), `'${cn}'`);
}
return gettext('Join');
},
showClusterFields: (get) => {
let manualMode = !get('assistedEntry.checked');
return get('hasAssistedInfo') || manualMode;
},
},
},
controller: {
xclass: 'Ext.app.ViewController',
control: {
'#': {
close: function() {
delete PVE.Utils.silenceAuthFailures;
},
},
'proxmoxcheckbox[name=assistedEntry]': {
change: 'onInputTypeChange',
},
'textarea[name=serializedinfo]': {
change: 'recomputeSerializedInfo',
enable: 'resetField',
},
'textfield': {
disable: 'resetField',
},
},
resetField: function(field) {
field.reset();
},
onInputTypeChange: function(field, assistedInput) {
let linkEditor = this.lookup('linkEditor');
// this also clears all links
linkEditor.setAllowNumberEdit(!assistedInput);
if (!assistedInput) {
linkEditor.setInfoText();
linkEditor.setDefaultLinks();
}
},
recomputeSerializedInfo: function(field, value) {
let vm = this.getViewModel();
let assistedEntryBox = this.lookup('assistedEntry');
if (!assistedEntryBox.getValue()) {
// not in assisted entry mode, nothing to do
vm.set('hasAssistedInfo', false);
return;
}
let linkEditor = this.lookup('linkEditor');
let jsons = Ext.util.Base64.decode(value);
let joinInfo = Ext.JSON.decode(jsons, true);
let info = {
fp: '',
ip: '',
clusterName: '',
};
if (!(joinInfo && joinInfo.totem)) {
field.valid = false;
linkEditor.setLinks([]);
linkEditor.setInfoText();
vm.set('hasAssistedInfo', false);
} else {
let interfaces = joinInfo.totem.interface;
let links = Object.values(interfaces).map(iface => {
let linkNumber = iface.linknumber;
let peerLink;
if (joinInfo.peerLinks) {
peerLink = joinInfo.peerLinks[linkNumber];
}
return {
number: linkNumber,
value: '',
text: peerLink ? Ext.String.format(gettext("peer's link address: {0}"), peerLink) : '',
allowBlank: false,
};
});
linkEditor.setInfoText();
if (links.length === 1 && joinInfo.ring_addr !== undefined &&
joinInfo.ring_addr[0] === joinInfo.ipAddress
) {
links[0].allowBlank = true;
links[0].emptyText = gettext("IP resolved by node's hostname");
}
linkEditor.setLinks(links);
info = {
ip: joinInfo.ipAddress,
fp: joinInfo.fingerprint,
clusterName: joinInfo.totem.cluster_name,
};
field.valid = true;
vm.set('hasAssistedInfo', true);
}
vm.set('info', info);
},
},
submit: function() {
// joining may produce temporarily auth failures, ignore as long the task runs
PVE.Utils.silenceAuthFailures = true;
this.callParent();
},
taskDone: function(success) {
delete PVE.Utils.silenceAuthFailures;
if (success) {
// reload always (if user wasn't faster), but wait a bit for pveproxy
Ext.defer(function() {
window.location.reload(true);
}, 5000);
let txt = gettext('Cluster join task finished, node certificate may have changed, reload GUI!');
// ensure user cannot do harm
Ext.getBody().mask(txt, ['pve-static-mask']);
// TaskView may hide above mask, so tell him directly
Ext.Msg.show({
title: gettext('Join Task Finished'),
icon: Ext.Msg.INFO,
msg: txt,
});
}
},
items: [{
xtype: 'proxmoxcheckbox',
reference: 'assistedEntry',
name: 'assistedEntry',
itemId: 'assistedEntry',
submitValue: false,
value: true,
autoEl: {
tag: 'div',
'data-qtip': gettext('Select if join information should be extracted from pasted cluster information, deselect for manual entering'),
},
boxLabel: gettext('Assisted join: Paste encoded cluster join information and enter password.'),
},
{
xtype: 'textarea',
name: 'serializedinfo',
submitValue: false,
allowBlank: false,
fieldLabel: gettext('Information'),
emptyText: gettext('Paste encoded Cluster Information here'),
validator: function(val) {
return val === '' || this.valid || gettext('Does not seem like a valid encoded Cluster Information!');
},
bind: {
disabled: '{!assistedEntry.checked}',
hidden: '{!assistedEntry.checked}',
},
value: '',
},
{
xtype: 'panel',
width: 776,
layout: {
type: 'hbox',
align: 'center',
},
bind: {
hidden: '{!showClusterFields}',
},
items: [
{
xtype: 'textfield',
flex: 1,
margin: '0 5px 0 0',
fieldLabel: gettext('Peer Address'),
allowBlank: false,
bind: {
value: '{info.ip}',
readOnly: '{assistedEntry.checked}',
},
name: 'hostname',
},
{
xtype: 'textfield',
flex: 1,
margin: '0 0 10px 5px',
inputType: 'password',
emptyText: gettext("Peer's root password"),
fieldLabel: gettext('Password'),
allowBlank: false,
name: 'password',
},
],
},
{
xtype: 'textfield',
fieldLabel: gettext('Fingerprint'),
allowBlank: false,
bind: {
value: '{info.fp}',
readOnly: '{assistedEntry.checked}',
hidden: '{!showClusterFields}',
},
name: 'fingerprint',
},
{
xtype: 'fieldcontainer',
fieldLabel: gettext("Cluster Network"),
bind: {
hidden: '{!showClusterFields}',
},
items: [
{
xtype: 'pveCorosyncLinkEditor',
itemId: 'linkEditor',
reference: 'linkEditor',
allowNumberEdit: false,
},
],
}],
});
/*
* Datacenter config panel, located in the center of the ViewPort after the Datacenter view is selected
*/
Ext.define('PVE.dc.Config', {
extend: 'PVE.panel.Config',
alias: 'widget.PVE.dc.Config',
onlineHelp: 'pve_admin_guide',
initComponent: function() {
var me = this;
var caps = Ext.state.Manager.get('GuiCap');
me.items = [];
Ext.apply(me, {
title: gettext("Datacenter"),
hstateid: 'dctab',
});
if (caps.dc['Sys.Audit']) {
me.items.push({
title: gettext('Summary'),
xtype: 'pveDcSummary',
iconCls: 'fa fa-book',
itemId: 'summary',
},
{
xtype: 'pmxNotesView',
title: gettext('Notes'),
iconCls: 'fa fa-sticky-note-o',
itemId: 'notes',
},
{
title: gettext('Cluster'),
xtype: 'pveClusterAdministration',
iconCls: 'fa fa-server',
itemId: 'cluster',
},
{
title: 'Ceph',
itemId: 'ceph',
iconCls: 'fa fa-ceph',
xtype: 'pveNodeCephStatus',
},
{
xtype: 'pveDcOptionView',
title: gettext('Options'),
iconCls: 'fa fa-gear',
itemId: 'options',
});
}
if (caps.storage['Datastore.Allocate'] || caps.dc['Sys.Audit']) {
me.items.push({
xtype: 'pveStorageView',
title: gettext('Storage'),
iconCls: 'fa fa-database',
itemId: 'storage',
});
}
if (caps.dc['Sys.Audit']) {
me.items.push({
xtype: 'pveDcBackupView',
iconCls: 'fa fa-floppy-o',
title: gettext('Backup'),
itemId: 'backup',
},
{
xtype: 'pveReplicaView',
iconCls: 'fa fa-retweet',
title: gettext('Replication'),
itemId: 'replication',
},
{
xtype: 'pveACLView',
title: gettext('Permissions'),
iconCls: 'fa fa-unlock',
itemId: 'permissions',
expandedOnInit: true,
});
}
me.items.push({
xtype: 'pveUserView',
groups: ['permissions'],
iconCls: 'fa fa-user',
title: gettext('Users'),
itemId: 'users',
});
me.items.push({
xtype: 'pveTokenView',
groups: ['permissions'],
iconCls: 'fa fa-user-o',
title: gettext('API Tokens'),
itemId: 'apitokens',
});
me.items.push({
xtype: 'pmxTfaView',
title: gettext('Two Factor'),
groups: ['permissions'],
iconCls: 'fa fa-key',
itemId: 'tfa',
yubicoEnabled: true,
issuerName: `Proxmox VE - ${PVE.ClusterName || Proxmox.NodeName}`,
});
if (caps.dc['Sys.Audit']) {
me.items.push({
xtype: 'pveGroupView',
title: gettext('Groups'),
iconCls: 'fa fa-users',
groups: ['permissions'],
itemId: 'groups',
},
{
xtype: 'pvePoolView',
title: gettext('Pools'),
iconCls: 'fa fa-tags',
groups: ['permissions'],
itemId: 'pools',
},
{
xtype: 'pveRoleView',
title: gettext('Roles'),
iconCls: 'fa fa-male',
groups: ['permissions'],
itemId: 'roles',
},
{
title: gettext('Realms'),
xtype: 'panel',
layout: {
type: 'border',
},
groups: ['permissions'],
iconCls: 'fa fa-address-book-o',
itemId: 'domains',
items: [
{
xtype: 'pveAuthView',
region: 'center',
border: false,
},
{
xtype: 'pveRealmSyncJobView',
title: gettext('Realm Sync Jobs'),
region: 'south',
collapsible: true,
animCollapse: false,
border: false,
height: '50%',
},
],
},
{
xtype: 'pveHAStatus',
title: 'HA',
iconCls: 'fa fa-heartbeat',
itemId: 'ha',
},
{
title: gettext('Groups'),
groups: ['ha'],
xtype: 'pveHAGroupsView',
iconCls: 'fa fa-object-group',
itemId: 'ha-groups',
},
{
title: gettext('Fencing'),
groups: ['ha'],
iconCls: 'fa fa-bolt',
xtype: 'pveFencingView',
itemId: 'ha-fencing',
});
// always show on initial load, will be hiddea later if the SDN API calls don't exist,
// else it won't be shown at first if the user initially loads with DC selected
if (PVE.SDNInfo || PVE.SDNInfo === undefined) {
me.items.push({
xtype: 'pveSDNStatus',
title: gettext('SDN'),
iconCls: 'fa fa-sdn x-fa-sdn-treelist',
hidden: true,
itemId: 'sdn',
expandedOnInit: true,
},
{
xtype: 'pveSDNZoneView',
groups: ['sdn'],
title: gettext('Zones'),
hidden: true,
iconCls: 'fa fa-th',
itemId: 'sdnzone',
},
{
xtype: 'pveSDNVnet',
groups: ['sdn'],
title: 'VNets',
hidden: true,
iconCls: 'fa fa-network-wired x-fa-sdn-treelist',
itemId: 'sdnvnet',
},
{
xtype: 'pveSDNOptions',
groups: ['sdn'],
title: gettext('Options'),
hidden: true,
iconCls: 'fa fa-gear',
itemId: 'sdnoptions',
},
{
xtype: 'pveDhcpTree',
groups: ['sdn'],
title: gettext('IPAM'),
hidden: true,
iconCls: 'fa fa-map-signs',
itemId: 'sdnmappings',
},
{
xtype: 'pveSDNFirewall',
groups: ['sdn'],
title: gettext('VNet Firewall'),
hidden: true,
iconCls: 'fa fa-shield',
itemId: 'sdnfirewall',
});
}
if (Proxmox.UserName === 'root@pam') {
me.items.push({
xtype: 'pveACMEClusterView',
title: 'ACME',
iconCls: 'fa fa-certificate',
itemId: 'acme',
});
}
me.items.push({
xtype: 'pveFirewallRules',
title: gettext('Firewall'),
allow_iface: true,
base_url: '/cluster/firewall/rules',
list_refs_url: '/cluster/firewall/refs',
iconCls: 'fa fa-shield',
itemId: 'firewall',
firewall_type: 'dc',
},
{
xtype: 'pveFirewallOptions',
title: gettext('Options'),
groups: ['firewall'],
iconCls: 'fa fa-gear',
base_url: '/cluster/firewall/options',
onlineHelp: 'pve_firewall_cluster_wide_setup',
fwtype: 'dc',
itemId: 'firewall-options',
},
{
xtype: 'pveSecurityGroups',
title: gettext('Security Group'),
groups: ['firewall'],
iconCls: 'fa fa-group',
itemId: 'firewall-sg',
},
{
xtype: 'pveFirewallAliases',
title: gettext('Alias'),
groups: ['firewall'],
iconCls: 'fa fa-external-link',
base_url: '/cluster/firewall/aliases',
itemId: 'firewall-aliases',
},
{
xtype: 'pveIPSet',
title: 'IPSet',
groups: ['firewall'],
iconCls: 'fa fa-list-ol',
base_url: '/cluster/firewall/ipset',
list_refs_url: '/cluster/firewall/refs',
itemId: 'firewall-ipset',
},
{
xtype: 'pveMetricServerView',
title: gettext('Metric Server'),
iconCls: 'fa fa-bar-chart',
itemId: 'metricservers',
onlineHelp: 'external_metric_server',
});
}
if (caps.mapping['Mapping.Audit'] ||
caps.mapping['Mapping.Use'] ||
caps.mapping['Mapping.Modify']) {
me.items.push(
{
xtype: 'container',
onlineHelp: 'resource_mapping',
title: gettext('Resource Mappings'),
itemId: 'resources',
iconCls: 'fa fa-folder-o',
layout: {
type: 'vbox',
align: 'stretch',
multi: true,
},
scrollable: true,
defaults: {
border: false,
},
items: [
{
xtype: 'pveDcPCIMapView',
title: gettext('PCI Devices'),
flex: 1,
},
{
xtype: 'splitter',
collapsible: false,
performCollapse: false,
},
{
xtype: 'pveDcUSBMapView',
title: gettext('USB Devices'),
flex: 1,
},
],
},
);
}
if (caps.mapping['Mapping.Audit'] ||
caps.mapping['Mapping.Use'] ||
caps.mapping['Mapping.Modify']) {
me.items.push(
{
xtype: 'pmxNotificationConfigView',
title: gettext('Notifications'),
itemId: 'notification-targets',
iconCls: 'fa fa-bell-o',
baseUrl: '/cluster/notifications',
},
);
}
if (caps.dc['Sys.Audit']) {
me.items.push({
xtype: 'pveDcSupport',
title: gettext('Support'),
itemId: 'support',
iconCls: 'fa fa-comments-o',
});
}
me.callParent();
},
});
Ext.define('PVE.form.CorosyncLinkEditorController', {
extend: 'Ext.app.ViewController',
alias: 'controller.pveCorosyncLinkEditorController',
addLinkIfEmpty: function() {
let view = this.getView();
if (view.items || view.items.length === 0) {
this.addLink();
}
},
addEmptyLink: function() {
this.addLink(); // discard parameters to allow being called from 'handler'
},
addLink: function(link) {
let me = this;
let view = me.getView();
let vm = view.getViewModel();
let linkCount = vm.get('linkCount');
if (linkCount >= vm.get('maxLinkCount')) {
return;
}
link = link || {};
if (link.number === undefined) {
link.number = me.getNextFreeNumber();
}
if (link.value === undefined) {
link.value = me.getNextFreeNetwork();
}
let linkSelector = Ext.create('PVE.form.CorosyncLinkSelector', {
maxLinkNumber: vm.get('maxLinkCount') - 1,
allowNumberEdit: vm.get('allowNumberEdit'),
allowBlankNetwork: link.allowBlank,
initNumber: link.number,
initNetwork: link.value,
text: link.text,
emptyText: link.emptyText,
// needs to be set here, because we need to update the viewmodel
removeBtnHandler: function() {
let curLinkCount = vm.get('linkCount');
if (curLinkCount <= 1) {
return;
}
vm.set('linkCount', curLinkCount - 1);
// 'this' is the linkSelector here
view.remove(this);
me.updateDeleteButtonState();
},
});
view.add(linkSelector);
linkCount++;
vm.set('linkCount', linkCount);
me.updateDeleteButtonState();
},
// ExtJS trips on binding this for some reason, so do it manually
updateDeleteButtonState: function() {
let view = this.getView();
let vm = view.getViewModel();
let disabled = vm.get('linkCount') <= 1;
let deleteButtons = view.query('button[cls=removeLinkBtn]');
Ext.Array.each(deleteButtons, btn => {
btn.setDisabled(disabled);
});
},
getNextFreeNetwork: function() {
let view = this.getView();
let vm = view.getViewModel();
let networksInUse = view.query('proxmoxNetworkSelector').map(selector => selector.value);
for (const network of vm.get('networks')) {
if (!networksInUse.includes(network)) {
return network;
}
}
return undefined; // default to empty field, user has to set up link manually
},
getNextFreeNumber: function() {
let view = this.getView();
let vm = view.getViewModel();
let numbersInUse = view.query('numberfield').map(field => field.value);
for (let i = 0; i < vm.get('maxLinkCount'); i++) {
if (!numbersInUse.includes(i)) {
return i;
}
}
// all numbers in use, this should never happen since add button is disabled automatically
return 0;
},
});
Ext.define('PVE.form.CorosyncLinkSelector', {
extend: 'Ext.panel.Panel',
xtype: 'pveCorosyncLinkSelector',
mixins: ['Proxmox.Mixin.CBind'],
cbindData: [],
// config
maxLinkNumber: 7,
allowNumberEdit: true,
allowBlankNetwork: false,
removeBtnHandler: undefined,
emptyText: '',
// values
initNumber: 0,
initNetwork: '',
text: '',
layout: 'hbox',
bodyPadding: 5,
border: 0,
items: [
{
xtype: 'displayfield',
fieldLabel: 'Link',
cbind: {
hidden: '{allowNumberEdit}',
value: '{initNumber}',
},
width: 45,
labelWidth: 30,
allowBlank: false,
},
{
xtype: 'numberfield',
fieldLabel: 'Link',
cbind: {
maxValue: '{maxLinkNumber}',
hidden: '{!allowNumberEdit}',
value: '{initNumber}',
},
width: 80,
labelWidth: 30,
minValue: 0,
submitValue: false, // see getSubmitValue of network selector
allowBlank: false,
},
{
xtype: 'proxmoxNetworkSelector',
cbind: {
allowBlank: '{allowBlankNetwork}',
value: '{initNetwork}',
emptyText: '{emptyText}',
},
autoSelect: false,
valueField: 'address',
displayField: 'address',
width: 220,
margin: '0 5px 0 5px',
getSubmitValue: function() {
let me = this;
// link number is encoded into key, so we need to set field name before value retrieval
let linkNumber = me.prev('numberfield').getValue(); // always the correct one
me.name = 'link' + linkNumber;
return me.getValue();
},
},
{
xtype: 'button',
iconCls: 'fa fa-trash-o',
cls: 'removeLinkBtn',
cbind: {
hidden: '{!allowNumberEdit}',
},
handler: function() {
let me = this;
let parent = me.up('pveCorosyncLinkSelector');
if (parent.removeBtnHandler !== undefined) {
parent.removeBtnHandler();
}
},
},
{
xtype: 'label',
margin: '-1px 0 0 5px',
// for muted effect
cls: 'x-form-item-label-default',
cbind: {
text: '{text}',
},
},
],
initComponent: function() {
let me = this;
me.callParent();
let numSelect = me.down('numberfield');
let netSelect = me.down('proxmoxNetworkSelector');
numSelect.validator = me.createNoDuplicatesValidator(
'numberfield',
gettext("Duplicate link number not allowed."),
);
netSelect.validator = me.createNoDuplicatesValidator(
'proxmoxNetworkSelector',
gettext("Duplicate link address not allowed."),
);
},
createNoDuplicatesValidator: function(queryString, errorMsg) { // linkSelector generator
let view = this; // eslint-disable-line consistent-this
/** @this is the field itself, as the validator this is called from scopes it that way */
return function(val) {
let me = this;
let form = view.up('form');
let linkEditor = view.up('pveCorosyncLinkEditor');
if (!form.validating) {
// avoid recursion/double validation by setting temporary states
me.validating = true;
form.validating = true;
// validate all other fields as well, to always mark both
// parties involved in a 'duplicate' error
form.isValid();
form.validating = false;
me.validating = false;
} else if (me.validating) {
// we'll be validated by the original call in the other if-branch, avoid double work
return true;
}
if (val === undefined || (val instanceof String && val.length === 0)) {
return true; // let this be caught by allowBlank, if at all
}
let allFields = linkEditor.query(queryString);
for (const field of allFields) {
if (field !== me && String(field.getValue()) === String(val)) {
return errorMsg;
}
}
return true;
};
},
});
Ext.define('PVE.form.CorosyncLinkEditor', {
extend: 'Ext.panel.Panel',
xtype: 'pveCorosyncLinkEditor',
controller: 'pveCorosyncLinkEditorController',
// only initial config, use setter otherwise
allowNumberEdit: true,
viewModel: {
data: {
linkCount: 0,
maxLinkCount: 8,
networks: null,
allowNumberEdit: true,
infoText: '',
},
formulas: {
addDisabled: function(get) {
return !get('allowNumberEdit') ||
get('linkCount') >= get('maxLinkCount');
},
dockHidden: function(get) {
return !(get('allowNumberEdit') || get('infoText'));
},
},
},
dockedItems: [{
xtype: 'toolbar',
dock: 'bottom',
defaultButtonUI: 'default',
border: false,
padding: '6 0 6 0',
bind: {
hidden: '{dockHidden}',
},
items: [
{
xtype: 'button',
text: gettext('Add'),
bind: {
disabled: '{addDisabled}',
hidden: '{!allowNumberEdit}',
},
handler: 'addEmptyLink',
},
{
xtype: 'label',
bind: {
text: '{infoText}',
},
},
],
}],
setInfoText: function(text) {
let me = this;
let vm = me.getViewModel();
vm.set('infoText', text || '');
},
setLinks: function(links) {
let me = this;
let controller = me.getController();
let vm = me.getViewModel();
me.removeAll();
vm.set('linkCount', 0);
Ext.Array.each(links, link => controller.addLink(link));
},
setDefaultLinks: function() {
let me = this;
let controller = me.getController();
let vm = me.getViewModel();
me.removeAll();
vm.set('linkCount', 0);
controller.addLink();
},
// clears all links
setAllowNumberEdit: function(allow) {
let me = this;
let vm = me.getViewModel();
vm.set('allowNumberEdit', allow);
me.removeAll();
vm.set('linkCount', 0);
},
items: [{
// No links is never a valid scenario, but can occur during a slow load
xtype: 'hiddenfield',
submitValue: false,
isValid: function() {
let me = this;
let vm = me.up('pveCorosyncLinkEditor').getViewModel();
return vm.get('linkCount') > 0;
},
}],
initComponent: function() {
let me = this;
let vm = me.getViewModel();
let controller = me.getController();
vm.set('allowNumberEdit', me.allowNumberEdit);
vm.set('infoText', me.infoText || '');
me.callParent();
// Request local node networks to pre-populate first link.
Proxmox.Utils.API2Request({
url: '/nodes/localhost/network',
method: 'GET',
waitMsgTarget: me,
success: response => {
let data = response.result.data;
if (data.length > 0) {
data.sort((a, b) => a.iface.localeCompare(b.iface));
let addresses = [];
for (let net of data) {
if (net.address) {
addresses.push(net.address);
}
if (net.address6) {
addresses.push(net.address6);
}
}
vm.set('networks', addresses);
}
// Always have at least one link, but account for delay in API,
// someone might have called 'setLinks' in the meantime -
// except if 'allowNumberEdit' is false, in which case we're
// probably waiting for the user to input the join info
if (vm.get('allowNumberEdit')) {
controller.addLinkIfEmpty();
}
},
failure: () => {
if (vm.get('allowNumberEdit')) {
controller.addLinkIfEmpty();
}
},
});
},
});
Ext.define('PVE.dc.GroupEdit', {
extend: 'Proxmox.window.Edit',
alias: ['widget.pveDcGroupEdit'],
initComponent: function() {
var me = this;
me.isCreate = !me.groupid;
var url;
var method;
if (me.isCreate) {
url = '/api2/extjs/access/groups';
method = 'POST';
} else {
url = '/api2/extjs/access/groups/' + me.groupid;
method = 'PUT';
}
Ext.applyIf(me, {
subject: gettext('Group'),
url: url,
method: method,
items: [
{
xtype: me.isCreate ? 'proxmoxtextfield' : 'displayfield',
fieldLabel: gettext('Name'),
name: 'groupid',
value: me.groupid,
allowBlank: false,
},
{
xtype: 'textfield',
fieldLabel: gettext('Comment'),
name: 'comment',
allowBlank: true,
},
],
});
me.callParent();
if (!me.isCreate) {
me.load();
}
},
});
Ext.define('PVE.dc.GroupView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveGroupView'],
onlineHelp: 'pveum_groups',
stateful: true,
stateId: 'grid-groups',
initComponent: function() {
var me = this;
var store = new Ext.data.Store({
model: 'pve-groups',
sorters: {
property: 'groupid',
direction: 'ASC',
},
});
var reload = function() {
store.load();
};
var sm = Ext.create('Ext.selection.RowModel', {});
var remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
selModel: sm,
callback: function() {
reload();
},
baseurl: '/access/groups/',
});
var run_editor = function() {
var rec = sm.getSelection()[0];
if (!rec) {
return;
}
var win = Ext.create('PVE.dc.GroupEdit', {
groupid: rec.data.groupid,
});
win.on('destroy', reload);
win.show();
};
var edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
selModel: sm,
handler: run_editor,
});
var tbar = [
{
text: gettext('Create'),
handler: function() {
var win = Ext.create('PVE.dc.GroupEdit', {});
win.on('destroy', reload);
win.show();
},
},
edit_btn, remove_btn,
];
Proxmox.Utils.monStoreErrors(me, store);
Ext.apply(me, {
store: store,
selModel: sm,
tbar: tbar,
viewConfig: {
trackOver: false,
},
columns: [
{
header: gettext('Name'),
width: 200,
sortable: true,
dataIndex: 'groupid',
},
{
header: gettext('Comment'),
sortable: false,
renderer: Ext.String.htmlEncode,
dataIndex: 'comment',
flex: 1,
},
{
header: gettext('Users'),
sortable: false,
dataIndex: 'users',
renderer: Ext.String.htmlEncode,
flex: 1,
},
],
listeners: {
activate: reload,
itemdblclick: run_editor,
},
});
me.callParent();
},
});
Ext.define('PVE.dc.Guests', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveDcGuests',
title: gettext('Guests'),
height: 250,
layout: {
type: 'table',
columns: 2,
tableAttrs: {
style: {
width: '100%',
},
},
},
bodyPadding: '0 20 20 20',
defaults: {
xtype: 'box',
padding: '0 50 0 50',
style: {
'text-align': 'center',
'line-height': '1.5em',
'font-size': '14px',
},
},
items: [
{
itemId: 'qemu',
data: {
running: 0,
paused: 0,
stopped: 0,
template: 0,
},
cls: 'centered-flex-column',
tpl: [
'<h3>' + gettext("Virtual Machines") + '</h3>',
'<div>',
'<div class="left-aligned">',
'<i class="good fa fa-fw fa-play-circle">&nbsp;</i>',
gettext('Running'),
'</div>',
'<div class="right-aligned">{running}</div>',
'</div>',
'<tpl if="paused &gt; 0">',
'<div>',
'<div class="left-aligned">',
'<i class="warning fa fa-fw fa-pause-circle">&nbsp;</i>',
gettext('Paused'),
'</div>',
'<div class="right-aligned">{paused}</div>',
'</div>',
'</tpl>',
'<div>',
'<div class="left-aligned">',
'<i class="faded fa fa-fw fa-stop-circle">&nbsp;</i>',
gettext('Stopped'),
'</div>',
'<div class="right-aligned">{stopped}</div>',
'</div>',
'<tpl if="template &gt; 0">',
'<div>',
'<div class="left-aligned">',
'<i class="fa fa-fw fa-circle-o">&nbsp;</i>',
gettext('Templates'),
'</div>',
'<div class="right-aligned">{template}</div>',
'</div>',
'</tpl>',
],
},
{
itemId: 'lxc',
data: {
running: 0,
paused: 0,
stopped: 0,
template: 0,
},
cls: 'centered-flex-column',
tpl: [
'<h3>' + gettext("LXC Container") + '</h3>',
'<div>',
'<div class="left-aligned">',
'<i class="good fa fa-fw fa-play-circle">&nbsp;</i>',
gettext('Running'),
'</div>',
'<div class="right-aligned">{running}</div>',
'</div>',
'<tpl if="paused &gt; 0">',
'<div>',
'<div class="left-aligned">',
'<i class="warning fa fa-fw fa-pause-circle">&nbsp;</i>',
gettext('Paused'),
'</div>',
'<div class="right-aligned">{paused}</div>',
'</div>',
'</tpl>',
'<div>',
'<div class="left-aligned">',
'<i class="faded fa fa-fw fa-stop-circle">&nbsp;</i>',
gettext('Stopped'),
'</div>',
'<div class="right-aligned">{stopped}</div>',
'</div>',
'<tpl if="template &gt; 0">',
'<div>',
'<div class="left-aligned">',
'<i class="fa fa-fw fa-circle-o">&nbsp;</i>',
gettext('Templates'),
'</div>',
'<div class="right-aligned">{template}</div>',
'</div>',
'</tpl>',
],
},
{
itemId: 'error',
colspan: 2,
data: {
num: 0,
},
columnWidth: 1,
padding: '10 250 0 250',
tpl: [
'<tpl if="num &gt; 0">',
'<div class="left-aligned">',
'<i class="critical fa fa-fw fa-times-circle">&nbsp;</i>',
gettext('Error'),
'</div>',
'<div class="right-aligned">{num}</div>',
'</tpl>',
],
},
],
updateValues: function(qemu, lxc, error) {
let me = this;
let lazyUpdate = (query, newData) => {
let el = me.getComponent(query);
let currentData = el.data;
let keys = Object.keys(newData);
if (keys.length === Object.keys(currentData).length) {
if (keys.every(k => newData[k] === currentData[k])) {
return; // all stayed the same here, return early to avoid bogus regeneration
}
}
el.update(newData);
};
lazyUpdate('qemu', qemu);
lazyUpdate('lxc', lxc);
lazyUpdate('error', { num: error });
},
});
Ext.define('PVE.dc.Health', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveDcHealth',
title: gettext('Health'),
bodyPadding: 10,
height: 250,
layout: {
type: 'hbox',
align: 'stretch',
},
defaults: {
flex: 1,
xtype: 'box',
style: {
'text-align': 'center',
},
},
nodeList: [],
nodeIndex: 0,
updateStatus: function(store, records, success) {
let me = this;
if (!success) {
return;
}
let cluster = {
iconCls: PVE.Utils.get_health_icon('good', true),
text: gettext("Standalone node - no cluster defined"),
};
let nodes = {
online: 0,
offline: 0,
};
let numNodes = 1; // by default we have one node
for (const { data } of records) {
if (data.type === 'node') {
nodes[data.online === 1 ? 'online':'offline']++;
} else if (data.type === 'cluster') {
cluster.text = `${gettext("Cluster")}: ${data.name}, ${gettext("Quorate")}: `;
cluster.text += Proxmox.Utils.format_boolean(data.quorate);
if (data.quorate !== 1) {
cluster.iconCls = PVE.Utils.get_health_icon('critical', true);
}
numNodes = data.nodes;
}
}
if (numNodes !== nodes.online + nodes.offline) {
nodes.offline = numNodes - nodes.online;
}
me.getComponent('clusterstatus').updateHealth(cluster);
me.getComponent('nodestatus').update(nodes);
},
updateCeph: function(store, records, success) {
let me = this;
let cephstatus = me.getComponent('ceph');
if (!success || records.length < 1) {
if (cephstatus.isVisible()) {
return; // if ceph status is already visible don't stop to update
}
// try all nodes until we either get a successful api call, or we tried all nodes
if (++me.nodeIndex >= me.nodeList.length) {
me.cephstore.stopUpdate();
} else {
store.getProxy().setUrl(`/api2/json/nodes/${me.nodeList[me.nodeIndex].node}/ceph/status`);
}
return;
}
let state = PVE.Utils.render_ceph_health(records[0].data.health || {});
cephstatus.updateHealth(state);
cephstatus.setVisible(true);
},
listeners: {
destroy: function() {
let me = this;
me.cephstore.stopUpdate();
},
},
items: [
{
itemId: 'clusterstatus',
xtype: 'pveHealthWidget',
title: gettext('Status'),
},
{
itemId: 'nodestatus',
data: {
online: 0,
offline: 0,
},
tpl: [
'<h3>' + gettext('Nodes') + '</h3><br />',
'<div style="width: 150px;margin: auto;font-size: 12pt">',
'<div class="left-aligned">',
'<i class="good fa fa-fw fa-check">&nbsp;</i>',
gettext('Online'),
'</div>',
'<div class="right-aligned">{online}</div>',
'<br /><br />',
'<div class="left-aligned">',
'<i class="critical fa fa-fw fa-times">&nbsp;</i>',
gettext('Offline'),
'</div>',
'<div class="right-aligned">{offline}</div>',
'</div>',
],
},
{
itemId: 'ceph',
width: 250,
columnWidth: undefined,
userCls: 'pointer',
title: 'Ceph',
xtype: 'pveHealthWidget',
hidden: true,
listeners: {
element: 'el',
click: function() {
Ext.state.Manager.getProvider().set('dctab', { value: 'ceph' }, true);
},
},
},
],
initComponent: function() {
let me = this;
me.nodeList = PVE.data.ResourceStore.getNodes();
me.nodeIndex = 0;
me.cephstore = Ext.create('Proxmox.data.UpdateStore', {
interval: 3000,
storeid: 'pve-cluster-ceph',
proxy: {
type: 'proxmox',
url: `/api2/json/nodes/${me.nodeList[me.nodeIndex].node}/ceph/status`,
},
});
me.callParent();
me.mon(me.cephstore, 'load', me.updateCeph, me);
me.cephstore.startUpdate();
},
});
/* This class defines the "Cluster log" tab of the bottom status panel
* A log entry is a timestamp associated with an action on a cluster
*/
Ext.define('PVE.dc.Log', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveClusterLog'],
initComponent: function() {
let me = this;
let logstore = Ext.create('Proxmox.data.UpdateStore', {
storeid: 'pve-cluster-log',
model: 'proxmox-cluster-log',
proxy: {
type: 'proxmox',
url: '/api2/json/cluster/log',
},
});
let store = Ext.create('Proxmox.data.DiffStore', {
rstore: logstore,
appendAtStart: true,
});
Ext.apply(me, {
store: store,
stateful: false,
viewConfig: {
trackOver: false,
stripeRows: true,
getRowClass: function(record, index) {
let pri = record.get('pri');
if (pri && pri <= 3) {
return "proxmox-invalid-row";
}
return undefined;
},
},
sortableColumns: false,
columns: [
{
header: gettext("Time"),
dataIndex: 'time',
width: 150,
renderer: function(value) {
return Ext.Date.format(value, "M d H:i:s");
},
},
{
header: gettext("Node"),
dataIndex: 'node',
width: 150,
},
{
header: gettext("Service"),
dataIndex: 'tag',
width: 100,
},
{
header: "PID",
dataIndex: 'pid',
width: 100,
},
{
header: gettext("User name"),
dataIndex: 'user',
renderer: Ext.String.htmlEncode,
width: 150,
},
{
header: gettext("Severity"),
dataIndex: 'pri',
renderer: PVE.Utils.render_serverity,
width: 100,
},
{
header: gettext("Message"),
dataIndex: 'msg',
renderer: Ext.String.htmlEncode,
flex: 1,
},
],
listeners: {
activate: () => logstore.startUpdate(),
deactivate: () => logstore.stopUpdate(),
destroy: () => logstore.stopUpdate(),
},
});
me.callParent();
},
});
Ext.define('PVE.dc.NodeView', {
extend: 'Ext.grid.GridPanel',
alias: 'widget.pveDcNodeView',
title: gettext('Nodes'),
disableSelection: true,
scrollable: true,
columns: [
{
header: gettext('Name'),
flex: 1,
sortable: true,
dataIndex: 'name',
},
{
header: 'ID',
width: 40,
sortable: true,
dataIndex: 'nodeid',
},
{
header: gettext('Online'),
width: 60,
sortable: true,
dataIndex: 'online',
renderer: function(value) {
var cls = value?'good':'critical';
return '<i class="fa ' + PVE.Utils.get_health_icon(cls) + '"><i/>';
},
},
{
header: gettext('Support'),
width: 100,
sortable: true,
dataIndex: 'level',
renderer: PVE.Utils.render_support_level,
},
{
header: gettext('Server Address'),
width: 115,
sortable: true,
dataIndex: 'ip',
},
{
header: gettext('CPU usage'),
sortable: true,
width: 110,
dataIndex: 'cpuusage',
tdCls: 'x-progressbar-default-cell',
xtype: 'widgetcolumn',
widget: {
xtype: 'pveProgressBar',
},
},
{
header: gettext('Memory usage'),
width: 110,
sortable: true,
tdCls: 'x-progressbar-default-cell',
dataIndex: 'memoryusage',
xtype: 'widgetcolumn',
widget: {
xtype: 'pveProgressBar',
},
},
{
header: gettext('Uptime'),
sortable: true,
dataIndex: 'uptime',
align: 'right',
renderer: Proxmox.Utils.render_uptime,
},
],
stateful: true,
stateId: 'grid-cluster-nodes',
tools: [
{
type: 'up',
handler: function() {
let view = this.up('grid');
view.setHeight(Math.max(view.getHeight() - 50, 250));
},
},
{
type: 'down',
handler: function() {
let view = this.up('grid');
view.setHeight(view.getHeight() + 50);
},
},
],
}, function() {
Ext.define('pve-dc-nodes', {
extend: 'Ext.data.Model',
fields: ['id', 'type', 'name', 'nodeid', 'ip', 'level', 'local', 'online'],
idProperty: 'id',
});
});
Ext.define('PVE.widget.ProgressBar', {
extend: 'Ext.Progress',
alias: 'widget.pveProgressBar',
animate: true,
textTpl: [
'{percent}%',
],
setValue: function(value) {
let me = this;
me.callParent([value]);
me.removeCls(['warning', 'critical']);
if (value > 0.89) {
me.addCls('critical');
} else if (value > 0.75) {
me.addCls('warning');
}
},
});
Ext.define('PVE.dc.OptionView', {
extend: 'Proxmox.grid.ObjectGrid',
alias: ['widget.pveDcOptionView'],
onlineHelp: 'datacenter_configuration_file',
monStoreErrors: true,
userCls: 'proxmox-tags-full',
add_inputpanel_row: function(name, text, opts) {
var me = this;
opts = opts || {};
me.rows = me.rows || {};
let canEdit = !Object.prototype.hasOwnProperty.call(opts, 'caps') || opts.caps;
me.rows[name] = {
required: true,
defaultValue: opts.defaultValue,
header: text,
renderer: opts.renderer,
editor: canEdit ? {
xtype: 'proxmoxWindowEdit',
width: opts.width || 350,
subject: text,
onlineHelp: opts.onlineHelp,
fieldDefaults: {
labelWidth: opts.labelWidth || 100,
},
setValues: function(values) {
var edit_value = values[name];
if (opts.parseBeforeSet) {
edit_value = PVE.Parser.parsePropertyString(edit_value);
}
Ext.Array.each(this.query('inputpanel'), function(panel) {
panel.setValues(edit_value);
});
},
url: opts.url,
items: [{
xtype: 'inputpanel',
onGetValues: function(values) {
if (values === undefined || Object.keys(values).length === 0) {
return { 'delete': name };
}
var ret_val = {};
ret_val[name] = PVE.Parser.printPropertyString(values);
return ret_val;
},
items: opts.items,
}],
} : undefined,
};
},
render_bwlimits: function(value) {
if (!value) {
return gettext("None");
}
let parsed = PVE.Parser.parsePropertyString(value);
return Object.entries(parsed)
.map(([k, v]) => k + ": " + Proxmox.Utils.format_size(v * 1024) + "/s")
.join(',');
},
initComponent: function() {
var me = this;
me.add_combobox_row('keyboard', gettext('Keyboard Layout'), {
renderer: PVE.Utils.render_kvm_language,
comboItems: Object.entries(PVE.Utils.kvm_keymaps),
defaultValue: '__default__',
deleteEmpty: true,
});
me.add_text_row('http_proxy', gettext('HTTP proxy'), {
defaultValue: Proxmox.Utils.noneText,
vtype: 'HttpProxy',
deleteEmpty: true,
});
me.add_combobox_row('console', gettext('Console Viewer'), {
renderer: PVE.Utils.render_console_viewer,
comboItems: Object.entries(PVE.Utils.console_map),
defaultValue: '__default__',
deleteEmpty: true,
});
me.add_text_row('email_from', gettext('Email from address'), {
deleteEmpty: true,
vtype: 'proxmoxMail',
defaultValue: 'root@$hostname',
});
me.add_text_row('mac_prefix', gettext('MAC address prefix'), {
deleteEmpty: true,
vtype: 'MacPrefix',
defaultValue: 'BC:24:11',
});
me.add_inputpanel_row('migration', gettext('Migration Settings'), {
renderer: PVE.Utils.render_as_property_string,
labelWidth: 120,
url: "/api2/extjs/cluster/options",
defaultKey: 'type',
items: [{
xtype: 'displayfield',
name: 'type',
fieldLabel: gettext('Type'),
value: 'secure',
submitValue: true,
}, {
xtype: 'proxmoxNetworkSelector',
name: 'network',
fieldLabel: gettext('Network'),
value: null,
emptyText: Proxmox.Utils.defaultText,
autoSelect: false,
skipEmptyText: true,
editable: true,
notFoundIsValid: true,
vtype: 'IP64CIDRAddress',
}],
});
me.add_inputpanel_row('ha', gettext('HA Settings'), {
renderer: PVE.Utils.render_dc_ha_opts,
labelWidth: 120,
url: "/api2/extjs/cluster/options",
onlineHelp: 'ha_manager_shutdown_policy',
items: [{
xtype: 'proxmoxKVComboBox',
name: 'shutdown_policy',
fieldLabel: gettext('Shutdown Policy'),
deleteEmpty: false,
value: '__default__',
comboItems: [
['__default__', Proxmox.Utils.defaultText + ' (conditional)'],
['freeze', 'freeze'],
['failover', 'failover'],
['migrate', 'migrate'],
['conditional', 'conditional'],
],
defaultValue: '__default__',
}],
});
me.add_inputpanel_row('crs', gettext('Cluster Resource Scheduling'), {
renderer: PVE.Utils.render_as_property_string,
width: 450,
labelWidth: 120,
url: "/api2/extjs/cluster/options",
onlineHelp: 'ha_manager_crs',
items: [{
xtype: 'proxmoxKVComboBox',
name: 'ha',
fieldLabel: gettext('HA Scheduling'),
deleteEmpty: false,
value: '__default__',
comboItems: [
['__default__', Proxmox.Utils.defaultText + ' (basic)'],
['basic', 'Basic (Resource Count)'],
['static', 'Static Load'],
],
defaultValue: '__default__',
}, {
xtype: 'proxmoxcheckbox',
name: 'ha-rebalance-on-start',
fieldLabel: gettext('Rebalance on Start'),
boxLabel: gettext('Use CRS to select the least loaded node when starting an HA service'),
value: 0,
}],
});
me.add_inputpanel_row('u2f', gettext('U2F Settings'), {
renderer: v => !v ? Proxmox.Utils.NoneText : PVE.Parser.printPropertyString(v),
width: 450,
url: "/api2/extjs/cluster/options",
onlineHelp: 'pveum_configure_u2f',
items: [{
xtype: 'textfield',
name: 'appid',
fieldLabel: gettext('U2F AppID URL'),
emptyText: gettext('Defaults to origin'),
value: '',
deleteEmpty: true,
skipEmptyText: true,
submitEmptyText: false,
}, {
xtype: 'textfield',
name: 'origin',
fieldLabel: gettext('U2F Origin'),
emptyText: gettext('Defaults to requesting host URI'),
value: '',
deleteEmpty: true,
skipEmptyText: true,
submitEmptyText: false,
},
{
xtype: 'box',
height: 25,
html: `<span class='pmx-hint'>${gettext('Note:')}</span> `
+ Ext.String.format(gettext('{0} is deprecated, use {1}'), 'U2F', 'WebAuthn'),
},
{
xtype: 'displayfield',
userCls: 'pmx-hint',
value: gettext('NOTE: Changing an AppID breaks existing U2F registrations!'),
}],
});
me.add_inputpanel_row('webauthn', gettext('WebAuthn Settings'), {
renderer: v => !v ? Proxmox.Utils.NoneText : PVE.Parser.printPropertyString(v),
width: 450,
url: "/api2/extjs/cluster/options",
onlineHelp: 'pveum_configure_webauthn',
items: [{
xtype: 'textfield',
fieldLabel: gettext('Name'),
name: 'rp', // NOTE: relying party consists of name and id, this is the name
allowBlank: false,
},
{
xtype: 'textfield',
fieldLabel: gettext('Origin'),
emptyText: Ext.String.format(gettext("Domain Lockdown (e.g., {0})"), document.location.origin),
name: 'origin',
allowBlank: true,
},
{
xtype: 'textfield',
fieldLabel: 'ID',
name: 'id',
allowBlank: false,
listeners: {
dirtychange: (f, isDirty) =>
f.up('panel').down('box[id=idChangeWarning]').setHidden(!f.originalValue || !isDirty),
},
},
{
xtype: 'container',
layout: 'hbox',
items: [
{
xtype: 'box',
flex: 1,
},
{
xtype: 'button',
text: gettext('Auto-fill'),
iconCls: 'fa fa-fw fa-pencil-square-o',
handler: function(button, ev) {
let panel = this.up('panel');
let fqdn = document.location.hostname;
panel.down('field[name=rp]').setValue(fqdn);
let idField = panel.down('field[name=id]');
let currentID = idField.getValue();
if (!currentID || currentID.length === 0) {
idField.setValue(fqdn);
}
},
},
],
},
{
xtype: 'box',
height: 25,
html: `<span class='pmx-hint'>${gettext('Note:')}</span> `
+ gettext('WebAuthn requires using a trusted certificate.'),
},
{
xtype: 'box',
id: 'idChangeWarning',
hidden: true,
padding: '5 0 0 0',
html: '<i class="fa fa-exclamation-triangle warning"></i> '
+ gettext('Changing the ID breaks existing WebAuthn TFA entries.'),
}],
});
me.add_inputpanel_row('bwlimit', gettext('Bandwidth Limits'), {
renderer: me.render_bwlimits,
width: 450,
url: "/api2/extjs/cluster/options",
parseBeforeSet: true,
labelWidth: 120,
items: [{
xtype: 'pveBandwidthField',
name: 'default',
fieldLabel: gettext('Default'),
emptyText: gettext('none'),
backendUnit: "KiB",
},
{
xtype: 'pveBandwidthField',
name: 'restore',
fieldLabel: gettext('Backup Restore'),
emptyText: gettext('default'),
backendUnit: "KiB",
},
{
xtype: 'pveBandwidthField',
name: 'migration',
fieldLabel: gettext('Migration'),
emptyText: gettext('default'),
backendUnit: "KiB",
},
{
xtype: 'pveBandwidthField',
name: 'clone',
fieldLabel: gettext('Clone'),
emptyText: gettext('default'),
backendUnit: "KiB",
},
{
xtype: 'pveBandwidthField',
name: 'move',
fieldLabel: gettext('Disk Move'),
emptyText: gettext('default'),
backendUnit: "KiB",
}],
});
me.add_integer_row('max_workers', gettext('Maximal Workers/bulk-action'), {
deleteEmpty: true,
defaultValue: 4,
minValue: 1,
maxValue: 64, // arbitrary but generous limit as limits are good
});
me.add_inputpanel_row('next-id', gettext('Next Free VMID Range'), {
renderer: PVE.Utils.render_as_property_string,
url: "/api2/extjs/cluster/options",
items: [{
xtype: 'proxmoxintegerfield',
name: 'lower',
fieldLabel: gettext('Lower'),
emptyText: '100',
minValue: 100,
maxValue: 1000 * 1000 * 1000 - 1,
submitValue: true,
}, {
xtype: 'proxmoxintegerfield',
name: 'upper',
fieldLabel: gettext('Upper'),
emptyText: '1.000.000',
minValue: 100,
maxValue: 1000 * 1000 * 1000 - 1,
submitValue: true,
}],
});
me.rows['tag-style'] = {
required: true,
renderer: (value) => {
if (value === undefined) {
return gettext('No Overrides');
}
let colors = PVE.UIOptions.parseTagOverrides(value?.['color-map']);
let shape = value.shape;
let shapeText = PVE.UIOptions.tagTreeStyles[shape ?? '__default__'];
let txt = Ext.String.format(gettext("Tree Shape: {0}"), shapeText);
let orderText = PVE.UIOptions.tagOrderOptions[value.ordering ?? '__default__'];
txt += `, ${Ext.String.format(gettext("Ordering: {0}"), orderText)}`;
if (value['case-sensitive']) {
txt += `, ${gettext('Case-Sensitive')}`;
}
if (Object.keys(colors).length > 0) {
txt += `, ${gettext('Color Overrides')}: `;
for (const tag of Object.keys(colors)) {
txt += Proxmox.Utils.getTagElement(tag, colors);
}
}
return txt;
},
header: gettext('Tag Style Override'),
editor: {
xtype: 'proxmoxWindowEdit',
width: 800,
subject: gettext('Tag Color Override'),
onlineHelp: 'datacenter_configuration_file',
fieldDefaults: {
labelWidth: 100,
},
url: '/api2/extjs/cluster/options',
items: [
{
xtype: 'inputpanel',
setValues: function(values) {
if (values === undefined) {
return undefined;
}
values = values?.['tag-style'] ?? {};
values.shape = values.shape || '__default__';
values.colors = values['color-map'];
return Proxmox.panel.InputPanel.prototype.setValues.call(this, values);
},
onGetValues: function(values) {
let style = {};
if (values.colors) {
style['color-map'] = values.colors;
}
if (values.shape && values.shape !== '__default__') {
style.shape = values.shape;
}
if (values.ordering) {
style.ordering = values.ordering;
}
if (values['case-sensitive']) {
style['case-sensitive'] = 1;
}
let value = PVE.Parser.printPropertyString(style);
if (value === '') {
return {
'delete': 'tag-style',
};
}
return {
'tag-style': value,
};
},
items: [
{
name: 'shape',
xtype: 'proxmoxComboGrid',
fieldLabel: gettext('Tree Shape'),
valueField: 'value',
displayField: 'display',
allowBlank: false,
listConfig: {
columns: [
{
header: gettext('Option'),
dataIndex: 'display',
flex: 1,
},
{
header: gettext('Preview'),
dataIndex: 'value',
renderer: function(value) {
let cls = value ?? '__default__';
if (value === '__default__') {
cls = 'circle';
}
let tags = PVE.Utils.renderTags('preview');
return `<div class="proxmox-tags-${cls}">${tags}</div>`;
},
flex: 1,
},
],
},
store: {
data: Object.entries(PVE.UIOptions.tagTreeStyles).map(v => ({
value: v[0],
display: v[1],
})),
},
deleteDefault: true,
defaultValue: '__default__',
deleteEmpty: true,
},
{
name: 'ordering',
xtype: 'proxmoxKVComboBox',
fieldLabel: gettext('Ordering'),
comboItems: Object.entries(PVE.UIOptions.tagOrderOptions),
defaultValue: '__default__',
value: '__default__',
deleteEmpty: true,
},
{
name: 'case-sensitive',
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Case-Sensitive'),
boxLabel: gettext('Applies to new edits'),
value: 0,
},
{
xtype: 'displayfield',
fieldLabel: gettext('Color Overrides'),
},
{
name: 'colors',
xtype: 'pveTagColorGrid',
deleteEmpty: true,
height: 300,
},
],
},
],
},
};
me.rows['user-tag-access'] = {
required: true,
renderer: (value) => {
if (value === undefined) {
return Ext.String.format(gettext('Mode: {0}'), 'free');
}
let mode = value?.['user-allow'] ?? 'free';
let list = value?.['user-allow-list']?.join(',') ?? '';
let modeTxt = Ext.String.format(gettext('Mode: {0}'), mode);
let overrides = PVE.UIOptions.tagOverrides;
let tags = PVE.Utils.renderTags(list, overrides);
let listTxt = tags !== '' ? `, ${gettext('Pre-defined:')} ${tags}` : '';
return `${modeTxt}${listTxt}`;
},
header: gettext('User Tag Access'),
editor: {
xtype: 'pveUserTagAccessEdit',
},
};
me.rows['registered-tags'] = {
required: true,
renderer: (value) => {
if (value === undefined) {
return gettext('No Registered Tags');
}
let overrides = PVE.UIOptions.tagOverrides;
return PVE.Utils.renderTags(value.join(','), overrides);
},
header: gettext('Registered Tags'),
editor: {
xtype: 'pveRegisteredTagEdit',
},
};
me.selModel = Ext.create('Ext.selection.RowModel', {});
Ext.apply(me, {
tbar: [{
text: gettext('Edit'),
xtype: 'proxmoxButton',
disabled: true,
handler: function() { me.run_editor(); },
selModel: me.selModel,
}],
url: "/api2/json/cluster/options",
editorConfig: {
url: "/api2/extjs/cluster/options",
},
interval: 5000,
cwidth1: 200,
listeners: {
itemdblclick: me.run_editor,
},
});
me.callParent();
// set the new value for the default console
me.mon(me.rstore, 'load', function(store, records, success) {
if (!success) {
return;
}
var rec = store.getById('console');
PVE.UIOptions.options.console = rec.data.value;
if (rec.data.value === '__default__') {
delete PVE.UIOptions.options.console;
}
PVE.UIOptions.options['tag-style'] = store.getById('tag-style')?.data?.value;
PVE.UIOptions.updateTagSettings(PVE.UIOptions.options['tag-style']);
PVE.UIOptions.fireUIConfigChanged();
});
me.on('activate', me.rstore.startUpdate);
me.on('destroy', me.rstore.stopUpdate);
me.on('deactivate', me.rstore.stopUpdate);
},
});
Ext.define('pve-permissions', {
extend: 'Ext.data.TreeModel',
fields: [
'text', 'type',
{
type: 'boolean', name: 'propagate',
},
],
});
Ext.define('PVE.dc.PermissionGridPanel', {
extend: 'Ext.tree.Panel',
alias: 'widget.pveUserPermissionGrid',
onlineHelp: 'chapter_user_management',
scrollable: true,
layout: 'fit',
rootVisible: false,
animate: false,
sortableColumns: false,
columns: [
{
xtype: 'treecolumn',
header: gettext('Path') + '/' + gettext('Permission'),
dataIndex: 'text',
flex: 6,
},
{
header: gettext('Propagate'),
dataIndex: 'propagate',
flex: 1,
renderer: function(value) {
if (Ext.isDefined(value)) {
return Proxmox.Utils.format_boolean(value);
}
return '';
},
},
],
initComponent: function() {
let me = this;
Proxmox.Utils.API2Request({
url: '/access/permissions?userid=' + me.userid,
method: 'GET',
failure: function(response, opts) {
Proxmox.Utils.setErrorMask(me, response.htmlStatus);
me.load_task.delay(me.load_delay);
},
success: function(response, opts) {
Proxmox.Utils.setErrorMask(me, false);
let result = Ext.decode(response.responseText);
let data = result.data || {};
let root = {
name: '__root',
expanded: true,
children: [],
};
let idhash = {
'/': {
children: [],
text: '/',
type: 'path',
},
};
Ext.Object.each(data, function(path, perms) {
let path_item = {
text: path,
type: 'path',
children: [],
};
Ext.Object.each(perms, function(perm, propagate) {
let perm_item = {
text: perm,
type: 'perm',
propagate: propagate === 1,
iconCls: 'fa fa-fw fa-unlock',
leaf: true,
};
path_item.children.push(perm_item);
path_item.expandable = true;
});
idhash[path] = path_item;
});
Ext.Object.each(idhash, function(path, item) {
let parent_item = idhash['/'];
if (path === '/') {
parent_item = root;
item.expanded = true;
} else {
let split_path = path.split('/');
while (split_path.pop()) {
let parent_path = split_path.join('/');
if (idhash[parent_path]) {
parent_item = idhash[parent_path];
break;
}
}
}
parent_item.children.push(item);
});
me.setRootNode(root);
},
});
me.callParent();
me.store.sorters.add(new Ext.util.Sorter({
sorterFn: function(rec1, rec2) {
let v1 = rec1.data.text,
v2 = rec2.data.text;
if (rec1.data.type !== rec2.data.type) {
v2 = rec1.data.type;
v1 = rec2.data.type;
}
if (v1 > v2) {
return 1;
} else if (v1 < v2) {
return -1;
}
return 0;
},
}));
},
});
Ext.define('PVE.dc.PermissionView', {
extend: 'Ext.window.Window',
alias: 'widget.userShowPermissionWindow',
mixins: ['Proxmox.Mixin.CBind'],
scrollable: true,
width: 800,
height: 600,
layout: 'fit',
cbind: {
title: (get) => Ext.String.htmlEncode(get('userid')) +
` - ${gettext('Granted Permissions')}`,
},
items: [{
xtype: 'pveUserPermissionGrid',
cbind: {
userid: '{userid}',
},
}],
});
Ext.define('PVE.dc.PoolEdit', {
extend: 'Proxmox.window.Edit',
alias: ['widget.pveDcPoolEdit'],
mixins: ['Proxmox.Mixin.CBind'],
subject: gettext('Pool'),
cbindData: {
poolid: '',
isCreate: (cfg) => !cfg.poolid,
},
cbind: {
url: get => `/api2/extjs/pools/${!get('isCreate') ? '?poolid=' + get('poolid') : ''}`,
method: get => get('isCreate') ? 'POST' : 'PUT',
},
items: [
{
xtype: 'pmxDisplayEditField',
fieldLabel: gettext('Name'),
cbind: {
editable: '{isCreate}',
value: '{poolid}',
},
name: 'poolid',
allowBlank: false,
},
{
xtype: 'textfield',
fieldLabel: gettext('Comment'),
name: 'comment',
allowBlank: true,
},
],
initComponent: function() {
let me = this;
me.callParent();
if (me.poolid) {
me.load({
success: function(response) {
let data = response.result.data;
if (Ext.isArray(data)) {
me.setValues(data[0]);
} else {
me.setValues(data);
}
},
});
}
},
});
Ext.define('PVE.dc.PoolView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pvePoolView'],
onlineHelp: 'pveum_pools',
stateful: true,
stateId: 'grid-pools',
initComponent: function() {
var me = this;
var store = new Ext.data.Store({
model: 'pve-pools',
sorters: {
property: 'poolid',
direction: 'ASC',
},
});
var reload = function() {
store.load();
};
var sm = Ext.create('Ext.selection.RowModel', {});
var remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
selModel: sm,
baseurl: '/pools/',
callback: function() {
reload();
},
getUrl: function(rec) {
return '/pools/?poolid=' + rec.getId();
},
});
var run_editor = function() {
var rec = sm.getSelection()[0];
if (!rec) {
return;
}
var win = Ext.create('PVE.dc.PoolEdit', {
poolid: rec.data.poolid,
});
win.on('destroy', reload);
win.show();
};
var edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
selModel: sm,
handler: run_editor,
});
var tbar = [
{
text: gettext('Create'),
handler: function() {
var win = Ext.create('PVE.dc.PoolEdit', {});
win.on('destroy', reload);
win.show();
},
},
edit_btn, remove_btn,
];
Proxmox.Utils.monStoreErrors(me, store);
Ext.apply(me, {
store: store,
selModel: sm,
tbar: tbar,
viewConfig: {
trackOver: false,
},
columns: [
{
header: gettext('Name'),
width: 200,
sortable: true,
dataIndex: 'poolid',
},
{
header: gettext('Comment'),
sortable: false,
renderer: Ext.String.htmlEncode,
dataIndex: 'comment',
flex: 1,
},
],
listeners: {
activate: reload,
itemdblclick: run_editor,
},
});
me.callParent();
},
});
Ext.define('PVE.dc.RoleEdit', {
extend: 'Proxmox.window.Edit',
xtype: 'pveDcRoleEdit',
width: 400,
initComponent: function() {
var me = this;
me.isCreate = !me.roleid;
var url;
var method;
if (me.isCreate) {
url = '/api2/extjs/access/roles';
method = 'POST';
} else {
url = '/api2/extjs/access/roles/' + me.roleid;
method = 'PUT';
}
Ext.applyIf(me, {
subject: gettext('Role'),
url: url,
method: method,
items: [
{
xtype: me.isCreate ? 'proxmoxtextfield' : 'displayfield',
name: 'roleid',
value: me.roleid,
allowBlank: false,
fieldLabel: gettext('Name'),
},
{
xtype: 'pvePrivilegesSelector',
name: 'privs',
value: me.privs,
allowBlank: false,
fieldLabel: gettext('Privileges'),
},
],
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response) {
var data = response.result.data;
var keys = Ext.Object.getKeys(data);
me.setValues({
privs: keys,
roleid: me.roleid,
});
},
});
}
},
});
Ext.define('PVE.dc.RoleView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveRoleView'],
onlineHelp: 'pveum_roles',
stateful: true,
stateId: 'grid-roles',
initComponent: function() {
let me = this;
let store = new Ext.data.Store({
model: 'pmx-roles',
sorters: {
property: 'roleid',
direction: 'ASC',
},
});
Proxmox.Utils.monStoreErrors(me, store);
let sm = Ext.create('Ext.selection.RowModel', {});
let run_editor = function() {
let rec = sm.getSelection()[0];
if (!rec) {
return;
}
if (rec.data.special) {
return;
}
Ext.create('PVE.dc.RoleEdit', {
roleid: rec.data.roleid,
privs: rec.data.privs,
listeners: {
destroy: () => store.load(),
},
autoShow: true,
});
};
Ext.apply(me, {
store: store,
selModel: sm,
viewConfig: {
trackOver: false,
},
columns: [
{
header: gettext('Built-In'),
width: 65,
sortable: true,
dataIndex: 'special',
renderer: Proxmox.Utils.format_boolean,
},
{
header: gettext('Name'),
width: 150,
sortable: true,
dataIndex: 'roleid',
},
{
itemid: 'privs',
header: gettext('Privileges'),
sortable: false,
renderer: (value, metaData) => {
if (!value) {
return '-';
}
metaData.style = 'white-space:normal;'; // allow word wrap
return value.replace(/,/g, ' ');
},
variableRowHeight: true,
dataIndex: 'privs',
flex: 1,
},
],
listeners: {
activate: function() {
store.load();
},
itemdblclick: run_editor,
},
tbar: [
{
text: gettext('Create'),
handler: function() {
Ext.create('PVE.dc.RoleEdit', {
listeners: {
destroy: () => store.load(),
},
autoShow: true,
});
},
},
{
xtype: 'proxmoxButton',
text: gettext('Edit'),
disabled: true,
selModel: sm,
handler: run_editor,
enableFn: (rec) => !rec.data.special,
},
{
xtype: 'proxmoxStdRemoveButton',
selModel: sm,
callback: () => store.load(),
baseurl: '/access/roles/',
enableFn: (rec) => !rec.data.special,
},
],
});
me.callParent();
},
});
Ext.define('pve-security-groups', {
extend: 'Ext.data.Model',
fields: ['group', 'comment', 'digest'],
idProperty: 'group',
});
Ext.define('PVE.SecurityGroupEdit', {
extend: 'Proxmox.window.Edit',
base_url: "/cluster/firewall/groups",
allow_iface: false,
initComponent: function() {
var me = this;
me.isCreate = me.group_name === undefined;
var subject;
me.url = '/api2/extjs' + me.base_url;
me.method = 'POST';
var items = [
{
xtype: 'textfield',
name: 'group',
value: me.group_name || '',
fieldLabel: gettext('Name'),
allowBlank: false,
},
{
xtype: 'textfield',
name: 'comment',
value: me.group_comment || '',
fieldLabel: gettext('Comment'),
},
];
if (me.isCreate) {
subject = gettext('Security Group');
} else {
subject = gettext('Security Group') + " '" + me.group_name + "'";
items.push({
xtype: 'hiddenfield',
name: 'rename',
value: me.group_name,
});
}
var ipanel = Ext.create('Proxmox.panel.InputPanel', {
// InputPanel does not have a 'create' property, does it need a 'isCreate'
isCreate: me.isCreate,
items: items,
});
Ext.apply(me, {
subject: subject,
items: [ipanel],
});
me.callParent();
},
});
Ext.define('PVE.SecurityGroupList', {
extend: 'Ext.grid.Panel',
alias: 'widget.pveSecurityGroupList',
stateful: true,
stateId: 'grid-securitygroups',
rulePanel: undefined,
addBtn: undefined,
removeBtn: undefined,
editBtn: undefined,
base_url: "/cluster/firewall/groups",
initComponent: function() {
let me = this;
if (!me.base_url) {
throw "no base_url specified";
}
let store = new Ext.data.Store({
model: 'pve-security-groups',
proxy: {
type: 'proxmox',
url: '/api2/json' + me.base_url,
},
sorters: {
property: 'group',
direction: 'ASC',
},
});
let sm = Ext.create('Ext.selection.RowModel', {});
let caps = Ext.state.Manager.get('GuiCap');
let canEdit = !!caps.dc['Sys.Modify'];
let reload = function() {
let oldrec = sm.getSelection()[0];
store.load((records, operation, success) => {
if (oldrec) {
let rec = store.findRecord('group', oldrec.data.group, 0, false, true, true);
if (rec) {
sm.select(rec);
}
}
});
};
let run_editor = function() {
let rec = sm.getSelection()[0];
if (!rec || !canEdit) {
return;
}
Ext.create('PVE.SecurityGroupEdit', {
digest: rec.data.digest,
group_name: rec.data.group,
group_comment: rec.data.comment,
listeners: {
destroy: () => reload(),
},
autoShow: true,
});
};
me.editBtn = new Proxmox.button.Button({
text: gettext('Edit'),
enableFn: rec => canEdit,
disabled: true,
selModel: sm,
handler: run_editor,
});
me.addBtn = new Proxmox.button.Button({
text: gettext('Create'),
disabled: !canEdit,
handler: function() {
sm.deselectAll();
var win = Ext.create('PVE.SecurityGroupEdit', {});
win.show();
win.on('destroy', reload);
},
});
me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', {
selModel: sm,
baseurl: me.base_url + '/',
enableFn: (rec) => canEdit && rec && me.base_url,
callback: () => reload(),
});
Ext.apply(me, {
store: store,
tbar: ['<b>' + gettext('Group') + ':</b>', me.addBtn, me.removeBtn, me.editBtn],
selModel: sm,
columns: [
{
header: gettext('Group'),
dataIndex: 'group',
width: '100',
},
{
header: gettext('Comment'),
dataIndex: 'comment',
renderer: Ext.String.htmlEncode,
flex: 1,
},
],
listeners: {
itemdblclick: run_editor,
select: function(_sm, rec) {
if (!me.rulePanel) {
me.rulePanel = me.up('panel').down('pveFirewallRules');
}
me.rulePanel.setBaseUrl(`/cluster/firewall/groups/${rec.data.group}`);
},
deselect: function() {
if (!me.rulePanel) {
me.rulePanel = me.up('panel').down('pveFirewallRules');
}
me.rulePanel.setBaseUrl(undefined);
},
show: reload,
},
});
me.callParent();
store.load();
},
});
Ext.define('PVE.SecurityGroups', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveSecurityGroups',
title: 'Security Groups',
onlineHelp: 'pve_firewall_security_groups',
layout: 'border',
items: [
{
xtype: 'pveFirewallRules',
region: 'center',
allow_groups: false,
list_refs_url: '/cluster/firewall/refs',
tbar_prefix: '<b>' + gettext('Rules') + ':</b>',
border: false,
firewall_type: 'group',
},
{
xtype: 'pveSecurityGroupList',
region: 'west',
width: '25%',
border: false,
split: true,
},
],
listeners: {
show: function() {
let sglist = this.down('pveSecurityGroupList');
sglist.fireEvent('show', sglist);
},
},
});
Ext.define('PVE.dc.StorageView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveStorageView'],
onlineHelp: 'chapter_storage',
stateful: true,
stateId: 'grid-dc-storage',
createStorageEditWindow: function(type, sid) {
let schema = PVE.Utils.storageSchema[type];
if (!schema || !schema.ipanel) {
throw "no editor registered for storage type: " + type;
}
Ext.create('PVE.storage.BaseEdit', {
paneltype: 'PVE.storage.' + schema.ipanel,
type: type,
storageId: sid,
canDoBackups: schema.backups,
autoShow: true,
listeners: {
destroy: this.reloadStore,
},
});
},
initComponent: function() {
let me = this;
let store = new Ext.data.Store({
model: 'pve-storage',
proxy: {
type: 'proxmox',
url: "/api2/json/storage",
},
sorters: {
property: 'storage',
direction: 'ASC',
},
});
let sm = Ext.create('Ext.selection.RowModel', {});
let run_editor = function() {
let rec = sm.getSelection()[0];
if (!rec) {
return;
}
let { type, storage } = rec.data;
me.createStorageEditWindow(type, storage);
};
let edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
selModel: sm,
handler: run_editor,
});
let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
selModel: sm,
baseurl: '/storage/',
callback: () => store.load(),
});
// else we cannot dynamically generate the add menu handlers
let addHandleGenerator = function(type) {
return function() { me.createStorageEditWindow(type); };
};
let addMenuItems = [];
for (const [type, storage] of Object.entries(PVE.Utils.storageSchema)) {
if (storage.hideAdd) {
continue;
}
addMenuItems.push({
text: PVE.Utils.format_storage_type(type),
iconCls: 'fa fa-fw fa-' + storage.faIcon,
handler: addHandleGenerator(type),
});
}
Ext.apply(me, {
store: store,
reloadStore: () => store.load(),
selModel: sm,
viewConfig: {
trackOver: false,
},
tbar: [
{
text: gettext('Add'),
menu: new Ext.menu.Menu({
items: addMenuItems,
}),
},
remove_btn,
edit_btn,
],
columns: [
{
header: 'ID',
flex: 2,
sortable: true,
dataIndex: 'storage',
},
{
header: gettext('Type'),
flex: 1,
sortable: true,
dataIndex: 'type',
renderer: PVE.Utils.format_storage_type,
},
{
header: gettext('Content'),
flex: 3,
sortable: true,
dataIndex: 'content',
renderer: PVE.Utils.format_content_types,
},
{
header: gettext('Path') + '/' + gettext('Target'),
flex: 2,
sortable: true,
dataIndex: 'path',
renderer: function(value, metaData, record) {
if (record.data.target) {
return record.data.target;
}
return value;
},
},
{
header: gettext('Shared'),
flex: 1,
sortable: true,
dataIndex: 'shared',
renderer: Proxmox.Utils.format_boolean,
},
{
header: gettext('Enabled'),
flex: 1,
sortable: true,
dataIndex: 'disable',
renderer: Proxmox.Utils.format_neg_boolean,
},
{
header: gettext('Bandwidth Limit'),
flex: 2,
sortable: true,
dataIndex: 'bwlimit',
},
],
listeners: {
activate: () => store.load(),
itemdblclick: run_editor,
},
});
me.callParent();
},
}, function() {
Ext.define('pve-storage', {
extend: 'Ext.data.Model',
fields: [
'path', 'type', 'content', 'server', 'portal', 'target', 'export', 'storage',
{ name: 'shared', type: 'boolean' },
{ name: 'disable', type: 'boolean' },
],
idProperty: 'storage',
});
});
Ext.define('PVE.dc.Summary', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveDcSummary',
scrollable: true,
bodyPadding: 5,
layout: 'column',
defaults: {
padding: 5,
columnWidth: 1,
},
items: [
{
itemId: 'dcHealth',
xtype: 'pveDcHealth',
},
{
itemId: 'dcGuests',
xtype: 'pveDcGuests',
},
{
title: gettext('Resources'),
xtype: 'panel',
minHeight: 250,
bodyPadding: 5,
layout: 'hbox',
defaults: {
xtype: 'proxmoxGauge',
flex: 1,
},
items: [
{
title: gettext('CPU'),
itemId: 'cpu',
},
{
title: gettext('Memory'),
itemId: 'memory',
},
{
title: gettext('Storage'),
itemId: 'storage',
},
],
},
{
itemId: 'nodeview',
xtype: 'pveDcNodeView',
height: 250,
},
{
title: gettext('Subscriptions'),
height: 220,
items: [
{
xtype: 'pveHealthWidget',
itemId: 'subscriptions',
userCls: 'pointer',
listeners: {
element: 'el',
click: function() {
if (this.component.userCls === 'pointer') {
window.open('https://www.proxmox.com/en/proxmox-virtual-environment/pricing', '_blank');
}
},
},
},
],
},
],
listeners: {
resize: function(panel) {
Proxmox.Utils.updateColumns(panel);
},
},
initComponent: function() {
var me = this;
var rstore = Ext.create('Proxmox.data.UpdateStore', {
interval: 3000,
storeid: 'pve-cluster-status',
model: 'pve-dc-nodes',
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/status",
},
});
var gridstore = Ext.create('Proxmox.data.DiffStore', {
rstore: rstore,
filters: {
property: 'type',
value: 'node',
},
sorters: {
property: 'id',
direction: 'ASC',
},
});
me.callParent();
me.getComponent('nodeview').setStore(gridstore);
var gueststatus = me.getComponent('dcGuests');
var cpustat = me.down('#cpu');
var memorystat = me.down('#memory');
var storagestat = me.down('#storage');
var sp = Ext.state.Manager.getProvider();
me.mon(PVE.data.ResourceStore, 'load', function(curstore, results) {
me.suspendLayout = true;
let cpu = 0, maxcpu = 0;
let memory = 0, maxmem = 0;
let used = 0, total = 0;
let countedStorage = {}, usableStorages = {};
let storages = sp.get('dash-storages') || '';
storages.split(',').filter(v => v !== '').forEach(storage => {
usableStorages[storage] = true;
});
let qemu = {
running: 0,
paused: 0,
stopped: 0,
template: 0,
};
let lxc = {
running: 0,
paused: 0,
stopped: 0,
template: 0,
};
let error = 0;
for (const { data } of results) {
switch (data.type) {
case 'node':
cpu += data.cpu * data.maxcpu;
maxcpu += data.maxcpu || 0;
memory += data.mem || 0;
maxmem += data.maxmem || 0;
if (gridstore.getById(data.id)) {
let griditem = gridstore.getById(data.id);
griditem.set('cpuusage', data.cpu);
let max = data.maxmem || 1;
let val = data.mem || 0;
griditem.set('memoryusage', val / max);
griditem.set('uptime', data.uptime);
griditem.commit(); // else the store marks the field as dirty
}
break;
case 'storage': {
let sid = !data.shared || data.storage === 'local' ? data.id : data.storage;
if (!Ext.Object.isEmpty(usableStorages)) {
if (usableStorages[data.id] !== true) {
break;
}
sid = data.id;
} else if (countedStorage[sid]) {
break;
}
if (data.status === "unknown") {
break;
}
used += data.disk;
total += data.maxdisk;
countedStorage[sid] = true;
break;
}
case 'qemu':
qemu[data.template ? 'template' : data.status]++;
if (data.hastate === 'error') {
error++;
}
break;
case 'lxc':
lxc[data.template ? 'template' : data.status]++;
if (data.hastate === 'error') {
error++;
}
break;
default: break;
}
}
let text = Ext.String.format(gettext('of {0} CPU(s)'), maxcpu);
cpustat.updateValue(cpu/maxcpu, text);
text = Ext.String.format(gettext('{0} of {1}'), Proxmox.Utils.render_size(memory), Proxmox.Utils.render_size(maxmem));
memorystat.updateValue(memory/maxmem, text);
text = Ext.String.format(gettext('{0} of {1}'), Proxmox.Utils.render_size(used), Proxmox.Utils.render_size(total));
storagestat.updateValue(used/total, text);
gueststatus.updateValues(qemu, lxc, error);
me.suspendLayout = false;
me.updateLayout(true);
});
let dcHealth = me.getComponent('dcHealth');
me.mon(rstore, 'load', dcHealth.updateStatus, dcHealth);
let subs = me.down('#subscriptions');
me.mon(rstore, 'load', function(store, records, success) {
var level;
var mixed = false;
for (let i = 0; i < records.length; i++) {
let node = records[i];
if (node.get('type') !== 'node' || node.get('status') === 'offline') {
continue;
}
let curlevel = node.get('level');
if (curlevel === '') { // no subscription beats all, set it and break the loop
level = '';
break;
}
if (level === undefined) { // save level
level = curlevel;
} else if (level !== curlevel) { // detect different levels
mixed = true;
}
}
let data = {
title: Proxmox.Utils.unknownText,
text: Proxmox.Utils.unknownText,
iconCls: PVE.Utils.get_health_icon(undefined, true),
};
if (level === '') {
data = {
title: gettext('No Subscription'),
iconCls: PVE.Utils.get_health_icon('critical', true),
text: gettext('You have at least one node without subscription.'),
};
subs.setUserCls('pointer');
} else if (mixed) {
data = {
title: gettext('Mixed Subscriptions'),
iconCls: PVE.Utils.get_health_icon('warning', true),
text: gettext('Warning: Your subscription levels are not the same.'),
};
subs.setUserCls('pointer');
} else if (level) {
data = {
title: PVE.Utils.render_support_level(level),
iconCls: PVE.Utils.get_health_icon('good', true),
text: gettext('Your subscription status is valid.'),
};
subs.setUserCls('');
}
subs.setData(data);
});
me.on('destroy', function() {
rstore.stopUpdate();
});
me.mon(sp, 'statechange', function(provider, key, value) {
if (key !== 'summarycolumns') {
return;
}
Proxmox.Utils.updateColumns(me);
});
rstore.startUpdate();
},
});
Ext.define('PVE.dc.Support', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveDcSupport',
pveGuidePath: '/pve-docs/index.html',
onlineHelp: 'getting_help',
invalidHtml: '<h1>No valid subscription</h1>' + PVE.Utils.noSubKeyHtml,
communityHtml: 'Please use the public community <a target="_blank" href="https://forum.proxmox.com">forum</a> for any questions.',
activeHtml: 'Please use our <a target="_blank" href="https://my.proxmox.com">support portal</a> for any questions. You can also use the public community <a target="_blank" href="https://forum.proxmox.com">forum</a> to get additional information.',
bugzillaHtml: '<h1>Bug Tracking</h1>Our bug tracking system is available <a target="_blank" href="https://bugzilla.proxmox.com">here</a>.',
docuHtml: function() {
var me = this;
var guideUrl = window.location.origin + me.pveGuidePath;
var text = Ext.String.format('<h1>Documentation</h1>'
+ 'The official Proxmox VE Administration Guide'
+ ' is included with this installation and can be browsed at '
+ '<a target="_blank" href="{0}">{0}</a>', guideUrl);
return text;
},
updateActive: function(data) {
var me = this;
var html = '<h1>' + data.productname + '</h1>' + me.activeHtml;
html += '<br><br>' + me.docuHtml();
html += '<br><br>' + me.bugzillaHtml;
me.update(html);
},
updateCommunity: function(data) {
var me = this;
var html = '<h1>' + data.productname + '</h1>' + me.communityHtml;
html += '<br><br>' + me.docuHtml();
html += '<br><br>' + me.bugzillaHtml;
me.update(html);
},
updateInactive: function(data) {
var me = this;
me.update(me.invalidHtml);
},
initComponent: function() {
let me = this;
let reload = function() {
Proxmox.Utils.API2Request({
url: '/nodes/localhost/subscription',
method: 'GET',
waitMsgTarget: me,
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
me.update(`${gettext('Unable to load subscription status')}: ${response.htmlStatus}`);
},
success: function(response, opts) {
let data = response.result.data;
if (data?.status.toLowerCase() === 'active') {
if (data.level === 'c') {
me.updateCommunity(data);
} else {
me.updateActive(data);
}
} else {
me.updateInactive(data);
}
},
});
};
Ext.apply(me, {
autoScroll: true,
bodyStyle: 'padding:10px',
listeners: {
activate: reload,
},
});
me.callParent();
},
});
Ext.define('PVE.dc.SyncWindow', {
extend: 'Ext.window.Window',
title: gettext('Realm Sync'),
width: 600,
bodyPadding: 10,
modal: true,
resizable: false,
controller: {
xclass: 'Ext.app.ViewController',
control: {
'form': {
validitychange: function(field, valid) {
let me = this;
me.lookup('preview_btn').setDisabled(!valid);
me.lookup('sync_btn').setDisabled(!valid);
},
},
'button': {
click: function(btn) {
if (btn.reference === 'help_btn') return;
this.sync_realm(btn.reference === 'preview_btn');
},
},
},
sync_realm: function(is_preview) {
let me = this;
let view = me.getView();
let ipanel = me.lookup('ipanel');
let params = ipanel.getValues();
let vanished_opts = [];
['acl', 'entry', 'properties'].forEach((prop) => {
if (params[`remove-vanished-${prop}`]) {
vanished_opts.push(prop);
}
delete params[`remove-vanished-${prop}`];
});
if (vanished_opts.length > 0) {
params['remove-vanished'] = vanished_opts.join(';');
} else {
params['remove-vanished'] = 'none';
}
params['dry-run'] = is_preview ? 1 : 0;
Proxmox.Utils.API2Request({
url: `/access/domains/${view.realm}/sync`,
waitMsgTarget: view,
method: 'POST',
params,
failure: function(response) {
view.show();
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
success: function(response) {
view.hide();
Ext.create('Proxmox.window.TaskViewer', {
upid: response.result.data,
listeners: {
destroy: function() {
if (is_preview) {
view.show();
} else {
view.close();
}
},
},
}).show();
},
});
},
},
items: [
{
xtype: 'form',
reference: 'form',
border: false,
fieldDefaults: {
labelWidth: 100,
anchor: '100%',
},
items: [{
xtype: 'inputpanel',
reference: 'ipanel',
column1: [
{
xtype: 'proxmoxKVComboBox',
name: 'scope',
fieldLabel: gettext('Scope'),
value: '',
emptyText: gettext('No default available'),
deleteEmpty: false,
allowBlank: false,
comboItems: [
['users', gettext('Users')],
['groups', gettext('Groups')],
['both', gettext('Users and Groups')],
],
},
],
column2: [
{
xtype: 'proxmoxKVComboBox',
value: '1',
deleteEmpty: false,
allowBlank: false,
comboItems: [
['1', Proxmox.Utils.yesText],
['0', Proxmox.Utils.noText],
],
name: 'enable-new',
fieldLabel: gettext('Enable new'),
},
],
columnB: [
{
xtype: 'fieldset',
title: gettext('Remove Vanished Options'),
items: [
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('ACL'),
name: 'remove-vanished-acl',
boxLabel: gettext('Remove ACLs of vanished users and groups.'),
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Entry'),
name: 'remove-vanished-entry',
boxLabel: gettext('Remove vanished user and group entries.'),
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Properties'),
name: 'remove-vanished-properties',
boxLabel: gettext('Remove vanished properties from synced users.'),
},
],
},
{
xtype: 'displayfield',
reference: 'defaulthint',
value: gettext('Default sync options can be set by editing the realm.'),
userCls: 'pmx-hint',
hidden: true,
},
],
}],
},
],
buttons: [
{
xtype: 'proxmoxHelpButton',
reference: 'help_btn',
onlineHelp: 'pveum_ldap_sync',
hidden: false,
},
'->',
{
text: gettext('Preview'),
reference: 'preview_btn',
},
{
text: gettext('Sync'),
reference: 'sync_btn',
},
],
initComponent: function() {
let me = this;
if (!me.realm) {
throw "no realm defined";
}
me.callParent();
Proxmox.Utils.API2Request({
url: `/access/domains/${me.realm}`,
waitMsgTarget: me,
method: 'GET',
failure: function(response) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
me.close();
},
success: function(response) {
let default_options = response.result.data['sync-defaults-options'];
if (default_options) {
let options = PVE.Parser.parsePropertyString(default_options);
if (options['remove-vanished']) {
let opts = options['remove-vanished'].split(';');
for (const opt of opts) {
options[`remove-vanished-${opt}`] = 1;
}
}
let ipanel = me.lookup('ipanel');
ipanel.setValues(options);
} else {
me.lookup('defaulthint').setVisible(true);
}
// check validity for button state
me.lookup('form').isValid();
},
});
},
});
/* This class defines the "Tasks" tab of the bottom status panel
* Tasks are jobs with a start, end and log output
*/
Ext.define('PVE.dc.Tasks', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveClusterTasks'],
initComponent: function() {
let me = this;
let taskstore = Ext.create('Proxmox.data.UpdateStore', {
storeId: 'pve-cluster-tasks',
model: 'proxmox-tasks',
proxy: {
type: 'proxmox',
url: '/api2/json/cluster/tasks',
},
});
let store = Ext.create('Proxmox.data.DiffStore', {
rstore: taskstore,
sortAfterUpdate: true,
appendAtStart: true,
sorters: [
{
property: 'pid',
direction: 'DESC',
},
{
property: 'starttime',
direction: 'DESC',
},
],
});
let run_task_viewer = function() {
var sm = me.getSelectionModel();
var rec = sm.getSelection()[0];
if (!rec) {
return;
}
Ext.create('Proxmox.window.TaskViewer', {
autoShow: true,
upid: rec.data.upid,
endtime: rec.data.endtime,
});
};
Ext.apply(me, {
store: store,
stateful: false,
viewConfig: {
trackOver: false,
stripeRows: true, // does not work with getRowClass()
getRowClass: function(record, index) {
let taskState = record.get('status');
if (taskState) {
let parsed = Proxmox.Utils.parse_task_status(taskState);
if (parsed === 'warning') {
return "proxmox-warning-row";
} else if (parsed !== 'ok') {
return "proxmox-invalid-row";
}
}
return '';
},
},
sortableColumns: false,
columns: [
{
header: gettext("Start Time"),
dataIndex: 'starttime',
width: 150,
renderer: function(value) {
return Ext.Date.format(value, "M d H:i:s");
},
},
{
header: gettext("End Time"),
dataIndex: 'endtime',
width: 150,
renderer: function(value, metaData, record) {
if (record.data.pid) {
if (record.data.type === "vncproxy" ||
record.data.type === "vncshell" ||
record.data.type === "spiceproxy") {
metaData.tdCls = "x-grid-row-console";
} else {
metaData.tdCls = "x-grid-row-loading";
}
return "";
}
return Ext.Date.format(value, "M d H:i:s");
},
},
{
header: gettext("Node"),
dataIndex: 'node',
width: 100,
},
{
header: gettext("User name"),
dataIndex: 'user',
renderer: Ext.String.htmlEncode,
width: 150,
},
{
header: gettext("Description"),
dataIndex: 'upid',
flex: 1,
renderer: Proxmox.Utils.render_upid,
},
{
header: gettext("Status"),
dataIndex: 'status',
width: 200,
renderer: function(value, metaData, record) {
if (record.data.pid) {
if (record.data.type !== "vncproxy") {
metaData.tdCls = "x-grid-row-loading";
}
return "";
}
return Proxmox.Utils.format_task_status(value);
},
},
],
listeners: {
itemdblclick: run_task_viewer,
show: () => taskstore.startUpdate(),
destroy: () => taskstore.stopUpdate(),
},
});
me.callParent();
},
});
Ext.define('PVE.dc.TokenEdit', {
extend: 'Proxmox.window.Edit',
alias: ['widget.pveDcTokenEdit'],
mixins: ['Proxmox.Mixin.CBind'],
subject: gettext('Token'),
onlineHelp: 'pveum_tokens',
isAdd: true,
isCreate: false,
method: 'POST',
url: '/api2/extjs/access/users/',
defaultFocus: 'field[disabled=false][hidden=false][name=tokenid]',
items: {
xtype: 'inputpanel',
onGetValues: function(values) {
let me = this;
let win = me.up('pveDcTokenEdit');
win.url = '/api2/extjs/access/users/';
let uid = encodeURIComponent(values.userid);
let tid = encodeURIComponent(values.tokenid);
delete values.userid;
delete values.tokenid;
win.url += `${uid}/token/${tid}`;
return values;
},
column1: [
{
xtype: 'pmxDisplayEditField',
cbind: {
editable: '{isCreate}',
},
submitValue: true,
editConfig: {
xtype: 'pmxUserSelector',
allowBlank: false,
},
name: 'userid',
value: Proxmox.UserName,
renderer: Ext.String.htmlEncode,
fieldLabel: gettext('User'),
},
{
xtype: 'pmxDisplayEditField',
cbind: {
editable: '{isCreate}',
},
name: 'tokenid',
fieldLabel: gettext('Token ID'),
submitValue: true,
minLength: 2,
allowBlank: false,
},
],
column2: [
{
xtype: 'proxmoxcheckbox',
name: 'privsep',
checked: true,
uncheckedValue: 0,
fieldLabel: gettext('Privilege Separation'),
},
{
xtype: 'pmxExpireDate',
name: 'expire',
},
],
columnB: [
{
xtype: 'textfield',
name: 'comment',
fieldLabel: gettext('Comment'),
},
],
},
initComponent: function() {
let me = this;
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
me.setValues(response.result.data);
},
});
}
},
apiCallDone: function(success, response, options) {
let res = response.result.data;
if (!success || !res.value) {
return;
}
Ext.create('PVE.dc.TokenShow', {
autoShow: true,
tokenid: res['full-tokenid'],
secret: res.value,
});
},
});
Ext.define('PVE.dc.TokenShow', {
extend: 'Ext.window.Window',
alias: ['widget.pveTokenShow'],
mixins: ['Proxmox.Mixin.CBind'],
width: 600,
modal: true,
resizable: false,
title: gettext('Token Secret'),
items: [
{
xtype: 'container',
layout: 'form',
bodyPadding: 10,
border: false,
fieldDefaults: {
labelWidth: 100,
anchor: '100%',
},
padding: '0 10 10 10',
items: [
{
xtype: 'textfield',
fieldLabel: gettext('Token ID'),
cbind: {
value: '{tokenid}',
},
editable: false,
},
{
xtype: 'textfield',
fieldLabel: gettext('Secret'),
inputId: 'token-secret-value',
cbind: {
value: '{secret}',
},
editable: false,
},
],
},
{
xtype: 'component',
border: false,
padding: '10 10 10 10',
userCls: 'pmx-hint',
html: gettext('Please record the API token secret - it will only be displayed now'),
},
],
buttons: [
{
handler: function(b) {
document.getElementById('token-secret-value').select();
document.execCommand("copy");
},
text: gettext('Copy Secret Value'),
iconCls: 'fa fa-clipboard',
},
],
});
Ext.define('PVE.dc.TokenView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveTokenView'],
onlineHelp: 'chapter_user_management',
stateful: true,
stateId: 'grid-tokens',
initComponent: function() {
let me = this;
let caps = Ext.state.Manager.get('GuiCap');
let store = new Ext.data.Store({
id: "tokens",
model: 'pve-tokens',
sorters: 'id',
});
let reload = function() {
Proxmox.Utils.API2Request({
url: '/access/users/?full=1',
method: 'GET',
failure: function(response, opts) {
Proxmox.Utils.setErrorMask(me, response.htmlStatus);
me.load_task.delay(me.load_delay);
},
success: function(response, opts) {
Proxmox.Utils.setErrorMask(me, false);
let result = Ext.decode(response.responseText);
let data = result.data || [];
let records = [];
Ext.Array.each(data, function(user) {
let tokens = user.tokens || [];
Ext.Array.each(tokens, function(token) {
let r = {};
r.id = user.userid + '!' + token.tokenid;
r.userid = user.userid;
r.tokenid = token.tokenid;
r.comment = token.comment;
r.expire = token.expire;
r.privsep = token.privsep === 1;
records.push(r);
});
});
store.loadData(records);
},
});
};
let sm = Ext.create('Ext.selection.RowModel', {});
let urlFromRecord = (rec) => {
let uid = encodeURIComponent(rec.data.userid);
let tid = encodeURIComponent(rec.data.tokenid);
return `/access/users/${uid}/token/${tid}`;
};
let hasTokenCRUDPermissions = function(userid) {
return userid === Proxmox.UserName || !!caps.access['User.Modify'];
};
let run_editor = function(rec) {
if (!hasTokenCRUDPermissions(rec.data.userid)) {
return;
}
let win = Ext.create('PVE.dc.TokenEdit', {
method: 'PUT',
url: urlFromRecord(rec),
});
win.setValues(rec.data);
win.on('destroy', reload);
win.show();
};
let tbar = [
{
text: gettext('Add'),
handler: function(btn, e) {
let data = {};
let win = Ext.create('PVE.dc.TokenEdit', {
isCreate: true,
});
win.setValues(data);
win.on('destroy', reload);
win.show();
},
},
{
xtype: 'proxmoxButton',
text: gettext('Edit'),
disabled: true,
enableFn: (rec) => hasTokenCRUDPermissions(rec.data.userid),
selModel: sm,
handler: (btn, e, rec) => run_editor(rec),
},
{
xtype: 'proxmoxStdRemoveButton',
selModel: sm,
enableFn: (rec) => hasTokenCRUDPermissions(rec.data.userid),
callback: reload,
getUrl: urlFromRecord,
},
'-',
{
xtype: 'proxmoxButton',
text: gettext('Show Permissions'),
disabled: true,
selModel: sm,
handler: function(btn, event, rec) {
Ext.create('PVE.dc.PermissionView', {
autoShow: true,
userid: rec.data.id,
});
},
},
];
Ext.apply(me, {
store: store,
selModel: sm,
tbar: tbar,
viewConfig: {
trackOver: false,
},
columns: [
{
header: gettext('User name'),
dataIndex: 'userid',
renderer: (uid) => {
let realmIndex = uid.lastIndexOf('@');
let user = Ext.String.htmlEncode(uid.substr(0, realmIndex));
let realm = Ext.String.htmlEncode(uid.substr(realmIndex));
return `${user} <span style='float:right;'>${realm}</span>`;
},
flex: 2,
},
{
header: gettext('Token Name'),
dataIndex: 'tokenid',
hideable: false,
flex: 1,
},
{
header: gettext('Expire'),
dataIndex: 'expire',
hideable: false,
renderer: Proxmox.Utils.format_expire,
flex: 1,
},
{
header: gettext('Comment'),
dataIndex: 'comment',
renderer: Ext.String.htmlEncode,
flex: 3,
},
{
header: gettext('Privilege Separation'),
dataIndex: 'privsep',
hideable: false,
renderer: Proxmox.Utils.format_boolean,
flex: 1,
},
],
listeners: {
activate: reload,
itemdblclick: (view, rec) => run_editor(rec),
},
});
me.callParent();
},
});
Ext.define('PVE.dc.UserEdit', {
extend: 'Proxmox.window.Edit',
alias: ['widget.pveDcUserEdit'],
isAdd: true,
initComponent: function() {
let me = this;
me.isCreate = !me.userid;
let url = '/api2/extjs/access/users';
let method = 'POST';
if (!me.isCreate) {
url += '/' + encodeURIComponent(me.userid);
method = 'PUT';
}
let verifypw, pwfield;
let validate_pw = function() {
if (verifypw.getValue() !== pwfield.getValue()) {
return gettext("Passwords do not match");
}
return true;
};
verifypw = Ext.createWidget('textfield', {
inputType: 'password',
fieldLabel: gettext('Confirm password'),
name: 'verifypassword',
submitValue: false,
disabled: true,
hidden: true,
validator: validate_pw,
});
pwfield = Ext.createWidget('textfield', {
inputType: 'password',
fieldLabel: gettext('Password'),
minLength: 8,
name: 'password',
disabled: true,
hidden: true,
validator: validate_pw,
});
let column1 = [
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'userid',
fieldLabel: gettext('User name'),
value: me.userid,
renderer: Ext.String.htmlEncode,
allowBlank: false,
submitValue: !!me.isCreate,
},
pwfield,
verifypw,
{
xtype: 'pveGroupSelector',
name: 'groups',
multiSelect: true,
allowBlank: true,
fieldLabel: gettext('Group'),
},
{
xtype: 'pmxExpireDate',
name: 'expire',
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Enabled'),
name: 'enable',
uncheckedValue: 0,
defaultValue: 1,
checked: true,
},
];
let column2 = [
{
xtype: 'textfield',
name: 'firstname',
fieldLabel: gettext('First Name'),
},
{
xtype: 'textfield',
name: 'lastname',
fieldLabel: gettext('Last Name'),
},
{
xtype: 'textfield',
name: 'email',
fieldLabel: gettext('E-Mail'),
vtype: 'proxmoxMail',
},
];
if (me.isCreate) {
column1.splice(1, 0, {
xtype: 'pmxRealmComboBox',
name: 'realm',
fieldLabel: gettext('Realm'),
allowBlank: false,
matchFieldWidth: false,
listConfig: { width: 300 },
listeners: {
change: function(combo, realm) {
me.realm = realm;
pwfield.setVisible(realm === 'pve');
pwfield.setDisabled(realm !== 'pve');
verifypw.setVisible(realm === 'pve');
verifypw.setDisabled(realm !== 'pve');
},
},
submitValue: false,
});
}
var ipanel = Ext.create('Proxmox.panel.InputPanel', {
column1: column1,
column2: column2,
columnB: [
{
xtype: 'textfield',
name: 'comment',
fieldLabel: gettext('Comment'),
},
],
advancedItems: [
{
xtype: 'textfield',
name: 'keys',
fieldLabel: gettext('Key IDs'),
},
],
onGetValues: function(values) {
if (me.realm) {
values.userid = values.userid + '@' + me.realm;
}
if (!values.password) {
delete values.password;
}
return values;
},
});
Ext.applyIf(me, {
subject: gettext('User'),
url: url,
method: method,
fieldDefaults: {
labelWidth: 110, // some translation are quite long (e.g., Spanish)
},
items: [ipanel],
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
var data = response.result.data;
me.setValues(data);
if (data.keys) {
if (data.keys === 'x' ||
data.keys === 'x!oath' ||
data.keys === 'x!u2f' ||
data.keys === 'x!yubico') {
me.down('[name="keys"]').setDisabled(1);
}
}
},
});
}
},
});
Ext.define('PVE.dc.UserView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveUserView'],
onlineHelp: 'pveum_users',
stateful: true,
stateId: 'grid-users',
initComponent: function() {
var me = this;
var caps = Ext.state.Manager.get('GuiCap');
var store = new Ext.data.Store({
id: "users",
model: 'pmx-users',
sorters: {
property: 'userid',
direction: 'ASC',
},
});
let reload = () => store.load();
let sm = Ext.create('Ext.selection.RowModel', {});
let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
selModel: sm,
baseurl: '/access/users/',
dangerous: true,
enableFn: rec => caps.access['User.Modify'] && rec.data.userid !== 'root@pam',
callback: () => reload(),
});
let run_editor = function() {
var rec = sm.getSelection()[0];
if (!rec || !caps.access['User.Modify']) {
return;
}
Ext.create('PVE.dc.UserEdit', {
userid: rec.data.userid,
autoShow: true,
listeners: {
destroy: () => reload(),
},
});
};
let edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
enableFn: function(rec) {
return !!caps.access['User.Modify'];
},
selModel: sm,
handler: run_editor,
});
let pwchange_btn = new Proxmox.button.Button({
text: gettext('Password'),
disabled: true,
selModel: sm,
enableFn: function(record) {
let type = record.data['realm-type'];
if (type) {
if (PVE.Utils.authSchema[type]) {
return !!PVE.Utils.authSchema[type].pwchange;
}
}
return false;
},
handler: function(btn, event, rec) {
Ext.create('Proxmox.window.PasswordEdit', {
userid: rec.data.userid,
confirmCurrentPassword: Proxmox.UserName !== 'root@pam',
autoShow: true,
minLength: 8,
listeners: {
destroy: () => reload(),
},
});
},
});
var perm_btn = new Proxmox.button.Button({
text: gettext('Permissions'),
disabled: true,
selModel: sm,
handler: function(btn, event, rec) {
Ext.create('PVE.dc.PermissionView', {
userid: rec.data.userid,
autoShow: true,
listeners: {
destroy: () => reload(),
},
});
},
});
let unlock_btn = new Proxmox.button.Button({
text: gettext('Unlock TFA'),
disabled: true,
selModel: sm,
enableFn: rec => !!(caps.access['User.Modify'] &&
(rec.data['totp-locked'] || rec.data['tfa-locked-until'])),
handler: function(btn, event, rec) {
Ext.Msg.confirm(
Ext.String.format(gettext('Unlock TFA authentication for {0}'), rec.data.userid),
gettext("Locked 2nd factors can happen if the user's password was leaked. Are you sure you want to unlock the user?"),
function(btn_response) {
if (btn_response === 'yes') {
Proxmox.Utils.API2Request({
url: `/access/users/${rec.data.userid}/unlock-tfa`,
waitMsgTarget: me,
method: 'PUT',
failure: function(response, options) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
success: function(response, options) {
reload();
},
});
}
},
);
},
});
Ext.apply(me, {
store: store,
selModel: sm,
tbar: [
{
text: gettext('Add'),
disabled: !caps.access['User.Modify'],
handler: function() {
Ext.create('PVE.dc.UserEdit', {
autoShow: true,
listeners: {
destroy: () => reload(),
},
});
},
},
'-',
edit_btn,
remove_btn,
'-',
pwchange_btn,
'-',
perm_btn,
'-',
unlock_btn,
],
viewConfig: {
trackOver: false,
},
columns: [
{
header: gettext('User name'),
width: 200,
sortable: true,
renderer: Proxmox.Utils.render_username,
dataIndex: 'userid',
},
{
header: gettext('Realm'),
width: 100,
sortable: true,
renderer: Proxmox.Utils.render_realm,
dataIndex: 'userid',
},
{
header: gettext('Enabled'),
width: 80,
sortable: true,
renderer: Proxmox.Utils.format_boolean,
dataIndex: 'enable',
},
{
header: gettext('Expire'),
width: 80,
sortable: true,
renderer: Proxmox.Utils.format_expire,
dataIndex: 'expire',
},
{
header: gettext('Name'),
width: 150,
sortable: true,
renderer: PVE.Utils.render_full_name,
dataIndex: 'firstname',
},
{
header: 'TFA',
width: 120,
sortable: true,
renderer: function(v, metaData, record) {
let tfa_type = PVE.Parser.parseTfaType(v);
if (tfa_type === undefined) {
return Proxmox.Utils.noText;
}
if (tfa_type !== 1) {
return tfa_type;
}
let locked_until = record.data['tfa-locked-until'];
if (locked_until !== undefined) {
let now = new Date().getTime() / 1000;
if (locked_until > now) {
return gettext('Locked');
}
}
if (record.data['totp-locked']) {
return gettext('TOTP Locked');
}
return Proxmox.Utils.yesText;
},
dataIndex: 'keys',
},
{
header: gettext('Groups'),
dataIndex: 'groups',
renderer: Ext.htmlEncode,
flex: 2,
},
{
header: gettext('Comment'),
sortable: false,
renderer: Ext.String.htmlEncode,
dataIndex: 'comment',
flex: 3,
},
],
listeners: {
activate: reload,
itemdblclick: run_editor,
},
});
me.callParent();
Proxmox.Utils.monStoreErrors(me, store);
},
});
Ext.define('PVE.dc.MetricServerView', {
extend: 'Ext.grid.Panel',
alias: ['widget.pveMetricServerView'],
stateful: true,
stateId: 'grid-metricserver',
controller: {
xclass: 'Ext.app.ViewController',
render_type: function(value) {
switch (value) {
case 'influxdb': return "InfluxDB";
case 'graphite': return "Graphite";
default: return Proxmox.Utils.unknownText;
}
},
editWindow: function(xtype, id) {
let me = this;
Ext.create(`PVE.dc.${xtype}Edit`, {
serverid: id,
autoShow: true,
listeners: {
destroy: () => me.reload(),
},
});
},
addServer: function(button) {
this.editWindow(button.text);
},
editServer: function() {
let me = this;
let view = me.getView();
let selection = view.getSelection();
if (!selection || selection.length < 1) {
return;
}
let cfg = selection[0].data;
let xtype = me.render_type(cfg.type);
me.editWindow(xtype, cfg.id);
},
reload: function() {
this.getView().getStore().load();
},
},
store: {
autoLoad: true,
id: 'metricservers',
proxy: {
type: 'proxmox',
url: '/api2/json/cluster/metrics/server',
},
},
columns: [
{
text: gettext('Name'),
flex: 2,
dataIndex: 'id',
},
{
text: gettext('Type'),
flex: 1,
dataIndex: 'type',
renderer: 'render_type',
},
{
text: gettext('Enabled'),
dataIndex: 'disable',
width: 100,
renderer: Proxmox.Utils.format_neg_boolean,
},
{
text: gettext('Server'),
width: 200,
dataIndex: 'server',
},
{
text: gettext('Port'),
width: 100,
dataIndex: 'port',
},
],
tbar: [
{
text: gettext('Add'),
menu: [
{
text: 'Graphite',
iconCls: 'fa fa-fw fa-bar-chart',
handler: 'addServer',
},
{
text: 'InfluxDB',
iconCls: 'fa fa-fw fa-bar-chart',
handler: 'addServer',
},
],
},
{
text: gettext('Edit'),
xtype: 'proxmoxButton',
handler: 'editServer',
disabled: true,
},
{
xtype: 'proxmoxStdRemoveButton',
baseurl: `/api2/extjs/cluster/metrics/server`,
callback: 'reload',
},
],
listeners: {
itemdblclick: 'editServer',
},
initComponent: function() {
var me = this;
me.callParent();
Proxmox.Utils.monStoreErrors(me, me.getStore());
},
});
Ext.define('PVE.dc.MetricServerBaseEdit', {
extend: 'Proxmox.window.Edit',
mixins: ['Proxmox.Mixin.CBind'],
cbindData: function() {
let me = this;
me.isCreate = !me.serverid;
me.serverid = me.serverid || "";
me.url = `/api2/extjs/cluster/metrics/server/${me.serverid}`;
me.method = me.isCreate ? 'POST' : 'PUT';
if (!me.isCreate) {
me.subject = `${me.subject}: ${me.serverid}`;
}
return {};
},
submitUrl: function(url, values) {
return this.isCreate ? `${url}/${values.id}` : url;
},
initComponent: function() {
let me = this;
me.callParent();
if (me.serverid) {
me.load({
success: function(response, options) {
let values = response.result.data;
values.enable = !values.disable;
me.down('inputpanel').setValues(values);
},
});
}
},
});
Ext.define('PVE.dc.InfluxDBEdit', {
extend: 'PVE.dc.MetricServerBaseEdit',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'metric_server_influxdb',
subject: 'InfluxDB',
cbindData: function() {
let me = this;
me.callParent();
me.tokenEmptyText = me.isCreate ? '' : gettext('unchanged');
return {};
},
items: [
{
xtype: 'inputpanel',
cbind: {
isCreate: '{isCreate}',
},
onGetValues: function(values) {
let me = this;
values.disable = values.enable ? 0 : 1;
delete values.enable;
PVE.Utils.delete_if_default(values, 'verify-certificate', '1', me.isCreate);
return values;
},
column1: [
{
xtype: 'hidden',
name: 'type',
value: 'influxdb',
cbind: {
submitValue: '{isCreate}',
},
},
{
xtype: 'pmxDisplayEditField',
name: 'id',
fieldLabel: gettext('Name'),
allowBlank: false,
cbind: {
editable: '{isCreate}',
value: '{serverid}',
},
},
{
xtype: 'proxmoxtextfield',
name: 'server',
fieldLabel: gettext('Server'),
allowBlank: false,
},
{
xtype: 'proxmoxintegerfield',
name: 'port',
fieldLabel: gettext('Port'),
value: 8089,
minValue: 1,
maximum: 65536,
allowBlank: false,
},
{
xtype: 'proxmoxKVComboBox',
name: 'influxdbproto',
fieldLabel: gettext('Protocol'),
value: '__default__',
cbind: {
deleteEmpty: '{!isCreate}',
},
comboItems: [
['__default__', 'UDP'],
['http', 'HTTP'],
['https', 'HTTPS'],
],
listeners: {
change: function(field, value) {
let me = this;
let view = me.up('inputpanel');
let isUdp = value !== 'http' && value !== 'https';
view.down('field[name=organization]').setDisabled(isUdp);
view.down('field[name=bucket]').setDisabled(isUdp);
view.down('field[name=token]').setDisabled(isUdp);
view.down('field[name=api-path-prefix]').setDisabled(isUdp);
view.down('field[name=mtu]').setDisabled(!isUdp);
view.down('field[name=timeout]').setDisabled(isUdp);
view.down('field[name=max-body-size]').setDisabled(isUdp);
view.down('field[name=verify-certificate]').setDisabled(value !== 'https');
},
},
},
],
column2: [
{
xtype: 'checkbox',
name: 'enable',
fieldLabel: gettext('Enabled'),
inputValue: 1,
uncheckedValue: 0,
checked: true,
},
{
xtype: 'proxmoxtextfield',
name: 'organization',
fieldLabel: gettext('Organization'),
emptyText: 'proxmox',
disabled: true,
cbind: {
deleteEmpty: '{!isCreate}',
},
},
{
xtype: 'proxmoxtextfield',
name: 'bucket',
fieldLabel: gettext('Bucket'),
emptyText: 'proxmox',
disabled: true,
cbind: {
deleteEmpty: '{!isCreate}',
},
},
{
xtype: 'proxmoxtextfield',
name: 'token',
fieldLabel: gettext('Token'),
disabled: true,
allowBlank: true,
deleteEmpty: false,
submitEmpty: false,
cbind: {
disabled: '{!isCreate}',
emptyText: '{tokenEmptyText}',
},
},
],
advancedColumn1: [
{
xtype: 'proxmoxtextfield',
name: 'api-path-prefix',
fieldLabel: gettext('API Path Prefix'),
allowBlank: true,
disabled: true,
cbind: {
deleteEmpty: '{!isCreate}',
},
},
{
xtype: 'proxmoxintegerfield',
name: 'timeout',
fieldLabel: gettext('Timeout (s)'),
disabled: true,
cbind: {
deleteEmpty: '{!isCreate}',
},
minValue: 1,
emptyText: 1,
},
{
xtype: 'proxmoxcheckbox',
name: 'verify-certificate',
fieldLabel: gettext('Verify Certificate'),
value: 1,
uncheckedValue: 0,
disabled: true,
},
],
advancedColumn2: [
{
xtype: 'proxmoxintegerfield',
name: 'max-body-size',
fieldLabel: gettext('Batch Size (b)'),
minValue: 1,
emptyText: '25000000',
submitEmpty: false,
cbind: {
deleteEmpty: '{!isCreate}',
},
},
{
xtype: 'proxmoxintegerfield',
name: 'mtu',
fieldLabel: 'MTU',
minValue: 1,
emptyText: '1500',
submitEmpty: false,
cbind: {
deleteEmpty: '{!isCreate}',
},
},
],
},
],
});
Ext.define('PVE.dc.GraphiteEdit', {
extend: 'PVE.dc.MetricServerBaseEdit',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'metric_server_graphite',
subject: 'Graphite',
items: [
{
xtype: 'inputpanel',
onGetValues: function(values) {
values.disable = values.enable ? 0 : 1;
delete values.enable;
return values;
},
column1: [
{
xtype: 'hidden',
name: 'type',
value: 'graphite',
cbind: {
submitValue: '{isCreate}',
},
},
{
xtype: 'pmxDisplayEditField',
name: 'id',
fieldLabel: gettext('Name'),
allowBlank: false,
cbind: {
editable: '{isCreate}',
value: '{serverid}',
},
},
{
xtype: 'proxmoxtextfield',
name: 'server',
fieldLabel: gettext('Server'),
allowBlank: false,
},
],
column2: [
{
xtype: 'checkbox',
name: 'enable',
fieldLabel: gettext('Enabled'),
inputValue: 1,
uncheckedValue: 0,
checked: true,
},
{
xtype: 'proxmoxintegerfield',
name: 'port',
fieldLabel: gettext('Port'),
value: 2003,
minimum: 1,
maximum: 65536,
allowBlank: false,
},
{
fieldLabel: gettext('Path'),
xtype: 'proxmoxtextfield',
emptyText: 'proxmox',
name: 'path',
cbind: {
deleteEmpty: '{!isCreate}',
},
},
],
advancedColumn1: [
{
xtype: 'proxmoxKVComboBox',
name: 'proto',
fieldLabel: gettext('Protocol'),
value: '__default__',
cbind: {
deleteEmpty: '{!isCreate}',
},
comboItems: [
['__default__', 'UDP'],
['tcp', 'TCP'],
],
listeners: {
change: function(field, value) {
let me = this;
me.up('inputpanel').down('field[name=timeout]').setDisabled(value !== 'tcp');
me.up('inputpanel').down('field[name=mtu]').setDisabled(value === 'tcp');
},
},
},
],
advancedColumn2: [
{
xtype: 'proxmoxintegerfield',
name: 'mtu',
fieldLabel: 'MTU',
minimum: 1,
emptyText: '1500',
submitEmpty: false,
cbind: {
deleteEmpty: '{!isCreate}',
},
},
{
xtype: 'proxmoxintegerfield',
name: 'timeout',
fieldLabel: gettext('TCP Timeout'),
disabled: true,
cbind: {
deleteEmpty: '{!isCreate}',
},
minValue: 1,
emptyText: 1,
},
],
},
],
});
Ext.define('PVE.dc.UserTagAccessEdit', {
extend: 'Proxmox.window.Edit',
alias: 'widget.pveUserTagAccessEdit',
subject: gettext('User Tag Access'),
onlineHelp: 'datacenter_configuration_file',
url: '/api2/extjs/cluster/options',
hintText: gettext('NOTE: The following tags are also defined as registered tags.'),
controller: {
xclass: 'Ext.app.ViewController',
tagChange: function(field, value) {
let me = this;
let view = me.getView();
let also_registered = [];
value = Ext.isArray(value) ? value : value.split(';');
value.forEach(tag => {
if (view.registered_tags.indexOf(tag) !== -1) {
also_registered.push(tag);
}
});
let hint_field = me.lookup('hintField');
hint_field.setVisible(also_registered.length > 0);
if (also_registered.length > 0) {
hint_field.setValue(`${view.hintText} ${also_registered.join(', ')}`);
}
},
},
items: [
{
xtype: 'inputpanel',
setValues: function(values) {
this.up('pveUserTagAccessEdit').registered_tags = values?.['registered-tags'] ?? [];
let data = values?.['user-tag-access'] ?? {};
return Proxmox.panel.InputPanel.prototype.setValues.call(this, data);
},
onGetValues: function(values) {
if (values === undefined || Object.keys(values).length === 0) {
return { 'delete': 'user-tag-access' };
}
return {
'user-tag-access': PVE.Parser.printPropertyString(values),
};
},
items: [
{
name: 'user-allow',
fieldLabel: gettext('Mode'),
xtype: 'proxmoxKVComboBox',
deleteEmpty: false,
value: '__default__',
comboItems: [
['__default__', Proxmox.Utils.defaultText + ' (free)'],
['free', 'free'],
['existing', 'existing'],
['list', 'list'],
['none', 'none'],
],
defaultValue: '__default__',
},
{
xtype: 'displayfield',
fieldLabel: gettext('Predefined Tags'),
},
{
name: 'user-allow-list',
xtype: 'pveListField',
emptyText: gettext('No Tags defined'),
fieldTitle: gettext('Tag'),
maskRe: PVE.Utils.tagCharRegex,
gridConfig: {
height: 200,
scrollable: true,
},
listeners: {
change: 'tagChange',
},
},
{
hidden: true,
xtype: 'displayfield',
reference: 'hintField',
userCls: 'pmx-hint',
},
],
},
],
});
Ext.define('PVE.dc.RegisteredTagsEdit', {
extend: 'Proxmox.window.Edit',
alias: 'widget.pveRegisteredTagEdit',
subject: gettext('Registered Tags'),
onlineHelp: 'datacenter_configuration_file',
url: '/api2/extjs/cluster/options',
hintText: gettext('NOTE: The following tags are also defined in the user allow list.'),
controller: {
xclass: 'Ext.app.ViewController',
tagChange: function(field, value) {
let me = this;
let view = me.getView();
let also_allowed = [];
value = Ext.isArray(value) ? value : value.split(';');
value.forEach(tag => {
if (view.allowed_tags.indexOf(tag) !== -1) {
also_allowed.push(tag);
}
});
let hint_field = me.lookup('hintField');
hint_field.setVisible(also_allowed.length > 0);
if (also_allowed.length > 0) {
hint_field.setValue(`${view.hintText} ${also_allowed.join(', ')}`);
}
},
},
items: [
{
xtype: 'inputpanel',
setValues: function(values) {
let allowed_tags = values?.['user-tag-access']?.['user-allow-list'] ?? [];
this.up('pveRegisteredTagEdit').allowed_tags = allowed_tags;
let tags = values?.['registered-tags'];
return Proxmox.panel.InputPanel.prototype.setValues.call(this, { tags });
},
onGetValues: function(values) {
if (!values.tags) {
return {
'delete': 'registered-tags',
};
} else {
return {
'registered-tags': values.tags,
};
}
},
items: [
{
name: 'tags',
xtype: 'pveListField',
maskRe: PVE.Utils.tagCharRegex,
gridConfig: {
height: 200,
scrollable: true,
emptyText: gettext('No Tags defined'),
},
listeners: {
change: 'tagChange',
},
},
{
hidden: true,
xtype: 'displayfield',
reference: 'hintField',
userCls: 'pmx-hint',
},
],
},
],
});
Ext.define('PVE.dc.RealmSyncJobView', {
extend: 'Ext.grid.Panel',
alias: 'widget.pveRealmSyncJobView',
stateful: true,
stateId: 'grid-realmsyncjobs',
emptyText: Ext.String.format(gettext('No {0} configured'), gettext('Realm Sync Job')),
controller: {
xclass: 'Ext.app.ViewController',
addRealmSyncJob: function(button) {
let me = this;
Ext.create(`PVE.dc.RealmSyncJobEdit`, {
autoShow: true,
listeners: {
destroy: () => me.reload(),
},
});
},
editRealmSyncJob: function() {
let me = this;
let view = me.getView();
let selection = view.getSelection();
if (!selection || selection.length < 1) {
return;
}
Ext.create(`PVE.dc.RealmSyncJobEdit`, {
jobid: selection[0].data.id,
autoShow: true,
listeners: {
destroy: () => me.reload(),
},
});
},
runNow: function() {
let me = this;
let view = me.getView();
let selection = view.getSelection();
if (!selection || selection.length < 1) {
return;
}
let params = selection[0].data;
let realm = params.realm;
let propertiesToDelete = ['comment', 'realm', 'id', 'type', 'schedule', 'last-run', 'next-run', 'enabled'];
for (const prop of propertiesToDelete) {
delete params[prop];
}
Proxmox.Utils.API2Request({
url: `/access/domains/${realm}/sync`,
params,
waitMsgTarget: view,
method: 'POST',
failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
success: function(response, options) {
Ext.create('Proxmox.window.TaskProgress', {
autoShow: true,
upid: response.result.data,
taskDone: () => { me.reload(); },
});
},
});
},
reload: function() {
this.getView().getStore().load();
},
},
store: {
autoLoad: true,
id: 'realm-syncs',
proxy: {
type: 'proxmox',
url: '/api2/json/cluster/jobs/realm-sync',
},
},
viewConfig: {
getRowClass: (record, _index) => record.get('enabled') ? '' : 'proxmox-disabled-row',
},
columns: [
{
header: gettext('Enabled'),
width: 80,
dataIndex: 'enabled',
sortable: true,
align: 'center',
stopSelection: false,
renderer: Proxmox.Utils.renderEnabledIcon,
},
{
text: gettext('Name'),
flex: 1,
dataIndex: 'id',
hidden: true,
},
{
text: gettext('Realm'),
width: 200,
dataIndex: 'realm',
},
{
header: gettext('Schedule'),
width: 150,
dataIndex: 'schedule',
},
{
text: gettext('Next Run'),
dataIndex: 'next-run',
width: 150,
renderer: PVE.Utils.render_next_event,
},
{
header: gettext('Comment'),
dataIndex: 'comment',
renderer: Ext.htmlEncode,
sorter: (a, b) => (a.data.comment || '').localeCompare(b.data.comment || ''),
flex: 1,
},
],
tbar: [
{
text: gettext('Add'),
handler: 'addRealmSyncJob',
},
{
text: gettext('Edit'),
xtype: 'proxmoxButton',
handler: 'editRealmSyncJob',
disabled: true,
},
{
xtype: 'proxmoxStdRemoveButton',
baseurl: `/api2/extjs/cluster/jobs/realm-sync`,
callback: 'reload',
},
{
xtype: 'proxmoxButton',
handler: 'runNow',
disabled: true,
text: gettext('Run Now'),
},
],
listeners: {
itemdblclick: 'editRealmSyncJob',
},
initComponent: function() {
var me = this;
me.callParent();
Proxmox.Utils.monStoreErrors(me, me.getStore());
},
});
Ext.define('PVE.dc.RealmSyncJobEdit', {
extend: 'Proxmox.window.Edit',
mixins: ['Proxmox.Mixin.CBind'],
subject: gettext('Realm Sync Job'),
onlineHelp: 'pveum_ldap_sync',
// don't focus the schedule field on edit
defaultFocus: 'field[name=id]',
cbindData: function() {
let me = this;
me.isCreate = !me.jobid;
me.jobid = me.jobid || "";
let url = '/api2/extjs/cluster/jobs/realm-sync';
me.url = me.jobid ? `${url}/${me.jobid}` : url;
me.method = me.isCreate ? 'POST' : 'PUT';
if (!me.isCreate) {
me.subject = `${me.subject}: ${me.jobid}`;
}
return {};
},
submitUrl: function(url, values) {
return this.isCreate ? `${url}/${values.id}` : url;
},
controller: {
xclass: 'Ext.app.ViewController',
updateDefaults: function(_field, newValue) {
let me = this;
['scope', 'enable-new', 'schedule'].forEach((reference) => {
me.lookup(reference)?.setDisabled(false);
});
// only update on create
if (!me.getView().isCreate) {
return;
}
Proxmox.Utils.API2Request({
url: `/access/domains/${newValue}`,
success: function(response) {
// first reset the fields to their default
['acl', 'entry', 'properties'].forEach(opt => {
me.lookup(`remove-vanished-${opt}`)?.setValue(false);
});
me.lookup('enable-new')?.setValue('1');
me.lookup('scope')?.setValue(undefined);
let options = response?.result?.data?.['sync-defaults-options'];
if (options) {
let parsed = PVE.Parser.parsePropertyString(options);
if (parsed['remove-vanished']) {
let opts = parsed['remove-vanished'].split(';');
for (const opt of opts) {
me.lookup(`remove-vanished-${opt}`)?.setValue(true);
}
delete parsed['remove-vanished'];
}
for (const [name, value] of Object.entries(parsed)) {
me.lookup(name)?.setValue(value);
}
}
},
});
},
},
items: [
{
xtype: 'inputpanel',
cbind: {
isCreate: '{isCreate}',
},
onGetValues: function(values) {
let me = this;
let vanished_opts = [];
['acl', 'entry', 'properties'].forEach((prop) => {
if (values[`remove-vanished-${prop}`]) {
vanished_opts.push(prop);
}
delete values[`remove-vanished-${prop}`];
});
if (!values.id && me.isCreate) {
values.id = 'realmsync-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13);
}
if (vanished_opts.length > 0) {
values['remove-vanished'] = vanished_opts.join(';');
} else {
values['remove-vanished'] = 'none';
}
PVE.Utils.delete_if_default(values, 'node', '');
if (me.isCreate) {
delete values.delete; // on create we cannot delete values
}
return values;
},
column1: [
{
xtype: 'pmxDisplayEditField',
editConfig: {
xtype: 'pmxRealmComboBox',
storeFilter: rec => rec.data.type === 'ldap' || rec.data.type === 'ad',
},
listConfig: {
emptyText: `<div class="x-grid-empty">${gettext('No LDAP/AD Realm found')}</div>`,
},
cbind: {
editable: '{isCreate}',
},
listeners: {
change: 'updateDefaults',
},
fieldLabel: gettext('Realm'),
name: 'realm',
reference: 'realm',
},
{
xtype: 'pveCalendarEvent',
fieldLabel: gettext('Schedule'),
disabled: true,
allowBlank: false,
name: 'schedule',
reference: 'schedule',
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Enable Job'),
name: 'enabled',
reference: 'enabled',
uncheckedValue: 0,
defaultValue: 1,
checked: true,
},
],
column2: [
{
xtype: 'proxmoxKVComboBox',
name: 'scope',
reference: 'scope',
disabled: true,
fieldLabel: gettext('Scope'),
value: '',
emptyText: gettext('No default available'),
deleteEmpty: false,
allowBlank: false,
comboItems: [
['users', gettext('Users')],
['groups', gettext('Groups')],
['both', gettext('Users and Groups')],
],
},
{
xtype: 'proxmoxKVComboBox',
value: '1',
deleteEmpty: false,
disabled: true,
allowBlank: false,
comboItems: [
['1', Proxmox.Utils.yesText],
['0', Proxmox.Utils.noText],
],
name: 'enable-new',
reference: 'enable-new',
fieldLabel: gettext('Enable New'),
},
],
columnB: [
{
xtype: 'fieldset',
title: gettext('Remove Vanished Options'),
items: [
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('ACL'),
name: 'remove-vanished-acl',
reference: 'remove-vanished-acl',
boxLabel: gettext('Remove ACLs of vanished users and groups.'),
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Entry'),
name: 'remove-vanished-entry',
reference: 'remove-vanished-entry',
boxLabel: gettext('Remove vanished user and group entries.'),
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Properties'),
name: 'remove-vanished-properties',
reference: 'remove-vanished-properties',
boxLabel: gettext('Remove vanished properties from synced users.'),
},
],
},
{
xtype: 'proxmoxtextfield',
name: 'comment',
fieldLabel: gettext('Job Comment'),
cbind: {
deleteEmpty: '{!isCreate}',
},
autoEl: {
tag: 'div',
'data-qtip': gettext('Description of the job'),
},
},
{
xtype: 'displayfield',
reference: 'defaulthint',
value: gettext('Default sync options can be set by editing the realm.'),
userCls: 'pmx-hint',
hidden: true,
},
],
},
],
initComponent: function() {
let me = this;
me.callParent();
if (me.jobid) {
me.load({
success: function(response, options) {
let values = response.result.data;
if (values['remove-vanished']) {
let opts = values['remove-vanished'].split(';');
for (const opt of opts) {
values[`remove-vanished-${opt}`] = 1;
}
}
me.down('inputpanel').setValues(values);
},
});
}
},
});
Ext.define('pve-resource-pci-tree', {
extend: 'Ext.data.Model',
idProperty: 'internalId',
fields: ['type', 'text', 'path', 'id', 'subsystem-id', 'iommugroup', 'description', 'digest'],
});
Ext.define('PVE.dc.PCIMapView', {
extend: 'PVE.tree.ResourceMapTree',
alias: 'widget.pveDcPCIMapView',
editWindowClass: 'PVE.window.PCIMapEditWindow',
baseUrl: '/cluster/mapping/pci',
mapIconCls: 'pve-itype-icon-pci',
getStatusCheckUrl: (node) => `/nodes/${node}/hardware/pci?pci-class-blacklist=`,
entryIdProperty: 'path',
checkValidity: function(data, node) {
let me = this;
let ids = {};
data.forEach((entry) => {
ids[entry.id] = entry;
});
me.getRootNode()?.cascade(function(rec) {
if (rec.data.node !== node || rec.data.type !== 'map') {
return;
}
let id = rec.data.path;
if (!id.match(/\.\d$/)) {
id += '.0';
}
let device = ids[id];
if (!device) {
rec.set('valid', 0);
rec.set('errmsg', Ext.String.format(gettext("Cannot find PCI id {0}"), id));
rec.commit();
return;
}
let deviceId = `${device.vendor}:${device.device}`.replace(/0x/g, '');
let subId = `${device.subsystem_vendor}:${device.subsystem_device}`.replace(/0x/g, '');
let toCheck = {
id: deviceId,
'subsystem-id': subId,
iommugroup: device.iommugroup !== -1 ? device.iommugroup : undefined,
};
let valid = 1;
let errors = [];
let errText = gettext("Configuration for {0} not correct ('{1}' != '{2}')");
for (const [key, validValue] of Object.entries(toCheck)) {
if (`${rec.data[key]}` !== `${validValue}`) {
errors.push(Ext.String.format(errText, key, rec.data[key] ?? '', validValue));
valid = 0;
}
}
rec.set('valid', valid);
rec.set('errmsg', errors.join('<br>'));
rec.commit();
});
},
store: {
sorters: 'text',
model: 'pve-resource-pci-tree',
data: {},
},
columns: [
{
xtype: 'treecolumn',
text: gettext('ID/Node/Path'),
dataIndex: 'text',
width: 200,
},
{
text: gettext('Vendor/Device'),
dataIndex: 'id',
},
{
text: gettext('Subsystem Vendor/Device'),
dataIndex: 'subsystem-id',
},
{
text: gettext('IOMMU-Group'),
dataIndex: 'iommugroup',
},
{
header: gettext('Status'),
dataIndex: 'valid',
flex: 1,
renderer: 'renderStatus',
},
{
header: gettext('Comment'),
dataIndex: 'description',
renderer: function(value, _meta, record) {
return Ext.String.htmlEncode(value ?? record.data.comment);
},
flex: 1,
},
],
});
Ext.define('pve-resource-usb-tree', {
extend: 'Ext.data.Model',
idProperty: 'internalId',
fields: ['type', 'text', 'path', 'id', 'description', 'digest'],
});
Ext.define('PVE.dc.USBMapView', {
extend: 'PVE.tree.ResourceMapTree',
alias: 'widget.pveDcUSBMapView',
editWindowClass: 'PVE.window.USBMapEditWindow',
baseUrl: '/cluster/mapping/usb',
mapIconCls: 'fa fa-usb',
getStatusCheckUrl: (node) => `/nodes/${node}/hardware/usb`,
entryIdProperty: 'id',
checkValidity: function(data, node) {
let me = this;
let ids = {};
let paths = {};
data.forEach((entry) => {
ids[`${entry.vendid}:${entry.prodid}`] = entry;
paths[`${entry.busnum}-${entry.usbpath}`] = entry;
});
me.getRootNode()?.cascade(function(rec) {
if (rec.data.node !== node || rec.data.type !== 'map') {
return;
}
let device;
if (rec.data.path) {
device = paths[rec.data.path];
}
device ??= ids[rec.data.id];
if (!device) {
rec.set('valid', 0);
rec.set('errmsg', Ext.String.format(gettext("Cannot find USB device {0}"), rec.data.id));
rec.commit();
return;
}
let deviceId = `${device.vendid}:${device.prodid}`.replace(/0x/g, '');
let toCheck = {
id: deviceId,
};
let valid = 1;
let errors = [];
let errText = gettext("Configuration for {0} not correct ('{1}' != '{2}')");
for (const [key, validValue] of Object.entries(toCheck)) {
if (rec.data[key] !== validValue) {
errors.push(Ext.String.format(errText, key, rec.data[key] ?? '', validValue));
valid = 0;
}
}
rec.set('valid', valid);
rec.set('errmsg', errors.join('<br>'));
rec.commit();
});
},
store: {
sorters: 'text',
model: 'pve-resource-usb-tree',
data: {},
},
columns: [
{
xtype: 'treecolumn',
text: gettext('ID/Node/Vendor&Device'),
dataIndex: 'text',
width: 200,
},
{
text: gettext('Path'),
dataIndex: 'path',
},
{
header: gettext('Status'),
dataIndex: 'valid',
flex: 1,
renderer: 'renderStatus',
},
{
header: gettext('Comment'),
dataIndex: 'description',
renderer: function(value, _meta, record) {
return Ext.String.htmlEncode(value ?? record.data.comment);
},
flex: 1,
},
],
});
Ext.define('PVE.lxc.CmdMenu', {
extend: 'Ext.menu.Menu',
showSeparator: false,
initComponent: function() {
let me = this;
let info = me.pveSelNode.data;
if (!info.node) {
throw "no node name specified";
}
if (!info.vmid) {
throw "no CT ID specified";
}
let vm_command = function(cmd, params) {
Proxmox.Utils.API2Request({
params: params,
url: `/nodes/${info.node}/${info.type}/${info.vmid}/status/${cmd}`,
method: 'POST',
failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
};
let confirmedVMCommand = (cmd, params) => {
let msg = PVE.Utils.formatGuestTaskConfirmation(`vz${cmd}`, info.vmid, info.name);
Ext.Msg.confirm(gettext('Confirm'), msg, btn => {
if (btn === 'yes') {
vm_command(cmd, params);
}
});
};
let caps = Ext.state.Manager.get('GuiCap');
let standalone = PVE.Utils.isStandaloneNode();
let running = false, stopped = true, suspended = false;
switch (info.status) {
case 'running':
running = true;
stopped = false;
break;
case 'paused':
stopped = false;
suspended = true;
break;
default: break;
}
me.title = 'CT ' + info.vmid;
me.items = [
{
text: gettext('Start'),
iconCls: 'fa fa-fw fa-play',
disabled: running,
handler: () => vm_command('start'),
},
{
text: gettext('Shutdown'),
iconCls: 'fa fa-fw fa-power-off',
disabled: stopped || suspended,
handler: () => confirmedVMCommand('shutdown'),
},
{
text: gettext('Stop'),
iconCls: 'fa fa-fw fa-stop',
disabled: stopped,
tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'CT'),
handler: () => {
Ext.create('PVE.GuestStop', {
nodename: info.node,
vm: info,
autoShow: true,
});
},
},
{
text: gettext('Reboot'),
iconCls: 'fa fa-fw fa-refresh',
disabled: stopped,
tooltip: Ext.String.format(gettext('Reboot {0}'), 'CT'),
handler: () => confirmedVMCommand('reboot'),
},
{
xtype: 'menuseparator',
hidden: (standalone || !caps.vms['VM.Migrate']) && !caps.vms['VM.Allocate'] && !caps.vms['VM.Clone'],
},
{
text: gettext('Clone'),
iconCls: 'fa fa-fw fa-clone',
hidden: !caps.vms['VM.Clone'],
handler: () => PVE.window.Clone.wrap(info.node, info.vmid, me.isTemplate, 'lxc'),
},
{
text: gettext('Migrate'),
iconCls: 'fa fa-fw fa-send-o',
hidden: standalone || !caps.vms['VM.Migrate'],
handler: function() {
Ext.create('PVE.window.Migrate', {
vmtype: 'lxc',
nodename: info.node,
vmid: info.vmid,
autoShow: true,
});
},
},
{
text: gettext('Convert to template'),
iconCls: 'fa fa-fw fa-file-o',
handler: function() {
let msg = PVE.Utils.formatGuestTaskConfirmation('vztemplate', info.vmid, info.name);
Ext.Msg.confirm(gettext('Confirm'), msg, function(btn) {
if (btn === 'yes') {
Proxmox.Utils.API2Request({
url: `/nodes/${info.node}/lxc/${info.vmid}/template`,
method: 'POST',
failure: (response, opts) => Ext.Msg.alert('Error', response.htmlStatus),
});
}
});
},
},
{ xtype: 'menuseparator' },
{
text: gettext('Console'),
iconCls: 'fa fa-fw fa-terminal',
handler: () =>
PVE.Utils.openDefaultConsoleWindow(true, 'lxc', info.vmid, info.node, info.vmname),
},
];
me.callParent();
},
});
Ext.define('PVE.lxc.Config', {
extend: 'PVE.panel.Config',
alias: 'widget.pveLXCConfig',
onlineHelp: 'chapter_pct',
userCls: 'proxmox-tags-full',
initComponent: function() {
var me = this;
var vm = me.pveSelNode.data;
var nodename = vm.node;
if (!nodename) {
throw "no node name specified";
}
var vmid = vm.vmid;
if (!vmid) {
throw "no VM ID specified";
}
var template = !!vm.template;
var running = !!vm.uptime;
var caps = Ext.state.Manager.get('GuiCap');
var base_url = '/nodes/' + nodename + '/lxc/' + vmid;
me.statusStore = Ext.create('Proxmox.data.ObjectStore', {
url: '/api2/json' + base_url + '/status/current',
interval: 1000,
});
var vm_command = function(cmd, params) {
Proxmox.Utils.API2Request({
params: params,
url: base_url + "/status/" + cmd,
waitMsgTarget: me,
method: 'POST',
failure: function(response, opts) {
Ext.Msg.alert('Error', response.htmlStatus);
},
});
};
var startBtn = Ext.create('Ext.Button', {
text: gettext('Start'),
disabled: !caps.vms['VM.PowerMgmt'] || running,
hidden: template,
handler: function() {
vm_command('start');
},
iconCls: 'fa fa-play',
});
var shutdownBtn = Ext.create('PVE.button.Split', {
text: gettext('Shutdown'),
disabled: !caps.vms['VM.PowerMgmt'] || !running,
hidden: template,
confirmMsg: PVE.Utils.formatGuestTaskConfirmation('vzshutdown', vmid, vm.name),
handler: function() {
vm_command('shutdown');
},
menu: {
items: [{
text: gettext('Reboot'),
disabled: !caps.vms['VM.PowerMgmt'],
confirmMsg: PVE.Utils.formatGuestTaskConfirmation('vzreboot', vmid, vm.name),
tooltip: Ext.String.format(gettext('Reboot {0}'), 'CT'),
handler: function() {
vm_command("reboot");
},
iconCls: 'fa fa-refresh',
},
{
text: gettext('Stop'),
disabled: !caps.vms['VM.PowerMgmt'],
tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'CT'),
handler: function() {
Ext.create('PVE.GuestStop', {
nodename: nodename,
vm: vm,
autoShow: true,
});
},
iconCls: 'fa fa-stop',
}],
},
iconCls: 'fa fa-power-off',
});
var migrateBtn = Ext.create('Ext.Button', {
text: gettext('Migrate'),
disabled: !caps.vms['VM.Migrate'],
hidden: PVE.Utils.isStandaloneNode(),
handler: function() {
var win = Ext.create('PVE.window.Migrate', {
vmtype: 'lxc',
nodename: nodename,
vmid: vmid,
});
win.show();
},
iconCls: 'fa fa-send-o',
});
var moreBtn = Ext.create('Proxmox.button.Button', {
text: gettext('More'),
menu: {
items: [
{
text: gettext('Clone'),
iconCls: 'fa fa-fw fa-clone',
hidden: !caps.vms['VM.Clone'],
handler: function() {
PVE.window.Clone.wrap(nodename, vmid, template, 'lxc');
},
},
{
text: gettext('Convert to template'),
disabled: template,
xtype: 'pveMenuItem',
iconCls: 'fa fa-fw fa-file-o',
hidden: !caps.vms['VM.Allocate'],
confirmMsg: PVE.Utils.formatGuestTaskConfirmation('vztemplate', vmid, vm.name),
handler: function() {
Proxmox.Utils.API2Request({
url: base_url + '/template',
waitMsgTarget: me,
method: 'POST',
failure: function(response, opts) {
Ext.Msg.alert('Error', response.htmlStatus);
},
});
},
},
{
iconCls: 'fa fa-heartbeat ',
hidden: !caps.nodes['Sys.Console'],
text: gettext('Manage HA'),
handler: function() {
var ha = vm.hastate;
Ext.create('PVE.ha.VMResourceEdit', {
vmid: vmid,
guestType: 'ct',
isCreate: !ha || ha === 'unmanaged',
}).show();
},
},
{
text: gettext('Remove'),
disabled: !caps.vms['VM.Allocate'],
itemId: 'removeBtn',
handler: function() {
Ext.create('PVE.window.SafeDestroyGuest', {
url: base_url,
item: { type: 'CT', id: vmid },
taskName: 'vzdestroy',
}).show();
},
iconCls: 'fa fa-trash-o',
},
],
},
});
var consoleBtn = Ext.create('PVE.button.ConsoleButton', {
disabled: !caps.vms['VM.Console'],
consoleType: 'lxc',
consoleName: vm.name,
hidden: template,
nodename: nodename,
vmid: vmid,
});
var statusTxt = Ext.create('Ext.toolbar.TextItem', {
data: {
lock: undefined,
},
tpl: [
'<tpl if="lock">',
'<i class="fa fa-lg fa-lock"></i> ({lock})',
'</tpl>',
],
});
let tagsContainer = Ext.create('PVE.panel.TagEditContainer', {
tags: vm.tags,
canEdit: !!caps.vms['VM.Config.Options'],
listeners: {
change: function(tags) {
Proxmox.Utils.API2Request({
url: base_url + '/config',
method: 'PUT',
params: {
tags,
},
success: function() {
me.statusStore.load();
},
failure: function(response) {
Ext.Msg.alert('Error', response.htmlStatus);
me.statusStore.load();
},
});
},
},
});
let vm_text = `${vm.vmid} (${vm.name})`;
Ext.apply(me, {
title: Ext.String.format(gettext("Container {0} on node '{1}'"), vm_text, nodename),
hstateid: 'lxctab',
tbarSpacing: false,
tbar: [statusTxt, tagsContainer, '->', startBtn, shutdownBtn, migrateBtn, consoleBtn, moreBtn],
defaults: { statusStore: me.statusStore },
items: [
{
title: gettext('Summary'),
xtype: 'pveGuestSummary',
iconCls: 'fa fa-book',
itemId: 'summary',
},
],
});
if (caps.vms['VM.Console'] && !template) {
me.items.push(
{
title: gettext('Console'),
itemId: 'consolejs',
iconCls: 'fa fa-terminal',
xtype: 'pveNoVncConsole',
vmid: vmid,
consoleType: 'lxc',
xtermjs: true,
nodename: nodename,
},
);
}
me.items.push(
{
title: gettext('Resources'),
itemId: 'resources',
expandedOnInit: true,
iconCls: 'fa fa-cube',
xtype: 'pveLxcRessourceView',
},
{
title: gettext('Network'),
iconCls: 'fa fa-exchange',
itemId: 'network',
xtype: 'pveLxcNetworkView',
},
{
title: gettext('DNS'),
iconCls: 'fa fa-globe',
itemId: 'dns',
xtype: 'pveLxcDNS',
},
{
title: gettext('Options'),
itemId: 'options',
iconCls: 'fa fa-gear',
xtype: 'pveLxcOptions',
},
{
title: gettext('Task History'),
itemId: 'tasks',
iconCls: 'fa fa-list-alt',
xtype: 'proxmoxNodeTasks',
nodename: nodename,
preFilter: {
vmid,
},
},
);
if (caps.vms['VM.Backup']) {
me.items.push({
title: gettext('Backup'),
iconCls: 'fa fa-floppy-o',
xtype: 'pveBackupView',
itemId: 'backup',
},
{
title: gettext('Replication'),
iconCls: 'fa fa-retweet',
xtype: 'pveReplicaView',
itemId: 'replication',
});
}
if ((caps.vms['VM.Snapshot'] || caps.vms['VM.Snapshot.Rollback'] ||
caps.vms['VM.Audit']) && !template) {
me.items.push({
title: gettext('Snapshots'),
iconCls: 'fa fa-history',
xtype: 'pveGuestSnapshotTree',
type: 'lxc',
itemId: 'snapshot',
});
}
if (caps.vms['VM.Audit']) {
me.items.push(
{
xtype: 'pveFirewallRules',
title: gettext('Firewall'),
iconCls: 'fa fa-shield',
allow_iface: true,
base_url: base_url + '/firewall/rules',
list_refs_url: base_url + '/firewall/refs',
itemId: 'firewall',
firewall_type: 'vm',
},
{
xtype: 'pveFirewallOptions',
groups: ['firewall'],
iconCls: 'fa fa-gear',
onlineHelp: 'pve_firewall_vm_container_configuration',
title: gettext('Options'),
base_url: base_url + '/firewall/options',
fwtype: 'vm',
itemId: 'firewall-options',
},
{
xtype: 'pveFirewallAliases',
title: gettext('Alias'),
groups: ['firewall'],
iconCls: 'fa fa-external-link',
base_url: base_url + '/firewall/aliases',
itemId: 'firewall-aliases',
},
{
xtype: 'pveIPSet',
title: gettext('IPSet'),
groups: ['firewall'],
iconCls: 'fa fa-list-ol',
base_url: base_url + '/firewall/ipset',
list_refs_url: base_url + '/firewall/refs',
itemId: 'firewall-ipset',
},
);
}
if (caps.vms['VM.Console']) {
me.items.push(
{
title: gettext('Log'),
groups: ['firewall'],
iconCls: 'fa fa-list',
onlineHelp: 'chapter_pve_firewall',
itemId: 'firewall-fwlog',
xtype: 'proxmoxLogView',
url: '/api2/extjs' + base_url + '/firewall/log',
log_select_timespan: true,
submitFormat: 'U',
},
);
}
if (caps.vms['Permissions.Modify']) {
me.items.push({
xtype: 'pveACLView',
title: gettext('Permissions'),
itemId: 'permissions',
iconCls: 'fa fa-unlock',
path: '/vms/' + vmid,
});
}
me.callParent();
var prevStatus = 'unknown';
me.mon(me.statusStore, 'load', function(s, records, success) {
var status;
var lock;
var rec;
if (!success) {
status = 'unknown';
} else {
rec = s.data.get('status');
status = rec ? rec.data.value : 'unknown';
rec = s.data.get('template');
template = rec ? rec.data.value : false;
rec = s.data.get('lock');
lock = rec ? rec.data.value : undefined;
}
statusTxt.update({ lock: lock });
rec = s.data.get('tags');
tagsContainer.loadTags(rec?.data?.value);
startBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || status === 'running' || template);
shutdownBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || status !== 'running');
me.down('#removeBtn').setDisabled(!caps.vms['VM.Allocate'] || status !== 'stopped');
consoleBtn.setDisabled(template);
if (prevStatus === 'stopped' && status === 'running') {
let con = me.down('#consolejs');
if (con) {
con.reload();
}
}
prevStatus = status;
});
me.on('afterrender', function() {
me.statusStore.startUpdate();
});
me.on('destroy', function() {
me.statusStore.stopUpdate();
});
},
});
Ext.define('PVE.lxc.CreateWizard', {
extend: 'PVE.window.Wizard',
mixins: ['Proxmox.Mixin.CBind'],
viewModel: {
data: {
nodename: '',
storage: '',
unprivileged: true,
},
formulas: {
cgroupMode: function(get) {
const nodeInfo = PVE.data.ResourceStore.getNodes().find(
node => node.node === get('nodename'),
);
return nodeInfo ? nodeInfo['cgroup-mode'] : 2;
},
},
},
cbindData: {
nodename: undefined,
},
subject: gettext('LXC Container'),
items: [
{
xtype: 'inputpanel',
title: gettext('General'),
onlineHelp: 'pct_general',
column1: [
{
xtype: 'pveNodeSelector',
name: 'nodename',
cbind: {
selectCurNode: '{!nodename}',
preferredValue: '{nodename}',
},
bind: {
value: '{nodename}',
},
fieldLabel: gettext('Node'),
allowBlank: false,
onlineValidator: true,
},
{
xtype: 'pveGuestIDSelector',
name: 'vmid', // backend only knows vmid
guestType: 'lxc',
value: '',
loadNextFreeID: true,
validateExists: false,
},
{
xtype: 'proxmoxtextfield',
name: 'hostname',
vtype: 'DnsName',
value: '',
fieldLabel: gettext('Hostname'),
skipEmptyText: true,
allowBlank: true,
},
{
xtype: 'proxmoxcheckbox',
name: 'unprivileged',
value: true,
bind: {
value: '{unprivileged}',
},
fieldLabel: gettext('Unprivileged container'),
},
{
xtype: 'proxmoxcheckbox',
name: 'features',
inputValue: 'nesting=1',
value: true,
bind: {
disabled: '{!unprivileged}',
},
fieldLabel: gettext('Nesting'),
},
],
column2: [
{
xtype: 'pvePoolSelector',
fieldLabel: gettext('Resource Pool'),
name: 'pool',
value: '',
allowBlank: true,
},
{
xtype: 'textfield',
inputType: 'password',
name: 'password',
value: '',
fieldLabel: gettext('Password'),
allowBlank: false,
minLength: 5,
change: function(f, value) {
if (f.rendered) {
f.up().down('field[name=confirmpw]').validate();
}
},
},
{
xtype: 'textfield',
inputType: 'password',
name: 'confirmpw',
value: '',
fieldLabel: gettext('Confirm password'),
allowBlank: true,
submitValue: false,
validator: function(value) {
var pw = this.up().down('field[name=password]').getValue();
if (pw !== value) {
return "Passwords do not match!";
}
return true;
},
},
{
xtype: 'textarea',
name: 'ssh-public-keys',
value: '',
fieldLabel: gettext('SSH public key(s)'),
allowBlank: true,
validator: function(value) {
let pwfield = this.up().down('field[name=password]');
if (value.length) {
let keys = value.indexOf('\n') !== -1 ? value.split('\n') : [value];
if (keys.some(key => key !== '' && !PVE.Parser.parseSSHKey(key))) {
return "Failed to recognize ssh key";
}
pwfield.allowBlank = true;
} else {
pwfield.allowBlank = false;
}
pwfield.validate();
return true;
},
afterRender: function() {
if (!window.FileReader) {
return; // No FileReader support in this browser
}
let cancelEvent = ev => {
ev = ev.event;
if (ev.preventDefault) {
ev.preventDefault();
}
};
this.inputEl.on('dragover', cancelEvent);
this.inputEl.on('dragenter', cancelEvent);
this.inputEl.on('drop', ev => {
cancelEvent(ev);
let files = ev.event.dataTransfer.files;
PVE.Utils.loadSSHKeyFromFile(files[0], v => this.setValue(v));
});
},
},
{
xtype: 'pveMultiFileButton',
name: 'file',
hidden: !window.FileReader,
text: gettext('Load SSH Key File'),
listeners: {
change: function(btn, e, value) {
e = e.event;
let field = this.up().down('textarea[name=ssh-public-keys]');
for (const file of e?.target?.files ?? []) {
PVE.Utils.loadSSHKeyFromFile(file, v => {
let oldValue = field.getValue();
field.setValue(oldValue ? `${oldValue}\n${v.trim()}` : v.trim());
});
}
btn.reset();
},
},
},
],
advancedColumnB: [
{
xtype: 'pveTagFieldSet',
name: 'tags',
maxHeight: 150,
},
],
},
{
xtype: 'inputpanel',
title: gettext('Template'),
onlineHelp: 'pct_container_images',
column1: [
{
xtype: 'pveStorageSelector',
name: 'tmplstorage',
fieldLabel: gettext('Storage'),
storageContent: 'vztmpl',
autoSelect: true,
allowBlank: false,
bind: {
value: '{storage}',
nodename: '{nodename}',
},
},
{
xtype: 'pveFileSelector',
name: 'ostemplate',
storageContent: 'vztmpl',
fieldLabel: gettext('Template'),
bind: {
storage: '{storage}',
nodename: '{nodename}',
},
allowBlank: false,
},
],
},
{
xtype: 'pveMultiMPPanel',
title: gettext('Disks'),
insideWizard: true,
isCreate: true,
unused: false,
confid: 'rootfs',
},
{
xtype: 'pveLxcCPUInputPanel',
title: gettext('CPU'),
insideWizard: true,
},
{
xtype: 'pveLxcMemoryInputPanel',
title: gettext('Memory'),
insideWizard: true,
},
{
xtype: 'pveLxcNetworkInputPanel',
title: gettext('Network'),
insideWizard: true,
bind: {
nodename: '{nodename}',
},
isCreate: true,
},
{
xtype: 'pveLxcDNSInputPanel',
title: gettext('DNS'),
insideWizard: true,
},
{
title: gettext('Confirm'),
layout: 'fit',
items: [
{
xtype: 'grid',
store: {
model: 'KeyValue',
sorters: [{
property: 'key',
direction: 'ASC',
}],
},
columns: [
{ header: 'Key', width: 150, dataIndex: 'key' },
{ header: 'Value', flex: 1, dataIndex: 'value', renderer: Ext.htmlEncode },
],
},
],
dockedItems: [
{
xtype: 'proxmoxcheckbox',
name: 'start',
dock: 'bottom',
margin: '5 0 0 0',
boxLabel: gettext('Start after created'),
},
],
listeners: {
show: function(panel) {
let wizard = this.up('window');
let kv = wizard.getValues();
let data = [];
Ext.Object.each(kv, function(key, value) {
if (key === 'delete' || key === 'tmplstorage') { // ignore
return;
}
if (key === 'password') { // don't show pw
return;
}
data.push({ key: key, value: value });
});
let summaryStore = panel.down('grid').getStore();
summaryStore.suspendEvents();
summaryStore.removeAll();
summaryStore.add(data);
summaryStore.sort();
summaryStore.resumeEvents();
summaryStore.fireEvent('refresh');
},
},
onSubmit: function() {
let wizard = this.up('window');
let kv = wizard.getValues();
delete kv.delete;
let nodename = kv.nodename;
delete kv.nodename;
delete kv.tmplstorage;
if (!kv.pool.length) {
delete kv.pool;
}
if (!kv.password.length && kv['ssh-public-keys']) {
delete kv.password;
}
Proxmox.Utils.API2Request({
url: `/nodes/${nodename}/lxc`,
waitMsgTarget: wizard,
method: 'POST',
params: kv,
success: function(response, opts) {
Ext.create('Proxmox.window.TaskViewer', {
autoShow: true,
upid: response.result.data,
});
wizard.close();
},
failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
},
},
],
});
Ext.define('PVE.lxc.DeviceInputPanel', {
extend: 'Proxmox.panel.InputPanel',
mixins: ['Proxmox.Mixin.CBind'],
autoComplete: false,
controller: {
xclass: 'Ext.app.ViewController',
},
setVMConfig: function(vmconfig) {
let me = this;
me.vmconfig = vmconfig;
if (me.isCreate) {
PVE.Utils.forEachLxcDev((i, name) => {
if (!Ext.isDefined(vmconfig[name])) {
me.confid = name;
me.down('field[name=devid]').setValue(i);
return false;
}
return undefined;
});
}
},
onGetValues: function(values) {
let me = this;
let confid = me.isCreate ? "dev" + values.devid : me.confid;
delete values.devid;
let val = PVE.Parser.printPropertyString(values, 'path');
let ret = {};
ret[confid] = val;
return ret;
},
items: [
{
xtype: 'proxmoxintegerfield',
name: 'devid',
minValue: 0,
maxValue: PVE.Utils.lxc_dev_count - 1,
hidden: true,
allowBlank: false,
disabled: true,
cbind: {
disabled: '{!isCreate}',
},
},
{
xtype: 'textfield',
name: 'path',
fieldLabel: gettext('Device Path'),
labelWidth: 120,
editable: true,
allowBlank: false,
emptyText: '/dev/xyz',
validator: v => v.startsWith('/dev/') ? true : gettext("Path has to start with /dev/"),
},
],
advancedColumn1: [
{
xtype: 'proxmoxintegerfield',
name: 'uid',
editable: true,
fieldLabel: Ext.String.format(gettext('{0} in CT'), 'UID'),
labelWidth: 120,
emptyText: '0',
minValue: 0,
},
{
xtype: 'proxmoxintegerfield',
name: 'gid',
editable: true,
fieldLabel: Ext.String.format(gettext('{0} in CT'), 'GID'),
labelWidth: 120,
emptyText: '0',
minValue: 0,
},
],
advancedColumn2: [
{
xtype: 'textfield',
name: 'mode',
editable: true,
fieldLabel: Ext.String.format(gettext('Access Mode in CT')),
labelWidth: 120,
emptyText: '0660',
validator: function(value) {
if (/^0[0-7]{3}$|^$/i.test(value)) {
return true;
}
return gettext("Access mode has to be an octal number");
},
},
{
xtype: 'checkbox',
name: 'deny-write',
fieldLabel: gettext('Read only'),
labelWidth: 120,
checked: false,
},
],
});
Ext.define('PVE.lxc.DeviceEdit', {
extend: 'Proxmox.window.Edit',
vmconfig: undefined,
isAdd: true,
width: 450,
initComponent: function() {
let me = this;
me.isCreate = !me.confid;
let ipanel = Ext.create('PVE.lxc.DeviceInputPanel', {
confid: me.confid,
isCreate: me.isCreate,
pveSelNode: me.pveSelNode,
});
let subject;
if (me.isCreate) {
subject = gettext('Device');
} else {
subject = gettext('Device') + ' (' + me.confid + ')';
}
Ext.apply(me, {
subject: subject,
items: [ipanel],
});
me.callParent();
me.load({
success: function(response, options) {
ipanel.setVMConfig(response.result.data);
if (me.isCreate) {
return;
}
let data = PVE.Parser.parsePropertyString(response.result.data[me.confid], 'path');
let values = {
path: data.path,
mode: data.mode,
uid: data.uid,
gid: data.gid,
'deny-write': data['deny-write'],
};
ipanel.setValues(values);
},
});
},
});
Ext.define('PVE.lxc.DNSInputPanel', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pveLxcDNSInputPanel',
insideWizard: false,
onGetValues: function(values) {
var me = this;
var deletes = [];
if (!values.searchdomain && !me.insideWizard) {
deletes.push('searchdomain');
}
if (values.nameserver) {
let list = values.nameserver.split(/[ ,;]+/);
values.nameserver = list.join(' ');
} else if (!me.insideWizard) {
deletes.push('nameserver');
}
if (deletes.length) {
values.delete = deletes.join(',');
}
return values;
},
initComponent: function() {
var me = this;
var items = [
{
xtype: 'proxmoxtextfield',
name: 'searchdomain',
skipEmptyText: true,
fieldLabel: gettext('DNS domain'),
emptyText: gettext('use host settings'),
allowBlank: true,
},
{
xtype: 'proxmoxtextfield',
fieldLabel: gettext('DNS servers'),
vtype: 'IP64AddressWithSuffixList',
allowBlank: true,
emptyText: gettext('use host settings'),
name: 'nameserver',
itemId: 'nameserver',
},
];
if (me.insideWizard) {
me.column1 = items;
} else {
me.items = items;
}
me.callParent();
},
});
Ext.define('PVE.lxc.DNSEdit', {
extend: 'Proxmox.window.Edit',
initComponent: function() {
var me = this;
var ipanel = Ext.create('PVE.lxc.DNSInputPanel');
Ext.apply(me, {
subject: gettext('Resources'),
items: [ipanel],
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
var values = response.result.data;
if (values.nameserver) {
values.nameserver.replace(/[,;]/, ' ');
values.nameserver.replace(/^\s+/, '');
}
ipanel.setValues(values);
},
});
}
},
});
Ext.define('PVE.lxc.DNS', {
extend: 'Proxmox.grid.PendingObjectGrid',
alias: ['widget.pveLxcDNS'],
onlineHelp: 'pct_container_network',
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
var vmid = me.pveSelNode.data.vmid;
if (!vmid) {
throw "no VM ID specified";
}
var caps = Ext.state.Manager.get('GuiCap');
var rows = {
hostname: {
required: true,
defaultValue: me.pveSelNode.data.name,
header: gettext('Hostname'),
editor: caps.vms['VM.Config.Network'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('Hostname'),
items: {
xtype: 'inputpanel',
items: {
fieldLabel: gettext('Hostname'),
xtype: 'textfield',
name: 'hostname',
vtype: 'DnsName',
allowBlank: true,
emptyText: 'CT' + vmid.toString(),
},
onGetValues: function(values) {
var params = values;
if (values.hostname === undefined ||
values.hostname === null ||
values.hostname === '') {
params = { hostname: 'CT'+vmid.toString() };
}
return params;
},
},
} : undefined,
},
searchdomain: {
header: gettext('DNS domain'),
defaultValue: '',
editor: caps.vms['VM.Config.Network'] ? 'PVE.lxc.DNSEdit' : undefined,
renderer: function(value) {
return value || gettext('use host settings');
},
},
nameserver: {
header: gettext('DNS server'),
defaultValue: '',
editor: caps.vms['VM.Config.Network'] ? 'PVE.lxc.DNSEdit' : undefined,
renderer: function(value) {
return value || gettext('use host settings');
},
},
};
var baseurl = 'nodes/' + nodename + '/lxc/' + vmid + '/config';
var reload = function() {
me.rstore.load();
};
var sm = Ext.create('Ext.selection.RowModel', {});
var run_editor = function() {
var rec = sm.getSelection()[0];
if (!rec) {
return;
}
var rowdef = rows[rec.data.key];
if (!rowdef.editor) {
return;
}
var win;
if (Ext.isString(rowdef.editor)) {
win = Ext.create(rowdef.editor, {
pveSelNode: me.pveSelNode,
confid: rec.data.key,
url: '/api2/extjs/nodes/' + nodename + '/lxc/' + vmid + '/config',
});
} else {
var config = Ext.apply({
pveSelNode: me.pveSelNode,
confid: rec.data.key,
url: '/api2/extjs/nodes/' + nodename + '/lxc/' + vmid + '/config',
}, rowdef.editor);
win = Ext.createWidget(rowdef.editor.xtype, config);
win.load();
}
//win.load();
win.show();
win.on('destroy', reload);
};
var edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
selModel: sm,
enableFn: function(rec) {
var rowdef = rows[rec.data.key];
return !!rowdef.editor;
},
handler: run_editor,
});
var revert_btn = new PVE.button.PendingRevert();
var set_button_status = function() {
let button_sm = me.getSelectionModel();
let rec = button_sm.getSelection()[0];
if (!rec) {
edit_btn.disable();
return;
}
let key = rec.data.key;
let rowdef = rows[key];
edit_btn.setDisabled(!rowdef.editor);
let pending = rec.data.delete || me.hasPendingChanges(key);
revert_btn.setDisabled(!pending);
};
Ext.apply(me, {
url: "/api2/json/nodes/" + nodename + "/lxc/" + vmid + "/pending",
selModel: sm,
cwidth1: 150,
interval: 5000,
run_editor: run_editor,
tbar: [edit_btn, revert_btn],
rows: rows,
editorConfig: {
url: "/api2/extjs/" + baseurl,
},
listeners: {
itemdblclick: run_editor,
selectionchange: set_button_status,
activate: reload,
},
});
me.callParent();
me.on('activate', me.rstore.startUpdate);
me.on('destroy', me.rstore.stopUpdate);
me.on('deactivate', me.rstore.stopUpdate);
me.mon(me.getStore(), 'datachanged', function() {
set_button_status();
});
},
});
Ext.define('PVE.lxc.FeaturesInputPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveLxcFeaturesInputPanel',
// used to save the mounts fstypes until sending
mounts: [],
fstypes: ['nfs', 'cifs'],
viewModel: {
parent: null,
data: {
unprivileged: false,
},
formulas: {
privilegedOnly: function(get) {
return get('unprivileged') ? gettext('privileged only') : '';
},
unprivilegedOnly: function(get) {
return !get('unprivileged') ? gettext('unprivileged only') : '';
},
},
},
items: [
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('keyctl'),
name: 'keyctl',
bind: {
disabled: '{!unprivileged}',
boxLabel: '{unprivilegedOnly}',
},
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Nesting'),
name: 'nesting',
},
{
xtype: 'proxmoxcheckbox',
name: 'nfs',
fieldLabel: 'NFS',
bind: {
disabled: '{unprivileged}',
boxLabel: '{privilegedOnly}',
},
},
{
xtype: 'proxmoxcheckbox',
name: 'cifs',
fieldLabel: 'SMB/CIFS',
bind: {
disabled: '{unprivileged}',
boxLabel: '{privilegedOnly}',
},
},
{
xtype: 'proxmoxcheckbox',
name: 'fuse',
fieldLabel: 'FUSE',
},
{
xtype: 'proxmoxcheckbox',
name: 'mknod',
fieldLabel: gettext('Create Device Nodes'),
boxLabel: gettext('Experimental'),
},
],
onGetValues: function(values) {
var me = this;
var mounts = me.mounts;
me.fstypes.forEach(function(fs) {
if (values[fs]) {
mounts.push(fs);
}
delete values[fs];
});
if (mounts.length) {
values.mount = mounts.join(';');
}
var featuresstring = PVE.Parser.printPropertyString(values, undefined);
if (featuresstring === '') {
return { 'delete': 'features' };
}
return { features: featuresstring };
},
setValues: function(values) {
var me = this;
me.viewModel.set('unprivileged', values.unprivileged);
if (values.features) {
var res = PVE.Parser.parsePropertyString(values.features);
me.mounts = [];
if (res.mount) {
res.mount.split(/[; ]/).forEach(function(item) {
if (me.fstypes.indexOf(item) === -1) {
me.mounts.push(item);
} else {
res[item] = 1;
}
});
}
this.callParent([res]);
}
},
initComponent: function() {
let me = this;
me.mounts = []; // reset state
me.callParent();
},
});
Ext.define('PVE.lxc.FeaturesEdit', {
extend: 'Proxmox.window.Edit',
xtype: 'pveLxcFeaturesEdit',
subject: gettext('Features'),
autoLoad: true,
width: 350,
items: [{
xtype: 'pveLxcFeaturesInputPanel',
}],
});
Ext.define('PVE.lxc.MountPointInputPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveLxcMountPointInputPanel',
onlineHelp: 'pct_container_storage',
insideWizard: false,
unused: false, // add unused disk imaged
unprivileged: false,
vmconfig: {}, // used to select unused disks
setUnprivileged: function(unprivileged) {
var me = this;
var vm = me.getViewModel();
me.unprivileged = unprivileged;
vm.set('unpriv', unprivileged);
},
onGetValues: function(values) {
var me = this;
var confid = me.confid || "mp"+values.mpid;
me.mp.file = me.down('field[name=file]').getValue();
if (me.unused) {
confid = "mp"+values.mpid;
} else if (me.isCreate) {
me.mp.file = values.hdstorage + ':' + values.disksize;
}
// delete unnecessary fields
delete values.mpid;
delete values.hdstorage;
delete values.disksize;
delete values.diskformat;
let setMPOpt = (k, src, v) => PVE.Utils.propertyStringSet(me.mp, src, k, v);
setMPOpt('mp', values.mp);
let mountOpts = (values.mountoptions || []).join(';');
setMPOpt('mountoptions', values.mountoptions, mountOpts);
setMPOpt('mp', values.mp);
setMPOpt('backup', values.backup);
setMPOpt('quota', values.quota);
setMPOpt('ro', values.ro);
setMPOpt('acl', values.acl);
setMPOpt('replicate', values.replicate);
let res = {};
res[confid] = PVE.Parser.printLxcMountPoint(me.mp);
return res;
},
setMountPoint: function(mp) {
let me = this;
let vm = me.getViewModel();
vm.set('mptype', mp.type);
if (mp.mountoptions) {
mp.mountoptions = mp.mountoptions.split(';');
}
me.mp = mp;
me.filterMountOptions();
me.setValues(mp);
},
filterMountOptions: function() {
let me = this;
if (me.confid === 'rootfs') {
let field = me.down('field[name=mountoptions]');
let exclude = ['nodev', 'noexec'];
let filtered = field.comboItems.filter(v => !exclude.includes(v[0]));
field.setComboItems(filtered);
}
},
updateVMConfig: function(vmconfig) {
let me = this;
let vm = me.getViewModel();
me.vmconfig = vmconfig;
vm.set('unpriv', vmconfig.unprivileged);
me.down('field[name=mpid]').validate();
},
setVMConfig: function(vmconfig) {
let me = this;
me.updateVMConfig(vmconfig);
PVE.Utils.forEachLxcMP((bus, i, name) => {
if (!Ext.isDefined(vmconfig[name])) {
me.down('field[name=mpid]').setValue(i);
return false;
}
return undefined;
});
},
setNodename: function(nodename) {
let me = this;
let vm = me.getViewModel();
vm.set('node', nodename);
me.down('#diskstorage').setNodename(nodename);
},
controller: {
xclass: 'Ext.app.ViewController',
control: {
'field[name=mpid]': {
change: function(field, value) {
let me = this;
let view = this.getView();
if (view.confid !== 'rootfs') {
view.fireEvent('diskidchange', view, `mp${value}`);
}
field.validate();
},
},
'#hdstorage': {
change: function(field, newValue) {
let me = this;
if (!newValue) {
return;
}
let rec = field.store.getById(newValue);
if (!rec) {
return;
}
me.getViewModel().set('type', rec.data.type);
},
},
},
init: function(view) {
let me = this;
let vm = this.getViewModel();
view.mp = {};
vm.set('confid', view.confid);
vm.set('unused', view.unused);
vm.set('node', view.nodename);
vm.set('unpriv', view.unprivileged);
vm.set('hideStorSelector', view.unused || !view.isCreate);
if (view.isCreate) { // can be array if created from unused disk
vm.set('isIncludedInBackup', true);
if (view.insideWizard) {
view.filterMountOptions();
}
}
if (view.selectFree) {
view.setVMConfig(view.vmconfig);
}
},
},
viewModel: {
data: {
unpriv: false,
unused: false,
showStorageSelector: false,
mptype: '',
type: '',
confid: '',
node: '',
},
formulas: {
quota: function(get) {
return !(get('type') === 'zfs' ||
get('type') === 'zfspool' ||
get('unpriv') ||
get('isBind'));
},
hasMP: function(get) {
return !!get('confid') && !get('unused');
},
isRoot: function(get) {
return get('confid') === 'rootfs';
},
isBind: function(get) {
return get('mptype') === 'bind';
},
isBindOrRoot: function(get) {
return get('isBind') || get('isRoot');
},
},
},
column1: [
{
xtype: 'proxmoxintegerfield',
name: 'mpid',
fieldLabel: gettext('Mount Point ID'),
minValue: 0,
maxValue: PVE.Utils.lxc_mp_counts.mp - 1,
hidden: true,
allowBlank: false,
disabled: true,
bind: {
hidden: '{hasMP}',
disabled: '{hasMP}',
},
validator: function(value) {
let view = this.up('inputpanel');
if (!view.rendered) {
return undefined;
}
if (Ext.isDefined(view.vmconfig["mp"+value])) {
return "Mount point is already in use.";
}
return true;
},
},
{
xtype: 'pveDiskStorageSelector',
itemId: 'diskstorage',
storageContent: 'rootdir',
hidden: true,
autoSelect: true,
selectformat: false,
defaultSize: 8,
bind: {
hidden: '{hideStorSelector}',
disabled: '{hideStorSelector}',
nodename: '{node}',
},
},
{
xtype: 'textfield',
disabled: true,
submitValue: false,
fieldLabel: gettext('Disk image'),
name: 'file',
bind: {
hidden: '{!hideStorSelector}',
},
},
],
column2: [
{
xtype: 'textfield',
name: 'mp',
value: '',
emptyText: gettext('/some/path'),
allowBlank: false,
disabled: true,
fieldLabel: gettext('Path'),
bind: {
hidden: '{isRoot}',
disabled: '{isRoot}',
},
},
{
xtype: 'proxmoxcheckbox',
name: 'backup',
fieldLabel: gettext('Backup'),
autoEl: {
tag: 'div',
'data-qtip': gettext('Include volume in backup job'),
},
bind: {
hidden: '{isRoot}',
disabled: '{isBindOrRoot}',
value: '{isIncludedInBackup}',
},
},
],
advancedColumn1: [
{
xtype: 'proxmoxcheckbox',
name: 'quota',
defaultValue: 0,
bind: {
disabled: '{!quota}',
},
fieldLabel: gettext('Enable quota'),
listeners: {
disable: function() {
this.reset();
},
},
},
{
xtype: 'proxmoxcheckbox',
name: 'ro',
defaultValue: 0,
bind: {
hidden: '{isRoot}',
disabled: '{isRoot}',
},
fieldLabel: gettext('Read-only'),
},
{
xtype: 'proxmoxKVComboBox',
name: 'mountoptions',
fieldLabel: gettext('Mount options'),
deleteEmpty: false,
comboItems: [
['discard', 'discard'],
['lazytime', 'lazytime'],
['noatime', 'noatime'],
['nodev', 'nodev'],
['noexec', 'noexec'],
['nosuid', 'nosuid'],
],
multiSelect: true,
value: [],
allowBlank: true,
},
],
advancedColumn2: [
{
xtype: 'proxmoxKVComboBox',
name: 'acl',
fieldLabel: 'ACLs',
deleteEmpty: false,
comboItems: [
['__default__', Proxmox.Utils.defaultText],
['1', Proxmox.Utils.enabledText],
['0', Proxmox.Utils.disabledText],
],
value: '__default__',
bind: {
disabled: '{isBind}',
},
allowBlank: true,
},
{
xtype: 'proxmoxcheckbox',
inputValue: '0', // reverses the logic
name: 'replicate',
fieldLabel: gettext('Skip replication'),
},
],
});
Ext.define('PVE.lxc.MountPointEdit', {
extend: 'Proxmox.window.Edit',
unprivileged: false,
initComponent: function() {
let me = this;
let nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
let unused = me.confid && me.confid.match(/^unused\d+$/);
me.isCreate = me.confid ? unused : true;
let ipanel = Ext.create('PVE.lxc.MountPointInputPanel', {
confid: me.confid,
nodename: nodename,
unused: unused,
unprivileged: me.unprivileged,
isCreate: me.isCreate,
});
let subject;
if (unused) {
subject = gettext('Unused Disk');
} else if (me.isCreate) {
subject = gettext('Mount Point');
} else {
subject = gettext('Mount Point') + ' (' + me.confid + ')';
}
Ext.apply(me, {
subject: subject,
defaultFocus: me.confid !== 'rootfs' ? 'textfield[name=mp]' : 'tool',
items: ipanel,
});
me.callParent();
me.load({
success: function(response, options) {
ipanel.setVMConfig(response.result.data);
if (me.confid) {
let value = response.result.data[me.confid];
let mp = PVE.Parser.parseLxcMountPoint(value);
if (!mp) {
Ext.Msg.alert(gettext('Error'), 'Unable to parse mount point options');
me.close();
return;
}
ipanel.setMountPoint(mp);
me.isValid(); // trigger validation
}
},
});
},
});
Ext.define('PVE.window.MPResize', {
extend: 'Ext.window.Window',
resizable: false,
resize_disk: function(disk, size) {
var me = this;
var params = { disk: disk, size: '+' + size + 'G' };
Proxmox.Utils.API2Request({
params: params,
url: '/nodes/' + me.nodename + '/lxc/' + me.vmid + '/resize',
waitMsgTarget: me,
method: 'PUT',
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
success: function(response, opts) {
var upid = response.result.data;
var win = Ext.create('Proxmox.window.TaskViewer', { upid: upid });
win.show();
me.close();
},
});
},
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (!me.vmid) {
throw "no VM ID specified";
}
var items = [
{
xtype: 'displayfield',
name: 'disk',
value: me.disk,
fieldLabel: gettext('Disk'),
vtype: 'StorageId',
allowBlank: false,
},
];
me.hdsizesel = Ext.createWidget('numberfield', {
name: 'size',
minValue: 0,
maxValue: 128*1024,
decimalPrecision: 3,
value: '0',
fieldLabel: `${gettext('Size Increment')} (${gettext('GiB')})`,
allowBlank: false,
});
items.push(me.hdsizesel);
me.formPanel = Ext.create('Ext.form.Panel', {
bodyPadding: 10,
border: false,
fieldDefaults: {
labelWidth: 120,
anchor: '100%',
},
items: items,
});
var form = me.formPanel.getForm();
var submitBtn;
me.title = gettext('Resize disk');
submitBtn = Ext.create('Ext.Button', {
text: gettext('Resize disk'),
handler: function() {
if (form.isValid()) {
var values = form.getValues();
me.resize_disk(me.disk, values.size);
}
},
});
Ext.apply(me, {
modal: true,
border: false,
layout: 'fit',
buttons: [submitBtn],
items: [me.formPanel],
});
me.callParent();
},
});
Ext.define('PVE.lxc.NetworkInputPanel', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pveLxcNetworkInputPanel',
insideWizard: false,
onlineHelp: 'pct_container_network',
setNodename: function(nodename) {
let me = this;
if (!nodename || me.nodename === nodename) {
return;
}
me.nodename = nodename;
let bridgeSelector = me.query("[isFormField][name=bridge]")[0];
bridgeSelector.setNodename(nodename);
},
onGetValues: function(values) {
let me = this;
let id;
if (me.isCreate) {
id = values.id;
delete values.id;
} else {
id = me.ifname;
}
let newdata = {};
if (id) {
if (values.ipv6mode !== 'static') {
values.ip6 = values.ipv6mode;
}
if (values.ipv4mode !== 'static') {
values.ip = values.ipv4mode;
}
newdata[id] = PVE.Parser.printLxcNetwork(values);
}
return newdata;
},
initComponent: function() {
let me = this;
let cdata = {};
if (me.insideWizard) {
me.ifname = 'net0';
cdata.name = 'eth0';
me.dataCache = {};
}
cdata.firewall = me.insideWizard || me.isCreate;
if (!me.dataCache) {
throw "no dataCache specified";
}
if (!me.isCreate) {
if (!me.ifname) {
throw "no interface name specified";
}
if (!me.dataCache[me.ifname]) {
throw "no such interface '" + me.ifname + "'";
}
cdata = PVE.Parser.parseLxcNetwork(me.dataCache[me.ifname]);
}
for (let i = 0; i < 32; i++) {
let ifname = 'net' + i.toString();
if (me.isCreate && !me.dataCache[ifname]) {
me.ifname = ifname;
break;
}
}
me.column1 = [
{
xtype: 'hidden',
name: 'id',
value: me.ifname,
},
{
xtype: 'textfield',
name: 'name',
fieldLabel: gettext('Name'),
emptyText: '(e.g., eth0)',
allowBlank: false,
value: cdata.name,
validator: function(value) {
for (const [key, netRaw] of Object.entries(me.dataCache)) {
if (!key.match(/^net\d+/) || key === me.ifname) {
continue;
}
let net = PVE.Parser.parseLxcNetwork(netRaw);
if (net.name === value) {
return "interface name already in use";
}
}
return true;
},
},
{
xtype: 'textfield',
name: 'hwaddr',
fieldLabel: gettext('MAC address'),
vtype: 'MacAddress',
value: cdata.hwaddr,
allowBlank: true,
emptyText: 'auto',
},
{
xtype: 'PVE.form.BridgeSelector',
name: 'bridge',
nodename: me.nodename,
fieldLabel: gettext('Bridge'),
value: cdata.bridge,
allowBlank: false,
},
{
xtype: 'pveVlanField',
name: 'tag',
value: cdata.tag,
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Firewall'),
name: 'firewall',
value: cdata.firewall,
},
];
let dhcp4 = cdata.ip === 'dhcp';
if (dhcp4) {
cdata.ip = '';
cdata.gw = '';
}
let auto6 = cdata.ip6 === 'auto';
let dhcp6 = cdata.ip6 === 'dhcp';
if (auto6 || dhcp6) {
cdata.ip6 = '';
cdata.gw6 = '';
}
me.column2 = [
{
layout: {
type: 'hbox',
align: 'middle',
},
border: false,
margin: '0 0 5 0',
items: [
{
xtype: 'label',
text: 'IPv4:', // do not localize
},
{
xtype: 'radiofield',
boxLabel: gettext('Static'),
name: 'ipv4mode',
inputValue: 'static',
checked: !dhcp4,
margin: '0 0 0 10',
listeners: {
change: function(cb, value) {
me.down('field[name=ip]').setEmptyText(
value ? Proxmox.Utils.NoneText : "",
);
me.down('field[name=ip]').setDisabled(!value);
me.down('field[name=gw]').setDisabled(!value);
},
},
},
{
xtype: 'radiofield',
boxLabel: 'DHCP', // do not localize
name: 'ipv4mode',
inputValue: 'dhcp',
checked: dhcp4,
margin: '0 0 0 10',
},
],
},
{
xtype: 'textfield',
name: 'ip',
vtype: 'IPCIDRAddress',
value: cdata.ip,
emptyText: dhcp4 ? '' : Proxmox.Utils.NoneText,
disabled: dhcp4,
fieldLabel: 'IPv4/CIDR', // do not localize
},
{
xtype: 'textfield',
name: 'gw',
value: cdata.gw,
vtype: 'IPAddress',
disabled: dhcp4,
fieldLabel: gettext('Gateway') + ' (IPv4)',
margin: '0 0 3 0', // override bottom margin to account for the menuseparator
},
{
xtype: 'menuseparator',
height: '3',
margin: '0',
},
{
layout: {
type: 'hbox',
align: 'middle',
},
border: false,
margin: '0 0 5 0',
items: [
{
xtype: 'label',
text: 'IPv6:', // do not localize
},
{
xtype: 'radiofield',
boxLabel: gettext('Static'),
name: 'ipv6mode',
inputValue: 'static',
checked: !(auto6 || dhcp6),
margin: '0 0 0 10',
listeners: {
change: function(cb, value) {
me.down('field[name=ip6]').setEmptyText(
value ? Proxmox.Utils.NoneText : "",
);
me.down('field[name=ip6]').setDisabled(!value);
me.down('field[name=gw6]').setDisabled(!value);
},
},
},
{
xtype: 'radiofield',
boxLabel: 'DHCP', // do not localize
name: 'ipv6mode',
inputValue: 'dhcp',
checked: dhcp6,
margin: '0 0 0 10',
},
{
xtype: 'radiofield',
boxLabel: 'SLAAC', // do not localize
name: 'ipv6mode',
inputValue: 'auto',
checked: auto6,
margin: '0 0 0 10',
},
],
},
{
xtype: 'textfield',
name: 'ip6',
value: cdata.ip6,
emptyText: dhcp6 || auto6 ? '' : Proxmox.Utils.NoneText,
vtype: 'IP6CIDRAddress',
disabled: dhcp6 || auto6,
fieldLabel: 'IPv6/CIDR', // do not localize
},
{
xtype: 'textfield',
name: 'gw6',
vtype: 'IP6Address',
value: cdata.gw6,
disabled: dhcp6 || auto6,
fieldLabel: gettext('Gateway') + ' (IPv6)',
},
];
me.advancedColumn1 = [
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Disconnect'),
name: 'link_down',
value: cdata.link_down,
},
{
xtype: 'proxmoxintegerfield',
fieldLabel: 'MTU',
emptyText: gettext('Same as bridge'),
name: 'mtu',
value: cdata.mtu,
minValue: 576,
maxValue: 65535,
},
];
me.advancedColumn2 = [
{
xtype: 'numberfield',
name: 'rate',
fieldLabel: gettext('Rate limit') + ' (MB/s)',
minValue: 0,
maxValue: 10*1024,
value: cdata.rate,
emptyText: 'unlimited',
allowBlank: true,
},
];
me.callParent();
},
});
Ext.define('PVE.lxc.NetworkEdit', {
extend: 'Proxmox.window.Edit',
isAdd: true,
initComponent: function() {
let me = this;
if (!me.dataCache) {
throw "no dataCache specified";
}
if (!me.nodename) {
throw "no node name specified";
}
Ext.apply(me, {
subject: gettext('Network Device') + ' (veth)',
digest: me.dataCache.digest,
items: [
{
xtype: 'pveLxcNetworkInputPanel',
ifname: me.ifname,
nodename: me.nodename,
dataCache: me.dataCache,
isCreate: me.isCreate,
},
],
});
me.callParent();
},
});
Ext.define('PVE.lxc.NetworkView', {
extend: 'Ext.grid.GridPanel',
alias: 'widget.pveLxcNetworkView',
onlineHelp: 'pct_container_network',
dataCache: {}, // used to store result of last load
stateful: true,
stateId: 'grid-lxc-network',
load: function() {
let me = this;
Proxmox.Utils.setErrorMask(me, true);
Proxmox.Utils.API2Request({
url: me.url,
failure: function(response, opts) {
Proxmox.Utils.setErrorMask(me, gettext('Error') + ': ' + response.htmlStatus);
},
success: function(response, opts) {
Proxmox.Utils.setErrorMask(me, false);
let result = Ext.decode(response.responseText);
me.dataCache = result.data || {};
let records = [];
for (const [key, value] of Object.entries(me.dataCache)) {
if (key.match(/^net\d+/)) {
let net = PVE.Parser.parseLxcNetwork(value);
net.id = key;
records.push(net);
}
}
me.store.loadData(records);
me.down('button[name=addButton]').setDisabled(records.length >= 32);
},
});
},
initComponent: function() {
let me = this;
let nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
let vmid = me.pveSelNode.data.vmid;
if (!vmid) {
throw "no VM ID specified";
}
let caps = Ext.state.Manager.get('GuiCap');
me.url = `/nodes/${nodename}/lxc/${vmid}/config`;
let store = new Ext.data.Store({
model: 'pve-lxc-network',
sorters: [
{
property: 'id',
direction: 'ASC',
},
],
});
let sm = Ext.create('Ext.selection.RowModel', {});
let run_editor = function() {
let rec = sm.getSelection()[0];
if (!rec || !caps.vms['VM.Config.Network']) {
return false; // disable default-propagation when triggered by grid dblclick
}
Ext.create('PVE.lxc.NetworkEdit', {
url: me.url,
nodename: nodename,
dataCache: me.dataCache,
ifname: rec.data.id,
listeners: {
destroy: () => me.load(),
},
autoShow: true,
});
return undefined; // make eslint happier
};
Ext.apply(me, {
store: store,
selModel: sm,
tbar: [
{
text: gettext('Add'),
name: 'addButton',
disabled: !caps.vms['VM.Config.Network'],
handler: function() {
Ext.create('PVE.lxc.NetworkEdit', {
url: me.url,
nodename: nodename,
isCreate: true,
dataCache: me.dataCache,
listeners: {
destroy: () => me.load(),
},
autoShow: true,
});
},
},
{
xtype: 'proxmoxButton',
text: gettext('Remove'),
disabled: true,
selModel: sm,
enableFn: function(rec) {
return !!caps.vms['VM.Config.Network'];
},
confirmMsg: ({ data }) =>
Ext.String.format(gettext('Are you sure you want to remove entry {0}'), `'${data.id}'`),
handler: function(btn, e, rec) {
Proxmox.Utils.API2Request({
url: me.url,
waitMsgTarget: me,
method: 'PUT',
params: {
'delete': rec.data.id,
digest: me.dataCache.digest,
},
callback: () => me.load(),
failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
},
},
{
xtype: 'proxmoxButton',
text: gettext('Edit'),
selModel: sm,
disabled: true,
enableFn: rec => !!caps.vms['VM.Config.Network'],
handler: run_editor,
},
],
columns: [
{
header: 'ID',
width: 50,
dataIndex: 'id',
},
{
header: gettext('Name'),
width: 80,
dataIndex: 'name',
},
{
header: gettext('Bridge'),
width: 80,
dataIndex: 'bridge',
},
{
header: gettext('Firewall'),
width: 80,
dataIndex: 'firewall',
renderer: Proxmox.Utils.format_boolean,
},
{
header: gettext('VLAN Tag'),
width: 80,
dataIndex: 'tag',
},
{
header: gettext('MAC address'),
width: 110,
dataIndex: 'hwaddr',
},
{
header: gettext('IP address'),
width: 150,
dataIndex: 'ip',
renderer: function(value, metaData, rec) {
if (rec.data.ip && rec.data.ip6) {
return rec.data.ip + "<br>" + rec.data.ip6;
} else if (rec.data.ip6) {
return rec.data.ip6;
} else {
return rec.data.ip;
}
},
},
{
header: gettext('Gateway'),
width: 150,
dataIndex: 'gw',
renderer: function(value, metaData, rec) {
if (rec.data.gw && rec.data.gw6) {
return rec.data.gw + "<br>" + rec.data.gw6;
} else if (rec.data.gw6) {
return rec.data.gw6;
} else {
return rec.data.gw;
}
},
},
{
header: gettext('MTU'),
width: 80,
dataIndex: 'mtu',
},
{
header: gettext('Disconnected'),
width: 100,
dataIndex: 'link_down',
renderer: Proxmox.Utils.format_boolean,
},
],
listeners: {
activate: me.load,
itemdblclick: run_editor,
},
});
me.callParent();
},
}, function() {
Ext.define('pve-lxc-network', {
extend: "Ext.data.Model",
proxy: { type: 'memory' },
fields: [
'id',
'name',
'hwaddr',
'bridge',
'ip',
'gw',
'ip6',
'gw6',
'tag',
'firewall',
'mtu',
'link_down',
],
});
});
Ext.define('PVE.lxc.Options', {
extend: 'Proxmox.grid.PendingObjectGrid',
alias: ['widget.pveLxcOptions'],
onlineHelp: 'pct_options',
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
var vmid = me.pveSelNode.data.vmid;
if (!vmid) {
throw "no VM ID specified";
}
var caps = Ext.state.Manager.get('GuiCap');
var rows = {
onboot: {
header: gettext('Start at boot'),
defaultValue: '',
renderer: Proxmox.Utils.format_boolean,
editor: caps.vms['VM.Config.Options'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('Start at boot'),
items: {
xtype: 'proxmoxcheckbox',
name: 'onboot',
uncheckedValue: 0,
defaultValue: 0,
fieldLabel: gettext('Start at boot'),
},
} : undefined,
},
startup: {
header: gettext('Start/Shutdown order'),
defaultValue: '',
renderer: PVE.Utils.render_kvm_startup,
editor: caps.vms['VM.Config.Options'] && caps.nodes['Sys.Modify']
? {
xtype: 'pveWindowStartupEdit',
onlineHelp: 'pct_startup_and_shutdown',
} : undefined,
},
ostype: {
header: gettext('OS Type'),
defaultValue: Proxmox.Utils.unknownText,
},
arch: {
header: gettext('Architecture'),
defaultValue: Proxmox.Utils.unknownText,
},
console: {
header: '/dev/console',
defaultValue: 1,
renderer: Proxmox.Utils.format_enabled_toggle,
editor: caps.vms['VM.Config.Options'] ? {
xtype: 'proxmoxWindowEdit',
subject: '/dev/console',
items: {
xtype: 'proxmoxcheckbox',
name: 'console',
uncheckedValue: 0,
defaultValue: 1,
deleteDefaultValue: true,
checked: true,
fieldLabel: '/dev/console',
},
} : undefined,
},
tty: {
header: gettext('TTY count'),
defaultValue: 2,
editor: caps.vms['VM.Config.Options'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('TTY count'),
items: {
xtype: 'proxmoxintegerfield',
name: 'tty',
minValue: 0,
maxValue: 6,
value: 2,
fieldLabel: gettext('TTY count'),
emptyText: gettext('Default'),
deleteEmpty: true,
},
} : undefined,
},
cmode: {
header: gettext('Console mode'),
defaultValue: 'tty',
editor: caps.vms['VM.Config.Options'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('Console mode'),
items: {
xtype: 'proxmoxKVComboBox',
name: 'cmode',
deleteEmpty: true,
value: '__default__',
comboItems: [
['__default__', Proxmox.Utils.defaultText + " (tty)"],
['tty', "/dev/tty[X]"],
['console', "/dev/console"],
['shell', "shell"],
],
fieldLabel: gettext('Console mode'),
},
} : undefined,
},
protection: {
header: gettext('Protection'),
defaultValue: false,
renderer: Proxmox.Utils.format_boolean,
editor: caps.vms['VM.Config.Options'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('Protection'),
items: {
xtype: 'proxmoxcheckbox',
name: 'protection',
uncheckedValue: 0,
defaultValue: 0,
deleteDefaultValue: true,
fieldLabel: gettext('Enabled'),
},
} : undefined,
},
unprivileged: {
header: gettext('Unprivileged container'),
renderer: Proxmox.Utils.format_boolean,
defaultValue: 0,
},
features: {
header: gettext('Features'),
defaultValue: Proxmox.Utils.noneText,
editor: 'PVE.lxc.FeaturesEdit',
},
hookscript: {
header: gettext('Hookscript'),
},
};
var baseurl = 'nodes/' + nodename + '/lxc/' + vmid + '/config';
var sm = Ext.create('Ext.selection.RowModel', {});
var edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
selModel: sm,
enableFn: function(rec) {
var rowdef = rows[rec.data.key];
return !!rowdef.editor;
},
handler: function() { me.run_editor(); },
});
var revert_btn = new PVE.button.PendingRevert();
var set_button_status = function() {
let button_sm = me.getSelectionModel();
let rec = button_sm.getSelection()[0];
if (!rec) {
edit_btn.disable();
return;
}
var key = rec.data.key;
var pending = rec.data.delete || me.hasPendingChanges(key);
var rowdef = rows[key];
if (key === 'features') {
let unprivileged = me.getStore().getById('unprivileged').data.value;
let root = Proxmox.UserName === 'root@pam';
let vmalloc = caps.vms['VM.Allocate'];
edit_btn.setDisabled(!(root || (vmalloc && unprivileged)));
} else {
edit_btn.setDisabled(!rowdef.editor);
}
revert_btn.setDisabled(!pending);
};
Ext.apply(me, {
url: "/api2/json/nodes/" + nodename + "/lxc/" + vmid + "/pending",
selModel: sm,
interval: 5000,
tbar: [edit_btn, revert_btn],
rows: rows,
editorConfig: {
url: '/api2/extjs/' + baseurl,
},
listeners: {
itemdblclick: me.run_editor,
selectionchange: set_button_status,
},
});
me.callParent();
me.on('activate', me.rstore.startUpdate);
me.on('destroy', me.rstore.stopUpdate);
me.on('deactivate', me.rstore.stopUpdate);
me.mon(me.getStore(), 'datachanged', function() {
set_button_status();
});
},
});
var labelWidth = 120;
Ext.define('PVE.lxc.MemoryEdit', {
extend: 'Proxmox.window.Edit',
initComponent: function() {
var me = this;
Ext.apply(me, {
subject: gettext('Memory'),
items: Ext.create('PVE.lxc.MemoryInputPanel'),
});
me.callParent();
me.load();
},
});
Ext.define('PVE.lxc.CPUEdit', {
extend: 'Proxmox.window.Edit',
alias: 'widget.pveLxcCPUEdit',
viewModel: {
data: {
cgroupMode: 2,
},
},
initComponent: function() {
let me = this;
me.getViewModel().set('cgroupMode', me.cgroupMode);
Ext.apply(me, {
subject: gettext('CPU'),
items: Ext.create('PVE.lxc.CPUInputPanel'),
});
me.callParent();
me.load();
},
});
// The view model of the parent should contain a 'cgroupMode' variable (or params for v2 are used).
Ext.define('PVE.lxc.CPUInputPanel', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pveLxcCPUInputPanel',
onlineHelp: 'pct_cpu',
insideWizard: false,
viewModel: {
formulas: {
cpuunitsDefault: (get) => get('cgroupMode') === 1 ? 1024 : 100,
cpuunitsMax: (get) => get('cgroupMode') === 1 ? 500000 : 10000,
},
},
onGetValues: function(values) {
let me = this;
let cpuunitsDefault = me.getViewModel().get('cpuunitsDefault');
PVE.Utils.delete_if_default(values, 'cpulimit', '0', me.insideWizard);
PVE.Utils.delete_if_default(values, 'cpuunits', `${cpuunitsDefault}`, me.insideWizard);
return values;
},
advancedColumn1: [
{
xtype: 'numberfield',
name: 'cpulimit',
minValue: 0,
value: '',
step: 1,
fieldLabel: gettext('CPU limit'),
allowBlank: true,
emptyText: gettext('unlimited'),
},
],
advancedColumn2: [
{
xtype: 'proxmoxintegerfield',
name: 'cpuunits',
fieldLabel: gettext('CPU units'),
value: '',
minValue: 8,
maxValue: '10000',
emptyText: '100',
bind: {
emptyText: '{cpuunitsDefault}',
maxValue: '{cpuunitsMax}',
},
labelWidth: labelWidth,
deleteEmpty: true,
allowBlank: true,
},
],
initComponent: function() {
var me = this;
me.column1 = [
{
xtype: 'proxmoxintegerfield',
name: 'cores',
minValue: 1,
maxValue: 8192,
value: me.insideWizard ? 1 : '',
fieldLabel: gettext('Cores'),
allowBlank: true,
deleteEmpty: true,
emptyText: gettext('unlimited'),
},
];
me.callParent();
},
});
Ext.define('PVE.lxc.MemoryInputPanel', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pveLxcMemoryInputPanel',
onlineHelp: 'pct_memory',
insideWizard: false,
initComponent: function() {
var me = this;
var items = [
{
xtype: 'proxmoxintegerfield',
name: 'memory',
minValue: 16,
value: '512',
step: 32,
fieldLabel: gettext('Memory') + ' (MiB)',
labelWidth: labelWidth,
allowBlank: false,
},
{
xtype: 'proxmoxintegerfield',
name: 'swap',
minValue: 0,
value: '512',
step: 32,
fieldLabel: gettext('Swap') + ' (MiB)',
labelWidth: labelWidth,
allowBlank: false,
},
];
if (me.insideWizard) {
me.column1 = items;
} else {
me.items = items;
}
me.callParent();
},
});
Ext.define('PVE.lxc.RessourceView', {
extend: 'Proxmox.grid.PendingObjectGrid',
alias: ['widget.pveLxcRessourceView'],
onlineHelp: 'pct_configuration',
renderKey: function(key, metaData, rec, rowIndex, colIndex, store) {
let me = this;
let rowdef = me.rows[key] || {};
let txt = rowdef.header || key;
let icon = '';
metaData.tdAttr = "valign=middle";
if (rowdef.tdCls) {
metaData.tdCls = rowdef.tdCls;
} else if (rowdef.iconCls) {
icon = `<i class='pve-grid-fa fa fa-fw fa-${rowdef.iconCls}'></i>`;
metaData.tdCls += " pve-itype-fa";
}
// only return icons in grid but not remove dialog
if (rowIndex !== undefined) {
return icon + txt;
} else {
return txt;
}
},
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
var vmid = me.pveSelNode.data.vmid;
if (!vmid) {
throw "no VM ID specified";
}
var caps = Ext.state.Manager.get('GuiCap');
var diskCap = caps.vms['VM.Config.Disk'];
var mpeditor = caps.vms['VM.Config.Disk'] ? 'PVE.lxc.MountPointEdit' : undefined;
const nodeInfo = PVE.data.ResourceStore.getNodes().find(node => node.node === nodename);
let cpuEditor = {
xtype: 'pveLxcCPUEdit',
cgroupMode: nodeInfo['cgroup-mode'],
};
var rows = {
memory: {
header: gettext('Memory'),
editor: caps.vms['VM.Config.Memory'] ? 'PVE.lxc.MemoryEdit' : undefined,
defaultValue: 512,
tdCls: 'pmx-itype-icon-memory',
group: 1,
renderer: function(value) {
return Proxmox.Utils.format_size(value*1024*1024);
},
},
swap: {
header: gettext('Swap'),
editor: caps.vms['VM.Config.Memory'] ? 'PVE.lxc.MemoryEdit' : undefined,
defaultValue: 512,
iconCls: 'refresh',
group: 2,
renderer: function(value) {
return Proxmox.Utils.format_size(value*1024*1024);
},
},
cores: {
header: gettext('Cores'),
editor: caps.vms['VM.Config.CPU'] ? cpuEditor : undefined,
defaultValue: '',
tdCls: 'pmx-itype-icon-processor',
group: 3,
renderer: function(value) {
var cpulimit = me.getObjectValue('cpulimit');
var cpuunits = me.getObjectValue('cpuunits');
var res;
if (value) {
res = value;
} else {
res = gettext('unlimited');
}
if (cpulimit) {
res += ' [cpulimit=' + cpulimit + ']';
}
if (cpuunits) {
res += ' [cpuunits=' + cpuunits + ']';
}
return res;
},
},
rootfs: {
header: gettext('Root Disk'),
defaultValue: Proxmox.Utils.noneText,
editor: mpeditor,
iconCls: 'hdd-o',
group: 4,
renderer: Ext.htmlEncode,
},
cpulimit: {
visible: false,
},
cpuunits: {
visible: false,
},
unprivileged: {
visible: false,
},
};
PVE.Utils.forEachLxcMP(function(bus, i, confid) {
var group = 5;
var header;
if (bus === 'mp') {
header = gettext('Mount Point') + ' (' + confid + ')';
} else {
header = gettext('Unused Disk') + ' ' + i;
group += 1;
}
rows[confid] = {
group: group,
order: i,
tdCls: 'pve-itype-icon-storage',
editor: mpeditor,
header: header,
renderer: Ext.htmlEncode,
};
}, true);
let deveditor = Proxmox.UserName === 'root@pam' ? 'PVE.lxc.DeviceEdit' : undefined;
PVE.Utils.forEachLxcDev(function(i, confid) {
rows[confid] = {
group: 7,
order: i,
tdCls: 'pve-itype-icon-pci',
editor: deveditor,
header: gettext('Device') + ' (' + confid + ')',
renderer: Ext.htmlEncode,
};
});
var baseurl = 'nodes/' + nodename + '/lxc/' + vmid + '/config';
me.selModel = Ext.create('Ext.selection.RowModel', {});
var run_resize = function() {
var rec = me.selModel.getSelection()[0];
if (!rec) {
return;
}
var win = Ext.create('PVE.window.MPResize', {
disk: rec.data.key,
nodename: nodename,
vmid: vmid,
});
win.show();
};
var run_remove = function(b, e, rec) {
Proxmox.Utils.API2Request({
url: '/api2/extjs/' + baseurl,
waitMsgTarget: me,
method: 'PUT',
params: {
'delete': rec.data.key,
},
failure: function(response, opts) {
Ext.Msg.alert('Error', response.htmlStatus);
},
});
};
let run_move = function() {
let rec = me.selModel.getSelection()[0];
if (!rec) {
return;
}
var win = Ext.create('PVE.window.HDMove', {
disk: rec.data.key,
nodename: nodename,
vmid: vmid,
type: 'lxc',
});
win.show();
win.on('destroy', me.reload, me);
};
let run_reassign = function() {
let rec = me.selModel.getSelection()[0];
if (!rec) {
return;
}
Ext.create('PVE.window.GuestDiskReassign', {
disk: rec.data.key,
nodename: nodename,
autoShow: true,
vmid: vmid,
type: 'lxc',
listeners: {
destroy: () => me.reload(),
},
});
};
var edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
selModel: me.selModel,
disabled: true,
enableFn: function(rec) {
if (!rec) {
return false;
}
var rowdef = rows[rec.data.key];
return !!rowdef.editor;
},
handler: function() { me.run_editor(); },
});
var remove_btn = new Proxmox.button.Button({
text: gettext('Remove'),
defaultText: gettext('Remove'),
altText: gettext('Detach'),
selModel: me.selModel,
disabled: true,
dangerous: true,
confirmMsg: function(rec) {
let warn = Ext.String.format(gettext('Are you sure you want to remove entry {0}'));
if (this.text === this.altText) {
warn = gettext('Are you sure you want to detach entry {0}');
}
let rendered = me.renderKey(rec.data.key, {}, rec);
let msg = Ext.String.format(warn, `'${rendered}'`);
if (rec.data.key.match(/^unused\d+$/)) {
msg += " " + gettext('This will permanently erase all data.');
}
return msg;
},
handler: run_remove,
listeners: {
render: function(btn) {
// hack: calculate the max button width on first display to prevent the whole
// toolbar to move when we switch between the "Remove" and "Detach" labels
let def = btn.getSize().width;
btn.setText(btn.altText);
let alt = btn.getSize().width;
btn.setText(btn.defaultText);
let optimal = alt > def ? alt : def;
btn.setSize({ width: optimal });
},
},
});
let move_menuitem = new Ext.menu.Item({
text: gettext('Move Storage'),
tooltip: gettext('Move volume to another storage'),
iconCls: 'fa fa-database',
selModel: me.selModel,
handler: run_move,
});
let reassign_menuitem = new Ext.menu.Item({
text: gettext('Reassign Owner'),
tooltip: gettext('Reassign volume to another CT'),
iconCls: 'fa fa-cube',
handler: run_reassign,
reference: 'reassing_item',
});
let resize_menuitem = new Ext.menu.Item({
text: gettext('Resize'),
iconCls: 'fa fa-plus',
selModel: me.selModel,
handler: run_resize,
});
let volumeaction_btn = new Proxmox.button.Button({
text: gettext('Volume Action'),
disabled: true,
menu: {
items: [
move_menuitem,
reassign_menuitem,
resize_menuitem,
],
},
});
let revert_btn = new PVE.button.PendingRevert();
let set_button_status = function() {
let rec = me.selModel.getSelection()[0];
if (!rec) {
edit_btn.disable();
remove_btn.disable();
volumeaction_btn.disable();
revert_btn.disable();
return;
}
let { key, value, 'delete': isDelete } = rec.data;
let rowdef = rows[key];
let pending = isDelete || me.hasPendingChanges(key);
let isRootFS = key === 'rootfs';
let isDisk = isRootFS || key.match(/^(mp|unused)\d+/);
let isUnusedDisk = key.match(/^unused\d+/);
let isUsedDisk = isDisk && !isUnusedDisk;
let isDevice = key.match(/^dev\d+/);
let noedit = isDelete || !rowdef.editor;
if (!noedit && Proxmox.UserName !== 'root@pam' && key.match(/^mp\d+$/)) {
let mp = PVE.Parser.parseLxcMountPoint(value);
if (mp.type !== 'volume') {
noedit = true;
}
}
edit_btn.setDisabled(noedit);
volumeaction_btn.setDisabled(!isDisk || !diskCap);
move_menuitem.setDisabled(isUnusedDisk);
reassign_menuitem.setDisabled(isRootFS);
resize_menuitem.setDisabled(isUnusedDisk);
remove_btn.setDisabled(!(isDisk || isDevice) || isRootFS || !diskCap || pending);
revert_btn.setDisabled(!pending);
remove_btn.setText(isUsedDisk ? remove_btn.altText : remove_btn.defaultText);
};
let sorterFn = function(rec1, rec2) {
let v1 = rec1.data.key, v2 = rec2.data.key;
let g1 = rows[v1].group || 0, g2 = rows[v2].group || 0;
if (g1 - g2 !== 0) {
return g1 - g2;
}
let order1 = rows[v1].order || 0, order2 = rows[v2].order || 0;
if (order1 - order2 !== 0) {
return order1 - order2;
}
if (v1 > v2) {
return 1;
} else if (v1 < v2) {
return -1;
} else {
return 0;
}
};
Ext.apply(me, {
url: `/api2/json/nodes/${nodename}/lxc/${vmid}/pending`,
selModel: me.selModel,
interval: 2000,
cwidth1: 170,
tbar: [
{
text: gettext('Add'),
menu: new Ext.menu.Menu({
items: [
{
text: gettext('Mount Point'),
iconCls: 'fa fa-fw fa-hdd-o black',
disabled: !caps.vms['VM.Config.Disk'],
handler: function() {
Ext.create('PVE.lxc.MountPointEdit', {
autoShow: true,
url: `/api2/extjs/${baseurl}`,
unprivileged: me.getObjectValue('unprivileged'),
pveSelNode: me.pveSelNode,
listeners: {
destroy: () => me.reload(),
},
});
},
},
{
text: gettext('Device Passthrough'),
iconCls: 'pve-itype-icon-pci',
disabled: Proxmox.UserName !== 'root@pam',
handler: function() {
Ext.create('PVE.lxc.DeviceEdit', {
autoShow: true,
url: `/api2/extjs/${baseurl}`,
pveSelNode: me.pveSelNode,
listeners: {
destroy: () => me.reload(),
},
});
},
},
],
}),
},
edit_btn,
remove_btn,
volumeaction_btn,
revert_btn,
],
rows: rows,
sorterFn: sorterFn,
editorConfig: {
pveSelNode: me.pveSelNode,
url: '/api2/extjs/' + baseurl,
},
listeners: {
itemdblclick: me.run_editor,
selectionchange: set_button_status,
},
});
me.callParent();
me.on('activate', me.rstore.startUpdate);
me.on('destroy', me.rstore.stopUpdate);
me.on('deactivate', me.rstore.stopUpdate);
me.mon(me.getStore(), 'datachanged', function() {
set_button_status();
});
Ext.apply(me.editorConfig, { unprivileged: me.getObjectValue('unprivileged') });
},
});
Ext.define('PVE.lxc.MultiMPPanel', {
extend: 'PVE.panel.MultiDiskPanel',
alias: 'widget.pveMultiMPPanel',
onlineHelp: 'pct_container_storage',
controller: {
xclass: 'Ext.app.ViewController',
// count of mps + rootfs
maxCount: PVE.Utils.lxc_mp_counts.mp + 1,
getNextFreeDisk: function(vmconfig) {
let nextFreeDisk;
if (!vmconfig.rootfs) {
return {
confid: 'rootfs',
};
} else {
for (let i = 0; i < PVE.Utils.lxc_mp_counts.mp; i++) {
let confid = `mp${i}`;
if (!vmconfig[confid]) {
nextFreeDisk = {
confid,
};
break;
}
}
}
return nextFreeDisk;
},
addPanel: function(itemId, vmconfig, nextFreeDisk) {
let me = this;
return me.getView().add({
vmconfig,
border: false,
showAdvanced: Ext.state.Manager.getProvider().get('proxmox-advanced-cb'),
xtype: 'pveLxcMountPointInputPanel',
confid: nextFreeDisk.confid === 'rootfs' ? 'rootfs' : null,
bind: {
nodename: '{nodename}',
unprivileged: '{unprivileged}',
},
padding: '0 5 0 10',
itemId,
selectFree: true,
isCreate: true,
insideWizard: true,
});
},
getBaseVMConfig: function() {
let me = this;
return {
unprivileged: me.getViewModel().get('unprivileged'),
};
},
diskSorter: {
sorterFn: function(rec1, rec2) {
if (rec1.data.name === 'rootfs') {
return -1;
} else if (rec2.data.name === 'rootfs') {
return 1;
}
let mp_match = /^mp(\d+)$/;
let [, id1] = mp_match.exec(rec1.data.name);
let [, id2] = mp_match.exec(rec2.data.name);
return parseInt(id1, 10) - parseInt(id2, 10);
},
},
deleteDisabled: (view, rI, cI, item, rec) => rec.data.name === 'rootfs',
},
});
Ext.define('PVE.menu.Item', {
extend: 'Ext.menu.Item',
alias: 'widget.pveMenuItem',
// set to wrap the handler callback in a confirm dialog showing this text
confirmMsg: false,
// set to focus 'No' instead of 'Yes' button and show a warning symbol
dangerous: false,
initComponent: function() {
let me = this;
if (me.handler) {
me.setHandler(me.handler, me.scope);
}
me.callParent();
},
setHandler: function(fn, scope) {
let me = this;
me.scope = scope;
me.handler = function(button, e) {
if (me.confirmMsg) {
Ext.MessageBox.defaultButton = me.dangerous ? 2 : 1;
Ext.Msg.show({
title: gettext('Confirm'),
icon: me.dangerous ? Ext.Msg.WARNING : Ext.Msg.QUESTION,
msg: me.confirmMsg,
buttons: Ext.Msg.YESNO,
defaultFocus: me.dangerous ? 'no' : 'yes',
callback: function(btn) {
if (btn === 'yes') {
Ext.callback(fn, me.scope, [me, e], 0, me);
}
},
});
} else {
Ext.callback(fn, me.scope, [me, e], 0, me);
}
};
},
});
Ext.define('PVE.menu.TemplateMenu', {
extend: 'Ext.menu.Menu',
initComponent: function() {
let me = this;
let info = me.pveSelNode.data;
if (!info.node) {
throw "no node name specified";
}
if (!info.vmid) {
throw "no VM ID specified";
}
let guestType = me.pveSelNode.data.type;
if (guestType !== 'qemu' && guestType !== 'lxc') {
throw `invalid guest type ${guestType}`;
}
let template = me.pveSelNode.data.template;
me.title = (guestType === 'qemu' ? 'VM ' : 'CT ') + info.vmid;
let caps = Ext.state.Manager.get('GuiCap');
let standaloneNode = PVE.Utils.isStandaloneNode();
me.items = [
{
text: gettext('Migrate'),
iconCls: 'fa fa-fw fa-send-o',
hidden: standaloneNode || !caps.vms['VM.Migrate'],
handler: function() {
Ext.create('PVE.window.Migrate', {
vmtype: guestType,
nodename: info.node,
vmid: info.vmid,
autoShow: true,
});
},
},
{
text: gettext('Clone'),
iconCls: 'fa fa-fw fa-clone',
hidden: !caps.vms['VM.Clone'],
handler: function() {
Ext.create('PVE.window.Clone', {
nodename: info.node,
guestType: guestType,
vmid: info.vmid,
isTemplate: template,
autoShow: true,
});
},
},
];
me.callParent();
},
});
Ext.define('PVE.ceph.CephInstallWizardInfo', {
extend: 'Ext.panel.Panel',
xtype: 'pveCephInstallWizardInfo',
html: `<h3>Ceph?</h3>
<blockquote cite="https://ceph.com/"><p>"<b>Ceph</b> is a unified,
distributed storage system, designed for excellent performance, reliability,
and scalability."</p></blockquote>
<p>
<b>Ceph</b> is currently <b>not installed</b> on this node. This wizard
will guide you through the installation. Click on the next button below
to begin. After the initial installation, the wizard will offer to create
an initial configuration. This configuration step is only
needed once per cluster and will be skipped if a config is already present.
</p>
<p>
Before starting the installation, please take a look at our documentation,
by clicking the help button below. If you want to gain deeper knowledge about
Ceph, visit <a target="_blank" href="https://docs.ceph.com/en/latest/">ceph.com</a>.
</p>`,
});
Ext.define('PVE.ceph.CephVersionSelector', {
extend: 'Ext.form.field.ComboBox',
xtype: 'pveCephVersionSelector',
fieldLabel: gettext('Ceph version to install'),
displayField: 'display',
valueField: 'release',
queryMode: 'local',
editable: false,
forceSelection: true,
store: {
fields: [
'release',
'version',
{
name: 'display',
calculate: d => `${d.release} (${d.version})`,
},
],
proxy: {
type: 'memory',
reader: {
type: 'json',
},
},
data: [
{ release: "quincy", version: "17.2" },
{ release: "reef", version: "18.2" },
{ release: "squid", version: "19.2" },
],
},
});
Ext.define('PVE.ceph.CephHighestVersionDisplay', {
extend: 'Ext.form.field.Display',
xtype: 'pveCephHighestVersionDisplay',
fieldLabel: gettext('Ceph in the cluster'),
value: 'unknown',
// called on success with (release, versionTxt, versionParts)
gotNewestVersion: Ext.emptyFn,
initComponent: function() {
let me = this;
me.callParent(arguments);
Proxmox.Utils.API2Request({
method: 'GET',
url: '/cluster/ceph/metadata',
params: {
scope: 'versions',
},
waitMsgTarget: me,
success: (response) => {
let res = response.result;
if (!res || !res.data || !res.data.node) {
me.setValue(
gettext('Could not detect a ceph installation in the cluster'),
);
return;
}
let nodes = res.data.node;
if (me.nodename) {
// can happen on ceph purge, we do not yet cleanup old version data
delete nodes[me.nodename];
}
let maxversion = [];
let maxversiontext = "";
for (const [_nodename, data] of Object.entries(nodes)) {
let version = data.version.parts;
if (PVE.Utils.compare_ceph_versions(version, maxversion) > 0) {
maxversion = version;
maxversiontext = data.version.str;
}
}
// FIXME: get from version selector store
const major2release = {
13: 'luminous',
14: 'nautilus',
15: 'octopus',
16: 'pacific',
17: 'quincy',
18: 'reef',
19: 'squid',
};
let release = major2release[maxversion[0]] || 'unknown';
let newestVersionTxt = `${Ext.String.capitalize(release)} (${maxversiontext})`;
if (release === 'unknown') {
me.setValue(
gettext('Could not detect a ceph installation in the cluster'),
);
} else {
me.setValue(Ext.String.format(
gettext('Newest ceph version in cluster is {0}'),
newestVersionTxt,
));
}
me.gotNewestVersion(release, maxversiontext, maxversion);
},
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
});
},
});
Ext.define('PVE.ceph.CephInstallWizard', {
extend: 'PVE.window.Wizard',
alias: 'widget.pveCephInstallWizard',
mixins: ['Proxmox.Mixin.CBind'],
resizable: false,
nodename: undefined,
width: 760, // 4:3
height: 570,
viewModel: {
data: {
nodename: '',
cephRelease: 'reef', // default
cephRepo: 'enterprise',
configuration: true,
isInstalled: false,
nodeHasSubscription: true, // avoid warning hint until fully loaded
allHaveSubscription: true, // avoid warning hint until fully loaded
selectedReleaseIsTechPreview: false, // avoid warning hint until fully loaded
},
formulas: {
repoHintHidden: get => get('allHaveSubscription') && get('cephRepo') === 'enterprise',
repoHint: function(get) {
let repo = get('cephRepo');
let nodeSub = get('nodeHasSubscription'), allSub = get('allHaveSubscription');
if (repo === 'enterprise') {
if (!nodeSub) {
return gettext('The enterprise repository is enabled, but there is no active subscription!');
} else if (!allSub) {
return gettext('Not all nodes have an active subscription, which is required for cluster-wide enterprise repo access');
}
return ''; // should be hidden
} else if (repo === 'no-subscription') {
return allSub
? gettext("Cluster has active subscriptions and would be eligible for using the enterprise repository.")
: gettext("The no-subscription repository is not the best choice for production setups.");
} else {
return gettext('The test repository should only be used for test setups or after consulting the official Proxmox support!');
}
},
},
},
cbindData: {
nodename: undefined,
},
title: gettext('Setup'),
navigateNext: function() {
var tp = this.down('#wizcontent');
var atab = tp.getActiveTab();
var next = tp.items.indexOf(atab) + 1;
var ntab = tp.items.getAt(next);
if (ntab) {
ntab.enable();
tp.setActiveTab(ntab);
}
},
setInitialTab: function(index) {
var tp = this.down('#wizcontent');
var initialTab = tp.items.getAt(index);
initialTab.enable();
tp.setActiveTab(initialTab);
},
onShow: function() {
this.callParent(arguments);
let viewModel = this.getViewModel();
var isInstalled = this.getViewModel().get('isInstalled');
if (isInstalled) {
viewModel.set('configuration', false);
this.setInitialTab(2);
}
PVE.Utils.getClusterSubscriptionLevel().then(subcriptionMap => {
viewModel.set('nodeHasSubscription', !!subcriptionMap[this.nodename]);
let allHaveSubscription = Object.values(subcriptionMap).every(level => !!level);
viewModel.set('allHaveSubscription', allHaveSubscription);
});
},
items: [
{
xtype: 'panel',
title: gettext('Info'),
viewModel: {}, // needed to inherit parent viewModel data
border: false,
bodyBorder: false,
onlineHelp: 'chapter_pveceph',
layout: {
type: 'vbox',
align: 'stretch',
},
defaults: {
border: false,
bodyBorder: false,
},
items: [
{
xtype: 'pveCephInstallWizardInfo',
},
{
flex: 1,
},
{
xtype: 'displayfield',
fieldLabel: gettext('Hint'),
labelClsExtra: 'pmx-hint',
submitValue: false,
labelWidth: 50,
bind: {
value: '{repoHint}',
hidden: '{repoHintHidden}',
},
},
{
xtype: 'displayfield',
fieldLabel: gettext('Note'),
labelClsExtra: 'pmx-hint',
submitValue: false,
labelWidth: 50,
value: gettext('The selected release is currently considered a Technology Preview. Although we are not aware of any major issues, there may be some bugs and the Enterprise Repository is not yet available.'),
bind: {
hidden: '{!selectedReleaseIsTechPreview}',
},
},
{
xtype: 'pveCephHighestVersionDisplay',
labelWidth: 150,
cbind: {
nodename: '{nodename}',
},
gotNewestVersion: function(release, maxversiontext, maxversion) {
if (release === 'unknown') {
return;
}
let wizard = this.up('pveCephInstallWizard');
wizard.getViewModel().set('cephRelease', release);
},
},
{
xtype: 'container',
layout: 'hbox',
defaults: {
border: false,
layout: 'anchor',
flex: 1,
},
items: [{
xtype: 'pveCephVersionSelector',
labelWidth: 150,
padding: '0 10 0 0',
submitValue: false,
bind: {
value: '{cephRelease}',
},
listeners: {
change: function(field, release) {
let me = this;
let wizard = this.up('pveCephInstallWizard');
wizard.down('#next').setText(
Ext.String.format(gettext('Start {0} installation'), release),
);
let record = me.store.findRecord('release', release, 0, false, true, true);
let releaseIsTechPreview = !!record.data.preview;
wizard.getViewModel().set('selectedReleaseIsTechPreview', releaseIsTechPreview);
let repoSelector = wizard.down('#repoSelector');
if (releaseIsTechPreview) {
repoSelector.store.filterBy(entry => entry.get('key') !== 'enterprise');
} else {
repoSelector.store.clearFilter();
}
},
},
},
{
xtype: 'proxmoxKVComboBox',
id: 'repoSelector', // TODO: use name or reference (how to lookup that here?)
fieldLabel: gettext('Repository'),
padding: '0 0 0 10',
comboItems: [
['enterprise', gettext('Enterprise (recommended)')],
['no-subscription', gettext('No-Subscription')],
['test', gettext('Test')],
],
labelWidth: 150,
submitValue: false,
value: 'enterprise',
bind: {
value: '{cephRepo}',
},
}],
},
],
listeners: {
activate: function() {
// notify owning container that it should display a help button
if (this.onlineHelp) {
Ext.GlobalEvents.fireEvent('proxmoxShowHelp', this.onlineHelp);
}
let wizard = this.up('pveCephInstallWizard');
let release = wizard.getViewModel().get('cephRelease');
wizard.down('#back').hide(true);
wizard.down('#next').setText(
Ext.String.format(gettext('Start {0} installation'), release),
);
},
deactivate: function() {
if (this.onlineHelp) {
Ext.GlobalEvents.fireEvent('proxmoxHideHelp', this.onlineHelp);
}
this.up('pveCephInstallWizard').down('#next').setText(gettext('Next'));
},
},
},
{
title: gettext('Installation'),
xtype: 'panel',
layout: 'fit',
cbind: {
nodename: '{nodename}',
},
viewModel: {}, // needed to inherit parent viewModel data
listeners: {
afterrender: function() {
var me = this;
if (this.getViewModel().get('isInstalled')) {
this.mask("Ceph is already installed, click next to create your configuration.", ['pve-static-mask']);
} else {
me.down('pveNoVncConsole').fireEvent('activate');
}
},
activate: function() {
let me = this;
const nodename = me.nodename;
me.updateStore = Ext.create('Proxmox.data.UpdateStore', {
storeid: 'ceph-status-' + nodename,
interval: 1000,
proxy: {
type: 'proxmox',
url: '/api2/json/nodes/' + nodename + '/ceph/status',
},
listeners: {
load: function(rec, response, success, operation) {
if (success) {
me.updateStore.stopUpdate();
me.down('textfield').setValue('success');
} else if (operation.error.statusText.match("not initialized", "i")) {
me.updateStore.stopUpdate();
me.up('pveCephInstallWizard').getViewModel().set('configuration', false);
me.down('textfield').setValue('success');
} else if (operation.error.statusText.match("rados_connect failed", "i")) {
me.updateStore.stopUpdate();
me.up('pveCephInstallWizard').getViewModel().set('configuration', true);
me.down('textfield').setValue('success');
} else if (!operation.error.statusText.match("not installed", "i")) {
Proxmox.Utils.setErrorMask(me, operation.error.statusText);
}
},
},
});
me.updateStore.startUpdate();
},
destroy: function() {
var me = this;
if (me.updateStore) {
me.updateStore.stopUpdate();
}
},
},
items: [
{
xtype: 'pveNoVncConsole',
itemId: 'jsconsole',
consoleType: 'cmd',
xtermjs: true,
cbind: {
nodename: '{nodename}',
},
beforeLoad: function() {
let me = this;
let wizard = me.up('pveCephInstallWizard');
let release = wizard.getViewModel().get('cephRelease');
let repo = wizard.getViewModel().get('cephRepo');
me.cmdOpts = `--version\0${release}\0--repository\0${repo}`;
},
cmd: 'ceph_install',
},
{
xtype: 'textfield',
name: 'installSuccess',
value: '',
allowBlank: false,
submitValue: false,
hidden: true,
},
],
},
{
xtype: 'inputpanel',
title: gettext('Configuration'),
onlineHelp: 'chapter_pveceph',
height: 300,
cbind: {
nodename: '{nodename}',
},
viewModel: {
data: {
replicas: undefined,
minreplicas: undefined,
},
},
listeners: {
activate: function() {
this.up('pveCephInstallWizard').down('#submit').setText(gettext('Next'));
},
afterrender: function() {
if (this.up('pveCephInstallWizard').getViewModel().get('configuration')) {
this.mask("Configuration already initialized", ['pve-static-mask']);
} else {
this.unmask();
}
},
deactivate: function() {
this.up('pveCephInstallWizard').down('#submit').setText(gettext('Finish'));
},
},
column1: [
{
xtype: 'displayfield',
value: gettext('Ceph cluster configuration') + ':',
},
{
xtype: 'proxmoxNetworkSelector',
name: 'network',
value: '',
fieldLabel: 'Public Network IP/CIDR',
autoSelect: false,
bind: {
allowBlank: '{configuration}',
},
cbind: {
nodename: '{nodename}',
},
},
{
xtype: 'proxmoxNetworkSelector',
name: 'cluster-network',
fieldLabel: 'Cluster Network IP/CIDR',
allowBlank: true,
autoSelect: false,
emptyText: gettext('Same as Public Network'),
cbind: {
nodename: '{nodename}',
},
},
// FIXME: add hint about cluster network and/or reference user to docs??
],
column2: [
{
xtype: 'displayfield',
value: gettext('First Ceph monitor') + ':',
},
{
xtype: 'displayfield',
fieldLabel: gettext('Monitor node'),
cbind: {
value: '{nodename}',
},
},
{
xtype: 'displayfield',
value: gettext('Additional monitors are recommended. They can be created at any time in the Monitor tab.'),
userCls: 'pmx-hint',
},
],
advancedColumn1: [
{
xtype: 'numberfield',
name: 'size',
fieldLabel: 'Number of replicas',
bind: {
value: '{replicas}',
},
maxValue: 7,
minValue: 2,
emptyText: '3',
},
{
xtype: 'numberfield',
name: 'min_size',
fieldLabel: 'Minimum replicas',
bind: {
maxValue: '{replicas}',
value: '{minreplicas}',
},
minValue: 2,
maxValue: 3,
setMaxValue: function(value) {
this.maxValue = Ext.Number.from(value, 2);
// allow enough to avoid split brains with max 'size', but more makes simply no sense
if (this.maxValue > 4) {
this.maxValue = 4;
}
this.toggleSpinners();
this.validate();
},
emptyText: '2',
},
],
onGetValues: function(values) {
['cluster-network', 'size', 'min_size'].forEach(function(field) {
if (!values[field]) {
delete values[field];
}
});
return values;
},
onSubmit: function() {
var me = this;
if (!this.up('pveCephInstallWizard').getViewModel().get('configuration')) {
var wizard = me.up('window');
var kv = wizard.getValues();
delete kv.delete;
var nodename = me.nodename;
delete kv.nodename;
Proxmox.Utils.API2Request({
url: `/nodes/${nodename}/ceph/init`,
waitMsgTarget: wizard,
method: 'POST',
params: kv,
success: function() {
Proxmox.Utils.API2Request({
url: `/nodes/${nodename}/ceph/mon/${nodename}`,
waitMsgTarget: wizard,
method: 'POST',
success: function() {
me.up('pveCephInstallWizard').navigateNext();
},
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
});
},
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
});
} else {
me.up('pveCephInstallWizard').navigateNext();
}
},
},
{
title: gettext('Success'),
xtype: 'panel',
border: false,
bodyBorder: false,
onlineHelp: 'pve_ceph_install',
html: '<h3>Installation successful!</h3>'+
'<p>The basic installation and configuration is complete. Depending on your setup, some of the following steps are required to start using Ceph:</p>'+
'<ol><li>Install Ceph on other nodes</li>'+
'<li>Create additional Ceph Monitors</li>'+
'<li>Create Ceph OSDs</li>'+
'<li>Create Ceph Pools</li></ol>'+
'<p>To learn more, click on the help button below.</p>',
listeners: {
activate: function() {
// notify owning container that it should display a help button
if (this.onlineHelp) {
Ext.GlobalEvents.fireEvent('proxmoxShowHelp', this.onlineHelp);
}
var tp = this.up('#wizcontent');
var idx = tp.items.indexOf(this)-1;
for (;idx >= 0; idx--) {
var nc = tp.items.getAt(idx);
if (nc) {
nc.disable();
}
}
},
deactivate: function() {
if (this.onlineHelp) {
Ext.GlobalEvents.fireEvent('proxmoxHideHelp', this.onlineHelp);
}
},
},
onSubmit: function() {
var wizard = this.up('pveCephInstallWizard');
wizard.close();
},
},
],
});
Ext.define('PVE.node.CephConfigDb', {
extend: 'Ext.grid.Panel',
alias: 'widget.pveNodeCephConfigDb',
border: false,
store: {
proxy: {
type: 'proxmox',
},
},
columns: [
{
dataIndex: 'section',
text: 'WHO',
width: 100,
},
{
dataIndex: 'mask',
text: 'MASK',
hidden: true,
width: 80,
},
{
dataIndex: 'level',
hidden: true,
text: 'LEVEL',
},
{
dataIndex: 'name',
flex: 1,
text: 'OPTION',
},
{
dataIndex: 'value',
flex: 1,
text: 'VALUE',
},
{
dataIndex: 'can_update_at_runtime',
text: 'Runtime Updatable',
hidden: true,
width: 80,
renderer: Proxmox.Utils.format_boolean,
},
],
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
me.store.proxy.url = '/api2/json/nodes/' + nodename + '/ceph/cfg/db';
me.callParent();
Proxmox.Utils.monStoreErrors(me, me.getStore());
me.getStore().load();
},
});
Ext.define('PVE.node.CephConfig', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveNodeCephConfig',
bodyStyle: 'white-space:pre',
bodyPadding: 5,
border: false,
scrollable: true,
load: function() {
var me = this;
Proxmox.Utils.API2Request({
url: me.url,
waitMsgTarget: me,
failure: function(response, opts) {
me.update(gettext('Error') + " " + response.htmlStatus);
var msg = response.htmlStatus;
PVE.Utils.showCephInstallOrMask(me.ownerCt, msg, me.pveSelNode.data.node,
function(win) {
me.mon(win, 'cephInstallWindowClosed', function() {
me.load();
});
},
);
},
success: function(response, opts) {
var data = response.result.data;
me.update(Ext.htmlEncode(data));
},
});
},
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
Ext.apply(me, {
url: '/nodes/' + nodename + '/ceph/cfg/raw',
listeners: {
activate: function() {
me.load();
},
},
});
me.callParent();
me.load();
},
});
Ext.define('PVE.node.CephConfigCrush', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveNodeCephConfigCrush',
onlineHelp: 'chapter_pveceph',
layout: 'border',
items: [{
title: gettext('Configuration'),
xtype: 'pveNodeCephConfig',
region: 'center',
},
{
title: 'Crush Map', // do not localize
xtype: 'pveNodeCephCrushMap',
region: 'east',
split: true,
width: '50%',
},
{
title: gettext('Configuration Database'),
xtype: 'pveNodeCephConfigDb',
region: 'south',
split: true,
weight: -30,
height: '50%',
}],
initComponent: function() {
var me = this;
me.defaults = {
pveSelNode: me.pveSelNode,
};
me.callParent();
},
});
Ext.define('PVE.node.CephCrushMap', {
extend: 'Ext.panel.Panel',
alias: ['widget.pveNodeCephCrushMap'],
bodyStyle: 'white-space:pre',
bodyPadding: 5,
border: false,
stateful: true,
stateId: 'layout-ceph-crush',
scrollable: true,
load: function() {
var me = this;
Proxmox.Utils.API2Request({
url: me.url,
waitMsgTarget: me,
failure: function(response, opts) {
me.update(gettext('Error') + " " + response.htmlStatus);
var msg = response.htmlStatus;
PVE.Utils.showCephInstallOrMask(
me.ownerCt,
msg,
me.pveSelNode.data.node,
win => me.mon(win, 'cephInstallWindowClosed', () => me.load()),
);
},
success: function(response, opts) {
var data = response.result.data;
me.update(Ext.htmlEncode(data));
},
});
},
initComponent: function() {
let me = this;
let nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
Ext.apply(me, {
url: `/nodes/${nodename}/ceph/crush`,
listeners: {
activate: () => me.load(),
},
});
me.callParent();
me.load();
},
});
Ext.define('PVE.CephCreateFS', {
extend: 'Proxmox.window.Edit',
alias: 'widget.pveCephCreateFS',
showTaskViewer: true,
onlineHelp: 'pveceph_fs_create',
subject: 'Ceph FS',
isCreate: true,
method: 'POST',
setFSName: function(fsName) {
var me = this;
if (fsName === '' || fsName === undefined) {
fsName = 'cephfs';
}
me.url = "/nodes/" + me.nodename + "/ceph/fs/" + fsName;
},
items: [
{
xtype: 'textfield',
fieldLabel: gettext('Name'),
name: 'name',
value: 'cephfs',
listeners: {
change: function(f, value) {
this.up('pveCephCreateFS').setFSName(value);
},
},
submitValue: false, // already encoded in apicall URL
emptyText: 'cephfs',
},
{
xtype: 'proxmoxintegerfield',
fieldLabel: 'Placement Groups',
name: 'pg_num',
value: 128,
emptyText: 128,
minValue: 8,
maxValue: 32768,
allowBlank: false,
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Add as Storage'),
value: true,
name: 'add-storage',
autoEl: {
tag: 'div',
'data-qtip': gettext('Add the new CephFS to the cluster storage configuration.'),
},
},
],
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no node name specified";
}
me.setFSName();
me.callParent();
},
});
Ext.define('PVE.NodeCephFSPanel', {
extend: 'Ext.panel.Panel',
xtype: 'pveNodeCephFSPanel',
mixins: ['Proxmox.Mixin.CBind'],
title: gettext('CephFS'),
onlineHelp: 'pveceph_fs',
border: false,
defaults: {
border: false,
cbind: {
nodename: '{nodename}',
},
},
viewModel: {
parent: null,
data: {
mdsCount: 0,
},
formulas: {
canCreateFS: function(get) {
return get('mdsCount') > 0;
},
},
},
items: [
{
xtype: 'grid',
emptyText: Ext.String.format(gettext('No {0} configured.'), 'CephFS'),
controller: {
xclass: 'Ext.app.ViewController',
init: function(view) {
view.rstore = Ext.create('Proxmox.data.UpdateStore', {
autoLoad: true,
xtype: 'update',
interval: 5 * 1000,
autoStart: true,
storeid: 'pve-ceph-fs',
proxy: {
type: 'proxmox',
url: `/api2/json/nodes/${view.nodename}/ceph/fs`,
},
model: 'pve-ceph-fs',
});
view.setStore(Ext.create('Proxmox.data.DiffStore', {
rstore: view.rstore,
sorters: {
property: 'name',
direction: 'ASC',
},
}));
// manages the "install ceph?" overlay
PVE.Utils.monitor_ceph_installed(view, view.rstore, view.nodename, true);
view.on('destroy', () => view.rstore.stopUpdate());
},
onCreate: function() {
let view = this.getView();
view.rstore.stopUpdate();
Ext.create('PVE.CephCreateFS', {
autoShow: true,
nodename: view.nodename,
listeners: {
destroy: () => view.rstore.startUpdate(),
},
});
},
},
tbar: [
{
text: gettext('Create CephFS'),
reference: 'createButton',
handler: 'onCreate',
bind: {
disabled: '{!canCreateFS}',
},
},
],
columns: [
{
header: gettext('Name'),
flex: 1,
dataIndex: 'name',
},
{
header: gettext('Data Pool'),
flex: 1,
dataIndex: 'data_pool',
},
{
header: gettext('Metadata Pool'),
flex: 1,
dataIndex: 'metadata_pool',
},
],
cbind: {
nodename: '{nodename}',
},
},
{
xtype: 'pveNodeCephMDSList',
title: gettext('Metadata Servers'),
stateId: 'grid-ceph-mds',
type: 'mds',
storeLoadCallback: function(store, records, success) {
var vm = this.getViewModel();
if (!success || !records) {
vm.set('mdsCount', 0);
return;
}
let count = 0;
for (const mds of records) {
if (mds.data.state === 'up:standby') {
count++;
}
}
vm.set('mdsCount', count);
},
cbind: {
nodename: '{nodename}',
},
},
],
}, function() {
Ext.define('pve-ceph-fs', {
extend: 'Ext.data.Model',
fields: ['name', 'data_pool', 'metadata_pool'],
proxy: {
type: 'proxmox',
url: "/api2/json/nodes/localhost/ceph/fs",
},
idProperty: 'name',
});
});
Ext.define('PVE.ceph.Log', {
extend: 'Proxmox.panel.LogView',
xtype: 'cephLogView',
nodename: undefined,
failCallback: function(response) {
var me = this;
var msg = response.htmlStatus;
var windowShow = PVE.Utils.showCephInstallOrMask(me, msg, me.nodename,
function(win) {
me.mon(win, 'cephInstallWindowClosed', function() {
me.loadTask.delay(200);
});
},
);
if (!windowShow) {
Proxmox.Utils.setErrorMask(me, msg);
}
},
});
Ext.define('PVE.node.CephMonMgrList', {
extend: 'Ext.container.Container',
xtype: 'pveNodeCephMonMgr',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'chapter_pveceph',
defaults: {
border: false,
onlineHelp: 'chapter_pveceph',
flex: 1,
},
layout: {
type: 'vbox',
align: 'stretch',
},
items: [
{
xtype: 'pveNodeCephServiceList',
cbind: { pveSelNode: '{pveSelNode}' },
type: 'mon',
additionalColumns: [
{
header: gettext('Quorum'),
width: 70,
sortable: true,
renderer: Proxmox.Utils.format_boolean,
dataIndex: 'quorum',
},
],
stateId: 'grid-ceph-monitor',
showCephInstallMask: true,
title: gettext('Monitor'),
},
{
xtype: 'pveNodeCephServiceList',
type: 'mgr',
stateId: 'grid-ceph-manager',
cbind: { pveSelNode: '{pveSelNode}' },
title: gettext('Manager'),
},
],
});
Ext.define('PVE.CephCreateOsd', {
extend: 'Proxmox.window.Edit',
xtype: 'pveCephCreateOsd',
subject: 'Ceph OSD',
showProgress: true,
onlineHelp: 'pve_ceph_osds',
initComponent: function() {
let me = this;
if (!me.nodename) {
throw "no node name specified";
}
me.isCreate = true;
Proxmox.Utils.API2Request({
url: `/nodes/${me.nodename}/ceph/crush`,
method: 'GET',
failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
success: function({ result: { data } }) {
let classes = [...new Set(
Array.from(
data.matchAll(/^device\s[0-9]*\sosd\.[0-9]*\sclass\s(.*)$/gim),
m => m[1],
).filter(v => !['hdd', 'ssd', 'nvme'].includes(v)),
)].map(v => [v, v]);
if (classes.length) {
let kvField = me.down('field[name=crush-device-class]');
kvField.setComboItems([...kvField.comboItems, ...classes]);
}
},
});
Ext.applyIf(me, {
url: "/nodes/" + me.nodename + "/ceph/osd",
method: 'POST',
items: [
{
xtype: 'inputpanel',
onGetValues: function(values) {
Object.keys(values || {}).forEach(function(name) {
if (values[name] === '') {
delete values[name];
}
});
return values;
},
column1: [
{
xtype: 'pmxDiskSelector',
name: 'dev',
nodename: me.nodename,
diskType: 'unused',
includePartitions: true,
fieldLabel: gettext('Disk'),
allowBlank: false,
},
],
column2: [
{
xtype: 'pmxDiskSelector',
name: 'db_dev',
nodename: me.nodename,
diskType: 'journal_disks',
includePartitions: true,
fieldLabel: gettext('DB Disk'),
value: '',
autoSelect: false,
allowBlank: true,
emptyText: gettext('use OSD disk'),
listeners: {
change: function(field, val) {
me.down('field[name=db_dev_size]').setDisabled(!val);
},
},
},
{
xtype: 'numberfield',
name: 'db_dev_size',
fieldLabel: `${gettext('DB size')} (${gettext('GiB')})`,
minValue: 1,
maxValue: 128*1024,
decimalPrecision: 2,
allowBlank: true,
disabled: true,
emptyText: gettext('Automatic'),
},
],
advancedColumn1: [
{
xtype: 'proxmoxcheckbox',
name: 'encrypted',
fieldLabel: gettext('Encrypt OSD'),
},
{
xtype: 'proxmoxKVComboBox',
comboItems: [
['hdd', 'HDD'],
['ssd', 'SSD'],
['nvme', 'NVMe'],
],
name: 'crush-device-class',
nodename: me.nodename,
fieldLabel: gettext('Device Class'),
value: '',
autoSelect: false,
allowBlank: true,
editable: true,
emptyText: gettext('auto detect'),
deleteEmpty: !me.isCreate,
},
],
advancedColumn2: [
{
xtype: 'pmxDiskSelector',
name: 'wal_dev',
nodename: me.nodename,
diskType: 'journal_disks',
includePartitions: true,
fieldLabel: gettext('WAL Disk'),
value: '',
autoSelect: false,
allowBlank: true,
emptyText: gettext('use OSD/DB disk'),
listeners: {
change: function(field, val) {
me.down('field[name=wal_dev_size]').setDisabled(!val);
},
},
},
{
xtype: 'numberfield',
name: 'wal_dev_size',
fieldLabel: `${gettext('WAL size')} (${gettext('GiB')})`,
minValue: 0.5,
maxValue: 128*1024,
decimalPrecision: 2,
allowBlank: true,
disabled: true,
emptyText: gettext('Automatic'),
},
],
},
{
xtype: 'displayfield',
padding: '5 0 0 0',
userCls: 'pmx-hint',
value: 'Note: Ceph is not compatible with disks backed by a hardware ' +
'RAID controller. For details see ' +
'<a target="_blank" href="' + Proxmox.Utils.get_help_link('chapter_pveceph') + '">the reference documentation</a>.',
},
],
});
me.callParent();
},
});
Ext.define('PVE.CephRemoveOsd', {
extend: 'Proxmox.window.Edit',
alias: ['widget.pveCephRemoveOsd'],
isRemove: true,
showProgress: true,
method: 'DELETE',
items: [
{
xtype: 'proxmoxcheckbox',
name: 'cleanup',
checked: true,
labelWidth: 130,
fieldLabel: gettext('Cleanup Disks'),
},
{
xtype: 'displayfield',
name: 'osd-flag-hint',
userCls: 'pmx-hint',
value: gettext('Global flags limiting the self healing of Ceph are enabled.'),
hidden: true,
},
{
xtype: 'displayfield',
name: 'degraded-objects-hint',
userCls: 'pmx-hint',
value: gettext('Objects are degraded. Consider waiting until the cluster is healthy.'),
hidden: true,
},
],
initComponent: function() {
let me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (me.osdid === undefined || me.osdid < 0) {
throw "no osdid specified";
}
me.isCreate = true;
me.title = gettext('Destroy') + ': Ceph OSD osd.' + me.osdid.toString();
Ext.applyIf(me, {
url: "/nodes/" + me.nodename + "/ceph/osd/" + me.osdid.toString(),
});
me.callParent();
if (me.warnings.flags) {
me.down('field[name=osd-flag-hint]').setHidden(false);
}
if (me.warnings.degraded) {
me.down('field[name=degraded-objects-hint]').setHidden(false);
}
},
});
Ext.define('PVE.CephSetFlags', {
extend: 'Proxmox.window.Edit',
xtype: 'pveCephSetFlags',
showProgress: true,
width: 720,
layout: 'fit',
onlineHelp: 'pve_ceph_osds',
isCreate: true,
title: Ext.String.format(gettext('Manage {0}'), 'Global OSD Flags'),
submitText: gettext('Apply'),
items: [
{
xtype: 'inputpanel',
onGetValues: function(values) {
let me = this;
let val = {};
me.down('#flaggrid').getStore().each((rec) => {
val[rec.data.name] = rec.data.value ? 1 : 0;
});
return val;
},
items: [
{
xtype: 'grid',
itemId: 'flaggrid',
store: {
listeners: {
update: function() {
this.commitChanges();
},
},
},
columns: [
{
text: gettext('Enable'),
xtype: 'checkcolumn',
width: 75,
dataIndex: 'value',
},
{
text: 'Name',
dataIndex: 'name',
},
{
text: 'Description',
flex: 1,
dataIndex: 'description',
},
],
},
],
},
],
initComponent: function() {
let me = this;
if (!me.nodename) {
throw "no node name specified";
}
Ext.applyIf(me, {
url: "/cluster/ceph/flags",
method: 'PUT',
});
me.callParent();
let grid = me.down('#flaggrid');
me.load({
success: function(response, options) {
let data = response.result.data;
grid.getStore().setData(data);
// re-align after store load, else the window is not centered
me.alignTo(Ext.getBody(), 'c-c');
},
});
},
});
Ext.define('PVE.node.CephOsdTree', {
extend: 'Ext.tree.Panel',
alias: ['widget.pveNodeCephOsdTree'],
onlineHelp: 'chapter_pveceph',
viewModel: {
data: {
nodename: '',
flags: [],
maxversion: '0',
mixedversions: false,
versions: {},
isOsd: false,
downOsd: false,
upOsd: false,
inOsd: false,
outOsd: false,
osdid: '',
osdhost: '',
},
},
controller: {
xclass: 'Ext.app.ViewController',
reload: function() {
let me = this;
let view = me.getView();
let vm = me.getViewModel();
let nodename = vm.get('nodename');
let sm = view.getSelectionModel();
Proxmox.Utils.API2Request({
url: "/nodes/" + nodename + "/ceph/osd",
waitMsgTarget: view,
method: 'GET',
failure: function(response, opts) {
let msg = response.htmlStatus;
PVE.Utils.showCephInstallOrMask(view, msg, nodename, win =>
view.mon(win, 'cephInstallWindowClosed', () => { me.reload(); }),
);
},
success: function(response, opts) {
let data = response.result.data;
let selected = view.getSelection();
let name;
if (selected.length) {
name = selected[0].data.name;
}
data.versions = data.versions || {};
vm.set('versions', data.versions);
// extract max version
let maxversion = "0";
let mixedversions = false;
let traverse;
traverse = function(node, fn) {
fn(node);
if (Array.isArray(node.children)) {
node.children.forEach(c => { traverse(c, fn); });
}
};
traverse(data.root, node => {
// compatibility for old api call
if (node.type === 'host' && !node.version) {
node.version = data.versions[node.name];
}
if (node.version === undefined) {
return;
}
if (PVE.Utils.compare_ceph_versions(node.version, maxversion) !== 0 && maxversion !== "0") {
mixedversions = true;
}
if (PVE.Utils.compare_ceph_versions(node.version, maxversion) > 0) {
maxversion = node.version;
}
});
vm.set('maxversion', maxversion);
vm.set('mixedversions', mixedversions);
sm.deselectAll();
view.setRootNode(data.root);
view.expandAll();
if (name) {
let node = view.getRootNode().findChild('name', name, true);
if (node) {
view.setSelection([node]);
}
}
let flags = data.flags.split(',');
vm.set('flags', flags);
},
});
},
osd_cmd: function(comp) {
let me = this;
let vm = this.getViewModel();
let cmd = comp.cmd;
let params = comp.params || {};
let osdid = vm.get('osdid');
let doRequest = function() {
let targetnode = vm.get('osdhost');
// cmds not node specific and need to work if the OSD node is down
if (['in', 'out'].includes(cmd)) {
targetnode = vm.get('nodename');
}
Proxmox.Utils.API2Request({
url: `/nodes/${targetnode}/ceph/osd/${osdid}/${cmd}`,
waitMsgTarget: me.getView(),
method: 'POST',
params: params,
success: () => { me.reload(); },
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
});
};
if (cmd === 'scrub') {
Ext.MessageBox.defaultButton = params.deep === 1 ? 2 : 1;
Ext.Msg.show({
title: gettext('Confirm'),
icon: params.deep === 1 ? Ext.Msg.WARNING : Ext.Msg.QUESTION,
msg: params.deep !== 1
? Ext.String.format(gettext("Scrub OSD.{0}"), osdid)
: Ext.String.format(gettext("Deep Scrub OSD.{0}"), osdid) +
"<br>Caution: This can reduce performance while it is running.",
buttons: Ext.Msg.YESNO,
callback: function(btn) {
if (btn !== 'yes') {
return;
}
doRequest();
},
});
} else {
doRequest();
}
},
create_osd: function() {
let me = this;
let vm = this.getViewModel();
Ext.create('PVE.CephCreateOsd', {
nodename: vm.get('nodename'),
taskDone: () => { me.reload(); },
}).show();
},
destroy_osd: async function() {
let me = this;
let vm = this.getViewModel();
let warnings = {
flags: false,
degraded: false,
};
let flagsPromise = Proxmox.Async.api2({
url: `/cluster/ceph/flags`,
method: 'GET',
});
let statusPromise = Proxmox.Async.api2({
url: `/cluster/ceph/status`,
method: 'GET',
});
me.getView().mask(gettext('Loading...'));
try {
let result = await Promise.all([flagsPromise, statusPromise]);
let flagsData = result[0].result.data;
let statusData = result[1].result.data;
let flags = Array.from(
flagsData.filter(v => v.value),
v => v.name,
).filter(v => ['norebalance', 'norecover', 'noout'].includes(v));
if (flags.length) {
warnings.flags = true;
}
if (Object.keys(statusData.pgmap).includes('degraded_objects')) {
warnings.degraded = true;
}
} catch (error) {
Ext.Msg.alert(gettext('Error'), error.htmlStatus);
me.getView().unmask();
return;
}
me.getView().unmask();
Ext.create('PVE.CephRemoveOsd', {
nodename: vm.get('osdhost'),
osdid: vm.get('osdid'),
warnings: warnings,
taskDone: () => { me.reload(); },
autoShow: true,
});
},
set_flags: function() {
let me = this;
let vm = this.getViewModel();
Ext.create('PVE.CephSetFlags', {
nodename: vm.get('nodename'),
taskDone: () => { me.reload(); },
}).show();
},
service_cmd: function(comp) {
let me = this;
let vm = this.getViewModel();
let cmd = comp.cmd || comp;
let doRequest = function() {
Proxmox.Utils.API2Request({
url: `/nodes/${vm.get('osdhost')}/ceph/${cmd}`,
params: { service: "osd." + vm.get('osdid') },
waitMsgTarget: me.getView(),
method: 'POST',
success: function(response, options) {
let upid = response.result.data;
let win = Ext.create('Proxmox.window.TaskProgress', {
upid: upid,
taskDone: () => { me.reload(); },
});
win.show();
},
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
});
};
if (cmd === "stop") {
Proxmox.Utils.API2Request({
url: `/nodes/${vm.get('osdhost')}/ceph/cmd-safety`,
params: {
service: 'osd',
id: vm.get('osdid'),
action: 'stop',
},
waitMsgTarget: me.getView(),
method: 'GET',
success: function({ result: { data } }) {
if (!data.safe) {
Ext.Msg.show({
title: gettext('Warning'),
message: data.status,
icon: Ext.Msg.WARNING,
buttons: Ext.Msg.OKCANCEL,
buttonText: { ok: gettext('Stop OSD') },
fn: function(selection) {
if (selection === 'ok') {
doRequest();
}
},
});
} else {
doRequest();
}
},
failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
} else {
doRequest();
}
},
run_details: function(view, rec) {
if (rec.data.host && rec.data.type === 'osd' && rec.data.id >= 0) {
this.details();
}
},
details: function() {
let vm = this.getViewModel();
Ext.create('PVE.CephOsdDetails', {
nodename: vm.get('osdhost'),
osdid: vm.get('osdid'),
}).show();
},
set_selection_status: function(tp, selection) {
if (selection.length < 1) {
return;
}
let rec = selection[0];
let vm = this.getViewModel();
let isOsd = rec.data.host && rec.data.type === 'osd' && rec.data.id >= 0;
vm.set('isOsd', isOsd);
vm.set('downOsd', isOsd && rec.data.status === 'down');
vm.set('upOsd', isOsd && rec.data.status !== 'down');
vm.set('inOsd', isOsd && rec.data.in);
vm.set('outOsd', isOsd && !rec.data.in);
vm.set('osdid', isOsd ? rec.data.id : undefined);
vm.set('osdhost', isOsd ? rec.data.host : undefined);
},
render_status: function(value, metaData, rec) {
if (!value) {
return value;
}
let inout = rec.data.in ? 'in' : 'out';
let updownicon = value === 'up' ? 'good fa-arrow-circle-up'
: 'critical fa-arrow-circle-down';
let inouticon = rec.data.in ? 'good fa-circle'
: 'warning fa-circle-o';
let text = value + ' <i class="fa ' + updownicon + '"></i> / ' +
inout + ' <i class="fa ' + inouticon + '"></i>';
return text;
},
render_wal: function(value, metaData, rec) {
if (!value &&
rec.data.osdtype === 'bluestore' &&
rec.data.type === 'osd') {
return 'N/A';
}
return value;
},
render_version: function(value, metadata, rec) {
let vm = this.getViewModel();
let versions = vm.get('versions');
let icon = "";
let version = value || "";
let maxversion = vm.get('maxversion');
if (value && PVE.Utils.compare_ceph_versions(value, maxversion) !== 0) {
let host_version = rec.parentNode?.data?.version || versions[rec.data.host] || "";
if (rec.data.type === 'host' || PVE.Utils.compare_ceph_versions(host_version, maxversion) !== 0) {
icon = PVE.Utils.get_ceph_icon_html('HEALTH_UPGRADE');
} else {
icon = PVE.Utils.get_ceph_icon_html('HEALTH_OLD');
}
} else if (value && vm.get('mixedversions')) {
icon = PVE.Utils.get_ceph_icon_html('HEALTH_OK');
}
return icon + version;
},
render_osd_val: function(value, metaData, rec) {
return rec.data.type === 'osd' ? value : '';
},
render_osd_weight: function(value, metaData, rec) {
if (rec.data.type !== 'osd') {
return '';
}
return Ext.util.Format.number(value, '0.00###');
},
render_osd_latency: function(value, metaData, rec) {
if (rec.data.type !== 'osd') {
return '';
}
let commit_ms = rec.data.commit_latency_ms,
apply_ms = rec.data.apply_latency_ms;
return apply_ms + ' / ' + commit_ms;
},
render_osd_size: function(value, metaData, rec) {
return this.render_osd_val(Proxmox.Utils.render_size(value), metaData, rec);
},
control: {
'#': {
selectionchange: 'set_selection_status',
},
},
init: function(view) {
let me = this;
let vm = this.getViewModel();
if (!view.pveSelNode.data.node) {
throw "no node name specified";
}
vm.set('nodename', view.pveSelNode.data.node);
me.callParent();
me.reload();
},
},
stateful: true,
stateId: 'grid-ceph-osd',
rootVisible: false,
useArrows: true,
listeners: {
itemdblclick: 'run_details',
},
columns: [
{
xtype: 'treecolumn',
text: 'Name',
dataIndex: 'name',
width: 150,
},
{
text: 'Type',
dataIndex: 'type',
hidden: true,
align: 'right',
width: 75,
},
{
text: gettext("Class"),
dataIndex: 'device_class',
align: 'right',
width: 75,
},
{
text: "OSD Type",
dataIndex: 'osdtype',
align: 'right',
width: 100,
},
{
text: "Bluestore Device",
dataIndex: 'blfsdev',
align: 'right',
width: 75,
hidden: true,
},
{
text: "DB Device",
dataIndex: 'dbdev',
align: 'right',
width: 75,
hidden: true,
},
{
text: "WAL Device",
dataIndex: 'waldev',
align: 'right',
renderer: 'render_wal',
width: 75,
hidden: true,
},
{
text: 'Status',
dataIndex: 'status',
align: 'right',
renderer: 'render_status',
width: 120,
},
{
text: gettext('Version'),
dataIndex: 'version',
align: 'right',
renderer: 'render_version',
},
{
text: 'weight',
dataIndex: 'crush_weight',
align: 'right',
renderer: 'render_osd_weight',
width: 90,
},
{
text: 'reweight',
dataIndex: 'reweight',
align: 'right',
renderer: 'render_osd_weight',
width: 90,
},
{
text: gettext('Used') + ' (%)',
dataIndex: 'percent_used',
align: 'right',
renderer: function(value, metaData, rec) {
if (rec.data.type !== 'osd') {
return '';
}
return Ext.util.Format.number(value, '0.00');
},
width: 100,
},
{
text: gettext('Total'),
dataIndex: 'total_space',
align: 'right',
renderer: 'render_osd_size',
width: 100,
},
{
text: 'Apply/Commit<br>Latency (ms)',
dataIndex: 'apply_latency_ms',
align: 'right',
renderer: 'render_osd_latency',
width: 120,
},
{
text: 'PGs',
dataIndex: 'pgs',
align: 'right',
renderer: 'render_osd_val',
width: 90,
},
],
tbar: {
items: [
{
text: gettext('Reload'),
iconCls: 'fa fa-refresh',
handler: 'reload',
},
'-',
{
text: gettext('Create') + ': OSD',
handler: 'create_osd',
},
{
text: Ext.String.format(gettext('Manage {0}'), 'Global Flags'),
handler: 'set_flags',
},
'->',
{
xtype: 'tbtext',
data: {
osd: undefined,
},
bind: {
data: {
osd: "{osdid}",
},
},
tpl: [
'<tpl if="osd">',
'osd.{osd}:',
'<tpl else>',
gettext('No OSD selected'),
'</tpl>',
],
},
{
text: gettext('Details'),
iconCls: 'fa fa-info-circle',
disabled: true,
bind: {
disabled: '{!isOsd}',
},
handler: 'details',
},
{
text: gettext('Start'),
iconCls: 'fa fa-play',
disabled: true,
bind: {
disabled: '{!downOsd}',
},
cmd: 'start',
handler: 'service_cmd',
},
{
text: gettext('Stop'),
iconCls: 'fa fa-stop',
disabled: true,
bind: {
disabled: '{!upOsd}',
},
cmd: 'stop',
handler: 'service_cmd',
},
{
text: gettext('Restart'),
iconCls: 'fa fa-refresh',
disabled: true,
bind: {
disabled: '{!upOsd}',
},
cmd: 'restart',
handler: 'service_cmd',
},
'-',
{
text: 'Out',
iconCls: 'fa fa-circle-o',
disabled: true,
bind: {
disabled: '{!inOsd}',
},
cmd: 'out',
handler: 'osd_cmd',
},
{
text: 'In',
iconCls: 'fa fa-circle',
disabled: true,
bind: {
disabled: '{!outOsd}',
},
cmd: 'in',
handler: 'osd_cmd',
},
'-',
{
text: gettext('More'),
iconCls: 'fa fa-bars',
disabled: true,
bind: {
disabled: '{!isOsd}',
},
menu: [
{
text: gettext('Scrub'),
iconCls: 'fa fa-shower',
cmd: 'scrub',
handler: 'osd_cmd',
},
{
text: gettext('Deep Scrub'),
iconCls: 'fa fa-bath',
cmd: 'scrub',
params: {
deep: 1,
},
handler: 'osd_cmd',
},
{
text: gettext('Destroy'),
itemId: 'remove',
iconCls: 'fa fa-fw fa-trash-o',
bind: {
disabled: '{!downOsd}',
},
handler: 'destroy_osd',
},
],
},
],
},
fields: [
'name', 'type', 'status', 'host', 'in', 'id',
{ type: 'number', name: 'reweight' },
{ type: 'number', name: 'percent_used' },
{ type: 'integer', name: 'bytes_used' },
{ type: 'integer', name: 'total_space' },
{ type: 'integer', name: 'apply_latency_ms' },
{ type: 'integer', name: 'commit_latency_ms' },
{ type: 'string', name: 'device_class' },
{ type: 'string', name: 'osdtype' },
{ type: 'string', name: 'blfsdev' },
{ type: 'string', name: 'dbdev' },
{ type: 'string', name: 'waldev' },
{
type: 'string', name: 'version', calculate: function(data) {
return PVE.Utils.parse_ceph_version(data);
},
},
{
type: 'string', name: 'iconCls', calculate: function(data) {
let iconMap = {
host: 'fa-building',
osd: 'fa-hdd-o',
root: 'fa-server',
};
return `fa x-fa-tree ${iconMap[data.type] ?? 'fa-folder-o'}`;
},
},
{ type: 'number', name: 'crush_weight' },
],
});
Ext.define('pve-osd-details-devices', {
extend: 'Ext.data.Model',
fields: ['device', 'type', 'physical_device', 'size', 'support_discard', 'dev_node'],
idProperty: 'device',
});
Ext.define('PVE.CephOsdDetails', {
extend: 'Ext.window.Window',
alias: ['widget.pveCephOsdDetails'],
mixins: ['Proxmox.Mixin.CBind'],
cbindData: function() {
let me = this;
me.baseUrl = `/nodes/${me.nodename}/ceph/osd/${me.osdid}`;
return {
title: `${gettext('Details')}: OSD ${me.osdid}`,
};
},
viewModel: {
data: {
device: '',
},
},
modal: true,
width: 650,
minHeight: 250,
resizable: true,
cbind: {
title: '{title}',
},
layout: {
type: 'vbox',
align: 'stretch',
},
defaults: {
layout: 'fit',
border: false,
},
controller: {
xclass: 'Ext.app.ViewController',
reload: function() {
let view = this.getView();
Proxmox.Utils.API2Request({
url: `${view.baseUrl}/metadata`,
waitMsgTarget: view.lookup('detailsTabs'),
method: 'GET',
failure: function(response, opts) {
Proxmox.Utils.setErrorMask(view.lookup('detailsTabs'), response.htmlStatus);
},
success: function(response, opts) {
let d = response.result.data;
let osdData = Object.keys(d.osd).sort().map(x => ({ key: x, value: d.osd[x] }));
view.osdStore.loadData(osdData);
let devices = view.lookup('devices');
let deviceStore = devices.getStore();
deviceStore.loadData(d.devices);
view.lookup('osdGeneral').rstore.fireEvent('load', view.osdStore, osdData, true);
view.lookup('osdNetwork').rstore.fireEvent('load', view.osdStore, osdData, true);
// select 'block' device automatically on first load
if (devices.getSelection().length === 0) {
devices.setSelection(deviceStore.findRecord('device', 'block'));
}
},
});
},
showDevInfo: function(grid, selected) {
let view = this.getView();
if (selected[0]) {
let device = selected[0].data.device;
this.getViewModel().set('device', device);
let detailStore = view.lookup('volumeDetails');
detailStore.rstore.getProxy().setUrl(`api2/json${view.baseUrl}/lv-info`);
detailStore.rstore.getProxy().setExtraParams({ 'type': device });
detailStore.setLoading();
detailStore.rstore.load({ callback: () => detailStore.setLoading(false) });
}
},
init: function() {
this.reload();
},
control: {
'grid[reference=devices]': {
selectionchange: 'showDevInfo',
},
},
},
tbar: [
{
text: gettext('Reload'),
iconCls: 'fa fa-refresh',
handler: 'reload',
},
],
initComponent: function() {
let me = this;
me.osdStore = Ext.create('Proxmox.data.ObjectStore');
Ext.applyIf(me, {
items: [
{
xtype: 'tabpanel',
reference: 'detailsTabs',
items: [
{
xtype: 'proxmoxObjectGrid',
reference: 'osdGeneral',
tooltip: gettext('Various information about the OSD'),
rstore: me.osdStore,
title: gettext('General'),
viewConfig: {
enableTextSelection: true,
},
gridRows: [
{
xtype: 'text',
name: 'version',
text: gettext('Version'),
},
{
xtype: 'text',
name: 'hostname',
text: gettext('Hostname'),
},
{
xtype: 'text',
name: 'osd_data',
text: gettext('OSD data path'),
},
{
xtype: 'text',
name: 'osd_objectstore',
text: gettext('OSD object store'),
},
{
xtype: 'text',
name: 'mem_usage',
text: gettext('Memory usage (PSS)'),
renderer: Proxmox.Utils.render_size,
},
{
xtype: 'text',
name: 'pid',
text: `${gettext('Process ID')} (PID)`,
},
],
},
{
xtype: 'proxmoxObjectGrid',
reference: 'osdNetwork',
tooltip: gettext('Addresses and ports used by the OSD service'),
rstore: me.osdStore,
title: gettext('Network'),
viewConfig: {
enableTextSelection: true,
},
gridRows: [
{
xtype: 'text',
name: 'front_addr',
text: `${gettext('Front Address')}<br>(Client & Monitor)`,
renderer: PVE.Utils.render_ceph_osd_addr,
},
{
xtype: 'text',
name: 'hb_front_addr',
text: gettext('Heartbeat Front Address'),
renderer: PVE.Utils.render_ceph_osd_addr,
},
{
xtype: 'text',
name: 'back_addr',
text: `${gettext('Back Address')}<br>(OSD)`,
renderer: PVE.Utils.render_ceph_osd_addr,
},
{
xtype: 'text',
name: 'hb_back_addr',
text: gettext('Heartbeat Back Address'),
renderer: PVE.Utils.render_ceph_osd_addr,
},
],
},
{
xtype: 'panel',
title: gettext('Devices'),
tooltip: gettext('Physical devices used by the OSD'),
items: [
{
xtype: 'grid',
border: false,
reference: 'devices',
store: {
model: 'pve-osd-details-devices',
},
columns: {
items: [
{ text: gettext('Device'), dataIndex: 'device' },
{ text: gettext('Type'), dataIndex: 'type' },
{
text: gettext('Physical Device'),
dataIndex: 'physical_device',
},
{
text: gettext('Size'),
dataIndex: 'size',
renderer: Proxmox.Utils.render_size,
},
{
text: 'Discard',
dataIndex: 'support_discard',
hidden: true,
},
{
text: gettext('Device node'),
dataIndex: 'dev_node',
hidden: true,
},
],
defaults: {
tdCls: 'pointer',
flex: 1,
},
},
},
{
xtype: 'proxmoxObjectGrid',
reference: 'volumeDetails',
maskOnLoad: true,
viewConfig: {
enableTextSelection: true,
},
bind: {
title: Ext.String.format(
gettext('Volume Details for {0}'),
'{device}',
),
},
rows: {
creation_time: {
header: gettext('Creation time'),
},
lv_name: {
header: gettext('LV Name'),
},
lv_path: {
header: gettext('LV Path'),
},
lv_uuid: {
header: gettext('LV UUID'),
},
vg_name: {
header: gettext('VG Name'),
},
},
url: 'nodes/', //placeholder will be set when device is selected
},
],
},
],
},
],
});
me.callParent();
},
});
Ext.define('PVE.CephPoolInputPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveCephPoolInputPanel',
mixins: ['Proxmox.Mixin.CBind'],
showProgress: true,
onlineHelp: 'pve_ceph_pools',
subject: 'Ceph Pool',
defaultSize: undefined,
defaultMinSize: undefined,
controller: {
xclass: 'Ext.app.ViewController',
init: function(view) {
let vm = this.getViewModel();
if (view.isCreate) {
vm.set('size', Number(view.defaultSize));
vm.set('minSize', Number(view.defaultMinSize));
}
},
sizeChange: function(field, val) {
let vm = this.getViewModel();
let minSize = Math.round(val / 2);
if (minSize > 1) {
vm.set('minSize', minSize);
}
vm.set('size', val); // bind does not work in a pmxDisplayEditField, update manually
},
},
viewModel: {
data: {
minSize: null,
size: null,
},
formulas: {
minSizeLabel: (get) => {
if (get('showMinSizeOneWarning') || get('showMinSizeHalfWarning')) {
return `${gettext('Min. Size')} <i class="fa fa-exclamation-triangle warning"></i>`;
}
return gettext('Min. Size');
},
showMinSizeOneWarning: (get) => get('minSize') === 1,
showMinSizeHalfWarning: (get) => {
let minSize = get('minSize');
let size = get('size');
if (minSize === 1) {
return false;
}
return minSize < (size / 2) && minSize !== size;
},
},
},
column1: [
{
xtype: 'pmxDisplayEditField',
fieldLabel: gettext('Name'),
cbind: {
editable: '{isCreate}',
value: '{pool_name}',
},
name: 'name',
allowBlank: false,
},
{
xtype: 'pmxDisplayEditField',
cbind: {
editable: '{!isErasure}',
},
fieldLabel: gettext('Size'),
name: 'size',
editConfig: {
xtype: 'proxmoxintegerfield',
cbind: {
value: (get) => get('defaultSize'),
},
minValue: 2,
maxValue: 7,
allowBlank: false,
listeners: {
change: 'sizeChange',
},
},
},
],
column2: [
{
xtype: 'proxmoxKVComboBox',
fieldLabel: gettext('PG Autoscaler Mode'),
name: 'pg_autoscale_mode',
comboItems: [
['warn', 'warn'],
['on', 'on'],
['off', 'off'],
],
value: 'on', // FIXME: check ceph version and only default to on on octopus and newer
allowBlank: false,
autoSelect: false,
labelWidth: 140,
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Add as Storage'),
cbind: {
value: '{isCreate}',
hidden: '{!isCreate}',
},
name: 'add_storages',
labelWidth: 140,
autoEl: {
tag: 'div',
'data-qtip': gettext('Add the new pool to the cluster storage configuration.'),
},
},
],
advancedColumn1: [
{
xtype: 'proxmoxintegerfield',
bind: {
fieldLabel: '{minSizeLabel}',
value: '{minSize}',
},
name: 'min_size',
cbind: {
value: (get) => get('defaultMinSize'),
minValue: (get) => {
if (Number(get('defaultMinSize')) === 1) {
return 1;
} else {
return get('isCreate') ? 2 : 1;
}
},
},
maxValue: 7,
allowBlank: false,
},
{
xtype: 'displayfield',
bind: {
hidden: '{!showMinSizeHalfWarning}',
},
hidden: true,
userCls: 'pmx-hint',
value: gettext('min_size < size/2 can lead to data loss, incomplete PGs or unfound objects.'),
},
{
xtype: 'displayfield',
bind: {
hidden: '{!showMinSizeOneWarning}',
},
hidden: true,
userCls: 'pmx-hint',
value: gettext('a min_size of 1 is not recommended and can lead to data loss'),
},
{
xtype: 'pmxDisplayEditField',
cbind: {
editable: '{!isErasure}',
nodename: '{nodename}',
isCreate: '{isCreate}',
},
fieldLabel: 'Crush Rule', // do not localize
name: 'crush_rule',
editConfig: {
xtype: 'pveCephRuleSelector',
allowBlank: false,
},
},
{
xtype: 'proxmoxintegerfield',
fieldLabel: '# of PGs',
name: 'pg_num',
value: 128,
minValue: 1,
maxValue: 32768,
allowBlank: false,
emptyText: 128,
},
],
advancedColumn2: [
{
xtype: 'numberfield',
fieldLabel: gettext('Target Ratio'),
name: 'target_size_ratio',
minValue: 0,
decimalPrecision: 3,
allowBlank: true,
emptyText: '0.0',
autoEl: {
tag: 'div',
'data-qtip': gettext('The ratio of storage amount this pool will consume compared to other pools with ratios. Used for auto-scaling.'),
},
},
{
xtype: 'pveSizeField',
name: 'target_size',
fieldLabel: gettext('Target Size'),
unit: 'GiB',
minValue: 0,
allowBlank: true,
allowZero: true,
emptyText: '0',
emptyValue: 0,
autoEl: {
tag: 'div',
'data-qtip': gettext('The amount of data eventually stored in this pool. Used for auto-scaling.'),
},
},
{
xtype: 'displayfield',
userCls: 'pmx-hint',
value: Ext.String.format(gettext('{0} takes precedence.'), gettext('Target Ratio')), // FIXME: tooltip?
},
{
xtype: 'proxmoxintegerfield',
fieldLabel: 'Min. # of PGs',
name: 'pg_num_min',
minValue: 0,
allowBlank: true,
emptyText: '0',
},
],
onGetValues: function(values) {
Object.keys(values || {}).forEach(function(name) {
if (values[name] === '') {
delete values[name];
}
});
return values;
},
});
Ext.define('PVE.Ceph.PoolEdit', {
extend: 'Proxmox.window.Edit',
alias: 'widget.pveCephPoolEdit',
mixins: ['Proxmox.Mixin.CBind'],
cbindData: {
pool_name: '',
isCreate: (cfg) => !cfg.pool_name,
defaultSize: undefined,
defaultMinSize: undefined,
},
cbind: {
autoLoad: get => !get('isCreate'),
url: get => get('isCreate')
? `/nodes/${get('nodename')}/ceph/pool`
: `/nodes/${get('nodename')}/ceph/pool/${get('pool_name')}`,
loadUrl: get => `/nodes/${get('nodename')}/ceph/pool/${get('pool_name')}/status`,
method: get => get('isCreate') ? 'POST' : 'PUT',
},
showProgress: true,
subject: gettext('Ceph Pool'),
items: [{
xtype: 'pveCephPoolInputPanel',
cbind: {
nodename: '{nodename}',
pool_name: '{pool_name}',
isErasure: '{isErasure}',
isCreate: '{isCreate}',
defaultSize: '{defaultSize}',
defaultMinSize: '{defaultMinSize}',
},
}],
});
Ext.define('PVE.node.Ceph.PoolList', {
extend: 'Ext.grid.GridPanel',
alias: 'widget.pveNodeCephPoolList',
onlineHelp: 'chapter_pveceph',
stateful: true,
stateId: 'grid-ceph-pools',
bufferedRenderer: false,
features: [{ ftype: 'summary' }],
columns: [
{
text: gettext('Pool #'),
minWidth: 70,
flex: 1,
align: 'right',
sortable: true,
dataIndex: 'pool',
},
{
text: gettext('Name'),
minWidth: 120,
flex: 2,
sortable: true,
dataIndex: 'pool_name',
},
{
text: gettext('Type'),
minWidth: 100,
flex: 1,
dataIndex: 'type',
hidden: true,
},
{
text: gettext('Application'),
minWidth: 100,
flex: 1,
dataIndex: 'application_metadata',
hidden: true,
renderer: (v, _meta, _rec) => Object.keys(v).toString(),
},
{
text: gettext('Size') + '/min',
minWidth: 100,
flex: 1,
align: 'right',
renderer: (v, meta, rec) => `${v}/${rec.data.min_size}`,
dataIndex: 'size',
},
{
text: '# of Placement Groups',
flex: 1,
minWidth: 100,
align: 'right',
dataIndex: 'pg_num',
},
{
text: gettext('Optimal # of PGs'),
flex: 1,
minWidth: 100,
align: 'right',
dataIndex: 'pg_num_final',
renderer: function(value, metaData) {
if (!value) {
value = '<i class="fa fa-info-circle faded"></i> n/a';
metaData.tdAttr = 'data-qtip="Needs pg_autoscaler module enabled."';
}
return value;
},
},
{
text: gettext('Min. # of PGs'),
flex: 1,
minWidth: 100,
align: 'right',
dataIndex: 'pg_num_min',
hidden: true,
},
{
text: gettext('Target Ratio'),
flex: 1,
minWidth: 100,
align: 'right',
dataIndex: 'target_size_ratio',
renderer: Ext.util.Format.numberRenderer('0.0000'),
hidden: true,
},
{
text: gettext('Target Size'),
flex: 1,
minWidth: 100,
align: 'right',
dataIndex: 'target_size',
hidden: true,
renderer: function(v, metaData, rec) {
let value = Proxmox.Utils.render_size(v);
if (rec.data.target_size_ratio > 0) {
value = '<i class="fa fa-info-circle faded"></i> ' + value;
metaData.tdAttr = 'data-qtip="Target Size Ratio takes precedence over Target Size."';
}
return value;
},
},
{
text: gettext('Autoscaler Mode'),
flex: 1,
minWidth: 100,
align: 'right',
dataIndex: 'pg_autoscale_mode',
},
{
text: 'CRUSH Rule (ID)',
flex: 1,
align: 'right',
minWidth: 150,
renderer: (v, meta, rec) => `${v} (${rec.data.crush_rule})`,
dataIndex: 'crush_rule_name',
},
{
text: gettext('Used') + ' (%)',
flex: 1,
minWidth: 150,
sortable: true,
align: 'right',
dataIndex: 'bytes_used',
summaryType: 'sum',
summaryRenderer: Proxmox.Utils.render_size,
renderer: function(v, meta, rec) {
let percentage = Ext.util.Format.percent(rec.data.percent_used, '0.00');
let used = Proxmox.Utils.render_size(v);
return `${used} (${percentage})`;
},
},
],
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
var sm = Ext.create('Ext.selection.RowModel', {});
var rstore = Ext.create('Proxmox.data.UpdateStore', {
interval: 3000,
storeid: 'ceph-pool-list' + nodename,
model: 'ceph-pool-list',
proxy: {
type: 'proxmox',
url: `/api2/json/nodes/${nodename}/ceph/pool`,
},
});
let store = Ext.create('Proxmox.data.DiffStore', { rstore: rstore });
// manages the "install ceph?" overlay
PVE.Utils.monitor_ceph_installed(me, rstore, nodename);
var run_editor = function() {
let rec = sm.getSelection()[0];
if (!rec || !rec.data.pool_name) {
return;
}
Ext.create('PVE.Ceph.PoolEdit', {
title: gettext('Edit') + ': Ceph Pool',
nodename: nodename,
pool_name: rec.data.pool_name,
isErasure: rec.data.type === 'erasure',
autoShow: true,
listeners: {
destroy: () => rstore.load(),
},
});
};
Ext.apply(me, {
store: store,
selModel: sm,
tbar: [
{
text: gettext('Create'),
handler: function() {
let keys = [
'global:osd-pool-default-min-size',
'global:osd-pool-default-size',
];
let params = {
'config-keys': keys.join(';'),
};
Proxmox.Utils.API2Request({
url: '/nodes/localhost/ceph/cfg/value',
method: 'GET',
params,
waitMsgTarget: me.getView(),
failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
success: function({ result: { data } }) {
let global = data.global;
let defaultSize = global?.['osd-pool-default-size'] ?? 3;
let defaultMinSize = global?.['osd-pool-default-min-size'] ?? 2;
Ext.create('PVE.Ceph.PoolEdit', {
title: gettext('Create') + ': Ceph Pool',
isCreate: true,
isErasure: false,
defaultSize,
defaultMinSize,
nodename: nodename,
autoShow: true,
listeners: {
destroy: () => rstore.load(),
},
});
},
});
},
},
{
xtype: 'proxmoxButton',
text: gettext('Edit'),
selModel: sm,
disabled: true,
handler: run_editor,
},
{
xtype: 'proxmoxButton',
text: gettext('Destroy'),
selModel: sm,
disabled: true,
handler: function() {
let rec = sm.getSelection()[0];
if (!rec || !rec.data.pool_name) {
return;
}
let poolName = rec.data.pool_name;
Ext.create('Proxmox.window.SafeDestroy', {
showProgress: true,
url: `/nodes/${nodename}/ceph/pool/${poolName}`,
params: {
remove_storages: 1,
},
item: {
type: 'CephPool',
id: poolName,
},
taskName: 'cephdestroypool',
autoShow: true,
listeners: {
destroy: () => rstore.load(),
},
});
},
},
],
listeners: {
activate: () => rstore.startUpdate(),
destroy: () => rstore.stopUpdate(),
itemdblclick: run_editor,
},
});
me.callParent();
},
}, function() {
Ext.define('ceph-pool-list', {
extend: 'Ext.data.Model',
fields: [
'pool_name',
{ name: 'pool', type: 'integer' },
{ name: 'size', type: 'integer' },
{ name: 'min_size', type: 'integer' },
{ name: 'pg_num', type: 'integer' },
{ name: 'pg_num_min', type: 'integer' },
{ name: 'bytes_used', type: 'integer' },
{ name: 'percent_used', type: 'number' },
{ name: 'crush_rule', type: 'integer' },
{ name: 'crush_rule_name', type: 'string' },
{ name: 'pg_autoscale_mode', type: 'string' },
{ name: 'pg_num_final', type: 'integer' },
{ name: 'target_size_ratio', type: 'number' },
{ name: 'target_size', type: 'integer' },
],
idProperty: 'pool_name',
});
});
Ext.define('PVE.form.CephRuleSelector', {
extend: 'Ext.form.field.ComboBox',
alias: 'widget.pveCephRuleSelector',
allowBlank: false,
valueField: 'name',
displayField: 'name',
editable: false,
queryMode: 'local',
initComponent: function() {
let me = this;
if (!me.nodename) {
throw "no nodename given";
}
me.originalAllowBlank = me.allowBlank;
me.allowBlank = true;
Ext.apply(me, {
store: {
fields: ['name'],
sorters: 'name',
proxy: {
type: 'proxmox',
url: `/api2/json/nodes/${me.nodename}/ceph/rules`,
},
autoLoad: {
callback: (records, op, success) => {
if (me.isCreate && success && records.length > 0) {
me.select(records[0]);
}
me.allowBlank = me.originalAllowBlank;
delete me.originalAllowBlank;
me.validate();
},
},
},
});
me.callParent();
},
});
Ext.define('PVE.CephCreateService', {
extend: 'Proxmox.window.Edit',
mixins: ['Proxmox.Mixin.CBind'],
xtype: 'pveCephCreateService',
method: 'POST',
isCreate: true,
showProgress: true,
width: 450,
setNode: function(node) {
let me = this;
me.nodename = node;
me.updateUrl();
},
setServiceID: function(value) {
let me = this;
me.serviceID = value;
me.updateUrl();
},
updateUrl: function() {
let me = this;
let node = me.nodename;
let serviceID = me.serviceID ?? me.nodename;
me.url = `/nodes/${node}/ceph/${me.type}/${serviceID}`;
},
defaults: {
labelWidth: 75,
},
items: [
{
xtype: 'pveNodeSelector',
fieldLabel: gettext('Host'),
selectCurNode: true,
allowBlank: false,
submitValue: false,
listeners: {
change: function(f, value) {
let view = this.up('pveCephCreateService');
view.lookup('mds-id').setValue(value);
view.setNode(value);
},
},
},
{
xtype: 'textfield',
reference: 'mds-id',
fieldLabel: gettext('MDS ID'),
regex: /^([a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?)$/,
regexText: gettext('ID may consist of alphanumeric characters and hyphen. It cannot start with a number or end in a hyphen.'),
submitValue: false,
allowBlank: false,
cbind: {
disabled: get => get('type') !== 'mds',
hidden: get => get('type') !== 'mds',
},
listeners: {
change: function(f, value) {
let view = this.up('pveCephCreateService');
view.setServiceID(value);
},
},
},
{
xtype: 'component',
border: false,
padding: '5 2',
style: {
fontSize: '12px',
},
userCls: 'pmx-hint',
cbind: {
hidden: get => get('type') !== 'mds',
},
html: gettext('By using different IDs, you can have multiple MDS per node, which increases redundancy with more than one CephFS.'),
},
],
initComponent: function() {
let me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (!me.type) {
throw "no type specified";
}
me.setNode(me.nodename);
me.callParent();
},
});
Ext.define('PVE.node.CephServiceController', {
extend: 'Ext.app.ViewController',
alias: 'controller.CephServiceList',
render_status: (value, metadata, rec) => value,
render_version: function(value, metadata, rec) {
if (value === undefined) {
return '';
}
let view = this.getView();
let host = rec.data.host, nodev = [0];
if (view.nodeversions[host] !== undefined) {
nodev = view.nodeversions[host].version.parts;
}
let icon = '';
if (PVE.Utils.compare_ceph_versions(view.maxversion, nodev) > 0) {
icon = PVE.Utils.get_ceph_icon_html('HEALTH_UPGRADE');
} else if (PVE.Utils.compare_ceph_versions(nodev, value) > 0) {
icon = PVE.Utils.get_ceph_icon_html('HEALTH_OLD');
} else if (view.mixedversions) {
icon = PVE.Utils.get_ceph_icon_html('HEALTH_OK');
}
return icon + value;
},
getMaxVersions: function(store, records, success) {
if (!success || records.length < 1) {
return;
}
let me = this;
let view = me.getView();
view.nodeversions = records[0].data.node;
view.maxversion = [];
view.mixedversions = false;
for (const [_nodename, data] of Object.entries(view.nodeversions)) {
let res = PVE.Utils.compare_ceph_versions(data.version.parts, view.maxversion);
if (res !== 0 && view.maxversion.length > 0) {
view.mixedversions = true;
}
if (res > 0) {
view.maxversion = data.version.parts;
}
}
},
init: function(view) {
if (view.pveSelNode) {
view.nodename = view.pveSelNode.data.node;
}
if (!view.nodename) {
throw "no node name specified";
}
if (!view.type) {
throw "no type specified";
}
view.versionsstore = Ext.create('Proxmox.data.UpdateStore', {
autoStart: true,
interval: 10000,
storeid: `ceph-versions-${view.type}-list${view.nodename}`,
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/ceph/metadata?scope=versions",
},
});
view.versionsstore.on('load', this.getMaxVersions, this);
view.on('destroy', view.versionsstore.stopUpdate);
view.rstore = Ext.create('Proxmox.data.UpdateStore', {
autoStart: true,
interval: 3000,
storeid: `ceph-${view.type}-list${view.nodename}`,
model: 'ceph-service-list',
proxy: {
type: 'proxmox',
url: `/api2/json/nodes/${view.nodename}/ceph/${view.type}`,
},
});
view.setStore(Ext.create('Proxmox.data.DiffStore', {
rstore: view.rstore,
sorters: [{ property: 'name' }],
}));
if (view.storeLoadCallback) {
view.rstore.on('load', view.storeLoadCallback, this);
}
view.on('destroy', view.rstore.stopUpdate);
if (view.showCephInstallMask) {
PVE.Utils.monitor_ceph_installed(view, view.rstore, view.nodename, true);
}
},
service_cmd: function(rec, cmd) {
let view = this.getView();
if (!rec.data.host) {
Ext.Msg.alert(gettext('Error'), "entry has no host");
return;
}
let doRequest = function() {
Proxmox.Utils.API2Request({
url: `/nodes/${rec.data.host}/ceph/${cmd}`,
method: 'POST',
params: { service: view.type + '.' + rec.data.name },
success: function(response, options) {
Ext.create('Proxmox.window.TaskProgress', {
autoShow: true,
upid: response.result.data,
taskDone: () => view.rstore.load(),
});
},
failure: (response, _opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
};
if (cmd === "stop" && ['mon', 'mds'].includes(view.type)) {
Proxmox.Utils.API2Request({
url: `/nodes/${rec.data.host}/ceph/cmd-safety`,
params: {
service: view.type,
id: rec.data.name,
action: 'stop',
},
method: 'GET',
success: function({ result: { data } }) {
let stopText = {
mon: gettext('Stop MON'),
mds: gettext('Stop MDS'),
};
if (!data.safe) {
Ext.Msg.show({
title: gettext('Warning'),
message: data.status,
icon: Ext.Msg.WARNING,
buttons: Ext.Msg.OKCANCEL,
buttonText: { ok: stopText[view.type] },
fn: function(selection) {
if (selection === 'ok') {
doRequest();
}
},
});
} else {
doRequest();
}
},
failure: (response, _opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
} else {
doRequest();
}
},
onChangeService: function(button) {
let me = this;
let record = me.getView().getSelection()[0];
me.service_cmd(record, button.action);
},
showSyslog: function() {
let view = this.getView();
let rec = view.getSelection()[0];
let service = `ceph-${view.type}@${rec.data.name}`;
Ext.create('Ext.window.Window', {
title: `${gettext('Syslog')}: ${service}`,
autoShow: true,
modal: true,
width: 800,
height: 400,
layout: 'fit',
items: [{
xtype: 'proxmoxLogView',
url: `/api2/extjs/nodes/${rec.data.host}/syslog?service=${encodeURIComponent(service)}`,
log_select_timespan: 1,
}],
});
},
onCreate: function() {
let view = this.getView();
Ext.create('PVE.CephCreateService', {
autoShow: true,
nodename: view.nodename,
subject: view.getTitle(),
type: view.type,
taskDone: () => view.rstore.load(),
});
},
});
Ext.define('PVE.node.CephServiceList', {
extend: 'Ext.grid.GridPanel',
xtype: 'pveNodeCephServiceList',
onlineHelp: 'chapter_pveceph',
emptyText: gettext('No such service configured.'),
stateful: true,
// will be called when the store loads
storeLoadCallback: Ext.emptyFn,
// if set to true, does shows the ceph install mask if needed
showCephInstallMask: false,
controller: 'CephServiceList',
tbar: [
{
xtype: 'proxmoxButton',
text: gettext('Start'),
iconCls: 'fa fa-play',
action: 'start',
disabled: true,
enableFn: rec => rec.data.state === 'stopped' || rec.data.state === 'unknown',
handler: 'onChangeService',
},
{
xtype: 'proxmoxButton',
text: gettext('Stop'),
iconCls: 'fa fa-stop',
action: 'stop',
enableFn: rec => rec.data.state !== 'stopped',
disabled: true,
handler: 'onChangeService',
},
{
xtype: 'proxmoxButton',
text: gettext('Restart'),
iconCls: 'fa fa-refresh',
action: 'restart',
disabled: true,
enableFn: rec => rec.data.state !== 'stopped',
handler: 'onChangeService',
},
'-',
{
text: gettext('Create'),
reference: 'createButton',
handler: 'onCreate',
},
{
text: gettext('Destroy'),
xtype: 'proxmoxStdRemoveButton',
getUrl: function(rec) {
let view = this.up('grid');
if (!rec.data.host) {
Ext.Msg.alert(gettext('Error'), "entry has no host, cannot build API url");
return '';
}
return `/nodes/${rec.data.host}/ceph/${view.type}/${rec.data.name}`;
},
callback: function(options, success, response) {
let view = this.up('grid');
if (!success) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
return;
}
Ext.create('Proxmox.window.TaskProgress', {
autoShow: true,
upid: response.result.data,
taskDone: () => view.rstore.load(),
});
},
handler: function(btn, event, rec) {
let me = this;
let view = me.up('grid');
let doRequest = function() {
Proxmox.button.StdRemoveButton.prototype.handler.call(me, btn, event, rec);
};
if (view.type === 'mon') {
Proxmox.Utils.API2Request({
url: `/nodes/${rec.data.host}/ceph/cmd-safety`,
params: {
service: view.type,
id: rec.data.name,
action: 'destroy',
},
method: 'GET',
success: function({ result: { data } }) {
if (!data.safe) {
Ext.Msg.show({
title: gettext('Warning'),
message: data.status,
icon: Ext.Msg.WARNING,
buttons: Ext.Msg.OKCANCEL,
buttonText: { ok: gettext('Destroy MON') },
fn: function(selection) {
if (selection === 'ok') {
doRequest();
}
},
});
} else {
doRequest();
}
},
failure: (response, _opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
} else {
doRequest();
}
},
},
'-',
{
xtype: 'proxmoxButton',
text: gettext('Syslog'),
disabled: true,
handler: 'showSyslog',
},
],
columns: [
{
header: gettext('Name'),
flex: 1,
sortable: true,
renderer: function(v) {
return this.type + '.' + v;
},
dataIndex: 'name',
},
{
header: gettext('Host'),
flex: 1,
sortable: true,
renderer: function(v) {
return v || Proxmox.Utils.unknownText;
},
dataIndex: 'host',
},
{
header: gettext('Status'),
flex: 1,
sortable: false,
renderer: 'render_status',
dataIndex: 'state',
},
{
header: gettext('Address'),
flex: 3,
sortable: true,
renderer: function(v) {
return v || Proxmox.Utils.unknownText;
},
dataIndex: 'addr',
},
{
header: gettext('Version'),
flex: 3,
sortable: true,
dataIndex: 'version',
renderer: 'render_version',
},
],
initComponent: function() {
let me = this;
if (me.additionalColumns) {
me.columns = me.columns.concat(me.additionalColumns);
}
me.callParent();
},
}, function() {
Ext.define('ceph-service-list', {
extend: 'Ext.data.Model',
fields: [
'addr',
'name',
'fs_name',
'rank',
'host',
'quorum',
'state',
'ceph_version',
'ceph_version_short',
{
type: 'string',
name: 'version',
calculate: data => PVE.Utils.parse_ceph_version(data),
},
],
idProperty: 'name',
});
});
Ext.define('PVE.node.CephMDSServiceController', {
extend: 'PVE.node.CephServiceController',
alias: 'controller.CephServiceMDSList',
render_status: (value, mD, rec) => rec.data.fs_name ? `${value} (${rec.data.fs_name})` : value,
});
Ext.define('PVE.node.CephMDSList', {
extend: 'PVE.node.CephServiceList',
xtype: 'pveNodeCephMDSList',
controller: {
type: 'CephServiceMDSList',
},
});
Ext.define('PVE.ceph.Services', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveCephServices',
layout: {
type: 'hbox',
align: 'stretch',
},
bodyPadding: '0 5 20',
defaults: {
xtype: 'box',
style: {
'text-align': 'center',
},
},
items: [
{
flex: 1,
xtype: 'pveCephServiceList',
itemId: 'mons',
title: gettext('Monitors'),
},
{
flex: 1,
xtype: 'pveCephServiceList',
itemId: 'mgrs',
title: gettext('Managers'),
},
{
flex: 1,
xtype: 'pveCephServiceList',
itemId: 'mdss',
title: gettext('Meta Data Servers'),
},
],
updateAll: function(metadata, status) {
var me = this;
const healthstates = {
'HEALTH_UNKNOWN': 0,
'HEALTH_ERR': 1,
'HEALTH_WARN': 2,
'HEALTH_UPGRADE': 3,
'HEALTH_OLD': 4,
'HEALTH_OK': 5,
};
// order guarantee since es2020, but browsers did so before. Note, integers would break it.
const healthmap = Object.keys(healthstates);
let maxversion = "00.0.00";
Object.values(metadata.node || {}).forEach(function(node) {
if (PVE.Utils.compare_ceph_versions(node?.version?.parts, maxversion) > 0) {
maxversion = node?.version?.parts;
}
});
var quorummap = status && status.quorum_names ? status.quorum_names : [];
let monmessages = {}, mgrmessages = {}, mdsmessages = {};
if (status) {
if (status.health) {
Ext.Object.each(status.health.checks, function(key, value, _obj) {
if (!Ext.String.startsWith(key, "MON_")) {
return;
}
for (let i = 0; i < value.detail.length; i++) {
let match = value.detail[i].message.match(/mon.([a-zA-Z0-9\-.]+)/);
if (!match) {
continue;
}
let monid = match[1];
if (!monmessages[monid]) {
monmessages[monid] = {
worstSeverity: healthstates.HEALTH_OK,
messages: [],
};
}
let severityIcon = PVE.Utils.get_ceph_icon_html(value.severity, true);
let details = value.detail.reduce((acc, v) => `${acc}\n${v.message}`, '');
monmessages[monid].messages.push(severityIcon + details);
if (healthstates[value.severity] < monmessages[monid].worstSeverity) {
monmessages[monid].worstSeverity = healthstates[value.severity];
}
}
});
}
if (status.mgrmap) {
mgrmessages[status.mgrmap.active_name] = "active";
status.mgrmap.standbys.forEach(function(mgr) {
mgrmessages[mgr.name] = "standby";
});
}
if (status.fsmap) {
status.fsmap.by_rank.forEach(function(mds) {
mdsmessages[mds.name] = 'rank: ' + mds.rank + "; " + mds.status;
});
}
}
let checks = {
mon: function(mon) {
if (quorummap.indexOf(mon.name) !== -1) {
mon.health = healthstates.HEALTH_OK;
} else {
mon.health = healthstates.HEALTH_ERR;
}
if (monmessages[mon.name]) {
if (monmessages[mon.name].worstSeverity < mon.health) {
mon.health = monmessages[mon.name].worstSeverity;
}
Array.prototype.push.apply(mon.messages, monmessages[mon.name].messages);
}
return mon;
},
mgr: function(mgr) {
if (mgrmessages[mgr.name] === 'active') {
mgr.title = '<b>' + mgr.title + '</b>';
mgr.statuses.push(gettext('Status') + ': <b>active</b>');
} else if (mgrmessages[mgr.name] === 'standby') {
mgr.statuses.push(gettext('Status') + ': standby');
} else if (mgr.health > healthstates.HEALTH_WARN) {
mgr.health = healthstates.HEALTH_WARN;
}
return mgr;
},
mds: function(mds) {
if (mdsmessages[mds.name]) {
mds.title = '<b>' + mds.title + '</b>';
mds.statuses.push(gettext('Status') + ': <b>' + mdsmessages[mds.name]+"</b>");
} else if (mds.addr !== Proxmox.Utils.unknownText) {
mds.statuses.push(gettext('Status') + ': standby');
}
return mds;
},
};
for (let type of ['mon', 'mgr', 'mds']) {
var ids = Object.keys(metadata[type] || {});
me[type] = {};
for (let id of ids) {
const [name, host] = id.split('@');
let result = {
id: id,
health: healthstates.HEALTH_OK,
statuses: [],
messages: [],
name: name,
title: metadata[type][id].name || name,
host: host,
version: PVE.Utils.parse_ceph_version(metadata[type][id]),
service: metadata[type][id].service,
addr: metadata[type][id].addr || metadata[type][id].addrs || Proxmox.Utils.unknownText,
};
result.statuses = [
gettext('Host') + ": " + host,
gettext('Address') + ": " + result.addr,
];
if (checks[type]) {
result = checks[type](result);
}
if (result.service && !result.version) {
result.messages.push(
PVE.Utils.get_ceph_icon_html('HEALTH_UNKNOWN', true) +
gettext('Stopped'),
);
result.health = healthstates.HEALTH_UNKNOWN;
}
if (!result.version && result.addr === Proxmox.Utils.unknownText) {
result.health = healthstates.HEALTH_UNKNOWN;
}
if (result.version) {
result.statuses.push(gettext('Version') + ": " + result.version);
if (PVE.Utils.compare_ceph_versions(result.version, maxversion) !== 0) {
let host_version = metadata.node[host]?.version?.parts || metadata.version?.[host] || "";
if (PVE.Utils.compare_ceph_versions(host_version, maxversion) === 0) {
if (result.health > healthstates.HEALTH_OLD) {
result.health = healthstates.HEALTH_OLD;
}
result.messages.push(
PVE.Utils.get_ceph_icon_html('HEALTH_OLD', true) +
gettext('A newer version was installed but old version still running, please restart'),
);
} else {
if (result.health > healthstates.HEALTH_UPGRADE) {
result.health = healthstates.HEALTH_UPGRADE;
}
result.messages.push(
PVE.Utils.get_ceph_icon_html('HEALTH_UPGRADE', true) +
gettext('Other cluster members use a newer version of this service, please upgrade and restart'),
);
}
}
}
result.statuses.push(''); // empty line
result.text = result.statuses.concat(result.messages).join('<br>');
result.health = healthmap[result.health];
me[type][id] = result;
}
}
me.getComponent('mons').updateAll(Object.values(me.mon));
me.getComponent('mgrs').updateAll(Object.values(me.mgr));
me.getComponent('mdss').updateAll(Object.values(me.mds));
},
});
Ext.define('PVE.ceph.ServiceList', {
extend: 'Ext.container.Container',
xtype: 'pveCephServiceList',
style: {
'text-align': 'center',
},
defaults: {
xtype: 'box',
style: {
'text-align': 'center',
},
},
items: [
{
itemId: 'title',
data: {
title: '',
},
tpl: '<h3>{title}</h3>',
},
],
updateAll: function(list) {
var me = this;
me.suspendLayout = true;
list.sort((a, b) => a.id > b.id ? 1 : a.id < b.id ? -1 : 0);
if (!me.ids) {
me.ids = [];
}
let pendingRemoval = {};
me.ids.forEach(id => { pendingRemoval[id] = true; }); // mark all as to-remove first here
for (let i = 0; i < list.length; i++) {
let service = me.getComponent(list[i].id);
if (!service) {
// services and list are sorted, so just insert at i + 1 (first el. is the title)
service = me.insert(i + 1, {
xtype: 'pveCephServiceWidget',
itemId: list[i].id,
});
me.ids.push(list[i].id);
} else {
delete pendingRemoval[list[i].id]; // drop existing from for-removal
}
service.updateService(list[i].title, list[i].text, list[i].health);
}
Object.keys(pendingRemoval).forEach(id => me.remove(id)); // GC
me.suspendLayout = false;
me.updateLayout();
},
initComponent: function() {
var me = this;
me.callParent();
me.getComponent('title').update({
title: me.title,
});
},
});
Ext.define('PVE.ceph.ServiceWidget', {
extend: 'Ext.Component',
alias: 'widget.pveCephServiceWidget',
userCls: 'monitor inline-block',
data: {
title: '0',
health: 'HEALTH_ERR',
text: '',
iconCls: PVE.Utils.get_health_icon(),
},
tpl: [
'{title}: ',
'<i class="fa fa-fw {iconCls}"></i>',
],
updateService: function(title, text, health) {
var me = this;
me.update(Ext.apply(me.data, {
health: health,
text: text,
title: title,
iconCls: PVE.Utils.get_health_icon(PVE.Utils.map_ceph_health[health]),
}));
if (me.tooltip) {
me.tooltip.setHtml(text);
}
},
listeners: {
destroy: function() {
let me = this;
if (me.tooltip) {
me.tooltip.destroy();
delete me.tooltip;
}
},
mouseenter: {
element: 'el',
fn: function(events, element) {
let view = this.component;
if (!view) {
return;
}
if (!view.tooltip || view.data.text !== view.tooltip.html) {
view.tooltip = Ext.create('Ext.tip.ToolTip', {
target: view.el,
trackMouse: true,
dismissDelay: 0,
renderTo: Ext.getBody(),
html: view.data.text,
});
}
view.tooltip.show();
},
},
mouseleave: {
element: 'el',
fn: function(events, element) {
let view = this.component;
if (view.tooltip) {
view.tooltip.destroy();
delete view.tooltip;
}
},
},
},
});
Ext.define('pve-ceph-warnings', {
extend: 'Ext.data.Model',
fields: ['id', 'summary', 'detail', 'severity'],
idProperty: 'id',
});
Ext.define('PVE.node.CephStatus', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveNodeCephStatus',
onlineHelp: 'chapter_pveceph',
scrollable: true,
bodyPadding: 5,
layout: {
type: 'column',
},
defaults: {
padding: 5,
},
items: [
{
xtype: 'panel',
title: gettext('Health'),
bodyPadding: 10,
plugins: 'responsive',
responsiveConfig: {
'width < 1600': {
minHeight: 230,
columnWidth: 1,
},
'width >= 1600': {
minHeight: 500,
columnWidth: 0.5,
},
},
layout: {
type: 'hbox',
align: 'stretch',
},
items: [
{
xtype: 'container',
layout: {
type: 'vbox',
align: 'stretch',
},
flex: 1,
items: [
{
xtype: 'pveHealthWidget',
itemId: 'overallhealth',
flex: 1,
title: gettext('Status'),
},
{
xtype: 'displayfield',
itemId: 'versioninfo',
fieldLabel: gettext('Ceph Version'),
value: "",
autoEl: {
tag: 'div',
'data-qtip': gettext('The newest version installed in the Cluster.'),
},
padding: '10 0 0 0',
style: {
'text-align': 'center',
},
},
],
},
{
xtype: 'grid',
itemId: 'warnings',
flex: 2,
maxHeight: 430,
stateful: true,
stateId: 'ceph-status-warnings',
viewConfig: {
enableTextSelection: true,
listeners: {
collapsebody: function(rowNode, record) {
record.set('expanded', false);
record.commit();
},
expandbody: function(rowNode, record) {
record.set('expanded', true);
record.commit();
},
},
},
// we load the store manually, to show an emptyText specify an empty intermediate store
store: {
type: 'diff',
trackRemoved: false,
data: [],
rstore: {
storeid: 'pve-ceph-warnings',
type: 'update',
model: 'pve-ceph-warnings',
},
},
updateHealth: function(health) {
let checks = health.checks || {};
let checkRecords = Object.keys(checks).sort().map(key => {
let check = checks[key];
let data = {
id: key,
summary: check.summary.message,
detail: check.detail.reduce((acc, v) => `${acc}\n${v.message}`, '').trimStart(),
severity: check.severity,
};
data.noDetails = data.detail.length === 0;
data.detailsCls = data.detail.length === 0 ? 'pmx-opacity-75' : '';
if (data.detail.length === 0) {
data.detail = "no additional data";
}
return data;
});
let rstore = this.getStore().rstore;
rstore.loadData(checkRecords, false);
rstore.fireEvent('load', rstore, checkRecords, true);
},
emptyText: gettext('No Warnings/Errors'),
columns: [
{
dataIndex: 'severity',
tooltip: gettext('Severity'),
align: 'center',
width: 38,
renderer: function(value) {
let health = PVE.Utils.map_ceph_health[value];
let icon = PVE.Utils.get_health_icon(health);
return `<i class="fa fa-fw ${icon}"></i>`;
},
sorter: {
sorterFn: function(a, b) {
let health = ['HEALTH_ERR', 'HEALTH_WARN', 'HEALTH_OK'];
return health.indexOf(b.data.severity) - health.indexOf(a.data.severity);
},
},
},
{
dataIndex: 'summary',
header: gettext('Summary'),
renderer: function(value, metaData, record, rI, cI, store, view) {
if (record.get('expanded')) {
metaData.tdCls = 'pmx-column-wrapped';
}
return value;
},
flex: 1,
},
{
xtype: 'actioncolumn',
width: 50,
align: 'center',
tooltip: gettext('Actions'),
items: [
{
iconCls: 'x-fa fa-clipboard',
tooltip: gettext('Copy to Clipboard'),
handler: function(grid, rowindex, colindex, item, e, { data }) {
let detail = data.noDetails ? '': `\n${data.detail}`;
navigator.clipboard
.writeText(`${data.severity}: ${data.summary}${detail}`)
.catch(err => Ext.Msg.alert(gettext('Error'), err));
},
},
],
},
],
listeners: {
itemdblclick: function(view, record, row, rowIdx, e) {
// inspired by Ext.grid.plugin.RowExpander, but for double click
let rowNode = view.getNode(rowIdx);
let normalRow = Ext.fly(rowNode);
let collapsedCls = view.rowBodyFeature.rowCollapsedCls;
if (normalRow.hasCls(collapsedCls)) {
view.rowBodyFeature.rowExpander.toggleRow(rowIdx, record);
}
},
},
plugins: [
{
ptype: 'rowexpander',
expandOnDblClick: false,
scrollIntoViewOnExpand: false,
rowBodyTpl: '<pre class="pve-ceph-warning-detail {detailsCls}">{detail}</pre>',
},
],
},
],
},
{
xtype: 'pveCephStatusDetail',
itemId: 'statusdetail',
plugins: 'responsive',
responsiveConfig: {
'width < 1600': {
columnWidth: 1,
minHeight: 250,
},
'width >= 1600': {
columnWidth: 0.5,
minHeight: 300,
},
},
title: gettext('Status'),
},
{
xtype: 'pveCephServices',
title: gettext('Services'),
itemId: 'services',
plugins: 'responsive',
layout: {
type: 'hbox',
align: 'stretch',
},
responsiveConfig: {
'width < 1600': {
columnWidth: 1,
minHeight: 200,
},
'width >= 1600': {
columnWidth: 0.5,
minHeight: 200,
},
},
},
{
xtype: 'panel',
title: gettext('Performance'),
columnWidth: 1,
bodyPadding: 5,
layout: {
type: 'hbox',
align: 'center',
},
items: [
{
xtype: 'container',
flex: 1,
items: [
{
xtype: 'proxmoxGauge',
itemId: 'space',
title: gettext('Usage'),
},
{
flex: 1,
border: false,
},
{
xtype: 'container',
itemId: 'recovery',
hidden: true,
padding: 25,
items: [
{
xtype: 'pveRunningChart',
itemId: 'recoverychart',
title: gettext('Recovery') +'/ '+ gettext('Rebalance'),
renderer: PVE.Utils.render_bandwidth,
height: 100,
},
{
xtype: 'progressbar',
itemId: 'recoveryprogress',
},
],
},
],
},
{
xtype: 'container',
flex: 2,
defaults: {
padding: 0,
height: 100,
},
items: [
{
xtype: 'pveRunningChart',
itemId: 'reads',
title: gettext('Reads'),
renderer: PVE.Utils.render_bandwidth,
},
{
xtype: 'pveRunningChart',
itemId: 'writes',
title: gettext('Writes'),
renderer: PVE.Utils.render_bandwidth,
},
{
xtype: 'pveRunningChart',
itemId: 'readiops',
title: 'IOPS: ' + gettext('Reads'),
renderer: Ext.util.Format.numberRenderer('0,000'),
},
{
xtype: 'pveRunningChart',
itemId: 'writeiops',
title: 'IOPS: ' + gettext('Writes'),
renderer: Ext.util.Format.numberRenderer('0,000'),
},
],
},
],
},
],
updateAll: function(store, records, success) {
if (!success || records.length === 0) {
return;
}
var me = this;
var rec = records[0];
me.status = rec.data;
// add health panel
me.down('#overallhealth').updateHealth(PVE.Utils.render_ceph_health(rec.data.health || {}));
me.down('#warnings').updateHealth(rec.data.health || {}); // add errors to gridstore
me.getComponent('services').updateAll(me.metadata || {}, rec.data);
me.getComponent('statusdetail').updateAll(me.metadata || {}, rec.data);
// add performance data
let pgmap = rec.data.pgmap;
let used = pgmap.bytes_used;
let total = pgmap.bytes_total;
var text = Ext.String.format(gettext('{0} of {1}'),
Proxmox.Utils.render_size(used),
Proxmox.Utils.render_size(total),
);
// update the usage widget
const usage = total > 0 ? used / total : 0;
me.down('#space').updateValue(usage, text);
let readiops = pgmap.read_op_per_sec;
let writeiops = pgmap.write_op_per_sec;
let reads = pgmap.read_bytes_sec || 0;
let writes = pgmap.write_bytes_sec || 0;
// update the graphs
me.reads.addDataPoint(reads);
me.writes.addDataPoint(writes);
me.readiops.addDataPoint(readiops);
me.writeiops.addDataPoint(writeiops);
let degraded = pgmap.degraded_objects || 0;
let misplaced = pgmap.misplaced_objects || 0;
let unfound = pgmap.unfound_objects || 0;
let unhealthy = degraded + unfound + misplaced;
// update recovery
if (pgmap.recovering_objects_per_sec !== undefined || unhealthy > 0) {
let toRecoverObjects = pgmap.misplaced_total || pgmap.unfound_total || pgmap.degraded_total || 0;
if (toRecoverObjects === 0) {
return; // FIXME: unexpected return and leaves things possible visible when it shouldn't?
}
let recovered = toRecoverObjects - unhealthy || 0;
let speed = pgmap.recovering_bytes_per_sec || 0;
let recoveryRatio = recovered / toRecoverObjects;
let txt = `${(recoveryRatio * 100).toFixed(2)}%`;
if (speed > 0) {
let obj_per_sec = speed / (4 * 1024 * 1024); // 4 MiB per Object
let duration = Proxmox.Utils.format_duration_human(unhealthy/obj_per_sec);
let speedTxt = PVE.Utils.render_bandwidth(speed);
txt += ` (${speedTxt} - ${duration} left)`;
}
me.down('#recovery').setVisible(true);
me.down('#recoveryprogress').updateValue(recoveryRatio);
me.down('#recoveryprogress').updateText(txt);
me.down('#recoverychart').addDataPoint(speed);
} else {
me.down('#recovery').setVisible(false);
me.down('#recoverychart').addDataPoint(0);
}
},
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
me.callParent();
var baseurl = '/api2/json' + (nodename ? '/nodes/' + nodename : '/cluster') + '/ceph';
me.store = Ext.create('Proxmox.data.UpdateStore', {
storeid: 'ceph-status-' + (nodename || 'cluster'),
interval: 5000,
proxy: {
type: 'proxmox',
url: baseurl + '/status',
},
});
me.metadatastore = Ext.create('Proxmox.data.UpdateStore', {
storeid: 'ceph-metadata-' + (nodename || 'cluster'),
interval: 15*1000,
proxy: {
type: 'proxmox',
url: '/api2/json/cluster/ceph/metadata',
},
});
// save references for the updatefunction
me.iops = me.down('#iops');
me.readiops = me.down('#readiops');
me.writeiops = me.down('#writeiops');
me.reads = me.down('#reads');
me.writes = me.down('#writes');
// manages the "install ceph?" overlay
PVE.Utils.monitor_ceph_installed(me, me.store, nodename);
me.mon(me.store, 'load', me.updateAll, me);
me.mon(me.metadatastore, 'load', function(store, records, success) {
if (!success || records.length < 1) {
return;
}
me.metadata = records[0].data;
// update services
me.getComponent('services').updateAll(me.metadata, me.status || {});
// update detailstatus panel
me.getComponent('statusdetail').updateAll(me.metadata, me.status || {});
let maxversion = [];
let maxversiontext = "";
for (const [_nodename, data] of Object.entries(me.metadata.node)) {
let version = data.version.parts;
if (PVE.Utils.compare_ceph_versions(version, maxversion) > 0) {
maxversion = version;
maxversiontext = data.version.str;
}
}
me.down('#versioninfo').setValue(maxversiontext);
}, me);
me.on('destroy', me.store.stopUpdate);
me.on('destroy', me.metadatastore.stopUpdate);
me.store.startUpdate();
me.metadatastore.startUpdate();
},
});
Ext.define('PVE.ceph.StatusDetail', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveCephStatusDetail',
layout: {
type: 'hbox',
align: 'stretch',
},
bodyPadding: '0 5',
defaults: {
xtype: 'box',
style: {
'text-align': 'center',
},
},
items: [{
flex: 1,
itemId: 'osds',
maxHeight: 250,
scrollable: true,
padding: '0 10 5 10',
data: {
total: 0,
upin: 0,
upout: 0,
downin: 0,
downout: 0,
oldOSD: [],
ghostOSD: [],
},
tpl: [
'<h3>OSDs</h3>',
'<table class="osds">',
'<tr><td></td>',
'<td><i class="fa fa-fw good fa-circle"></i>',
gettext('In'),
'</td>',
'<td><i class="fa fa-fw warning fa-circle-o"></i>',
gettext('Out'),
'</td>',
'</tr>',
'<tr>',
'<td><i class="fa fa-fw good fa-arrow-circle-up"></i>',
gettext('Up'),
'</td>',
'<td>{upin}</td>',
'<td>{upout}</td>',
'</tr>',
'<tr>',
'<td><i class="fa fa-fw critical fa-arrow-circle-down"></i>',
gettext('Down'),
'</td>',
'<td>{downin}</td>',
'<td>{downout}</td>',
'</tr>',
'</table>',
'<br /><div>',
gettext('Total'),
': {total}',
'</div><br />',
'<tpl if="oldOSD.length &gt; 0">',
'<i class="fa fa-refresh warning"></i> ' + gettext('Outdated OSDs') + "<br>",
'<div class="osds">',
'<tpl for="oldOSD">',
'<div class="left-aligned">osd.{id}:</div>',
'<div class="right-aligned">{version}</div><br />',
'<div style="clear:both"></div>',
'</tpl>',
'</div>',
'</tpl>',
'</div>',
'<tpl if="ghostOSD.length &gt; 0">',
'<br />',
`<i class="fa fa-question-circle warning"></i> ${gettext('Ghost OSDs')}<br>`,
`<div data-qtip="${gettext('OSDs with no metadata, possibly left over from removal')}" class="osds">`,
'<tpl for="ghostOSD">',
'<div class="left-aligned">osd.{id}</div>',
'<div style="clear:both"></div>',
'</tpl>',
'</div>',
'</tpl>',
],
},
{
flex: 1,
border: false,
itemId: 'pgchart',
xtype: 'polar',
height: 184,
innerPadding: 5,
insetPadding: 5,
colors: [
'#CFCFCF',
'#21BF4B',
'#3892d4',
'#FFCC00',
'#FF6C59',
],
store: { },
series: [
{
type: 'pie',
donut: 60,
angleField: 'count',
tooltip: {
trackMouse: true,
renderer: function(tooltip, record, ctx) {
var html = record.get('text');
html += '<br>';
record.get('states').forEach(function(state) {
html += '<br>' +
state.state_name + ': ' + state.count.toString();
});
tooltip.setHtml(html);
},
},
subStyle: {
strokeStyle: false,
},
},
],
},
{
flex: 1.6,
itemId: 'pgs',
padding: '0 10',
maxHeight: 250,
scrollable: true,
data: {
states: [],
},
tpl: [
'<h3>PGs</h3>',
'<tpl for="states">',
'<div class="left-aligned"><i class ="fa fa-circle {cls}"></i> {state_name}:</div>',
'<div class="right-aligned">{count}</div><br />',
'<div style="clear:both"></div>',
'</tpl>',
],
}],
// similar to mgr dashboard
pgstates: {
// clean
clean: 1,
active: 1,
// busy
activating: 2,
backfill_wait: 2,
backfilling: 2,
creating: 2,
deep: 2,
forced_backfill: 2,
forced_recovery: 2,
peered: 2,
peering: 2,
recovering: 2,
recovery_wait: 2,
remapped: 2,
repair: 2,
scrubbing: 2,
snaptrim: 2,
snaptrim_wait: 2,
// warning
degraded: 3,
undersized: 3,
// critical
backfill_toofull: 4,
backfill_unfound: 4,
down: 4,
incomplete: 4,
inconsistent: 4,
recovery_toofull: 4,
recovery_unfound: 4,
snaptrim_error: 4,
stale: 4,
},
statecategories: [
{
text: gettext('Unknown'),
count: 0,
states: [],
cls: 'faded',
},
{
text: gettext('Clean'),
cls: 'good',
},
{
text: gettext('Busy'),
cls: 'pve-ceph-status-busy',
},
{
text: gettext('Warning'),
cls: 'warning',
},
{
text: gettext('Critical'),
cls: 'critical',
},
],
checkThemeColors: function() {
let me = this;
let rootStyle = getComputedStyle(document.documentElement);
// get color
let background = rootStyle.getPropertyValue("--pwt-panel-background").trim() || "#ffffff";
// set the colors
me.chart.setBackground(background);
me.chart.redraw();
},
updateAll: function(metadata, status) {
let me = this;
me.suspendLayout = true;
let maxversion = "0";
Object.values(metadata.node || {}).forEach(function(node) {
if (PVE.Utils.compare_ceph_versions(node?.version?.parts, maxversion) > 0) {
maxversion = node.version.parts;
}
});
let oldOSD = [], ghostOSD = [];
metadata.osd?.forEach(osd => {
let version = PVE.Utils.parse_ceph_version(osd);
if (version !== undefined) {
if (PVE.Utils.compare_ceph_versions(version, maxversion) !== 0) {
oldOSD.push({
id: osd.id,
version: version,
});
}
} else {
if (Object.keys(osd).length > 1) {
console.warn('got OSD entry with no valid version but other keys', osd);
}
ghostOSD.push({
id: osd.id,
});
}
});
// update PGs sorted
let pgmap = status.pgmap || {};
let pgs_by_state = pgmap.pgs_by_state || [];
pgs_by_state.sort(function(a, b) {
return a.state_name < b.state_name?-1:a.state_name === b.state_name?0:1;
});
me.statecategories.forEach(function(cat) {
cat.count = 0;
cat.states = [];
});
pgs_by_state.forEach(function(state) {
let states = state.state_name.split(/[^a-z]+/);
let result = 0;
for (let i = 0; i < states.length; i++) {
if (me.pgstates[states[i]] > result) {
result = me.pgstates[states[i]];
}
}
// for the list
state.cls = me.statecategories[result].cls;
me.statecategories[result].count += state.count;
me.statecategories[result].states.push(state);
});
me.chart.getStore().setData(me.statecategories);
me.getComponent('pgs').update({ states: pgs_by_state });
let health = status.health || {};
// we collect monitor/osd information from the checks
const downinregex = /(\d+) osds down/;
let downin_osds = 0;
Ext.Object.each(health.checks, function(key, value, obj) {
var found = null;
if (key === 'OSD_DOWN') {
found = value.summary.message.match(downinregex);
if (found !== null) {
downin_osds = parseInt(found[1], 10);
}
}
});
let osdmap = status.osdmap || {};
if (typeof osdmap.osdmap !== "undefined") {
osdmap = osdmap.osdmap;
}
// update OSDs counts
let total_osds = osdmap.num_osds || 0;
let in_osds = osdmap.num_in_osds || 0;
let up_osds = osdmap.num_up_osds || 0;
let down_osds = total_osds - up_osds;
let downout_osds = down_osds - downin_osds;
let upin_osds = in_osds - downin_osds;
let upout_osds = up_osds - upin_osds;
let osds = {
total: total_osds,
upin: upin_osds,
upout: upout_osds,
downin: downin_osds,
downout: downout_osds,
oldOSD: oldOSD,
ghostOSD,
};
let osdcomponent = me.getComponent('osds');
osdcomponent.update(Ext.apply(osdcomponent.data, osds));
me.suspendLayout = false;
me.updateLayout();
},
initComponent: function() {
var me = this;
me.callParent();
me.chart = me.getComponent('pgchart');
me.checkThemeColors();
// switch colors on media query changes
me.mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
me.themeListener = (e) => { me.checkThemeColors(); };
me.mediaQueryList.addEventListener("change", me.themeListener);
},
doDestroy: function() {
let me = this;
me.mediaQueryList.removeEventListener("change", me.themeListener);
me.callParent();
},
});
Ext.define('PVE.node.ACMEAccountCreate', {
extend: 'Proxmox.window.Edit',
mixins: ['Proxmox.Mixin.CBind'],
width: 450,
title: gettext('Register Account'),
isCreate: true,
method: 'POST',
submitText: gettext('Register'),
url: '/cluster/acme/account',
showTaskViewer: true,
defaultExists: false,
referenceHolder: true,
onlineHelp: "sysadmin_certs_acme_account",
viewModel: {
data: {
customDirectory: false,
eabRequired: false,
},
formulas: {
eabEmptyText: function(get) {
return get('eabRequired') ? gettext("required") : gettext("optional");
},
},
},
items: [
{
xtype: 'proxmoxtextfield',
fieldLabel: gettext('Account Name'),
name: 'name',
cbind: {
emptyText: (get) => get('defaultExists') ? '' : 'default',
allowBlank: (get) => !get('defaultExists'),
},
},
{
xtype: 'textfield',
name: 'contact',
vtype: 'email',
allowBlank: false,
fieldLabel: gettext('E-Mail'),
},
{
xtype: 'proxmoxComboGrid',
notFoundIsValid: true,
isFormField: false,
allowBlank: false,
valueField: 'url',
displayField: 'name',
fieldLabel: gettext('ACME Directory'),
store: {
listeners: {
'load': function() {
this.add({ name: gettext("Custom"), url: '' });
},
},
autoLoad: true,
fields: ['name', 'url'],
idProperty: ['name'],
proxy: {
type: 'proxmox',
url: '/api2/json/cluster/acme/directories',
},
},
listConfig: {
columns: [
{
header: gettext('Name'),
dataIndex: 'name',
flex: 1,
},
{
header: gettext('URL'),
dataIndex: 'url',
flex: 1,
},
],
},
listeners: {
change: function(combogrid, value) {
let me = this;
let vm = me.up('window').getViewModel();
let dirField = me.up('window').lookupReference('directoryInput');
let tosButton = me.up('window').lookupReference('queryTos');
let isCustom = combogrid.getSelection().get('name') === gettext("Custom");
vm.set('customDirectory', isCustom);
dirField.setValue(value);
if (!isCustom) {
tosButton.click();
} else {
me.up('window').clearToSFields();
}
},
},
},
{
xtype: 'fieldcontainer',
layout: 'hbox',
fieldLabel: gettext('URL'),
bind: {
hidden: '{!customDirectory}',
},
items: [
{
xtype: 'proxmoxtextfield',
name: 'directory',
reference: 'directoryInput',
flex: 1,
allowBlank: false,
listeners: {
change: function(textbox, value) {
let me = this;
me.up('window').clearToSFields();
},
},
},
{
xtype: 'proxmoxButton',
margin: '0 0 0 5',
reference: 'queryTos',
text: gettext('Query URL'),
listeners: {
click: function(button) {
let me = this;
let w = me.up('window');
let vm = w.getViewModel();
let disp = w.down('#tos_url_display');
let field = w.down('#tos_url');
let checkbox = w.down('#tos_checkbox');
let value = w.lookupReference('directoryInput').getValue();
w.clearToSFields();
if (!value) {
return;
} else {
disp.setValue(gettext("Loading"));
}
Proxmox.Utils.API2Request({
url: '/cluster/acme/meta',
method: 'GET',
params: {
directory: value,
},
success: function(response, opt) {
if (response.result.data && response.result.data.termsOfService) {
field.setValue(response.result.data.termsOfService);
disp.setValue(response.result.data.termsOfService);
checkbox.setHidden(false);
} else {
// Needed to pass input verification and enable register button
// has no influence on the submitted form
checkbox.setValue(true);
disp.setValue("No terms of service agreement required");
}
vm.set('eabRequired', !!(response.result.data &&
response.result.data.externalAccountRequired));
},
failure: function(response, opt) {
disp.setValue(undefined);
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
});
},
},
},
],
},
{
xtype: 'displayfield',
itemId: 'tos_url_display',
renderer: PVE.Utils.render_optional_url,
name: 'tos_url_display',
},
{
xtype: 'hidden',
itemId: 'tos_url',
name: 'tos_url',
},
{
xtype: 'proxmoxcheckbox',
itemId: 'tos_checkbox',
boxLabel: gettext('Accept TOS'),
submitValue: false,
validateValue: function(value) {
if (value && this.checked) {
return true;
}
return false;
},
},
{
xtype: 'proxmoxtextfield',
name: 'eab-kid',
fieldLabel: gettext('EAB Key ID'),
bind: {
hidden: '{!customDirectory}',
allowBlank: '{!eabRequired}',
emptyText: '{eabEmptyText}',
},
},
{
xtype: 'proxmoxtextfield',
name: 'eab-hmac-key',
fieldLabel: gettext('EAB Key'),
bind: {
hidden: '{!customDirectory}',
allowBlank: '{!eabRequired}',
emptyText: '{eabEmptyText}',
},
},
],
clearToSFields: function() {
let me = this;
let disp = me.down('#tos_url_display');
let field = me.down('#tos_url');
let checkbox = me.down('#tos_checkbox');
disp.setValue("Terms of service not fetched yet");
field.setValue(undefined);
checkbox.setValue(undefined);
checkbox.setHidden(true);
},
});
Ext.define('PVE.node.ACMEDomainEdit', {
extend: 'Proxmox.window.Edit',
alias: 'widget.pveACMEDomainEdit',
subject: gettext('Domain'),
isCreate: false,
width: 450,
onlineHelp: 'sysadmin_certificate_management',
items: [
{
xtype: 'inputpanel',
onGetValues: function(values) {
let me = this;
let win = me.up('pveACMEDomainEdit');
let nodeconfig = win.nodeconfig;
let olddomain = win.domain || {};
let params = {
digest: nodeconfig.digest,
};
let configkey = olddomain.configkey;
let acmeObj = PVE.Parser.parseACME(nodeconfig.acme);
if (values.type === 'dns') {
if (!olddomain.configkey || olddomain.configkey === 'acme') {
// look for first free slot
for (let i = 0; i < PVE.Utils.acmedomain_count; i++) {
if (nodeconfig[`acmedomain${i}`] === undefined) {
configkey = `acmedomain${i}`;
break;
}
}
if (olddomain.domain) {
// we have to remove the domain from the acme domainlist
PVE.Utils.remove_domain_from_acme(acmeObj, olddomain.domain);
params.acme = PVE.Parser.printACME(acmeObj);
}
}
delete values.type;
params[configkey] = PVE.Parser.printPropertyString(values, 'domain');
} else {
if (olddomain.configkey && olddomain.configkey !== 'acme') {
// delete the old dns entry
params.delete = [olddomain.configkey];
}
// add new, remove old and make entries unique
PVE.Utils.add_domain_to_acme(acmeObj, values.domain);
PVE.Utils.remove_domain_from_acme(acmeObj, olddomain.domain);
params.acme = PVE.Parser.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('pveACMEDomainEdit');
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 < PVE.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('pveACMEDomainEdit');
let pluginField = view.down('field[name=plugin]');
pluginField.setDisabled(value !== 'dns');
pluginField.setHidden(value !== 'dns');
},
},
},
{
xtype: 'hidden',
name: 'alias',
},
{
xtype: 'pveACMEPluginSelector',
name: 'plugin',
disabled: true,
hidden: true,
allowBlank: false,
},
{
xtype: 'proxmoxtextfield',
name: 'domain',
allowBlank: false,
vtype: 'DnsName',
value: '',
fieldLabel: gettext('Domain'),
},
],
},
],
initComponent: function() {
let me = this;
if (!me.nodename) {
throw 'no nodename given';
}
if (!me.nodeconfig) {
throw 'no nodeconfig given';
}
me.isCreate = !me.domain;
if (me.isCreate) {
me.domain = `${me.nodename}.`; // TODO: FQDN of node
}
me.url = `/api2/extjs/nodes/${me.nodename}/config`;
me.callParent();
if (!me.isCreate) {
me.setValues(me.domain);
} else {
me.setValues({ domain: me.domain });
}
},
});
Ext.define('pve-acme-domains', {
extend: 'Ext.data.Model',
fields: ['domain', 'type', 'alias', 'plugin', 'configkey'],
idProperty: 'domain',
});
Ext.define('PVE.node.ACME', {
extend: 'Ext.grid.Panel',
alias: 'widget.pveACMEView',
margin: '10 0 0 0',
title: 'ACME',
emptyText: gettext('No Domains configured'),
viewModel: {
data: {
domaincount: 0,
account: undefined, // the account we display
configaccount: undefined, // the account set in the config
accountEditable: false,
accountsAvailable: false,
},
formulas: {
canOrder: (get) => !!get('account') && get('domaincount') > 0,
editBtnIcon: (get) => 'fa black fa-' + (get('accountEditable') ? 'check' : 'pencil'),
editBtnText: (get) => get('accountEditable') ? gettext('Apply') : gettext('Edit'),
accountTextHidden: (get) => get('accountEditable') || !get('accountsAvailable'),
accountValueHidden: (get) => !get('accountEditable') || !get('accountsAvailable'),
},
},
controller: {
xclass: 'Ext.app.ViewController',
init: function(view) {
let accountSelector = this.lookup('accountselector');
accountSelector.store.on('load', this.onAccountsLoad, this);
},
onAccountsLoad: function(store, records, success) {
let me = this;
let vm = me.getViewModel();
let configaccount = vm.get('configaccount');
vm.set('accountsAvailable', records.length > 0);
if (me.autoChangeAccount && records.length > 0) {
me.changeAccount(records[0].data.name, () => {
vm.set('accountEditable', false);
me.reload();
});
me.autoChangeAccount = false;
} else if (configaccount) {
if (store.findExact('name', configaccount) !== -1) {
vm.set('account', configaccount);
} else {
vm.set('account', null);
}
}
},
addDomain: function() {
let me = this;
let view = me.getView();
Ext.create('PVE.node.ACMEDomainEdit', {
nodename: view.nodename,
nodeconfig: view.nodeconfig,
apiCallDone: function() {
me.reload();
},
}).show();
},
editDomain: function() {
let me = this;
let view = me.getView();
let selection = view.getSelection();
if (selection.length < 1) return;
Ext.create('PVE.node.ACMEDomainEdit', {
nodename: view.nodename,
nodeconfig: view.nodeconfig,
domain: selection[0].data,
apiCallDone: function() {
me.reload();
},
}).show();
},
removeDomain: function() {
let me = this;
let view = me.getView();
let selection = view.getSelection();
if (selection.length < 1) return;
let rec = selection[0].data;
let params = {};
if (rec.configkey !== 'acme') {
params.delete = rec.configkey;
} else {
let acme = PVE.Parser.parseACME(view.nodeconfig.acme);
PVE.Utils.remove_domain_from_acme(acme, rec.domain);
params.acme = PVE.Parser.printACME(acme);
}
Proxmox.Utils.API2Request({
method: 'PUT',
url: `/nodes/${view.nodename}/config`,
params,
success: function(response, opt) {
me.reload();
},
failure: function(response, opt) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
});
},
toggleEditAccount: function() {
let me = this;
let vm = me.getViewModel();
let editable = vm.get('accountEditable');
if (editable) {
me.changeAccount(vm.get('account'), function() {
vm.set('accountEditable', false);
me.reload();
});
} else {
vm.set('accountEditable', true);
}
},
changeAccount: function(account, callback) {
let me = this;
let view = me.getView();
let params = {};
let acme = PVE.Parser.parseACME(view.nodeconfig.acme);
acme.account = account;
params.acme = PVE.Parser.printACME(acme);
Proxmox.Utils.API2Request({
method: 'PUT',
waitMsgTarget: view,
url: `/nodes/${view.nodename}/config`,
params,
success: function(response, opt) {
if (Ext.isFunction(callback)) {
callback();
}
},
failure: function(response, opt) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
});
},
order: function() {
let me = this;
let view = me.getView();
Proxmox.Utils.API2Request({
method: 'POST',
params: {
force: 1,
},
url: `/nodes/${view.nodename}/certificates/acme/certificate`,
success: function(response, opt) {
Ext.create('Proxmox.window.TaskViewer', {
upid: response.result.data,
taskDone: function(success) {
me.orderFinished(success);
},
}).show();
},
failure: function(response, opt) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
});
},
orderFinished: function(success) {
if (!success) return;
// reload only if the Web UI is open on the same node that the cert was ordered for
if (this.getView().nodename !== Proxmox.NodeName) {
return;
}
var txt = gettext('pveproxy will be restarted with new certificates, please reload the GUI!');
Ext.getBody().mask(txt, ['pve-static-mask']);
// reload after 10 seconds automatically
Ext.defer(function() {
window.location.reload(true);
}, 10000);
},
reload: function() {
let me = this;
let view = me.getView();
view.rstore.load();
},
addAccount: function() {
let me = this;
Ext.create('PVE.node.ACMEAccountCreate', {
autoShow: true,
taskDone: function() {
me.reload();
let accountSelector = me.lookup('accountselector');
me.autoChangeAccount = true;
accountSelector.store.load();
},
});
},
},
tbar: [
{
xtype: 'proxmoxButton',
text: gettext('Add'),
handler: 'addDomain',
selModel: false,
},
{
xtype: 'proxmoxButton',
text: gettext('Edit'),
disabled: true,
handler: 'editDomain',
},
{
xtype: 'proxmoxStdRemoveButton',
handler: 'removeDomain',
},
'-',
{
xtype: 'button',
reference: 'order',
text: gettext('Order Certificates Now'),
bind: {
disabled: '{!canOrder}',
},
handler: 'order',
},
'-',
{
xtype: 'displayfield',
value: gettext('Using Account') + ':',
bind: {
hidden: '{!accountsAvailable}',
},
},
{
xtype: 'displayfield',
reference: 'accounttext',
renderer: (val) => val || Proxmox.Utils.NoneText,
bind: {
value: '{account}',
hidden: '{accountTextHidden}',
},
},
{
xtype: 'pveACMEAccountSelector',
hidden: true,
reference: 'accountselector',
bind: {
value: '{account}',
hidden: '{accountValueHidden}',
},
},
{
xtype: 'button',
iconCls: 'fa black fa-pencil',
bind: {
iconCls: '{editBtnIcon}',
text: '{editBtnText}',
hidden: '{!accountsAvailable}',
},
handler: 'toggleEditAccount',
},
{
xtype: 'displayfield',
value: gettext('No Account available.'),
bind: {
hidden: '{accountsAvailable}',
},
},
{
xtype: 'button',
hidden: true,
reference: 'accountlink',
text: gettext('Add ACME Account'),
bind: {
hidden: '{accountsAvailable}',
},
handler: 'addAccount',
},
],
updateStore: function(store, records, success) {
let me = this;
let data = [];
let rec;
if (success && records.length > 0) {
rec = records[0];
} else {
rec = {
data: {},
};
}
me.nodeconfig = rec.data; // save nodeconfig for updates
let account = 'default';
if (rec.data.acme) {
let obj = PVE.Parser.parseACME(rec.data.acme);
(obj.domains || []).forEach(domain => {
if (domain === '') return;
let record = {
domain,
type: 'standalone',
configkey: 'acme',
};
data.push(record);
});
if (obj.account) {
account = obj.account;
}
}
let vm = me.getViewModel();
let oldaccount = vm.get('account');
// account changed, and we do not edit currently, load again to verify
if (oldaccount !== account && !vm.get('accountEditable')) {
vm.set('configaccount', account);
me.lookup('accountselector').store.load();
}
for (let i = 0; i < PVE.Utils.acmedomain_count; i++) {
let acmedomain = rec.data[`acmedomain${i}`];
if (!acmedomain) continue;
let record = PVE.Parser.parsePropertyString(acmedomain, 'domain');
record.type = 'dns';
record.configkey = `acmedomain${i}`;
data.push(record);
}
vm.set('domaincount', data.length);
me.store.loadData(data, false);
},
listeners: {
itemdblclick: 'editDomain',
},
columns: [
{
dataIndex: 'domain',
flex: 5,
text: gettext('Domain'),
},
{
dataIndex: 'type',
flex: 1,
text: gettext('Type'),
},
{
dataIndex: 'plugin',
flex: 1,
text: gettext('Plugin'),
},
],
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no nodename given";
}
me.rstore = Ext.create('Proxmox.data.UpdateStore', {
interval: 10 * 1000,
autoStart: true,
storeid: `pve-node-domains-${me.nodename}`,
proxy: {
type: 'proxmox',
url: `/api2/json/nodes/${me.nodename}/config`,
},
});
me.store = Ext.create('Ext.data.Store', {
model: 'pve-acme-domains',
sorters: 'domain',
});
me.callParent();
me.mon(me.rstore, 'load', 'updateStore', me);
Proxmox.Utils.monStoreErrors(me, me.rstore);
me.on('destroy', me.rstore.stopUpdate, me.rstore);
},
});
Ext.define('PVE.node.CertificateView', {
extend: 'Ext.container.Container',
xtype: 'pveCertificatesView',
onlineHelp: 'sysadmin_certificate_management',
mixins: ['Proxmox.Mixin.CBind'],
scrollable: 'y',
items: [
{
xtype: 'pveCertView',
border: 0,
cbind: {
nodename: '{nodename}',
},
},
{
xtype: 'pveACMEView',
border: 0,
cbind: {
nodename: '{nodename}',
},
},
],
});
Ext.define('PVE.node.CertificateViewer', {
extend: 'Proxmox.window.Edit',
title: gettext('Certificate'),
fieldDefaults: {
labelWidth: 120,
},
width: 800,
items: {
xtype: 'inputpanel',
maxHeight: 900,
scrollable: 'y',
columnT: [
{
xtype: 'displayfield',
fieldLabel: gettext('Name'),
name: 'filename',
},
{
xtype: 'displayfield',
fieldLabel: gettext('Fingerprint'),
name: 'fingerprint',
},
{
xtype: 'displayfield',
fieldLabel: gettext('Issuer'),
name: 'issuer',
},
{
xtype: 'displayfield',
fieldLabel: gettext('Subject'),
name: 'subject',
},
],
column1: [
{
xtype: 'displayfield',
fieldLabel: gettext('Public Key Type'),
name: 'public-key-type',
},
{
xtype: 'displayfield',
fieldLabel: gettext('Public Key Size'),
name: 'public-key-bits',
},
],
column2: [
{
xtype: 'displayfield',
fieldLabel: gettext('Valid Since'),
renderer: Proxmox.Utils.render_timestamp,
name: 'notbefore',
},
{
xtype: 'displayfield',
fieldLabel: gettext('Expires'),
renderer: Proxmox.Utils.render_timestamp,
name: 'notafter',
},
],
columnB: [
{
xtype: 'displayfield',
fieldLabel: gettext('Subject Alternative Names'),
name: 'san',
renderer: PVE.Utils.render_san,
},
{
xtype: 'fieldset',
title: gettext('Raw Certificate'),
collapsible: true,
collapsed: true,
items: [{
xtype: 'textarea',
name: 'pem',
editable: false,
grow: true,
growMax: 350,
fieldStyle: {
'white-space': 'pre-wrap',
'font-family': 'monospace',
},
}],
},
],
},
initComponent: function() {
let me = this;
if (!me.cert) {
throw "no cert given";
}
if (!me.nodename) {
throw "no nodename given";
}
me.url = `/nodes/${me.nodename}/certificates/info`;
me.callParent();
// hide OK/Reset button, because we just want to show data
me.down('toolbar[dock=bottom]').setVisible(false);
me.load({
success: function(response) {
if (Ext.isArray(response.result.data)) {
for (const item of response.result.data) {
if (item.filename === me.cert) {
me.setValues(item);
return;
}
}
}
},
});
},
});
Ext.define('PVE.node.CertUpload', {
extend: 'Proxmox.window.Edit',
xtype: 'pveCertUpload',
title: gettext('Upload Custom Certificate'),
resizable: false,
isCreate: true,
submitText: gettext('Upload'),
method: 'POST',
width: 600,
apiCallDone: function(success, response, options) {
if (!success) {
return;
}
let txt = gettext('API server will be restarted to use new certificates, please reload web-interface!');
Ext.getBody().mask(txt, ['pve-static-mask']);
Ext.defer(() => window.location.reload(true), 10000); // reload after 10 seconds automatically
},
items: {
xtype: 'inputpanel',
onGetValues: function(values) {
values.restart = 1;
values.force = 1;
if (!values.key) {
delete values.key;
}
return values;
},
items: [
{
fieldLabel: gettext('Private Key (Optional)'),
labelAlign: 'top',
emptyText: gettext('No change'),
name: 'key',
xtype: 'textarea',
},
{
xtype: 'filebutton',
text: gettext('From File'),
listeners: {
change: function(btn, e, value) {
let form = this.up('form');
for (const file of e.event.target.files) {
PVE.Utils.loadFile(file, res => form.down('field[name=key]').setValue(res));
}
btn.reset();
},
},
},
{
fieldLabel: gettext('Certificate Chain'),
labelAlign: 'top',
allowBlank: false,
name: 'certificates',
xtype: 'textarea',
},
{
xtype: 'filebutton',
text: gettext('From File'),
listeners: {
change: function(btn, e, value) {
let form = this.up('form');
for (const file of e.event.target.files) {
PVE.Utils.loadFile(file, res => form.down('field[name=certificates]').setValue(res));
}
btn.reset();
},
},
},
],
},
initComponent: function() {
let me = this;
if (!me.nodename) {
throw "no nodename given";
}
me.url = `/nodes/${me.nodename}/certificates/custom`;
me.callParent();
},
});
Ext.define('pve-certificate', {
extend: 'Ext.data.Model',
fields: ['filename', 'fingerprint', 'issuer', 'notafter', 'notbefore', 'subject', 'san', 'public-key-bits', 'public-key-type'],
idProperty: 'filename',
});
Ext.define('PVE.node.Certificates', {
extend: 'Ext.grid.Panel',
xtype: 'pveCertView',
tbar: [
{
xtype: 'button',
text: gettext('Upload Custom Certificate'),
handler: function() {
let view = this.up('grid');
Ext.create('PVE.node.CertUpload', {
nodename: view.nodename,
listeners: {
destroy: () => view.reload(),
},
autoShow: true,
});
},
},
{
xtype: 'proxmoxStdRemoveButton',
itemId: 'deletebtn',
text: gettext('Delete Custom Certificate'),
dangerous: true,
selModel: false,
getUrl: function(rec) {
let view = this.up('grid');
return `/nodes/${view.nodename}/certificates/custom?restart=1`;
},
confirmMsg: gettext('Delete custom certificate and switch to generated one?'),
callback: function(options, success, response) {
if (success) {
let txt = gettext('API server will be restarted to use new certificates, please reload web-interface!');
Ext.getBody().mask(txt, ['pve-static-mask']);
// reload after 10 seconds automatically
Ext.defer(() => window.location.reload(true), 10000);
}
},
},
'-',
{
xtype: 'proxmoxButton',
itemId: 'viewbtn',
disabled: true,
text: gettext('View Certificate'),
handler: function() {
this.up('grid').viewCertificate();
},
},
],
columns: [
{
header: gettext('File'),
width: 150,
dataIndex: 'filename',
},
{
header: gettext('Issuer'),
flex: 1,
dataIndex: 'issuer',
},
{
header: gettext('Subject'),
flex: 1,
dataIndex: 'subject',
},
{
header: gettext('Public Key Algorithm'),
flex: 1,
dataIndex: 'public-key-type',
hidden: true,
},
{
header: gettext('Public Key Size'),
flex: 1,
dataIndex: 'public-key-bits',
hidden: true,
},
{
header: gettext('Valid Since'),
width: 150,
dataIndex: 'notbefore',
renderer: Proxmox.Utils.render_timestamp,
},
{
header: gettext('Expires'),
width: 150,
dataIndex: 'notafter',
renderer: Proxmox.Utils.render_timestamp,
},
{
header: gettext('Subject Alternative Names'),
flex: 1,
dataIndex: 'san',
renderer: PVE.Utils.render_san,
},
{
header: gettext('Fingerprint'),
dataIndex: 'fingerprint',
hidden: true,
},
{
header: gettext('PEM'),
dataIndex: 'pem',
hidden: true,
},
],
reload: function() {
this.rstore.load();
},
viewCertificate: function() {
let me = this;
let selection = me.getSelection();
if (!selection || selection.length < 1) {
return;
}
var win = Ext.create('PVE.node.CertificateViewer', {
cert: selection[0].data.filename,
nodename: me.nodename,
});
win.show();
},
listeners: {
itemdblclick: 'viewCertificate',
},
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no nodename given";
}
me.rstore = Ext.create('Proxmox.data.UpdateStore', {
storeid: 'certs-' + me.nodename,
model: 'pve-certificate',
proxy: {
type: 'proxmox',
url: '/api2/json/nodes/' + me.nodename + '/certificates/info',
},
});
me.store = {
type: 'diff',
rstore: me.rstore,
};
me.callParent();
me.mon(me.rstore, 'load', store => me.down('#deletebtn').setDisabled(!store.getById('pveproxy-ssl.pem')));
me.rstore.startUpdate();
me.on('destroy', me.rstore.stopUpdate, me.rstore);
},
});
Ext.define('PVE.node.CmdMenu', {
extend: 'Ext.menu.Menu',
xtype: 'nodeCmdMenu',
showSeparator: false,
items: [
{
text: gettext('Create VM'),
itemId: 'createvm',
iconCls: 'fa fa-desktop',
handler: function() {
Ext.create('PVE.qemu.CreateWizard', {
nodename: this.up('menu').nodename,
autoShow: true,
});
},
},
{
text: gettext('Create CT'),
itemId: 'createct',
iconCls: 'fa fa-cube',
handler: function() {
Ext.create('PVE.lxc.CreateWizard', {
nodename: this.up('menu').nodename,
autoShow: true,
});
},
},
{ xtype: 'menuseparator' },
{
text: gettext('Bulk Start'),
itemId: 'bulkstart',
iconCls: 'fa fa-fw fa-play',
handler: function() {
Ext.create('PVE.window.BulkAction', {
nodename: this.up('menu').nodename,
title: gettext('Bulk Start'),
btnText: gettext('Start'),
action: 'startall',
autoShow: true,
});
},
},
{
text: gettext('Bulk Shutdown'),
itemId: 'bulkstop',
iconCls: 'fa fa-fw fa-stop',
handler: function() {
Ext.create('PVE.window.BulkAction', {
nodename: this.up('menu').nodename,
title: gettext('Bulk Shutdown'),
btnText: gettext('Shutdown'),
action: 'stopall',
autoShow: true,
});
},
},
{
text: gettext('Bulk Suspend'),
itemId: 'bulksuspend',
iconCls: 'fa fa-fw fa-download',
handler: function() {
Ext.create('PVE.window.BulkAction', {
nodename: this.up('menu').nodename,
title: gettext('Bulk Suspend'),
btnText: gettext('Suspend'),
action: 'suspendall',
autoShow: true,
});
},
},
{
text: gettext('Bulk Migrate'),
itemId: 'bulkmigrate',
iconCls: 'fa fa-fw fa-send-o',
handler: function() {
Ext.create('PVE.window.BulkAction', {
nodename: this.up('menu').nodename,
title: gettext('Bulk Migrate'),
btnText: gettext('Migrate'),
action: 'migrateall',
autoShow: true,
});
},
},
{ xtype: 'menuseparator' },
{
text: gettext('Shell'),
itemId: 'shell',
iconCls: 'fa fa-fw fa-terminal',
handler: function() {
let nodename = this.up('menu').nodename;
PVE.Utils.openDefaultConsoleWindow(true, 'shell', undefined, nodename, undefined);
},
},
{ xtype: 'menuseparator' },
{
text: gettext('Wake-on-LAN'),
itemId: 'wakeonlan',
iconCls: 'fa fa-fw fa-power-off',
handler: function() {
let nodename = this.up('menu').nodename;
Proxmox.Utils.API2Request({
url: `/nodes/${nodename}/wakeonlan`,
method: 'POST',
failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
success: function(response, opts) {
Ext.Msg.show({
title: 'Success',
icon: Ext.Msg.INFO,
msg: Ext.String.format(
gettext("Wake on LAN packet send for '{0}': '{1}'"),
nodename,
response.result.data,
),
});
},
});
},
},
],
initComponent: function() {
let me = this;
if (!me.nodename) {
throw 'no nodename specified';
}
me.title = gettext('Node') + " '" + me.nodename + "'";
me.callParent();
let caps = Ext.state.Manager.get('GuiCap');
if (!caps.vms['VM.Allocate']) {
me.getComponent('createct').setDisabled(true);
me.getComponent('createvm').setDisabled(true);
}
if (!caps.vms['VM.Migrate']) {
me.getComponent('bulkmigrate').setDisabled(true);
}
if (!caps.vms['VM.PowerMgmt']) {
me.getComponent('bulkstart').setDisabled(true);
me.getComponent('bulkstop').setDisabled(true);
me.getComponent('bulksuspend').setDisabled(true);
}
if (!caps.nodes['Sys.PowerMgmt']) {
me.getComponent('wakeonlan').setDisabled(true);
}
if (!caps.nodes['Sys.Console']) {
me.getComponent('shell').setDisabled(true);
}
if (me.pveSelNode.data.running) {
me.getComponent('wakeonlan').setDisabled(true);
}
if (PVE.Utils.isStandaloneNode()) {
me.getComponent('bulkmigrate').setVisible(false);
}
},
});
Ext.define('PVE.node.Config', {
extend: 'PVE.panel.Config',
alias: 'widget.PVE.node.Config',
onlineHelp: 'chapter_system_administration',
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
var caps = Ext.state.Manager.get('GuiCap');
me.statusStore = Ext.create('Proxmox.data.ObjectStore', {
url: "/api2/json/nodes/" + nodename + "/status",
interval: 5000,
});
var node_command = function(cmd) {
Proxmox.Utils.API2Request({
params: { command: cmd },
url: '/nodes/' + nodename + '/status',
method: 'POST',
waitMsgTarget: me,
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
});
};
var actionBtn = Ext.create('Ext.Button', {
text: gettext('Bulk Actions'),
iconCls: 'fa fa-fw fa-ellipsis-v',
disabled: !caps.vms['VM.PowerMgmt'] && !caps.vms['VM.Migrate'],
menu: new Ext.menu.Menu({
items: [
{
text: gettext('Bulk Start'),
iconCls: 'fa fa-fw fa-play',
disabled: !caps.vms['VM.PowerMgmt'],
handler: function() {
Ext.create('PVE.window.BulkAction', {
autoShow: true,
nodename: nodename,
title: gettext('Bulk Start'),
btnText: gettext('Start'),
action: 'startall',
});
},
},
{
text: gettext('Bulk Shutdown'),
iconCls: 'fa fa-fw fa-stop',
disabled: !caps.vms['VM.PowerMgmt'],
handler: function() {
Ext.create('PVE.window.BulkAction', {
autoShow: true,
nodename: nodename,
title: gettext('Bulk Shutdown'),
btnText: gettext('Shutdown'),
action: 'stopall',
});
},
},
{
text: gettext('Bulk Suspend'),
iconCls: 'fa fa-fw fa-download',
disabled: !caps.vms['VM.PowerMgmt'],
handler: function() {
Ext.create('PVE.window.BulkAction', {
autoShow: true,
nodename: nodename,
title: gettext('Bulk Suspend'),
btnText: gettext('Suspend'),
action: 'suspendall',
});
},
},
{
text: gettext('Bulk Migrate'),
iconCls: 'fa fa-fw fa-send-o',
disabled: !caps.vms['VM.Migrate'],
hidden: PVE.Utils.isStandaloneNode(),
handler: function() {
Ext.create('PVE.window.BulkAction', {
autoShow: true,
nodename: nodename,
title: gettext('Bulk Migrate'),
btnText: gettext('Migrate'),
action: 'migrateall',
});
},
},
],
}),
});
let restartBtn = Ext.create('Proxmox.button.Button', {
text: gettext('Reboot'),
disabled: !caps.nodes['Sys.PowerMgmt'],
dangerous: true,
confirmMsg: Ext.String.format(gettext("Reboot node '{0}'?"), nodename),
handler: function() {
node_command('reboot');
},
iconCls: 'fa fa-undo',
});
var shutdownBtn = Ext.create('Proxmox.button.Button', {
text: gettext('Shutdown'),
disabled: !caps.nodes['Sys.PowerMgmt'],
dangerous: true,
confirmMsg: Ext.String.format(gettext("Shutdown node '{0}'?"), nodename),
handler: function() {
node_command('shutdown');
},
iconCls: 'fa fa-power-off',
});
var shellBtn = Ext.create('PVE.button.ConsoleButton', {
disabled: !caps.nodes['Sys.Console'],
text: gettext('Shell'),
consoleType: 'shell',
nodename: nodename,
});
me.items = [];
Ext.apply(me, {
title: gettext('Node') + " '" + nodename + "'",
hstateid: 'nodetab',
defaults: {
statusStore: me.statusStore,
},
tbar: [restartBtn, shutdownBtn, shellBtn, actionBtn],
});
if (caps.nodes['Sys.Audit']) {
me.items.push(
{
xtype: 'pveNodeSummary',
title: gettext('Summary'),
iconCls: 'fa fa-book',
itemId: 'summary',
},
{
xtype: 'pmxNotesView',
title: gettext('Notes'),
iconCls: 'fa fa-sticky-note-o',
itemId: 'notes',
},
);
}
if (caps.nodes['Sys.Console']) {
me.items.push(
{
xtype: 'pveNoVncConsole',
title: gettext('Shell'),
iconCls: 'fa fa-terminal',
itemId: 'jsconsole',
consoleType: 'shell',
xtermjs: true,
nodename: nodename,
},
);
}
if (caps.nodes['Sys.Audit']) {
me.items.push(
{
xtype: 'proxmoxNodeServiceView',
title: gettext('System'),
iconCls: 'fa fa-cogs',
itemId: 'services',
expandedOnInit: true,
restartCommand: 'reload', // avoid disruptions
startOnlyServices: {
'pveproxy': true,
'pvedaemon': true,
'pve-cluster': true,
},
nodename: nodename,
onlineHelp: 'pve_service_daemons',
},
{
xtype: 'proxmoxNodeNetworkView',
title: gettext('Network'),
iconCls: 'fa fa-exchange',
itemId: 'network',
showApplyBtn: true,
groups: ['services'],
nodename: nodename,
editOptions: {
enableBridgeVlanIds: true,
},
onlineHelp: 'sysadmin_network_configuration',
},
{
xtype: 'pveCertificatesView',
title: gettext('Certificates'),
iconCls: 'fa fa-certificate',
itemId: 'certificates',
groups: ['services'],
nodename: nodename,
},
{
xtype: 'proxmoxNodeDNSView',
title: gettext('DNS'),
iconCls: 'fa fa-globe',
groups: ['services'],
itemId: 'dns',
nodename: nodename,
onlineHelp: 'sysadmin_network_configuration',
},
{
xtype: 'proxmoxNodeHostsView',
title: gettext('Hosts'),
iconCls: 'fa fa-globe',
groups: ['services'],
itemId: 'hosts',
nodename: nodename,
onlineHelp: 'sysadmin_network_configuration',
},
{
xtype: 'proxmoxNodeOptionsView',
title: gettext('Options'),
iconCls: 'fa fa-gear',
groups: ['services'],
itemId: 'options',
nodename: nodename,
onlineHelp: 'proxmox_node_management',
},
{
xtype: 'proxmoxNodeTimeView',
title: gettext('Time'),
itemId: 'time',
groups: ['services'],
nodename: nodename,
iconCls: 'fa fa-clock-o',
});
}
if (caps.nodes['Sys.Syslog']) {
me.items.push({
xtype: 'proxmoxJournalView',
title: gettext('System Log'),
iconCls: 'fa fa-list',
groups: ['services'],
disabled: !caps.nodes['Sys.Syslog'],
itemId: 'syslog',
url: "/api2/extjs/nodes/" + nodename + "/journal",
});
if (caps.nodes['Sys.Modify']) {
me.items.push({
xtype: 'proxmoxNodeAPT',
title: gettext('Updates'),
iconCls: 'fa fa-refresh',
expandedOnInit: true,
disabled: !caps.nodes['Sys.Console'],
// do we want to link to system updates instead?
itemId: 'apt',
upgradeBtn: {
xtype: 'pveConsoleButton',
disabled: Proxmox.UserName !== 'root@pam',
text: gettext('Upgrade'),
consoleType: 'upgrade',
nodename: nodename,
},
nodename: nodename,
});
me.items.push({
xtype: 'proxmoxNodeAPTRepositories',
title: gettext('Repositories'),
iconCls: 'fa fa-files-o',
itemId: 'aptrepositories',
nodename: nodename,
onlineHelp: 'sysadmin_package_repositories',
groups: ['apt'],
});
}
}
if (caps.nodes['Sys.Audit']) {
me.items.push(
{
xtype: 'pveFirewallRules',
iconCls: 'fa fa-shield',
title: gettext('Firewall'),
allow_iface: true,
base_url: '/nodes/' + nodename + '/firewall/rules',
list_refs_url: '/cluster/firewall/refs',
itemId: 'firewall',
firewall_type: 'node',
},
{
xtype: 'pveFirewallOptions',
title: gettext('Options'),
iconCls: 'fa fa-gear',
onlineHelp: 'pve_firewall_host_specific_configuration',
groups: ['firewall'],
base_url: '/nodes/' + nodename + '/firewall/options',
fwtype: 'node',
itemId: 'firewall-options',
});
}
if (caps.nodes['Sys.Audit']) {
me.items.push(
{
xtype: 'pmxDiskList',
title: gettext('Disks'),
itemId: 'storage',
expandedOnInit: true,
iconCls: 'fa fa-hdd-o',
nodename: nodename,
includePartitions: true,
supportsWipeDisk: true,
},
{
xtype: 'pveLVMList',
title: 'LVM',
itemId: 'lvm',
onlineHelp: 'chapter_lvm',
iconCls: 'fa fa-square',
groups: ['storage'],
},
{
xtype: 'pveLVMThinList',
title: 'LVM-Thin',
itemId: 'lvmthin',
onlineHelp: 'chapter_lvm',
iconCls: 'fa fa-square-o',
groups: ['storage'],
},
{
xtype: 'pveDirectoryList',
title: Proxmox.Utils.directoryText,
itemId: 'directory',
onlineHelp: 'chapter_storage',
iconCls: 'fa fa-folder',
groups: ['storage'],
},
{
title: 'ZFS',
itemId: 'zfs',
onlineHelp: 'chapter_zfs',
iconCls: 'fa fa-th-large',
groups: ['storage'],
xtype: 'pveZFSList',
},
{
xtype: 'pveNodeCephStatus',
title: 'Ceph',
itemId: 'ceph',
iconCls: 'fa fa-ceph',
},
{
xtype: 'pveNodeCephConfigCrush',
title: gettext('Configuration'),
iconCls: 'fa fa-gear',
groups: ['ceph'],
itemId: 'ceph-config',
},
{
xtype: 'pveNodeCephMonMgr',
title: gettext('Monitor'),
iconCls: 'fa fa-tv',
groups: ['ceph'],
itemId: 'ceph-monlist',
},
{
xtype: 'pveNodeCephOsdTree',
title: 'OSD',
iconCls: 'fa fa-hdd-o',
groups: ['ceph'],
itemId: 'ceph-osdtree',
},
{
xtype: 'pveNodeCephFSPanel',
title: 'CephFS',
iconCls: 'fa fa-folder',
groups: ['ceph'],
nodename: nodename,
itemId: 'ceph-cephfspanel',
},
{
xtype: 'pveNodeCephPoolList',
title: gettext('Pools'),
iconCls: 'fa fa-sitemap',
groups: ['ceph'],
itemId: 'ceph-pools',
},
{
xtype: 'pveReplicaView',
iconCls: 'fa fa-retweet',
title: gettext('Replication'),
itemId: 'replication',
},
);
}
if (caps.nodes['Sys.Syslog']) {
me.items.push(
{
xtype: 'proxmoxLogView',
title: gettext('Log'),
iconCls: 'fa fa-list',
groups: ['firewall'],
onlineHelp: 'chapter_pve_firewall',
url: '/api2/extjs/nodes/' + nodename + '/firewall/log',
itemId: 'firewall-fwlog',
log_select_timespan: true,
submitFormat: 'U',
},
{
xtype: 'cephLogView',
title: gettext('Log'),
itemId: 'ceph-log',
iconCls: 'fa fa-list',
groups: ['ceph'],
onlineHelp: 'chapter_pveceph',
url: "/api2/extjs/nodes/" + nodename + "/ceph/log",
nodename: nodename,
});
}
me.items.push(
{
title: gettext('Task History'),
iconCls: 'fa fa-list-alt',
itemId: 'tasks',
nodename: nodename,
xtype: 'proxmoxNodeTasks',
extraFilter: [
{
xtype: 'pveGuestIDSelector',
fieldLabel: 'VMID',
allowBlank: true,
name: 'vmid',
},
],
},
{
title: gettext('Subscription'),
iconCls: 'fa fa-support',
itemId: 'support',
xtype: 'pveNodeSubscription',
nodename: nodename,
},
);
me.callParent();
me.mon(me.statusStore, 'load', function(store, records, success) {
let uptimerec = store.data.get('uptime');
let powermgmt = caps.nodes['Sys.PowerMgmt'] && uptimerec && uptimerec.data.value;
restartBtn.setDisabled(!powermgmt);
shutdownBtn.setDisabled(!powermgmt);
shellBtn.setDisabled(!powermgmt);
});
me.on('afterrender', function() {
me.statusStore.startUpdate();
});
me.on('destroy', function() {
me.statusStore.stopUpdate();
});
},
});
Ext.define('PVE.node.CreateDirectory', {
extend: 'Proxmox.window.Edit',
xtype: 'pveCreateDirectory',
subject: Proxmox.Utils.directoryText,
showProgress: true,
onlineHelp: 'chapter_storage',
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no node name specified";
}
me.isCreate = true;
Ext.applyIf(me, {
url: "/nodes/" + me.nodename + "/disks/directory",
method: 'POST',
items: [
{
xtype: 'pmxDiskSelector',
name: 'device',
nodename: me.nodename,
diskType: 'unused',
includePartitions: true,
fieldLabel: gettext('Disk'),
allowBlank: false,
},
{
xtype: 'proxmoxKVComboBox',
comboItems: [
['ext4', 'ext4'],
['xfs', 'xfs'],
],
fieldLabel: gettext('Filesystem'),
name: 'filesystem',
value: '',
allowBlank: false,
},
{
xtype: 'proxmoxtextfield',
name: 'name',
fieldLabel: gettext('Name'),
allowBlank: false,
},
{
xtype: 'proxmoxcheckbox',
name: 'add_storage',
fieldLabel: gettext('Add Storage'),
value: '1',
},
],
});
me.callParent();
},
});
Ext.define('PVE.node.Directorylist', {
extend: 'Ext.grid.Panel',
xtype: 'pveDirectoryList',
viewModel: {
data: {
path: '',
},
formulas: {
dirName: (get) => get('path')?.replace('/mnt/pve/', '') || undefined,
},
},
controller: {
xclass: 'Ext.app.ViewController',
destroyDirectory: function() {
let me = this;
let vm = me.getViewModel();
let view = me.getView();
const dirName = vm.get('dirName');
if (!view.nodename) {
throw "no node name specified";
}
if (!dirName) {
throw "no directory name specified";
}
Ext.create('PVE.window.SafeDestroyStorage', {
url: `/nodes/${view.nodename}/disks/directory/${dirName}`,
item: { id: dirName },
taskName: 'dirremove',
taskDone: () => { view.reload(); },
}).show();
},
},
stateful: true,
stateId: 'grid-node-directory',
columns: [
{
text: gettext('Path'),
dataIndex: 'path',
flex: 1,
},
{
header: gettext('Device'),
flex: 1,
dataIndex: 'device',
},
{
header: gettext('Type'),
width: 100,
dataIndex: 'type',
},
{
header: gettext('Options'),
width: 100,
dataIndex: 'options',
},
{
header: gettext('Unit File'),
hidden: true,
dataIndex: 'unitfile',
},
],
rootVisible: false,
useArrows: true,
tbar: [
{
text: gettext('Reload'),
iconCls: 'fa fa-refresh',
handler: function() {
this.up('panel').reload();
},
},
{
text: `${gettext('Create')}: ${gettext('Directory')}`,
handler: function() {
let view = this.up('panel');
Ext.create('PVE.node.CreateDirectory', {
nodename: view.nodename,
listeners: {
destroy: () => view.reload(),
},
autoShow: true,
});
},
},
'->',
{
xtype: 'tbtext',
data: {
dirName: undefined,
},
bind: {
data: {
dirName: "{dirName}",
},
},
tpl: [
'<tpl if="dirName">',
gettext('Directory') + ' {dirName}:',
'<tpl else>',
Ext.String.format(gettext('No {0} selected'), gettext('directory')),
'</tpl>',
],
},
{
text: gettext('More'),
iconCls: 'fa fa-bars',
disabled: true,
bind: {
disabled: '{!dirName}',
},
menu: [
{
text: gettext('Destroy'),
itemId: 'remove',
iconCls: 'fa fa-fw fa-trash-o',
handler: 'destroyDirectory',
disabled: true,
bind: {
disabled: '{!dirName}',
},
},
],
},
],
reload: function() {
let me = this;
me.store.load();
me.store.sort();
},
listeners: {
activate: function() {
this.reload();
},
selectionchange: function(model, selected) {
let me = this;
let vm = me.getViewModel();
vm.set('path', selected[0]?.data.path || '');
},
},
initComponent: function() {
let me = this;
me.nodename = me.pveSelNode.data.node;
if (!me.nodename) {
throw "no node name specified";
}
Ext.apply(me, {
store: {
fields: ['path', 'device', 'type', 'options', 'unitfile'],
proxy: {
type: 'proxmox',
url: `/api2/json/nodes/${me.nodename}/disks/directory`,
},
sorters: 'path',
},
});
me.callParent();
Proxmox.Utils.monStoreErrors(me, me.getStore(), true);
me.reload();
},
});
Ext.define('PVE.node.CreateLVM', {
extend: 'Proxmox.window.Edit',
xtype: 'pveCreateLVM',
onlineHelp: 'chapter_lvm',
subject: 'LVM Volume Group',
showProgress: true,
isCreate: true,
initComponent: function() {
let me = this;
if (!me.nodename) {
throw "no node name specified";
}
me.isCreate = true;
Ext.applyIf(me, {
url: `/nodes/${me.nodename}/disks/lvm`,
method: 'POST',
items: [
{
xtype: 'pmxDiskSelector',
name: 'device',
nodename: me.nodename,
diskType: 'unused',
includePartitions: true,
fieldLabel: gettext('Disk'),
allowBlank: false,
},
{
xtype: 'proxmoxtextfield',
name: 'name',
fieldLabel: gettext('Name'),
allowBlank: false,
},
{
xtype: 'proxmoxcheckbox',
name: 'add_storage',
fieldLabel: gettext('Add Storage'),
value: '1',
},
],
});
me.callParent();
},
});
Ext.define('PVE.node.LVMList', {
extend: 'Ext.tree.Panel',
xtype: 'pveLVMList',
viewModel: {
data: {
volumeGroup: '',
},
},
controller: {
xclass: 'Ext.app.ViewController',
destroyVolumeGroup: function() {
let me = this;
let vm = me.getViewModel();
let view = me.getView();
const volumeGroup = vm.get('volumeGroup');
if (!view.nodename) {
throw "no node name specified";
}
if (!volumeGroup) {
throw "no volume group specified";
}
Ext.create('PVE.window.SafeDestroyStorage', {
url: `/nodes/${view.nodename}/disks/lvm/${volumeGroup}`,
item: { id: volumeGroup },
taskName: 'lvmremove',
taskDone: () => { view.reload(); },
}).show();
},
},
emptyText: PVE.Utils.renderNotFound('VGs'),
stateful: true,
stateId: 'grid-node-lvm',
rootVisible: false,
useArrows: true,
columns: [
{
xtype: 'treecolumn',
text: gettext('Name'),
dataIndex: 'name',
flex: 1,
},
{
text: gettext('Number of LVs'),
dataIndex: 'lvcount',
width: 150,
align: 'right',
},
{
header: gettext('Assigned to LVs'),
width: 130,
dataIndex: 'usage',
tdCls: 'x-progressbar-default-cell',
xtype: 'widgetcolumn',
widget: {
xtype: 'pveProgressBar',
},
},
{
header: gettext('Size'),
width: 100,
align: 'right',
sortable: true,
renderer: Proxmox.Utils.format_size,
dataIndex: 'size',
},
{
header: gettext('Free'),
width: 100,
align: 'right',
sortable: true,
renderer: Proxmox.Utils.format_size,
dataIndex: 'free',
},
],
tbar: [
{
text: gettext('Reload'),
iconCls: 'fa fa-refresh',
handler: function() {
this.up('panel').reload();
},
},
{
text: gettext('Create') + ': Volume Group',
handler: function() {
let view = this.up('panel');
Ext.create('PVE.node.CreateLVM', {
nodename: view.nodename,
taskDone: () => view.reload(),
autoShow: true,
});
},
},
'->',
{
xtype: 'tbtext',
data: {
volumeGroup: undefined,
},
bind: {
data: {
volumeGroup: "{volumeGroup}",
},
},
tpl: [
'<tpl if="volumeGroup">',
'Volume group {volumeGroup}:',
'<tpl else>',
Ext.String.format(gettext('No {0} selected'), 'volume group'),
'</tpl>',
],
},
{
text: gettext('More'),
iconCls: 'fa fa-bars',
disabled: true,
bind: {
disabled: '{!volumeGroup}',
},
menu: [
{
text: gettext('Destroy'),
itemId: 'remove',
iconCls: 'fa fa-fw fa-trash-o',
handler: 'destroyVolumeGroup',
disabled: true,
bind: {
disabled: '{!volumeGroup}',
},
},
],
},
],
reload: function() {
let me = this;
let sm = me.getSelectionModel();
Proxmox.Utils.API2Request({
url: `/nodes/${me.nodename}/disks/lvm`,
waitMsgTarget: me,
method: 'GET',
failure: (response, opts) => Proxmox.Utils.setErrorMask(me, response.htmlStatus),
success: function(response, opts) {
sm.deselectAll();
me.setRootNode(response.result.data);
me.expandAll();
},
});
},
listeners: {
activate: function() {
this.reload();
},
selectionchange: function(model, selected) {
let me = this;
let vm = me.getViewModel();
if (selected.length < 1 || selected[0].data.parentId !== 'root') {
vm.set('volumeGroup', '');
} else {
vm.set('volumeGroup', selected[0].data.name);
}
},
},
selModel: 'treemodel',
fields: [
'name',
'size',
'free',
{
type: 'string',
name: 'iconCls',
calculate: data => `fa x-fa-tree fa-${data.leaf ? 'hdd-o' : 'object-group'}`,
},
{
type: 'number',
name: 'usage',
calculate: data => (data.size - data.free) / data.size,
},
],
sorters: 'name',
initComponent: function() {
let me = this;
me.nodename = me.pveSelNode.data.node;
if (!me.nodename) {
throw "no node name specified";
}
me.callParent();
me.reload();
},
});
Ext.define('PVE.node.CreateLVMThin', {
extend: 'Proxmox.window.Edit',
xtype: 'pveCreateLVMThin',
onlineHelp: 'chapter_lvm',
subject: 'LVM Thinpool',
showProgress: true,
isCreate: true,
initComponent: function() {
let me = this;
if (!me.nodename) {
throw "no node name specified";
}
Ext.applyIf(me, {
url: `/nodes/${me.nodename}/disks/lvmthin`,
method: 'POST',
items: [
{
xtype: 'pmxDiskSelector',
name: 'device',
nodename: me.nodename,
diskType: 'unused',
includePartitions: true,
fieldLabel: gettext('Disk'),
allowBlank: false,
},
{
xtype: 'proxmoxtextfield',
name: 'name',
fieldLabel: gettext('Name'),
allowBlank: false,
},
{
xtype: 'proxmoxcheckbox',
name: 'add_storage',
fieldLabel: gettext('Add Storage'),
value: '1',
},
],
});
me.callParent();
},
});
Ext.define('PVE.node.LVMThinList', {
extend: 'Ext.grid.Panel',
xtype: 'pveLVMThinList',
viewModel: {
data: {
thinPool: '',
volumeGroup: '',
},
},
controller: {
xclass: 'Ext.app.ViewController',
destroyThinPool: function() {
let me = this;
let vm = me.getViewModel();
let view = me.getView();
const thinPool = vm.get('thinPool');
const volumeGroup = vm.get('volumeGroup');
if (!view.nodename) {
throw "no node name specified";
}
if (!thinPool) {
throw "no thin pool specified";
}
if (!volumeGroup) {
throw "no volume group specified";
}
Ext.create('PVE.window.SafeDestroyStorage', {
url: `/nodes/${view.nodename}/disks/lvmthin/${thinPool}`,
params: { 'volume-group': volumeGroup },
item: { id: `${volumeGroup}/${thinPool}` },
taskName: 'lvmthinremove',
taskDone: () => { view.reload(); },
}).show();
},
},
emptyText: PVE.Utils.renderNotFound('Thin-Pool'),
stateful: true,
stateId: 'grid-node-lvmthin',
rootVisible: false,
useArrows: true,
columns: [
{
text: gettext('Name'),
dataIndex: 'lv',
flex: 1,
},
{
header: 'Volume Group',
width: 110,
dataIndex: 'vg',
},
{
header: gettext('Usage'),
width: 110,
dataIndex: 'usage',
tdCls: 'x-progressbar-default-cell',
xtype: 'widgetcolumn',
widget: {
xtype: 'pveProgressBar',
},
},
{
header: gettext('Size'),
width: 100,
align: 'right',
sortable: true,
renderer: Proxmox.Utils.format_size,
dataIndex: 'lv_size',
},
{
header: gettext('Used'),
width: 100,
align: 'right',
sortable: true,
renderer: Proxmox.Utils.format_size,
dataIndex: 'used',
},
{
header: gettext('Metadata Usage'),
width: 120,
dataIndex: 'metadata_usage',
tdCls: 'x-progressbar-default-cell',
xtype: 'widgetcolumn',
widget: {
xtype: 'pveProgressBar',
},
},
{
header: gettext('Metadata Size'),
width: 120,
align: 'right',
sortable: true,
renderer: Proxmox.Utils.format_size,
dataIndex: 'metadata_size',
},
{
header: gettext('Metadata Used'),
width: 125,
align: 'right',
sortable: true,
renderer: Proxmox.Utils.format_size,
dataIndex: 'metadata_used',
},
],
tbar: [
{
text: gettext('Reload'),
iconCls: 'fa fa-refresh',
handler: function() {
this.up('panel').reload();
},
},
{
text: gettext('Create') + ': Thinpool',
handler: function() {
var view = this.up('panel');
Ext.create('PVE.node.CreateLVMThin', {
nodename: view.nodename,
taskDone: () => view.reload(),
autoShow: true,
});
},
},
'->',
{
xtype: 'tbtext',
data: {
thinPool: undefined,
volumeGroup: undefined,
},
bind: {
data: {
thinPool: "{thinPool}",
volumeGroup: "{volumeGroup}",
},
},
tpl: [
'<tpl if="thinPool">',
'<tpl if="volumeGroup">',
'Thinpool {volumeGroup}/{thinPool}:',
'<tpl else>', // volumeGroup
'Missing volume group (node running old version?)',
'</tpl>',
'<tpl else>', // thinPool
Ext.String.format(gettext('No {0} selected'), 'thinpool'),
'</tpl>',
],
},
{
text: gettext('More'),
iconCls: 'fa fa-bars',
disabled: true,
bind: {
disabled: '{!volumeGroup || !thinPool}',
},
menu: [
{
text: gettext('Destroy'),
itemId: 'remove',
iconCls: 'fa fa-fw fa-trash-o',
handler: 'destroyThinPool',
disabled: true,
bind: {
disabled: '{!volumeGroup || !thinPool}',
},
},
],
},
],
reload: function() {
let me = this;
me.store.load();
me.store.sort();
},
listeners: {
activate: function() {
this.reload();
},
selectionchange: function(model, selected) {
let me = this;
let vm = me.getViewModel();
vm.set('volumeGroup', selected[0]?.data.vg || '');
vm.set('thinPool', selected[0]?.data.lv || '');
},
},
initComponent: function() {
let me = this;
me.nodename = me.pveSelNode.data.node;
if (!me.nodename) {
throw "no node name specified";
}
Ext.apply(me, {
store: {
fields: [
'lv',
'lv_size',
'used',
'metadata_size',
'metadata_used',
{
type: 'number',
name: 'usage',
calculate: data => data.used / data.lv_size,
},
{
type: 'number',
name: 'metadata_usage',
calculate: data => data.metadata_used / data.metadata_size,
},
],
proxy: {
type: 'proxmox',
url: `/api2/json/nodes/${me.nodename}/disks/lvmthin`,
},
sorters: 'lv',
},
});
me.callParent();
Proxmox.Utils.monStoreErrors(me, me.getStore(), true);
me.reload();
},
});
Ext.define('PVE.node.StatusView', {
extend: 'Proxmox.panel.StatusView',
alias: 'widget.pveNodeStatus',
height: 390,
bodyPadding: '15 5 15 5',
layout: {
type: 'table',
columns: 2,
tableAttrs: {
style: {
width: '100%',
},
},
},
defaults: {
xtype: 'pmxInfoWidget',
padding: '0 10 5 10',
},
items: [
{
itemId: 'cpu',
iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon',
title: gettext('CPU usage'),
valueField: 'cpu',
maxField: 'cpuinfo',
renderer: Proxmox.Utils.render_node_cpu_usage,
},
{
itemId: 'wait',
iconCls: 'fa fa-fw fa-clock-o',
title: gettext('IO delay'),
valueField: 'wait',
rowspan: 2,
},
{
itemId: 'load',
iconCls: 'fa fa-fw fa-tasks',
title: gettext('Load average'),
printBar: false,
textField: 'loadavg',
},
{
xtype: 'box',
colspan: 2,
padding: '0 0 20 0',
},
{
iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon',
itemId: 'memory',
title: gettext('RAM usage'),
valueField: 'memory',
maxField: 'memory',
renderer: Proxmox.Utils.render_node_size_usage,
},
{
itemId: 'ksm',
printBar: false,
title: gettext('KSM sharing'),
textField: 'ksm',
renderer: function(record) {
return Proxmox.Utils.render_size(record.shared);
},
padding: '0 10 10 10',
},
{
iconCls: 'fa fa-fw fa-hdd-o',
itemId: 'rootfs',
title: '/ ' + gettext('HD space'),
valueField: 'rootfs',
maxField: 'rootfs',
renderer: Proxmox.Utils.render_node_size_usage,
},
{
iconCls: 'fa fa-fw fa-refresh',
itemId: 'swap',
printSize: true,
title: gettext('SWAP usage'),
valueField: 'swap',
maxField: 'swap',
renderer: Proxmox.Utils.render_node_size_usage,
},
{
xtype: 'box',
colspan: 2,
padding: '0 0 20 0',
},
{
itemId: 'cpus',
colspan: 2,
printBar: false,
title: gettext('CPU(s)'),
textField: 'cpuinfo',
renderer: Proxmox.Utils.render_cpu_model,
value: '',
},
{
colspan: 2,
title: gettext('Kernel Version'),
printBar: false,
// TODO: remove with next major and only use newish current-kernel textfield
multiField: true,
//textField: 'current-kernel',
renderer: ({ data }) => {
if (!data['current-kernel']) {
return data.kversion;
}
let kernel = data['current-kernel'];
let buildDate = kernel.version.match(/\((.+)\)\s*$/)?.[1] ?? 'unknown';
return `${kernel.sysname} ${kernel.release} (${buildDate})`;
},
value: '',
},
{
colspan: 2,
title: gettext('Boot Mode'),
printBar: false,
textField: 'boot-info',
renderer: boot => {
if (boot.mode === 'legacy-bios') {
return 'Legacy BIOS';
} else if (boot.mode === 'efi') {
return `EFI${boot.secureboot ? ' (Secure Boot)' : ''}`;
}
return Proxmox.Utils.unknownText;
},
value: '',
},
{
itemId: 'version',
colspan: 2,
printBar: false,
title: gettext('Manager Version'),
textField: 'pveversion',
value: '',
},
{
itemId: 'thermal',
colspan: 2,
printBar: false,
title: gettext('Thermal'),
textField: 'thermal',
renderer: function (value) {
if (!Array.isArray(value)) {
return '';
}
const temp = JSON.parse(value[0]);
const cpu0 = temp['coretemp-isa-0000']['Package id 0']['temp1_input'].toFixed(1);
// const board = temp['acpitz-acpi-0']['temp1']['temp1_input'].toFixed(1);
const nvme = temp['nvme-pci-0c00']['Composite']['temp1_input'].toFixed(1);
const gpu = Number(value[1]).toFixed(1);
return `CPU: ${cpu0}\xb0C | GPU: ${gpu}\xb0C | NVME: ${nvme}\xb0C`;
},
},
{
itemId: 'networksp',
colspan: 2,
printBar: false,
title: gettext('Network Speed'),
textField: 'networksp',
renderer: function (sp) {
if (!Array.isArray(sp)) {
return '';
}
const sps = sp.map(function (s) { return String(s).match(/(?<=:\s+)(.+)/g)?.[0] });
return sps.join(' | ');
},
},
],
updateTitle: function() {
var me = this;
var uptime = Proxmox.Utils.render_uptime(me.getRecordValue('uptime'));
me.setTitle(me.pveSelNode.data.node + ' (' + gettext('Uptime') + ': ' + uptime + ')');
},
initComponent: function() {
let me = this;
let stateProvider = Ext.state.Manager.getProvider();
let repoLink = stateProvider.encodeHToken({
view: "server",
rid: `node/${me.pveSelNode.data.node}`,
ltab: "tasks",
nodetab: "aptrepositories",
});
me.items.push({
xtype: 'pmxNodeInfoRepoStatus',
itemId: 'repositoryStatus',
product: 'Proxmox VE',
repoLink: `#${repoLink}`,
});
me.callParent();
},
});
Ext.define('PVE.node.SubscriptionKeyEdit', {
extend: 'Proxmox.window.Edit',
title: gettext('Upload Subscription Key'),
width: 350,
items: {
xtype: 'textfield',
name: 'key',
value: '',
fieldLabel: gettext('Subscription Key'),
labelWidth: 120,
getSubmitValue: function() {
return this.processRawValue(this.getRawValue())?.trim();
},
},
initComponent: function() {
var me = this;
me.callParent();
me.load();
},
});
Ext.define('PVE.node.Subscription', {
extend: 'Proxmox.grid.ObjectGrid',
alias: ['widget.pveNodeSubscription'],
onlineHelp: 'getting_help',
viewConfig: {
enableTextSelection: true,
},
showReport: function() {
var me = this;
var getReportFileName = function() {
var now = Ext.Date.format(new Date(), 'D-d-F-Y-G-i');
return `${me.nodename}-pve-report-${now}.txt`;
};
var view = Ext.createWidget('component', {
itemId: 'system-report-view',
scrollable: true,
style: {
'white-space': 'pre',
'font-family': 'monospace',
padding: '5px',
},
});
var reportWindow = Ext.create('Ext.window.Window', {
title: gettext('System Report'),
width: 1024,
height: 600,
layout: 'fit',
modal: true,
buttons: [
'->',
{
text: gettext('Download'),
handler: function() {
var fileContent = Ext.String.htmlDecode(reportWindow.getComponent('system-report-view').html);
var fileName = getReportFileName();
// Internet Explorer
if (window.navigator.msSaveOrOpenBlob) {
navigator.msSaveOrOpenBlob(new Blob([fileContent]), fileName);
} else {
var element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' +
encodeURIComponent(fileContent));
element.setAttribute('download', fileName);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
},
},
],
items: view,
});
Proxmox.Utils.API2Request({
url: '/api2/extjs/nodes/' + me.nodename + '/report',
method: 'GET',
waitMsgTarget: me,
failure: function(response) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
success: function(response) {
var report = Ext.htmlEncode(response.result.data);
reportWindow.show();
view.update(report);
},
});
},
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no node name specified";
}
let rows = {
productname: {
header: gettext('Type'),
},
key: {
header: gettext('Subscription Key'),
},
status: {
header: gettext('Status'),
renderer: v => {
let message = me.getObjectValue('message');
return message ? `${v}: ${message}` : v;
},
},
message: {
visible: false,
},
serverid: {
header: gettext('Server ID'),
},
sockets: {
header: gettext('Sockets'),
},
checktime: {
header: gettext('Last checked'),
renderer: Proxmox.Utils.render_timestamp,
},
nextduedate: {
header: gettext('Next due date'),
},
signature: {
header: gettext('Signed/Offline'),
renderer: v => v ? gettext('Yes') : gettext('No'),
},
};
Ext.apply(me, {
url: `/api2/json/nodes/${me.nodename}/subscription`,
cwidth1: 170,
tbar: [
{
text: gettext('Upload Subscription Key'),
handler: () => Ext.create('PVE.node.SubscriptionKeyEdit', {
autoShow: true,
url: `/api2/extjs/nodes/${me.nodename}/subscription`,
listeners: {
destroy: () => me.rstore.load(),
},
}),
},
{
text: gettext('Check'),
handler: () => Proxmox.Utils.API2Request({
params: { force: 1 },
url: `/nodes/${me.nodename}/subscription`,
method: 'POST',
waitMsgTarget: me,
failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
callback: () => me.rstore.load(),
}),
},
{
text: gettext('Remove Subscription'),
xtype: 'proxmoxStdRemoveButton',
confirmMsg: gettext('Are you sure you want to remove the subscription key?'),
baseurl: `/nodes/${me.nodename}/subscription`,
dangerous: true,
selModel: false,
callback: () => me.rstore.load(),
},
'-',
{
text: gettext('System Report'),
handler: function() {
Proxmox.Utils.checked_command(function() { me.showReport(); });
},
},
],
rows: rows,
listeners: {
activate: () => me.rstore.load(),
},
});
me.callParent();
},
});
Ext.define('PVE.node.Summary', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveNodeSummary',
scrollable: true,
bodyPadding: 5,
showVersions: function() {
var me = this;
// Note: we use simply text/html here, because ExtJS grid has problems
// with cut&paste
var nodename = me.pveSelNode.data.node;
var view = Ext.createWidget('component', {
autoScroll: true,
id: 'pkgversions',
padding: 5,
style: {
'white-space': 'pre',
'font-family': 'monospace',
},
});
var win = Ext.create('Ext.window.Window', {
title: gettext('Package versions'),
width: 600,
height: 600,
layout: 'fit',
modal: true,
items: [view],
buttons: [
{
xtype: 'button',
iconCls: 'fa fa-clipboard',
handler: function(button) {
window.getSelection().selectAllChildren(
document.getElementById('pkgversions'),
);
document.execCommand("copy");
},
text: gettext('Copy'),
},
{
text: gettext('Ok'),
handler: function() {
this.up('window').close();
},
},
],
});
Proxmox.Utils.API2Request({
waitMsgTarget: me,
url: `/nodes/${nodename}/apt/versions`,
method: 'GET',
failure: function(response, opts) {
win.close();
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
success: function(response, opts) {
win.show();
let text = '';
Ext.Array.each(response.result.data, function(rec) {
let version = "not correctly installed";
let pkg = rec.Package;
if (rec.OldVersion && rec.CurrentState === 'Installed') {
version = rec.OldVersion;
}
if (rec.RunningKernel) {
text += `${pkg}: ${version} (running kernel: ${rec.RunningKernel})\n`;
} else if (rec.ManagerVersion) {
text += `${pkg}: ${version} (running version: ${rec.ManagerVersion})\n`;
} else {
text += `${pkg}: ${version}\n`;
}
});
view.update(Ext.htmlEncode(text));
},
});
},
updateRepositoryStatus: function() {
let me = this;
let repoStatus = me.nodeStatus.down('#repositoryStatus');
let nodename = me.pveSelNode.data.node;
Proxmox.Utils.API2Request({
url: `/nodes/${nodename}/apt/repositories`,
method: 'GET',
failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
success: response => repoStatus.setRepositoryInfo(response.result.data['standard-repos']),
});
Proxmox.Utils.API2Request({
url: `/nodes/${nodename}/subscription`,
method: 'GET',
failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
success: function(response, opts) {
const res = response.result;
const subscription = res?.data?.status.toLowerCase() === 'active';
repoStatus.setSubscriptionStatus(subscription);
},
});
},
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
if (!me.statusStore) {
throw "no status storage specified";
}
var rstore = me.statusStore;
var version_btn = new Ext.Button({
text: gettext('Package versions'),
handler: function() {
Proxmox.Utils.checked_command(function() { me.showVersions(); });
},
});
var rrdstore = Ext.create('Proxmox.data.RRDStore', {
rrdurl: "/api2/json/nodes/" + nodename + "/rrddata",
model: 'pve-rrd-node',
});
let nodeStatus = Ext.create('PVE.node.StatusView', {
xtype: 'pveNodeStatus',
rstore: rstore,
width: 770,
pveSelNode: me.pveSelNode,
});
Ext.apply(me, {
tbar: [version_btn, '->', { xtype: 'proxmoxRRDTypeSelector' }],
nodeStatus: nodeStatus,
items: [
{
xtype: 'container',
itemId: 'itemcontainer',
layout: 'column',
minWidth: 700,
defaults: {
minHeight: 350,
padding: 5,
columnWidth: 1,
},
items: [
nodeStatus,
{
xtype: 'proxmoxRRDChart',
title: gettext('CPU usage'),
fields: ['cpu', 'iowait'],
fieldTitles: [gettext('CPU usage'), gettext('IO delay')],
unit: 'percent',
store: rrdstore,
},
{
xtype: 'proxmoxRRDChart',
title: gettext('Server load'),
fields: ['loadavg'],
fieldTitles: [gettext('Load average')],
store: rrdstore,
},
{
xtype: 'proxmoxRRDChart',
title: gettext('Memory usage'),
fields: ['memtotal', 'memused'],
fieldTitles: [gettext('Total'), gettext('RAM usage')],
unit: 'bytes',
powerOfTwo: true,
store: rrdstore,
},
{
xtype: 'proxmoxRRDChart',
title: gettext('Network traffic'),
fields: ['netin', 'netout'],
store: rrdstore,
},
],
listeners: {
resize: function(panel) {
Proxmox.Utils.updateColumns(panel);
},
},
},
],
listeners: {
activate: function() {
rstore.setInterval(1000);
rstore.startUpdate(); // just to be sure
rrdstore.startUpdate();
},
destroy: function() {
rstore.setInterval(5000); // don't stop it, it's not ours!
rrdstore.stopUpdate();
},
},
});
me.updateRepositoryStatus();
me.callParent();
let sp = Ext.state.Manager.getProvider();
me.mon(sp, 'statechange', function(provider, key, value) {
if (key !== 'summarycolumns') {
return;
}
Proxmox.Utils.updateColumns(me.getComponent('itemcontainer'));
});
},
});
Ext.define('PVE.node.CreateZFS', {
extend: 'Proxmox.window.Edit',
xtype: 'pveCreateZFS',
onlineHelp: 'chapter_zfs',
subject: 'ZFS',
showProgress: true,
isCreate: true,
width: 800,
viewModel: {
data: {
raidLevel: 'single',
},
formulas: {
isDraid: get => get('raidLevel')?.startsWith("draid"),
},
},
initComponent: function() {
let me = this;
if (!me.nodename) {
throw "no node name specified";
}
Ext.apply(me, {
url: `/nodes/${me.nodename}/disks/zfs`,
method: 'POST',
items: [
{
xtype: 'inputpanel',
onGetValues: function(values) {
if (values.draidData || values.draidSpares) {
let opt = { data: values.draidData, spares: values.draidSpares };
values['draid-config'] = PVE.Parser.printPropertyString(opt);
}
delete values.draidData;
delete values.draidSpares;
return values;
},
column1: [
{
xtype: 'proxmoxtextfield',
name: 'name',
fieldLabel: gettext('Name'),
allowBlank: false,
maxLength: 128, // ZFS_MAX_DATASET_NAME_LEN is (256 - some edge case)
validator: v => {
// see zpool_name_valid function in libzfs_zpool.c
if (v.match(/^(mirror|raidz|draid|spare)/) || v === 'log') {
return gettext('Cannot use reserved pool name');
} else if (!v.match(/^[a-zA-Z][a-zA-Z0-9\-_.]*$/)) {
// note: zfs would support also : and whitespace, but we don't
return gettext("Invalid characters in pool name");
}
return true;
},
},
{
xtype: 'proxmoxcheckbox',
name: 'add_storage',
fieldLabel: gettext('Add Storage'),
value: '1',
},
],
column2: [
{
xtype: 'proxmoxKVComboBox',
fieldLabel: gettext('RAID Level'),
name: 'raidlevel',
value: 'single',
comboItems: [
['single', gettext('Single Disk')],
['mirror', 'Mirror'],
['raid10', 'RAID10'],
['raidz', 'RAIDZ'],
['raidz2', 'RAIDZ2'],
['raidz3', 'RAIDZ3'],
['draid', 'dRAID'],
['draid2', 'dRAID2'],
['draid3', 'dRAID3'],
],
bind: {
value: '{raidLevel}',
},
},
{
xtype: 'proxmoxKVComboBox',
fieldLabel: gettext('Compression'),
name: 'compression',
value: 'on',
comboItems: [
['on', 'on'],
['off', 'off'],
['gzip', 'gzip'],
['lz4', 'lz4'],
['lzjb', 'lzjb'],
['zle', 'zle'],
['zstd', 'zstd'],
],
},
{
xtype: 'proxmoxintegerfield',
fieldLabel: gettext('ashift'),
minValue: 9,
maxValue: 16,
value: '12',
name: 'ashift',
},
],
columnB: [
{
xtype: 'fieldset',
title: gettext('dRAID Config'),
collapsible: false,
bind: {
hidden: '{!isDraid}',
},
layout: 'hbox',
padding: '5px 10px',
defaults: {
flex: 1,
layout: 'anchor',
},
items: [{
xtype: 'proxmoxintegerfield',
name: 'draidData',
fieldLabel: gettext('Data Devs'),
minValue: 1,
allowBlank: false,
disabled: true,
hidden: true,
bind: {
disabled: '{!isDraid}',
hidden: '{!isDraid}',
},
padding: '0 10 0 0',
},
{
xtype: 'proxmoxintegerfield',
name: 'draidSpares',
fieldLabel: gettext('Spares'),
minValue: 0,
allowBlank: false,
disabled: true,
hidden: true,
bind: {
disabled: '{!isDraid}',
hidden: '{!isDraid}',
},
padding: '0 0 0 10',
}],
},
{
xtype: 'pmxMultiDiskSelector',
name: 'devices',
nodename: me.nodename,
diskType: 'unused',
includePartitions: true,
height: 200,
emptyText: gettext('No Disks unused'),
itemId: 'disklist',
},
],
},
{
xtype: 'displayfield',
padding: '5 0 0 0',
userCls: 'pmx-hint',
value: 'Note: ZFS is not compatible with disks backed by a hardware ' +
'RAID controller. For details see <a target="_blank" href="' +
Proxmox.Utils.get_help_link('chapter_zfs') + '">the reference documentation</a>.',
},
],
});
me.callParent();
},
});
Ext.define('PVE.node.ZFSList', {
extend: 'Ext.grid.Panel',
xtype: 'pveZFSList',
viewModel: {
data: {
pool: '',
},
},
controller: {
xclass: 'Ext.app.ViewController',
destroyPool: function() {
let me = this;
let vm = me.getViewModel();
let view = me.getView();
const pool = vm.get('pool');
if (!view.nodename) {
throw "no node name specified";
}
if (!pool) {
throw "no pool specified";
}
Ext.create('PVE.window.SafeDestroyStorage', {
url: `/nodes/${view.nodename}/disks/zfs/${pool}`,
item: { id: pool },
taskName: 'zfsremove',
taskDone: () => { view.reload(); },
}).show();
},
},
stateful: true,
stateId: 'grid-node-zfs',
columns: [
{
text: gettext('Name'),
dataIndex: 'name',
flex: 1,
},
{
header: gettext('Size'),
renderer: Proxmox.Utils.format_size,
dataIndex: 'size',
},
{
header: gettext('Free'),
renderer: Proxmox.Utils.format_size,
dataIndex: 'free',
},
{
header: gettext('Allocated'),
renderer: Proxmox.Utils.format_size,
dataIndex: 'alloc',
},
{
header: gettext('Fragmentation'),
renderer: function(value) {
return value.toString() + '%';
},
dataIndex: 'frag',
},
{
header: gettext('Health'),
renderer: PVE.Utils.render_zfs_health,
dataIndex: 'health',
},
{
header: gettext('Deduplication'),
hidden: true,
renderer: function(value) {
return value.toFixed(2).toString() + 'x';
},
dataIndex: 'dedup',
},
],
rootVisible: false,
useArrows: true,
tbar: [
{
text: gettext('Reload'),
iconCls: 'fa fa-refresh',
handler: function() {
this.up('panel').reload();
},
},
{
text: gettext('Create') + ': ZFS',
handler: function() {
let view = this.up('panel');
Ext.create('PVE.node.CreateZFS', {
nodename: view.nodename,
listeners: {
destroy: () => view.reload(),
},
autoShow: true,
});
},
},
{
text: gettext('Detail'),
itemId: 'detailbtn',
disabled: true,
handler: function() {
let view = this.up('panel');
let selection = view.getSelection();
if (selection.length) {
view.show_detail(selection[0].get('name'));
}
},
},
'->',
{
xtype: 'tbtext',
data: {
pool: undefined,
},
bind: {
data: {
pool: "{pool}",
},
},
tpl: [
'<tpl if="pool">',
'Pool {pool}:',
'<tpl else>',
Ext.String.format(gettext('No {0} selected'), 'pool'),
'</tpl>',
],
},
{
text: gettext('More'),
iconCls: 'fa fa-bars',
disabled: true,
bind: {
disabled: '{!pool}',
},
menu: [
{
text: gettext('Destroy'),
itemId: 'remove',
iconCls: 'fa fa-fw fa-trash-o',
handler: 'destroyPool',
disabled: true,
bind: {
disabled: '{!pool}',
},
},
],
},
],
show_detail: function(zpool) {
let me = this;
Ext.create('Proxmox.window.ZFSDetail', {
zpool,
nodename: me.nodename,
}).show();
},
set_button_status: function() {
var me = this;
},
reload: function() {
var me = this;
me.store.load();
me.store.sort();
},
listeners: {
activate: function() {
this.reload();
},
selectionchange: function(model, selected) {
let me = this;
let vm = me.getViewModel();
me.down('#detailbtn').setDisabled(selected.length === 0);
vm.set('pool', selected[0]?.data.name || '');
},
itemdblclick: function(grid, record) {
this.show_detail(record.get('name'));
},
},
initComponent: function() {
let me = this;
me.nodename = me.pveSelNode.data.node;
if (!me.nodename) {
throw "no node name specified";
}
Ext.apply(me, {
store: {
fields: ['name', 'size', 'free', 'alloc', 'dedup', 'frag', 'health'],
proxy: {
type: 'proxmox',
url: `/api2/json/nodes/${me.nodename}/disks/zfs`,
},
sorters: 'name',
},
});
me.callParent();
Proxmox.Utils.monStoreErrors(me, me.getStore(), true);
me.reload();
},
});
Ext.define('Proxmox.node.NodeOptionsView', {
extend: 'Proxmox.grid.ObjectGrid',
alias: ['widget.proxmoxNodeOptionsView'],
mixins: ['Proxmox.Mixin.CBind'],
cbindData: function(_initialconfig) {
let me = this;
let baseUrl = `/nodes/${me.nodename}/config`;
me.url = `/api2/json${baseUrl}`;
me.editorConfig = {
url: `/api2/extjs/${baseUrl}`,
};
return {};
},
listeners: {
itemdblclick: function() { this.run_editor(); },
activate: function() { this.rstore.startUpdate(); },
destroy: function() { this.rstore.stopUpdate(); },
deactivate: function() { this.rstore.stopUpdate(); },
},
tbar: [
{
text: gettext('Edit'),
xtype: 'proxmoxButton',
disabled: true,
handler: btn => btn.up('grid').run_editor(),
},
],
gridRows: [
{
xtype: 'integer',
name: 'startall-onboot-delay',
text: gettext('Start on boot delay'),
minValue: 0,
maxValue: 300,
labelWidth: 130,
deleteEmpty: true,
renderer: function(value) {
if (value === undefined) {
return Proxmox.Utils.defaultText;
}
let secString = value === '1' ? gettext('Second') : gettext('Seconds');
return `${value} ${secString}`;
},
},
{
xtype: 'text',
name: 'wakeonlan',
text: gettext('MAC address for Wake on LAN'),
vtype: 'MacAddress',
labelWidth: 150,
deleteEmpty: true,
renderer: function(value) {
if (value === undefined) {
return Proxmox.Utils.NoneText;
}
return value;
},
},
],
});
Ext.define('PVE.pool.Config', {
extend: 'PVE.panel.Config',
alias: 'widget.pvePoolConfig',
onlineHelp: 'pveum_pools',
initComponent: function() {
var me = this;
var pool = me.pveSelNode.data.pool;
if (!pool) {
throw "no pool specified";
}
Ext.apply(me, {
title: Ext.String.format(gettext("Resource Pool") + ': ' + pool),
hstateid: 'pooltab',
items: [
{
title: gettext('Summary'),
iconCls: 'fa fa-book',
xtype: 'pvePoolSummary',
itemId: 'summary',
},
{
title: gettext('Members'),
xtype: 'pvePoolMembers',
iconCls: 'fa fa-th',
pool: pool,
itemId: 'members',
},
{
xtype: 'pveACLView',
title: gettext('Permissions'),
iconCls: 'fa fa-unlock',
itemId: 'permissions',
path: '/pool/' + pool,
},
],
});
me.callParent();
},
});
Ext.define('PVE.pool.StatusView', {
extend: 'Proxmox.grid.ObjectGrid',
alias: ['widget.pvePoolStatusView'],
disabled: true,
title: gettext('Status'),
cwidth1: 150,
interval: 30000,
//height: 195,
initComponent: function() {
var me = this;
var pool = me.pveSelNode.data.pool;
if (!pool) {
throw "no pool specified";
}
var rows = {
comment: {
header: gettext('Comment'),
renderer: Ext.String.htmlEncode,
required: true,
},
};
Ext.apply(me, {
url: "/api2/json/pools/?poolid=" + pool,
rows: rows,
});
me.callParent();
},
});
Ext.define('PVE.pool.Summary', {
extend: 'Ext.panel.Panel',
alias: 'widget.pvePoolSummary',
initComponent: function() {
var me = this;
var pool = me.pveSelNode.data.pool;
if (!pool) {
throw "no pool specified";
}
var statusview = Ext.create('PVE.pool.StatusView', {
pveSelNode: me.pveSelNode,
style: 'padding-top:0px',
});
var rstore = statusview.rstore;
Ext.apply(me, {
autoScroll: true,
bodyStyle: 'padding:10px',
defaults: {
style: 'padding-top:10px',
width: 800,
},
items: [statusview],
});
me.on('activate', rstore.startUpdate);
me.on('destroy', rstore.stopUpdate);
me.callParent();
},
});
Ext.define('PVE.window.IPInfo', {
extend: 'Ext.window.Window',
width: 600,
title: gettext('Guest Agent Network Information'),
height: 300,
layout: {
type: 'fit',
},
modal: true,
items: [
{
xtype: 'grid',
store: {},
emptyText: gettext('No network information'),
viewConfig: {
enableTextSelection: true,
},
columns: [
{
dataIndex: 'name',
text: gettext('Name'),
renderer: Ext.htmlEncode,
flex: 3,
},
{
dataIndex: 'hardware-address',
text: gettext('MAC address'),
renderer: Ext.htmlEncode,
width: 140,
},
{
dataIndex: 'ip-addresses',
text: gettext('IP address'),
align: 'right',
flex: 4,
renderer: function(val) {
if (!Ext.isArray(val)) {
return '';
}
var ips = [];
val.forEach(function(ip) {
var addr = ip['ip-address'];
var pref = ip.prefix;
if (addr && pref) {
ips.push(Ext.htmlEncode(addr + '/' + pref));
}
});
return ips.join('<br>');
},
},
],
},
],
});
Ext.define('PVE.qemu.AgentIPView', {
extend: 'Ext.container.Container',
xtype: 'pveAgentIPView',
layout: {
type: 'hbox',
align: 'top',
},
nics: [],
items: [
{
xtype: 'box',
html: '<i class="fa fa-exchange"></i> IPs',
},
{
xtype: 'container',
flex: 1,
layout: {
type: 'vbox',
align: 'right',
pack: 'end',
},
items: [
{
xtype: 'label',
flex: 1,
itemId: 'ipBox',
style: {
'text-align': 'right',
},
},
{
xtype: 'button',
itemId: 'moreBtn',
hidden: true,
ui: 'default-toolbar',
handler: function(btn) {
let view = this.up('pveAgentIPView');
var win = Ext.create('PVE.window.IPInfo');
win.down('grid').getStore().setData(view.nics);
win.show();
},
text: gettext('More'),
},
],
},
],
getDefaultIps: function(nics) {
var me = this;
var ips = [];
nics.forEach(function(nic) {
if (nic['hardware-address'] &&
nic['hardware-address'] !== '00:00:00:00:00:00' &&
nic['hardware-address'] !== '0:0:0:0:0:0') {
var nic_ips = nic['ip-addresses'] || [];
nic_ips.forEach(function(ip) {
var p = ip['ip-address'];
// show 2 ips at maximum
if (ips.length < 2) {
ips.push(Ext.htmlEncode(p));
}
});
}
});
return ips;
},
startIPStore: function(store, records, success) {
var me = this;
let agentRec = store.getById('agent');
let state = store.getById('status');
me.agent = agentRec && agentRec.data.value === 1;
me.running = state && state.data.value === 'running';
var caps = Ext.state.Manager.get('GuiCap');
if (!caps.vms['VM.Monitor']) {
var errorText = gettext("Requires '{0}' Privileges");
me.updateStatus(false, Ext.String.format(errorText, 'VM.Monitor'));
return;
}
if (me.agent && me.running && me.ipStore.isStopped) {
me.ipStore.startUpdate();
} else if (me.ipStore.isStopped) {
me.updateStatus();
}
},
updateStatus: function(unsuccessful, defaulttext) {
var me = this;
var text = defaulttext || gettext('No network information');
var more = false;
if (unsuccessful) {
text = gettext('Guest Agent not running');
} else if (me.agent && me.running) {
if (Ext.isArray(me.nics) && me.nics.length) {
more = true;
var ips = me.getDefaultIps(me.nics);
if (ips.length !== 0) {
text = ips.join('<br>');
}
} else if (me.nics && me.nics.error) {
let msg = gettext('Cannot get info from Guest Agent<br>Error: {0}');
text = Ext.String.format(msg, Ext.htmlEncode(me.nics.error.desc));
}
} else if (me.agent) {
text = gettext('Guest Agent not running');
} else {
text = gettext('No Guest Agent configured');
}
var ipBox = me.down('#ipBox');
ipBox.update(text);
var moreBtn = me.down('#moreBtn');
moreBtn.setVisible(more);
},
initComponent: function() {
var me = this;
if (!me.rstore) {
throw 'rstore not given';
}
if (!me.pveSelNode) {
throw 'pveSelNode not given';
}
var nodename = me.pveSelNode.data.node;
var vmid = me.pveSelNode.data.vmid;
me.ipStore = Ext.create('Proxmox.data.UpdateStore', {
interval: 10000,
storeid: 'pve-qemu-agent-' + vmid,
method: 'POST',
proxy: {
type: 'proxmox',
url: '/api2/json/nodes/' + nodename + '/qemu/' + vmid + '/agent/network-get-interfaces',
},
});
me.callParent();
me.mon(me.ipStore, 'load', function(store, records, success) {
if (records && records.length) {
me.nics = records[0].data.result;
} else {
me.nics = undefined;
}
me.updateStatus(!success);
});
me.on('destroy', me.ipStore.stopUpdate, me.ipStore);
// if we already have info about the vm, use it immediately
if (me.rstore.getCount()) {
me.startIPStore(me.rstore, me.rstore.getData(), false);
}
// check if the guest agent is there on every statusstore load
me.mon(me.rstore, 'load', me.startIPStore, me);
},
});
Ext.define('PVE.qemu.AudioInputPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveAudioInputPanel',
// FIXME: enable once we bumped doc-gen so this ref is included
//onlineHelp: 'qm_audio_device',
onGetValues: function(values) {
var ret = PVE.Parser.printPropertyString(values);
if (ret === '') {
return {
'delete': 'audio0',
};
}
return {
audio0: ret,
};
},
items: [{
name: 'device',
xtype: 'proxmoxKVComboBox',
value: 'ich9-intel-hda',
fieldLabel: gettext('Audio Device'),
comboItems: [
['ich9-intel-hda', 'ich9-intel-hda'],
['intel-hda', 'intel-hda'],
['AC97', 'AC97'],
],
}, {
name: 'driver',
xtype: 'proxmoxKVComboBox',
value: 'spice',
fieldLabel: gettext('Backend Driver'),
comboItems: [
['spice', 'SPICE'],
['none', `${Proxmox.Utils.NoneText} (${gettext('Dummy Device')})`],
],
}],
});
Ext.define('PVE.qemu.AudioEdit', {
extend: 'Proxmox.window.Edit',
vmconfig: undefined,
subject: gettext('Audio Device'),
items: [{
xtype: 'pveAudioInputPanel',
}],
initComponent: function() {
var me = this;
me.callParent();
me.load({
success: function(response) {
me.vmconfig = response.result.data;
var audio0 = me.vmconfig.audio0;
if (audio0) {
me.setValues(PVE.Parser.parsePropertyString(audio0));
}
},
});
},
});
Ext.define('pve-boot-order-entry', {
extend: 'Ext.data.Model',
fields: [
{ name: 'name', type: 'string' },
{ name: 'enabled', type: 'bool' },
{ name: 'desc', type: 'string' },
],
});
Ext.define('PVE.qemu.BootOrderPanel', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pveQemuBootOrderPanel',
onlineHelp: 'qm_bootorder',
vmconfig: {}, // store loaded vm config
store: undefined,
inUpdate: false,
controller: {
xclass: 'Ext.app.ViewController',
init: function(view) {
let me = this;
let grid = me.lookup('grid');
let marker = me.lookup('marker');
let emptyWarning = me.lookup('emptyWarning');
marker.originalValue = undefined;
view.store = Ext.create('Ext.data.Store', {
model: 'pve-boot-order-entry',
listeners: {
update: function() {
this.commitChanges();
let val = view.calculateValue();
if (marker.originalValue === undefined) {
marker.originalValue = val;
}
view.inUpdate = true;
marker.setValue(val);
view.inUpdate = false;
marker.checkDirty();
emptyWarning.setHidden(val !== '');
grid.getView().refresh();
},
},
});
grid.setStore(view.store);
},
},
isCloudinit: (v) => v.match(/media=cdrom/) && v.match(/[:/]vm-\d+-cloudinit/),
isDisk: function(value) {
return PVE.Utils.bus_match.test(value);
},
isBootdev: function(dev, value) {
return (this.isDisk(dev) && !this.isCloudinit(value)) ||
(/^net\d+/).test(dev) ||
(/^hostpci\d+/).test(dev) ||
((/^usb\d+/).test(dev) && !(/spice/).test(value));
},
setVMConfig: function(vmconfig) {
let me = this;
me.vmconfig = vmconfig;
me.store.removeAll();
let boot = PVE.Parser.parsePropertyString(me.vmconfig.boot, "legacy");
let bootorder = [];
if (boot.order) {
bootorder = boot.order.split(';').map(dev => ({ name: dev, enabled: true }));
} else if (!(/^\s*$/).test(me.vmconfig.boot)) {
// legacy style, transform to new bootorder
let order = boot.legacy || 'cdn';
let bootdisk = me.vmconfig.bootdisk || undefined;
// get the first 4 characters (acdn)
// ignore the rest (there should never be more than 4)
let orderList = order.split('').slice(0, 4);
// build bootdev list
for (let i = 0; i < orderList.length; i++) {
let list = [];
if (orderList[i] === 'c') {
if (bootdisk !== undefined && me.vmconfig[bootdisk]) {
list.push(bootdisk);
}
} else if (orderList[i] === 'd') {
Ext.Object.each(me.vmconfig, function(key, value) {
if (me.isDisk(key) && value.match(/media=cdrom/) && !me.isCloudinit(value)) {
list.push(key);
}
});
} else if (orderList[i] === 'n') {
Ext.Object.each(me.vmconfig, function(key, value) {
if ((/^net\d+/).test(key)) {
list.push(key);
}
});
}
// Object.each iterates in random order, sort alphabetically
list.sort();
list.forEach(dev => bootorder.push({ name: dev, enabled: true }));
}
}
// add disabled devices as well
let disabled = [];
Ext.Object.each(me.vmconfig, function(key, value) {
if (me.isBootdev(key, value) &&
!Ext.Array.some(bootorder, x => x.name === key)) {
disabled.push(key);
}
});
disabled.sort();
disabled.forEach(dev => bootorder.push({ name: dev, enabled: false }));
// add descriptions
bootorder.forEach(entry => {
entry.desc = me.vmconfig[entry.name];
});
me.store.insert(0, bootorder);
me.store.fireEvent("update");
},
calculateValue: function() {
let me = this;
return me.store.getData().items
.filter(x => x.data.enabled)
.map(x => x.data.name)
.join(';');
},
onGetValues: function() {
let me = this;
// Note: we allow an empty value, so no 'delete' option
let val = { order: me.calculateValue() };
let res = { boot: PVE.Parser.printPropertyString(val) };
return res;
},
items: [
{
xtype: 'grid',
reference: 'grid',
margin: '0 0 5 0',
minHeight: 150,
defaults: {
sortable: false,
hideable: false,
draggable: false,
},
columns: [
{
header: '#',
flex: 4,
renderer: (value, metaData, record, rowIndex) => {
let dragHandle = "<i class='pve-grid-fa fa fa-fw fa-reorder cursor-move'></i>";
let idx = (rowIndex + 1).toString();
if (record.get('enabled')) {
return dragHandle + idx;
} else {
return dragHandle + "<span class='faded'>" + idx + "</span>";
}
},
},
{
xtype: 'checkcolumn',
header: gettext('Enabled'),
dataIndex: 'enabled',
flex: 4,
},
{
header: gettext('Device'),
dataIndex: 'name',
flex: 6,
renderer: (value, metaData, record, rowIndex) => {
let desc = record.get('desc');
let icon = '', iconCls;
if (value.match(/^net\d+$/)) {
iconCls = 'exchange';
} else if (desc.match(/media=cdrom/)) {
metaData.tdCls = 'pve-itype-icon-cdrom';
} else {
iconCls = 'hdd-o';
}
if (iconCls !== undefined) {
metaData.tdCls += 'pve-itype-fa';
icon = `<i class="pve-grid-fa fa fa-fw fa-${iconCls}"></i>`;
}
return icon + value;
},
},
{
header: gettext('Description'),
dataIndex: 'desc',
flex: 20,
},
],
viewConfig: {
plugins: {
ptype: 'gridviewdragdrop',
dragText: gettext('Drag and drop to reorder'),
},
},
listeners: {
drop: function() {
// doesn't fire automatically on reorder
this.getStore().fireEvent("update");
},
},
},
{
xtype: 'component',
html: gettext('Drag and drop to reorder'),
},
{
xtype: 'displayfield',
reference: 'emptyWarning',
userCls: 'pmx-hint',
value: gettext('Warning: No devices selected, the VM will probably not boot!'),
},
{
// for dirty marking and 'reset' function
xtype: 'field',
reference: 'marker',
hidden: true,
setValue: function(val) {
let me = this;
let panel = me.up('pveQemuBootOrderPanel');
// on form reset, go back to original state
if (!panel.inUpdate) {
panel.setVMConfig(panel.vmconfig);
}
// not a subclass, so no callParent; just do it manually
me.setRawValue(me.valueToRaw(val));
return me.mixins.field.setValue.call(me, val);
},
},
],
});
Ext.define('PVE.qemu.BootOrderEdit', {
extend: 'Proxmox.window.Edit',
items: [{
xtype: 'pveQemuBootOrderPanel',
itemId: 'inputpanel',
}],
subject: gettext('Boot Order'),
width: 640,
initComponent: function() {
let me = this;
me.callParent();
me.load({
success: ({ result }) => me.down('#inputpanel').setVMConfig(result.data),
});
},
});
Ext.define('PVE.qemu.CDInputPanel', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pveQemuCDInputPanel',
insideWizard: false,
onGetValues: function(values) {
var me = this;
var confid = me.confid || values.controller + values.deviceid;
me.drive.media = 'cdrom';
if (values.mediaType === 'iso') {
me.drive.file = values.cdimage;
} else if (values.mediaType === 'cdrom') {
me.drive.file = 'cdrom';
} else {
me.drive.file = 'none';
}
var params = {};
params[confid] = PVE.Parser.printQemuDrive(me.drive);
return params;
},
setVMConfig: function(vmconfig) {
var me = this;
if (me.bussel) {
me.bussel.setVMConfig(vmconfig, 'cdrom');
}
},
setDrive: function(drive) {
var me = this;
var values = {};
if (drive.file === 'cdrom') {
values.mediaType = 'cdrom';
} else if (drive.file === 'none') {
values.mediaType = 'none';
} else {
values.mediaType = 'iso';
values.cdimage = drive.file;
}
me.drive = drive;
me.setValues(values);
},
setNodename: function(nodename) {
var me = this;
me.isosel.setNodename(nodename);
},
initComponent: function() {
var me = this;
me.drive = {};
var items = [];
if (!me.confid) {
me.bussel = Ext.create('PVE.form.ControllerSelector', {
withVirtIO: false,
});
items.push(me.bussel);
}
items.push({
xtype: 'radiofield',
name: 'mediaType',
inputValue: 'iso',
boxLabel: gettext('Use CD/DVD disc image file (iso)'),
checked: true,
listeners: {
change: function(f, value) {
if (!me.rendered) {
return;
}
var cdImageField = me.down('pveIsoSelector');
cdImageField.setDisabled(!value);
if (value) {
cdImageField.validate();
} else {
cdImageField.reset();
}
},
},
});
me.isosel = Ext.create('PVE.form.IsoSelector', {
nodename: me.nodename,
insideWizard: me.insideWizard,
name: 'cdimage',
});
items.push(me.isosel);
items.push({
xtype: 'radiofield',
name: 'mediaType',
inputValue: 'cdrom',
boxLabel: gettext('Use physical CD/DVD Drive'),
});
items.push({
xtype: 'radiofield',
name: 'mediaType',
inputValue: 'none',
boxLabel: gettext('Do not use any media'),
});
me.items = items;
me.callParent();
},
});
Ext.define('PVE.qemu.CDEdit', {
extend: 'Proxmox.window.Edit',
width: 400,
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
me.isCreate = !me.confid;
var ipanel = Ext.create('PVE.qemu.CDInputPanel', {
confid: me.confid,
nodename: nodename,
});
Ext.applyIf(me, {
subject: 'CD/DVD Drive',
items: [ipanel],
});
me.callParent();
me.load({
success: function(response, options) {
ipanel.setVMConfig(response.result.data);
if (me.confid) {
var value = response.result.data[me.confid];
var drive = PVE.Parser.parseQemuDrive(me.confid, value);
if (!drive) {
Ext.Msg.alert('Error', 'Unable to parse drive options');
me.close();
return;
}
ipanel.setDrive(drive);
}
},
});
},
});
Ext.define('PVE.qemu.CIDriveInputPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveCIDriveInputPanel',
insideWizard: false,
vmconfig: {}, // used to select usused disks
onGetValues: function(values) {
var me = this;
var drive = {};
var params = {};
drive.file = values.hdstorage + ":cloudinit";
drive.format = values.diskformat;
params[values.controller + values.deviceid] = PVE.Parser.printQemuDrive(drive);
return params;
},
setNodename: function(nodename) {
var me = this;
me.down('#hdstorage').setNodename(nodename);
me.down('#hdimage').setStorage(undefined, nodename);
},
setVMConfig: function(config) {
var me = this;
me.down('#drive').setVMConfig(config, 'cdrom');
},
initComponent: function() {
var me = this;
me.drive = {};
me.items = [
{
xtype: 'pveControllerSelector',
withVirtIO: false,
itemId: 'drive',
fieldLabel: gettext('CloudInit Drive'),
name: 'drive',
},
{
xtype: 'pveDiskStorageSelector',
itemId: 'storselector',
storageContent: 'images',
nodename: me.nodename,
hideSize: true,
},
];
me.callParent();
},
});
Ext.define('PVE.qemu.CIDriveEdit', {
extend: 'Proxmox.window.Edit',
xtype: 'pveCIDriveEdit',
isCreate: true,
subject: gettext('CloudInit Drive'),
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
me.items = [{
xtype: 'pveCIDriveInputPanel',
itemId: 'cipanel',
nodename: nodename,
}];
me.callParent();
me.load({
success: function(response, opts) {
me.down('#cipanel').setVMConfig(response.result.data);
},
});
},
});
Ext.define('PVE.qemu.CloudInit', {
extend: 'Proxmox.grid.PendingObjectGrid',
xtype: 'pveCiPanel',
onlineHelp: 'qm_cloud_init',
tbar: [
{
xtype: 'proxmoxButton',
disabled: true,
dangerous: true,
confirmMsg: function(rec) {
let view = this.up('grid');
var warn = gettext('Are you sure you want to remove entry {0}');
var entry = rec.data.key;
var msg = Ext.String.format(warn, "'"
+ view.renderKey(entry, {}, rec) + "'");
return msg;
},
enableFn: function(record) {
let view = this.up('grid');
var caps = Ext.state.Manager.get('GuiCap');
let caps_ci = caps.vms['VM.Config.Network'] || caps.vms['VM.Config.Cloudinit'];
if (view.rows[record.data.key].never_delete || !caps_ci) {
return false;
}
if (record.data.key === 'cipassword' && !record.data.value) {
return false;
}
return true;
},
handler: function() {
let view = this.up('grid');
let records = view.getSelection();
if (!records || !records.length) {
return;
}
var id = records[0].data.key;
var match = id.match(/^net(\d+)$/);
if (match) {
id = 'ipconfig' + match[1];
}
var params = {};
params.delete = id;
Proxmox.Utils.API2Request({
url: view.baseurl + '/config',
waitMsgTarget: view,
method: 'PUT',
params: params,
failure: response => Ext.Msg.alert('Error', response.htmlStatus),
callback: function() {
view.reload();
},
});
},
text: gettext('Remove'),
},
{
xtype: 'proxmoxButton',
disabled: true,
enableFn: function(rec) {
let view = this.up('pveCiPanel');
return !!view.rows[rec.data.key].editor;
},
handler: function() {
let view = this.up('grid');
view.run_editor();
},
text: gettext('Edit'),
},
'-',
{
xtype: 'button',
itemId: 'savebtn',
text: gettext('Regenerate Image'),
handler: function() {
let view = this.up('grid');
Proxmox.Utils.API2Request({
url: view.baseurl + '/cloudinit',
waitMsgTarget: view,
method: 'PUT',
failure: response => Ext.Msg.alert('Error', response.htmlStatus),
callback: function() {
view.reload();
},
});
},
},
],
border: false,
set_button_status: function(rstore, records, success) {
if (!success || records.length < 1) {
return;
}
var me = this;
var found;
records.forEach(function(record) {
if (found) {
return;
}
var id = record.data.key;
var value = record.data.value;
var ciregex = new RegExp("vm-" + me.pveSelNode.data.vmid + "-cloudinit");
if (id.match(/^(ide|scsi|sata)\d+$/) && ciregex.test(value)) {
found = id;
me.ciDriveId = found;
me.ciDrive = value;
}
});
let caps = Ext.state.Manager.get('GuiCap');
let canRegenerateImage = !!caps.vms['VM.Config.Cloudinit'];
me.down('#savebtn').setDisabled(!found || !canRegenerateImage);
me.setDisabled(!found);
if (!found) {
me.getView().mask(gettext('No CloudInit Drive found'), ['pve-static-mask']);
} else {
me.getView().unmask();
}
},
renderKey: function(key, metaData, rec, rowIndex, colIndex, store) {
var me = this;
var rows = me.rows;
var rowdef = rows[key] || {};
var icon = "";
if (rowdef.iconCls) {
icon = '<i class="' + rowdef.iconCls + '"></i> ';
}
return icon + (rowdef.header || key);
},
listeners: {
activate: function() {
var me = this;
me.rstore.startUpdate();
},
itemdblclick: function() {
var me = this;
me.run_editor();
},
},
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
var vmid = me.pveSelNode.data.vmid;
if (!vmid) {
throw "no VM ID specified";
}
var caps = Ext.state.Manager.get('GuiCap');
me.baseurl = '/api2/extjs/nodes/' + nodename + '/qemu/' + vmid;
me.url = me.baseurl + '/pending';
me.editorConfig.url = me.baseurl + '/config';
me.editorConfig.pveSelNode = me.pveSelNode;
let caps_ci = caps.vms['VM.Config.Cloudinit'] || caps.vms['VM.Config.Network'];
/* editor is string and object */
me.rows = {
ciuser: {
header: gettext('User'),
iconCls: 'fa fa-user',
never_delete: true,
defaultValue: '',
editor: caps_ci ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('User'),
items: [
{
xtype: 'proxmoxtextfield',
deleteEmpty: true,
emptyText: Proxmox.Utils.defaultText,
fieldLabel: gettext('User'),
name: 'ciuser',
},
],
} : undefined,
renderer: function(value) {
return Ext.String.htmlEncode(value || Proxmox.Utils.defaultText);
},
},
cipassword: {
header: gettext('Password'),
iconCls: 'fa fa-unlock',
defaultValue: '',
editor: caps_ci ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('Password'),
items: [
{
xtype: 'proxmoxtextfield',
inputType: 'password',
deleteEmpty: true,
emptyText: Proxmox.Utils.noneText,
fieldLabel: gettext('Password'),
name: 'cipassword',
},
],
} : undefined,
renderer: function(value) {
return Ext.String.htmlEncode(value || Proxmox.Utils.noneText);
},
},
searchdomain: {
header: gettext('DNS domain'),
iconCls: 'fa fa-globe',
editor: caps_ci ? 'PVE.lxc.DNSEdit' : undefined,
never_delete: true,
defaultValue: gettext('use host settings'),
},
nameserver: {
header: gettext('DNS servers'),
iconCls: 'fa fa-globe',
editor: caps_ci ? 'PVE.lxc.DNSEdit' : undefined,
never_delete: true,
defaultValue: gettext('use host settings'),
},
sshkeys: {
header: gettext('SSH public key'),
iconCls: 'fa fa-key',
editor: caps_ci ? 'PVE.qemu.SSHKeyEdit' : undefined,
never_delete: true,
renderer: function(value) {
value = decodeURIComponent(value);
var keys = value.split('\n');
var text = [];
keys.forEach(function(key) {
if (key.length) {
let res = PVE.Parser.parseSSHKey(key);
if (res) {
key = Ext.String.htmlEncode(res.comment);
if (res.options) {
key += ' <span style="color:gray">(' + gettext('with options') + ')</span>';
}
text.push(key);
return;
}
// Most likely invalid at this point, so just stick to
// the old value.
text.push(Ext.String.htmlEncode(key));
}
});
if (text.length) {
return text.join('<br>');
} else {
return Proxmox.Utils.noneText;
}
},
defaultValue: '',
},
ciupgrade: {
header: gettext('Upgrade packages'),
iconCls: 'fa fa-archive',
renderer: Proxmox.Utils.format_boolean,
defaultValue: 1,
editor: {
xtype: 'proxmoxWindowEdit',
subject: gettext('Upgrade packages on boot'),
items: {
xtype: 'proxmoxcheckbox',
name: 'ciupgrade',
uncheckedValue: 0,
value: 1, // serves as default value, using defaultValue is not enough
fieldLabel: gettext('Upgrade packages'),
labelWidth: 140,
},
},
},
};
var i;
var ipconfig_renderer = function(value, md, record, ri, ci, store, pending) {
var id = record.data.key;
var match = id.match(/^net(\d+)$/);
var val = '';
if (match) {
val = me.getObjectValue('ipconfig'+match[1], '', pending);
}
return val;
};
for (i = 0; i < 32; i++) {
// we want to show an entry for every network device
// even if it is empty
me.rows['net' + i.toString()] = {
multiKey: ['ipconfig' + i.toString(), 'net' + i.toString()],
header: gettext('IP Config') + ' (net' + i.toString() +')',
editor: caps_ci ? 'PVE.qemu.IPConfigEdit' : undefined,
iconCls: 'fa fa-exchange',
renderer: ipconfig_renderer,
};
me.rows['ipconfig' + i.toString()] = {
visible: false,
};
}
PVE.Utils.forEachBus(['ide', 'scsi', 'sata'], function(type, id) {
me.rows[type+id] = {
visible: false,
};
});
me.callParent();
me.mon(me.rstore, 'load', me.set_button_status, me);
},
});
Ext.define('PVE.qemu.CmdMenu', {
extend: 'Ext.menu.Menu',
showSeparator: false,
initComponent: function() {
let me = this;
let info = me.pveSelNode.data;
if (!info.node) {
throw "no node name specified";
}
if (!info.vmid) {
throw "no VM ID specified";
}
let vm_command = function(cmd, params) {
Proxmox.Utils.API2Request({
params: params,
url: `/nodes/${info.node}/${info.type}/${info.vmid}/status/${cmd}`,
method: 'POST',
failure: (response, opts) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
};
let confirmedVMCommand = (cmd, params, confirmTask) => {
let task = confirmTask || `qm${cmd}`;
let msg = PVE.Utils.formatGuestTaskConfirmation(task, info.vmid, info.name);
Ext.Msg.confirm(gettext('Confirm'), msg, btn => {
if (btn === 'yes') {
vm_command(cmd, params);
}
});
};
let caps = Ext.state.Manager.get('GuiCap');
let standalone = PVE.Utils.isStandaloneNode();
let running = false, stopped = true, suspended = false;
switch (info.status) {
case 'running':
running = true;
stopped = false;
break;
case 'suspended':
stopped = false;
suspended = true;
break;
case 'paused':
stopped = false;
suspended = true;
break;
default: break;
}
me.title = "VM " + info.vmid;
me.items = [
{
text: gettext('Start'),
iconCls: 'fa fa-fw fa-play',
hidden: running || suspended,
disabled: running || suspended,
handler: () => vm_command('start'),
},
{
text: gettext('Pause'),
iconCls: 'fa fa-fw fa-pause',
hidden: stopped || suspended,
disabled: stopped || suspended,
handler: () => confirmedVMCommand('suspend', undefined, 'qmpause'),
},
{
text: gettext('Hibernate'),
iconCls: 'fa fa-fw fa-download',
hidden: stopped || suspended,
disabled: stopped || suspended,
tooltip: gettext('Suspend to disk'),
handler: () => confirmedVMCommand('suspend', { todisk: 1 }),
},
{
text: gettext('Resume'),
iconCls: 'fa fa-fw fa-play',
hidden: !suspended,
handler: () => vm_command('resume'),
},
{
text: gettext('Shutdown'),
iconCls: 'fa fa-fw fa-power-off',
disabled: stopped || suspended,
handler: () => confirmedVMCommand('shutdown'),
},
{
text: gettext('Stop'),
iconCls: 'fa fa-fw fa-stop',
disabled: stopped,
tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'VM'),
handler: () => {
Ext.create('PVE.GuestStop', {
nodename: info.node,
vm: info,
autoShow: true,
});
},
},
{
text: gettext('Reboot'),
iconCls: 'fa fa-fw fa-refresh',
disabled: stopped,
tooltip: Ext.String.format(gettext('Reboot {0}'), 'VM'),
handler: () => confirmedVMCommand('reboot'),
},
{
xtype: 'menuseparator',
hidden: (standalone || !caps.vms['VM.Migrate']) && !caps.vms['VM.Allocate'] && !caps.vms['VM.Clone'],
},
{
text: gettext('Migrate'),
iconCls: 'fa fa-fw fa-send-o',
hidden: standalone || !caps.vms['VM.Migrate'],
handler: function() {
Ext.create('PVE.window.Migrate', {
vmtype: 'qemu',
nodename: info.node,
vmid: info.vmid,
autoShow: true,
});
},
},
{
text: gettext('Clone'),
iconCls: 'fa fa-fw fa-clone',
hidden: !caps.vms['VM.Clone'],
handler: () => PVE.window.Clone.wrap(info.node, info.vmid, me.isTemplate, 'qemu'),
},
{
text: gettext('Convert to template'),
iconCls: 'fa fa-fw fa-file-o',
hidden: !caps.vms['VM.Allocate'],
handler: function() {
let msg = PVE.Utils.formatGuestTaskConfirmation('qmtemplate', info.vmid, info.name);
Ext.Msg.confirm(gettext('Confirm'), msg, btn => {
if (btn === 'yes') {
Proxmox.Utils.API2Request({
url: `/nodes/${info.node}/qemu/${info.vmid}/template`,
method: 'POST',
failure: (response, opts) => Ext.Msg.alert('Error', response.htmlStatus),
});
}
});
},
},
{ xtype: 'menuseparator' },
{
text: gettext('Console'),
iconCls: 'fa fa-fw fa-terminal',
handler: function() {
Proxmox.Utils.API2Request({
url: `/nodes/${info.node}/qemu/${info.vmid}/status/current`,
failure: (response, opts) => Ext.Msg.alert('Error', response.htmlStatus),
success: function({ result: { data } }, opts) {
PVE.Utils.openDefaultConsoleWindow(
{
spice: data.spice,
xtermjs: data.serial,
},
'kvm',
info.vmid,
info.node,
info.name,
);
},
});
},
},
];
me.callParent();
},
});
Ext.define('PVE.qemu.Config', {
extend: 'PVE.panel.Config',
alias: 'widget.PVE.qemu.Config',
onlineHelp: 'chapter_virtual_machines',
userCls: 'proxmox-tags-full',
initComponent: function() {
var me = this;
var vm = me.pveSelNode.data;
var nodename = vm.node;
if (!nodename) {
throw "no node name specified";
}
var vmid = vm.vmid;
if (!vmid) {
throw "no VM ID specified";
}
var template = !!vm.template;
var running = !!vm.uptime;
var caps = Ext.state.Manager.get('GuiCap');
var base_url = '/nodes/' + nodename + "/qemu/" + vmid;
me.statusStore = Ext.create('Proxmox.data.ObjectStore', {
url: '/api2/json' + base_url + '/status/current',
interval: 1000,
});
var vm_command = function(cmd, params) {
Proxmox.Utils.API2Request({
params: params,
url: base_url + '/status/' + cmd,
waitMsgTarget: me,
method: 'POST',
failure: function(response, opts) {
Ext.Msg.alert('Error', response.htmlStatus);
},
});
};
var resumeBtn = Ext.create('Ext.Button', {
text: gettext('Resume'),
disabled: !caps.vms['VM.PowerMgmt'],
hidden: true,
handler: function() {
vm_command('resume');
},
iconCls: 'fa fa-play',
});
var startBtn = Ext.create('Ext.Button', {
text: gettext('Start'),
disabled: !caps.vms['VM.PowerMgmt'] || running,
hidden: template,
handler: function() {
vm_command('start');
},
iconCls: 'fa fa-play',
});
var migrateBtn = Ext.create('Ext.Button', {
text: gettext('Migrate'),
disabled: !caps.vms['VM.Migrate'],
hidden: PVE.Utils.isStandaloneNode(),
handler: function() {
var win = Ext.create('PVE.window.Migrate', {
vmtype: 'qemu',
nodename: nodename,
vmid: vmid,
});
win.show();
},
iconCls: 'fa fa-send-o',
});
var moreBtn = Ext.create('Proxmox.button.Button', {
text: gettext('More'),
menu: {
items: [
{
text: gettext('Clone'),
iconCls: 'fa fa-fw fa-clone',
hidden: !caps.vms['VM.Clone'],
handler: function() {
PVE.window.Clone.wrap(nodename, vmid, template, 'qemu');
},
},
{
text: gettext('Convert to template'),
disabled: template,
xtype: 'pveMenuItem',
iconCls: 'fa fa-fw fa-file-o',
hidden: !caps.vms['VM.Allocate'],
confirmMsg: PVE.Utils.formatGuestTaskConfirmation('qmtemplate', vmid, vm.name),
handler: function() {
Proxmox.Utils.API2Request({
url: base_url + '/template',
waitMsgTarget: me,
method: 'POST',
failure: function(response, opts) {
Ext.Msg.alert('Error', response.htmlStatus);
},
});
},
},
{
iconCls: 'fa fa-heartbeat ',
hidden: !caps.nodes['Sys.Console'],
text: gettext('Manage HA'),
handler: function() {
var ha = vm.hastate;
Ext.create('PVE.ha.VMResourceEdit', {
vmid: vmid,
isCreate: !ha || ha === 'unmanaged',
}).show();
},
},
{
text: gettext('Remove'),
itemId: 'removeBtn',
disabled: !caps.vms['VM.Allocate'],
handler: function() {
Ext.create('PVE.window.SafeDestroyGuest', {
url: base_url,
item: { type: 'VM', id: vmid },
taskName: 'qmdestroy',
}).show();
},
iconCls: 'fa fa-trash-o',
},
],
},
});
var shutdownBtn = Ext.create('PVE.button.Split', {
text: gettext('Shutdown'),
disabled: !caps.vms['VM.PowerMgmt'] || !running,
hidden: template,
confirmMsg: PVE.Utils.formatGuestTaskConfirmation('qmshutdown', vmid, vm.name),
handler: function() {
vm_command('shutdown');
},
menu: {
items: [{
text: gettext('Reboot'),
disabled: !caps.vms['VM.PowerMgmt'],
tooltip: Ext.String.format(gettext('Shutdown, apply pending changes and reboot {0}'), 'VM'),
confirmMsg: PVE.Utils.formatGuestTaskConfirmation('qmreboot', vmid, vm.name),
handler: function() {
vm_command("reboot");
},
iconCls: 'fa fa-refresh',
}, {
text: gettext('Pause'),
disabled: !caps.vms['VM.PowerMgmt'],
confirmMsg: PVE.Utils.formatGuestTaskConfirmation('qmpause', vmid, vm.name),
handler: function() {
vm_command("suspend");
},
iconCls: 'fa fa-pause',
}, {
text: gettext('Hibernate'),
disabled: !caps.vms['VM.PowerMgmt'],
confirmMsg: PVE.Utils.formatGuestTaskConfirmation('qmsuspend', vmid, vm.name),
tooltip: gettext('Suspend to disk'),
handler: function() {
vm_command("suspend", { todisk: 1 });
},
iconCls: 'fa fa-download',
}, {
text: gettext('Stop'),
disabled: !caps.vms['VM.PowerMgmt'],
tooltip: Ext.String.format(gettext('Stop {0} immediately'), 'VM'),
handler: function() {
Ext.create('PVE.GuestStop', {
nodename: nodename,
vm: vm,
autoShow: true,
});
},
iconCls: 'fa fa-stop',
}, {
text: gettext('Reset'),
disabled: !caps.vms['VM.PowerMgmt'],
tooltip: Ext.String.format(gettext('Reset {0} immediately'), 'VM'),
confirmMsg: PVE.Utils.formatGuestTaskConfirmation('qmreset', vmid, vm.name),
handler: function() {
vm_command("reset");
},
iconCls: 'fa fa-bolt',
}],
},
iconCls: 'fa fa-power-off',
});
var consoleBtn = Ext.create('PVE.button.ConsoleButton', {
disabled: !caps.vms['VM.Console'],
hidden: template,
consoleType: 'kvm',
// disable spice/xterm for default action until status api call succeeded
enableSpice: false,
enableXtermjs: false,
consoleName: vm.name,
nodename: nodename,
vmid: vmid,
});
var statusTxt = Ext.create('Ext.toolbar.TextItem', {
data: {
lock: undefined,
},
tpl: [
'<tpl if="lock">',
'<i class="fa fa-lg fa-lock"></i> ({lock})',
'</tpl>',
],
});
let tagsContainer = Ext.create('PVE.panel.TagEditContainer', {
tags: vm.tags,
canEdit: !!caps.vms['VM.Config.Options'],
listeners: {
change: function(tags) {
Proxmox.Utils.API2Request({
url: base_url + '/config',
method: 'PUT',
params: {
tags,
},
success: function() {
me.statusStore.load();
},
failure: function(response) {
Ext.Msg.alert('Error', response.htmlStatus);
me.statusStore.load();
},
});
},
},
});
let vm_text = `${vm.vmid} (${vm.name})`;
Ext.apply(me, {
title: Ext.String.format(gettext("Virtual Machine {0} on node '{1}'"), vm_text, nodename),
hstateid: 'kvmtab',
tbarSpacing: false,
tbar: [statusTxt, tagsContainer, '->', resumeBtn, startBtn, shutdownBtn, migrateBtn, consoleBtn, moreBtn],
defaults: { statusStore: me.statusStore },
items: [
{
title: gettext('Summary'),
xtype: 'pveGuestSummary',
iconCls: 'fa fa-book',
itemId: 'summary',
},
],
});
if (caps.vms['VM.Console'] && !template) {
me.items.push({
title: gettext('Console'),
itemId: 'console',
iconCls: 'fa fa-terminal',
xtype: 'pveNoVncConsole',
vmid: vmid,
consoleType: 'kvm',
nodename: nodename,
});
}
me.items.push(
{
title: gettext('Hardware'),
itemId: 'hardware',
iconCls: 'fa fa-desktop',
xtype: 'PVE.qemu.HardwareView',
},
{
title: 'Cloud-Init',
itemId: 'cloudinit',
iconCls: 'fa fa-cloud',
xtype: 'pveCiPanel',
},
{
title: gettext('Options'),
iconCls: 'fa fa-gear',
itemId: 'options',
xtype: 'PVE.qemu.Options',
},
{
title: gettext('Task History'),
itemId: 'tasks',
xtype: 'proxmoxNodeTasks',
iconCls: 'fa fa-list-alt',
nodename: nodename,
preFilter: {
vmid,
},
},
);
if (caps.vms['VM.Monitor'] && !template) {
me.items.push({
title: gettext('Monitor'),
iconCls: 'fa fa-eye',
itemId: 'monitor',
xtype: 'pveQemuMonitor',
});
}
if (caps.vms['VM.Backup']) {
me.items.push({
title: gettext('Backup'),
iconCls: 'fa fa-floppy-o',
xtype: 'pveBackupView',
itemId: 'backup',
},
{
title: gettext('Replication'),
iconCls: 'fa fa-retweet',
xtype: 'pveReplicaView',
itemId: 'replication',
});
}
if ((caps.vms['VM.Snapshot'] || caps.vms['VM.Snapshot.Rollback'] ||
caps.vms['VM.Audit']) && !template) {
me.items.push({
title: gettext('Snapshots'),
iconCls: 'fa fa-history',
type: 'qemu',
xtype: 'pveGuestSnapshotTree',
itemId: 'snapshot',
});
}
if (caps.vms['VM.Audit']) {
me.items.push(
{
xtype: 'pveFirewallRules',
title: gettext('Firewall'),
iconCls: 'fa fa-shield',
allow_iface: true,
base_url: base_url + '/firewall/rules',
list_refs_url: base_url + '/firewall/refs',
itemId: 'firewall',
firewall_type: 'vm',
},
{
xtype: 'pveFirewallOptions',
groups: ['firewall'],
iconCls: 'fa fa-gear',
onlineHelp: 'pve_firewall_vm_container_configuration',
title: gettext('Options'),
base_url: base_url + '/firewall/options',
fwtype: 'vm',
itemId: 'firewall-options',
},
{
xtype: 'pveFirewallAliases',
title: gettext('Alias'),
groups: ['firewall'],
iconCls: 'fa fa-external-link',
base_url: base_url + '/firewall/aliases',
itemId: 'firewall-aliases',
},
{
xtype: 'pveIPSet',
title: gettext('IPSet'),
groups: ['firewall'],
iconCls: 'fa fa-list-ol',
base_url: base_url + '/firewall/ipset',
list_refs_url: base_url + '/firewall/refs',
itemId: 'firewall-ipset',
},
);
}
if (caps.vms['VM.Console']) {
me.items.push(
{
title: gettext('Log'),
groups: ['firewall'],
iconCls: 'fa fa-list',
onlineHelp: 'chapter_pve_firewall',
itemId: 'firewall-fwlog',
xtype: 'proxmoxLogView',
url: '/api2/extjs' + base_url + '/firewall/log',
log_select_timespan: true,
submitFormat: 'U',
},
);
}
if (caps.vms['Permissions.Modify']) {
me.items.push({
xtype: 'pveACLView',
title: gettext('Permissions'),
iconCls: 'fa fa-unlock',
itemId: 'permissions',
path: '/vms/' + vmid,
});
}
me.callParent();
var prevQMPStatus = 'unknown';
me.mon(me.statusStore, 'load', function(s, records, success) {
var status;
var qmpstatus;
var spice = false;
var xtermjs = false;
var lock;
var rec;
if (!success) {
status = qmpstatus = 'unknown';
} else {
rec = s.data.get('status');
status = rec ? rec.data.value : 'unknown';
rec = s.data.get('qmpstatus');
qmpstatus = rec ? rec.data.value : 'unknown';
rec = s.data.get('template');
template = rec ? rec.data.value : false;
rec = s.data.get('lock');
lock = rec ? rec.data.value : undefined;
spice = !!s.data.get('spice');
xtermjs = !!s.data.get('serial');
}
rec = s.data.get('tags');
tagsContainer.loadTags(rec?.data?.value);
if (template) {
return;
}
var resume = ['prelaunch', 'paused', 'suspended'].indexOf(qmpstatus) !== -1;
if (resume || lock === 'suspended') {
startBtn.setVisible(false);
resumeBtn.setVisible(true);
} else {
startBtn.setVisible(true);
resumeBtn.setVisible(false);
}
consoleBtn.setEnableSpice(spice);
consoleBtn.setEnableXtermJS(xtermjs);
statusTxt.update({ lock: lock });
let guest_running = status === 'running' &&
!(qmpstatus === "shutdown" || qmpstatus === "prelaunch");
startBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || template || guest_running);
shutdownBtn.setDisabled(!caps.vms['VM.PowerMgmt'] || status !== 'running');
me.down('#removeBtn').setDisabled(!caps.vms['VM.Allocate'] || status !== 'stopped');
consoleBtn.setDisabled(template);
let wasStopped = ['prelaunch', 'stopped', 'suspended'].indexOf(prevQMPStatus) !== -1;
if (wasStopped && qmpstatus === 'running') {
let con = me.down('#console');
if (con) {
con.reload();
}
}
prevQMPStatus = qmpstatus;
});
me.on('afterrender', function() {
me.statusStore.startUpdate();
});
me.on('destroy', function() {
me.statusStore.stopUpdate();
});
},
});
Ext.define('PVE.qemu.CreateWizard', {
extend: 'PVE.window.Wizard',
alias: 'widget.pveQemuCreateWizard',
mixins: ['Proxmox.Mixin.CBind'],
viewModel: {
data: {
nodename: '',
current: {
scsihw: '',
},
},
formulas: {
cgroupMode: function(get) {
const nodeInfo = PVE.data.ResourceStore.getNodes().find(
node => node.node === get('nodename'),
);
return nodeInfo ? nodeInfo['cgroup-mode'] : 2;
},
},
},
cbindData: {
nodename: undefined,
},
subject: gettext('Virtual Machine'),
// fot the special case that we have 2 cdrom drives
//
// emulates part of the backend bootorder logic, but includes all
// cdrom drives since we don't know which one the user put in a bootable iso
// and hardcodes the known values (ide0/2, net0)
calculateBootOrder: function(values) {
// user selected windows + second cdrom
if (values.ide0 && values.ide0.match(/media=cdrom/)) {
let disk;
PVE.Utils.forEachBus(['ide', 'scsi', 'virtio', 'sata'], (type, id) => {
let confId = type + id;
if (!values[confId]) {
return undefined;
}
if (values[confId].match(/media=cdrom/)) {
return undefined;
}
disk = confId;
return false; // abort loop
});
let order = [];
if (disk) {
order.push(disk);
}
order.push('ide0', 'ide2');
if (values.net0) {
order.push('net0');
}
return `order=${order.join(';')}`;
}
return undefined;
},
items: [
{
xtype: 'inputpanel',
title: gettext('General'),
onlineHelp: 'qm_general_settings',
column1: [
{
xtype: 'pveNodeSelector',
name: 'nodename',
cbind: {
selectCurNode: '{!nodename}',
preferredValue: '{nodename}',
},
bind: {
value: '{nodename}',
},
fieldLabel: gettext('Node'),
allowBlank: false,
onlineValidator: true,
},
{
xtype: 'pveGuestIDSelector',
name: 'vmid',
guestType: 'qemu',
value: '',
loadNextFreeID: true,
validateExists: false,
},
{
xtype: 'textfield',
name: 'name',
vtype: 'DnsName',
value: '',
fieldLabel: gettext('Name'),
allowBlank: true,
},
],
column2: [
{
xtype: 'pvePoolSelector',
fieldLabel: gettext('Resource Pool'),
name: 'pool',
value: '',
allowBlank: true,
},
],
advancedColumn1: [
{
xtype: 'proxmoxcheckbox',
name: 'onboot',
uncheckedValue: 0,
defaultValue: 0,
deleteDefaultValue: true,
fieldLabel: gettext('Start at boot'),
},
],
advancedColumn2: [
{
xtype: 'textfield',
name: 'order',
defaultValue: '',
emptyText: 'any',
labelWidth: 120,
fieldLabel: gettext('Start/Shutdown order'),
},
{
xtype: 'textfield',
name: 'up',
defaultValue: '',
emptyText: 'default',
labelWidth: 120,
fieldLabel: gettext('Startup delay'),
},
{
xtype: 'textfield',
name: 'down',
defaultValue: '',
emptyText: 'default',
labelWidth: 120,
fieldLabel: gettext('Shutdown timeout'),
},
],
advancedColumnB: [
{
xtype: 'pveTagFieldSet',
name: 'tags',
maxHeight: 150,
},
],
onGetValues: function(values) {
['name', 'pool', 'onboot', 'agent'].forEach(function(field) {
if (!values[field]) {
delete values[field];
}
});
var res = PVE.Parser.printStartup({
order: values.order,
up: values.up,
down: values.down,
});
if (res) {
values.startup = res;
}
delete values.order;
delete values.up;
delete values.down;
return values;
},
},
{
xtype: 'container',
layout: 'hbox',
defaults: {
flex: 1,
padding: '0 10',
},
title: gettext('OS'),
items: [
{
xtype: 'pveQemuCDInputPanel',
bind: {
nodename: '{nodename}',
},
confid: 'ide2',
insideWizard: true,
},
{
xtype: 'pveQemuOSTypePanel',
insideWizard: true,
bind: {
nodename: '{nodename}',
},
},
],
},
{
xtype: 'pveQemuSystemPanel',
title: gettext('System'),
isCreate: true,
insideWizard: true,
},
{
xtype: 'pveMultiHDPanel',
bind: {
nodename: '{nodename}',
},
title: gettext('Disks'),
},
{
xtype: 'pveQemuProcessorPanel',
insideWizard: true,
title: gettext('CPU'),
},
{
xtype: 'pveQemuMemoryPanel',
insideWizard: true,
title: gettext('Memory'),
},
{
xtype: 'pveQemuNetworkInputPanel',
bind: {
nodename: '{nodename}',
},
title: gettext('Network'),
insideWizard: true,
},
{
title: gettext('Confirm'),
layout: 'fit',
items: [
{
xtype: 'grid',
store: {
model: 'KeyValue',
sorters: [{
property: 'key',
direction: 'ASC',
}],
},
columns: [
{ header: 'Key', width: 150, dataIndex: 'key' },
{ header: 'Value', flex: 1, dataIndex: 'value', renderer: Ext.htmlEncode },
],
},
],
dockedItems: [
{
xtype: 'proxmoxcheckbox',
name: 'start',
dock: 'bottom',
margin: '5 0 0 0',
boxLabel: gettext('Start after created'),
},
],
listeners: {
show: function(panel) {
let wizard = this.up('window');
var kv = wizard.getValues();
var data = [];
let boot = wizard.calculateBootOrder(kv);
if (boot) {
kv.boot = boot;
}
Ext.Object.each(kv, function(key, value) {
if (key === 'delete') { // ignore
return;
}
data.push({ key: key, value: value });
});
var summarystore = panel.down('grid').getStore();
summarystore.suspendEvents();
summarystore.removeAll();
summarystore.add(data);
summarystore.sort();
summarystore.resumeEvents();
summarystore.fireEvent('refresh');
},
},
onSubmit: function() {
var wizard = this.up('window');
var kv = wizard.getValues();
delete kv.delete;
var nodename = kv.nodename;
delete kv.nodename;
let boot = wizard.calculateBootOrder(kv);
if (boot) {
kv.boot = boot;
}
Proxmox.Utils.API2Request({
url: '/nodes/' + nodename + '/qemu',
waitMsgTarget: wizard,
method: 'POST',
params: kv,
success: function(response) {
wizard.close();
},
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
});
},
},
],
});
Ext.define('PVE.qemu.DisplayInputPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveDisplayInputPanel',
onlineHelp: 'qm_display',
onGetValues: function(values) {
let ret = PVE.Parser.printPropertyString(values, 'type');
if (ret === '') {
return { 'delete': 'vga' };
}
return { vga: ret };
},
viewModel: {
data: {
type: '__default__',
clipboard: '__default__',
},
formulas: {
matchNonGUIOption: function(get) {
return get('type').match(/^(serial\d|none)$/);
},
memoryEmptyText: function(get) {
let val = get('type');
if (val === "cirrus") {
return "4";
} else if (val === "std" || val.match(/^qxl\d?$/) || val === "vmware") {
return "16";
} else if (val.match(/^virtio/)) {
return "256";
} else if (get('matchNonGUIOption')) {
return "N/A";
} else {
console.debug("unexpected display type", val);
return Proxmox.Utils.defaultText;
}
},
isVNC: get => get('clipboard') === 'vnc',
hideDefaultHint: get => get('isVNC') || get('matchNonGUIOption'),
hideVNCHint: get => !get('isVNC') || get('matchNonGUIOption'),
},
},
items: [{
name: 'type',
xtype: 'proxmoxKVComboBox',
value: '__default__',
deleteEmpty: false,
fieldLabel: gettext('Graphic card'),
comboItems: Object.entries(PVE.Utils.kvm_vga_drivers),
validator: function(v) {
let cfg = this.up('proxmoxWindowEdit').vmconfig || {};
if (v.match(/^serial\d+$/) && (!cfg[v] || cfg[v] !== 'socket')) {
let fmt = gettext("Serial interface '{0}' is not correctly configured.");
return Ext.String.format(fmt, v);
}
return true;
},
bind: {
value: '{type}',
},
},
{
xtype: 'proxmoxintegerfield',
emptyText: Proxmox.Utils.defaultText,
fieldLabel: gettext('Memory') + ' (MiB)',
minValue: 4,
maxValue: 512,
step: 4,
name: 'memory',
bind: {
emptyText: '{memoryEmptyText}',
disabled: '{matchNonGUIOption}',
},
}],
advancedItems: [
{
xtype: 'proxmoxKVComboBox',
name: 'clipboard',
deleteEmpty: false,
value: '__default__',
fieldLabel: gettext('Clipboard'),
comboItems: [
['__default__', Proxmox.Utils.defaultText],
['vnc', 'VNC'],
],
bind: {
value: '{clipboard}',
disabled: '{matchNonGUIOption}',
},
},
{
xtype: 'displayfield',
name: 'vncHint',
userCls: 'pmx-hint',
value: gettext('You cannot use the default SPICE clipboard if the VNC clipboard is selected.') + ' ' +
gettext('VNC clipboard requires spice-tools installed in the Guest-VM.'),
bind: {
hidden: '{hideVNCHint}',
},
},
{
xtype: 'displayfield',
name: 'vncMigration',
userCls: 'pmx-hint',
value: gettext('You cannot live-migrate while using the VNC clipboard.'),
bind: {
hidden: '{hideVNCHint}',
},
},
{
xtype: 'displayfield',
name: 'defaultHint',
userCls: 'pmx-hint',
value: gettext('This option depends on your display type.') + ' ' +
gettext('If the display type uses SPICE you are able to use the default SPICE clipboard.'),
bind: {
hidden: '{hideDefaultHint}',
},
},
],
});
Ext.define('PVE.qemu.DisplayEdit', {
extend: 'Proxmox.window.Edit',
vmconfig: undefined,
subject: gettext('Display'),
width: 350,
items: [{
xtype: 'pveDisplayInputPanel',
}],
initComponent: function() {
let me = this;
me.callParent();
me.load({
success: function(response) {
me.vmconfig = response.result.data;
let vga = me.vmconfig.vga || '__default__';
me.setValues(PVE.Parser.parsePropertyString(vga, 'type'));
},
});
},
});
/* 'change' property is assigned a string and then a function */
Ext.define('PVE.qemu.HDInputPanel', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pveQemuHDInputPanel',
onlineHelp: 'qm_hard_disk',
insideWizard: false,
unused: false, // ADD usused disk imaged
vmconfig: {}, // used to select usused disks
viewModel: {
data: {
isSCSI: false,
isVirtIO: false,
isSCSISingle: false,
},
},
controller: {
xclass: 'Ext.app.ViewController',
onControllerChange: function(field) {
let me = this;
let vm = this.getViewModel();
let value = field.getValue();
vm.set('isSCSI', value.match(/^scsi/));
vm.set('isVirtIO', value.match(/^virtio/));
me.fireIdChange();
},
fireIdChange: function() {
let view = this.getView();
view.fireEvent('diskidchange', view, view.bussel.getConfId());
},
control: {
'field[name=controller]': {
change: 'onControllerChange',
afterrender: 'onControllerChange',
},
'field[name=deviceid]': {
change: 'fireIdChange',
},
'field[name=scsiController]': {
change: function(f, value) {
let vm = this.getViewModel();
vm.set('isSCSISingle', value === 'virtio-scsi-single');
},
},
},
init: function(view) {
var vm = this.getViewModel();
if (view.isCreate) {
vm.set('isIncludedInBackup', true);
}
if (view.confid) {
vm.set('isSCSI', view.confid.match(/^scsi/));
vm.set('isVirtIO', view.confid.match(/^virtio/));
}
},
},
onGetValues: function(values) {
var me = this;
var params = {};
var confid = me.confid || values.controller + values.deviceid;
if (me.unused) {
me.drive.file = me.vmconfig[values.unusedId];
confid = values.controller + values.deviceid;
} else if (me.isCreate) {
if (values.hdimage) {
me.drive.file = values.hdimage;
} else {
me.drive.file = values.hdstorage + ":" + values.disksize;
}
me.drive.format = values.diskformat;
}
PVE.Utils.propertyStringSet(me.drive, !values.backup, 'backup', '0');
PVE.Utils.propertyStringSet(me.drive, values.noreplicate, 'replicate', 'no');
PVE.Utils.propertyStringSet(me.drive, values.discard, 'discard', 'on');
PVE.Utils.propertyStringSet(me.drive, values.ssd, 'ssd', 'on');
PVE.Utils.propertyStringSet(me.drive, values.iothread, 'iothread', 'on');
PVE.Utils.propertyStringSet(me.drive, values.readOnly, 'ro', 'on');
PVE.Utils.propertyStringSet(me.drive, values.cache, 'cache');
PVE.Utils.propertyStringSet(me.drive, values.aio, 'aio');
['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr'].forEach(name => {
let burst_name = `${name}_max`;
PVE.Utils.propertyStringSet(me.drive, values[name], name);
PVE.Utils.propertyStringSet(me.drive, values[burst_name], burst_name);
});
params[confid] = PVE.Parser.printQemuDrive(me.drive);
return params;
},
updateVMConfig: function(vmconfig) {
var me = this;
me.vmconfig = vmconfig;
me.bussel?.updateVMConfig(vmconfig);
},
setVMConfig: function(vmconfig) {
var me = this;
me.vmconfig = vmconfig;
if (me.bussel) {
me.bussel.setVMConfig(vmconfig);
me.scsiController.setValue(vmconfig.scsihw);
}
if (me.unusedDisks) {
var disklist = [];
Ext.Object.each(vmconfig, function(key, value) {
if (key.match(/^unused\d+$/)) {
disklist.push([key, value]);
}
});
me.unusedDisks.store.loadData(disklist);
me.unusedDisks.setValue(me.confid);
}
},
setDrive: function(drive) {
var me = this;
me.drive = drive;
var values = {};
var match = drive.file.match(/^([^:]+):/);
if (match) {
values.hdstorage = match[1];
}
values.hdimage = drive.file;
values.backup = PVE.Parser.parseBoolean(drive.backup, 1);
values.noreplicate = !PVE.Parser.parseBoolean(drive.replicate, 1);
values.diskformat = drive.format || 'raw';
values.cache = drive.cache || '__default__';
values.discard = drive.discard === 'on';
values.ssd = PVE.Parser.parseBoolean(drive.ssd);
values.iothread = PVE.Parser.parseBoolean(drive.iothread);
values.readOnly = PVE.Parser.parseBoolean(drive.ro);
values.aio = drive.aio || '__default__';
values.mbps_rd = drive.mbps_rd;
values.mbps_wr = drive.mbps_wr;
values.iops_rd = drive.iops_rd;
values.iops_wr = drive.iops_wr;
values.mbps_rd_max = drive.mbps_rd_max;
values.mbps_wr_max = drive.mbps_wr_max;
values.iops_rd_max = drive.iops_rd_max;
values.iops_wr_max = drive.iops_wr_max;
me.setValues(values);
},
setNodename: function(nodename) {
var me = this;
me.down('#hdstorage').setNodename(nodename);
me.down('#hdimage').setStorage(undefined, nodename);
},
hasAdvanced: true,
initComponent: function() {
var me = this;
me.drive = {};
let column1 = [];
let column2 = [];
let advancedColumn1 = [];
let advancedColumn2 = [];
if (!me.confid || me.unused) {
me.bussel = Ext.create('PVE.form.ControllerSelector', {
vmconfig: me.vmconfig,
selectFree: true,
});
column1.push(me.bussel);
me.scsiController = Ext.create('Ext.form.field.Display', {
fieldLabel: gettext('SCSI Controller'),
reference: 'scsiController',
name: 'scsiController',
bind: me.insideWizard ? {
value: '{current.scsihw}',
visible: '{isSCSI}',
} : {
visible: '{isSCSI}',
},
renderer: PVE.Utils.render_scsihw,
submitValue: false,
hidden: true,
});
column1.push(me.scsiController);
}
if (me.unused) {
me.unusedDisks = Ext.create('Proxmox.form.KVComboBox', {
name: 'unusedId',
fieldLabel: gettext('Disk image'),
matchFieldWidth: false,
listConfig: {
width: 350,
},
data: [],
allowBlank: false,
});
column1.push(me.unusedDisks);
} else if (me.isCreate) {
column1.push({
xtype: 'pveDiskStorageSelector',
storageContent: 'images',
name: 'disk',
nodename: me.nodename,
autoSelect: me.insideWizard,
});
} else {
column1.push({
xtype: 'textfield',
disabled: true,
submitValue: false,
fieldLabel: gettext('Disk image'),
name: 'hdimage',
});
}
column2.push(
{
xtype: 'CacheTypeSelector',
name: 'cache',
value: '__default__',
fieldLabel: gettext('Cache'),
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Discard'),
reference: 'discard',
name: 'discard',
},
{
xtype: 'proxmoxcheckbox',
name: 'iothread',
fieldLabel: 'IO thread',
clearOnDisable: true,
bind: me.insideWizard || me.isCreate ? {
disabled: '{!isVirtIO && !isSCSI}',
// Checkbox.setValue handles Arrays in a different way, therefore cast to bool
value: '{!!isVirtIO || (isSCSI && isSCSISingle)}',
} : {
disabled: '{!isVirtIO && !isSCSI}',
},
},
);
advancedColumn1.push(
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('SSD emulation'),
name: 'ssd',
clearOnDisable: true,
bind: {
disabled: '{isVirtIO}',
},
},
{
xtype: 'proxmoxcheckbox',
name: 'readOnly', // `ro` in the config, we map in get/set values
defaultValue: 0,
fieldLabel: gettext('Read-only'),
clearOnDisable: true,
bind: {
disabled: '{!isVirtIO && !isSCSI}',
},
},
);
advancedColumn2.push(
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Backup'),
autoEl: {
tag: 'div',
'data-qtip': gettext('Include volume in backup job'),
},
name: 'backup',
bind: {
value: '{isIncludedInBackup}',
},
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Skip replication'),
name: 'noreplicate',
},
{
xtype: 'proxmoxKVComboBox',
name: 'aio',
fieldLabel: gettext('Async IO'),
allowBlank: false,
value: '__default__',
comboItems: [
['__default__', Proxmox.Utils.defaultText + ' (io_uring)'],
['io_uring', 'io_uring'],
['native', 'native'],
['threads', 'threads'],
],
},
);
let labelWidth = 140;
let bwColumn1 = [
{
xtype: 'numberfield',
name: 'mbps_rd',
minValue: 1,
step: 1,
fieldLabel: gettext('Read limit') + ' (MB/s)',
labelWidth: labelWidth,
emptyText: gettext('unlimited'),
},
{
xtype: 'numberfield',
name: 'mbps_wr',
minValue: 1,
step: 1,
fieldLabel: gettext('Write limit') + ' (MB/s)',
labelWidth: labelWidth,
emptyText: gettext('unlimited'),
},
{
xtype: 'proxmoxintegerfield',
name: 'iops_rd',
minValue: 10,
step: 10,
fieldLabel: gettext('Read limit') + ' (ops/s)',
labelWidth: labelWidth,
emptyText: gettext('unlimited'),
},
{
xtype: 'proxmoxintegerfield',
name: 'iops_wr',
minValue: 10,
step: 10,
fieldLabel: gettext('Write limit') + ' (ops/s)',
labelWidth: labelWidth,
emptyText: gettext('unlimited'),
},
];
let bwColumn2 = [
{
xtype: 'numberfield',
name: 'mbps_rd_max',
minValue: 1,
step: 1,
fieldLabel: gettext('Read max burst') + ' (MB)',
labelWidth: labelWidth,
emptyText: gettext('default'),
},
{
xtype: 'numberfield',
name: 'mbps_wr_max',
minValue: 1,
step: 1,
fieldLabel: gettext('Write max burst') + ' (MB)',
labelWidth: labelWidth,
emptyText: gettext('default'),
},
{
xtype: 'proxmoxintegerfield',
name: 'iops_rd_max',
minValue: 10,
step: 10,
fieldLabel: gettext('Read max burst') + ' (ops)',
labelWidth: labelWidth,
emptyText: gettext('default'),
},
{
xtype: 'proxmoxintegerfield',
name: 'iops_wr_max',
minValue: 10,
step: 10,
fieldLabel: gettext('Write max burst') + ' (ops)',
labelWidth: labelWidth,
emptyText: gettext('default'),
},
];
me.items = [
{
xtype: 'tabpanel',
plain: true,
bodyPadding: 10,
border: 0,
items: [
{
title: gettext('Disk'),
xtype: 'inputpanel',
reference: 'diskpanel',
column1,
column2,
advancedColumn1,
advancedColumn2,
showAdvanced: me.showAdvanced,
getValues: () => ({}),
},
{
title: gettext('Bandwidth'),
xtype: 'inputpanel',
reference: 'bwpanel',
column1: bwColumn1,
column2: bwColumn2,
showAdvanced: me.showAdvanced,
getValues: () => ({}),
},
],
},
];
me.callParent();
},
setAdvancedVisible: function(visible) {
this.lookup('diskpanel').setAdvancedVisible(visible);
this.lookup('bwpanel').setAdvancedVisible(visible);
},
});
Ext.define('PVE.qemu.HDEdit', {
extend: 'Proxmox.window.Edit',
isAdd: true,
backgroundDelay: 5,
width: 600,
bodyPadding: 0,
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
var unused = me.confid && me.confid.match(/^unused\d+$/);
me.isCreate = me.confid ? unused : true;
var ipanel = Ext.create('PVE.qemu.HDInputPanel', {
confid: me.confid,
nodename: nodename,
unused: unused,
isCreate: me.isCreate,
});
if (unused) {
me.subject = gettext('Unused Disk');
} else if (me.isCreate) {
me.subject = gettext('Hard Disk');
} else {
me.subject = gettext('Hard Disk') + ' (' + me.confid + ')';
}
me.items = [ipanel];
me.callParent();
/* 'data' is assigned an empty array in same file, and here we
* use it like an object
*/
me.load({
success: function(response, options) {
ipanel.setVMConfig(response.result.data);
if (me.confid) {
var value = response.result.data[me.confid];
var drive = PVE.Parser.parseQemuDrive(me.confid, value);
if (!drive) {
Ext.Msg.alert(gettext('Error'), 'Unable to parse drive options');
me.close();
return;
}
ipanel.setDrive(drive);
me.isValid(); // trigger validation
}
},
});
},
});
Ext.define('PVE.qemu.EFIDiskInputPanel', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pveEFIDiskInputPanel',
insideWizard: false,
unused: false, // ADD usused disk imaged
vmconfig: {}, // used to select usused disks
onGetValues: function(values) {
var me = this;
if (me.disabled) {
return {};
}
var confid = 'efidisk0';
if (values.hdimage) {
me.drive.file = values.hdimage;
} else {
// we use 1 here, because for efi the size gets overridden from the backend
me.drive.file = values.hdstorage + ":1";
}
// always default to newer 4m type with secure boot support, if we're
// adding a new EFI disk there can't be any old state anyway
me.drive.efitype = '4m';
me.drive['pre-enrolled-keys'] = values.preEnrolledKeys;
delete values.preEnrolledKeys;
me.drive.format = values.diskformat;
let params = {};
params[confid] = PVE.Parser.printQemuDrive(me.drive);
return params;
},
setNodename: function(nodename) {
var me = this;
me.down('#hdstorage').setNodename(nodename);
me.down('#hdimage').setStorage(undefined, nodename);
},
setDisabled: function(disabled) {
let me = this;
me.down('pveDiskStorageSelector').setDisabled(disabled);
me.down('proxmoxcheckbox[name=preEnrolledKeys]').setDisabled(disabled);
me.callParent(arguments);
},
initComponent: function() {
var me = this;
me.drive = {};
me.items = [
{
xtype: 'pveDiskStorageSelector',
name: 'efidisk0',
storageLabel: gettext('EFI Storage'),
storageContent: 'images',
nodename: me.nodename,
disabled: me.disabled,
hideSize: true,
},
{
xtype: 'proxmoxcheckbox',
name: 'preEnrolledKeys',
checked: true,
fieldLabel: gettext("Pre-Enroll keys"),
disabled: me.disabled,
//boxLabel: '(e.g., Microsoft secure-boot keys')',
autoEl: {
tag: 'div',
'data-qtip': gettext('Use EFIvars image with standard distribution and Microsoft secure boot keys enrolled.'),
},
},
{
xtype: 'label',
text: gettext("Warning: The VM currently does not uses 'OVMF (UEFI)' as BIOS."),
userCls: 'pmx-hint',
hidden: me.usesEFI,
},
];
me.callParent();
},
});
Ext.define('PVE.qemu.EFIDiskEdit', {
extend: 'Proxmox.window.Edit',
isAdd: true,
subject: gettext('EFI Disk'),
width: 450,
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
me.items = [{
xtype: 'pveEFIDiskInputPanel',
onlineHelp: 'qm_bios_and_uefi',
confid: me.confid,
nodename: nodename,
usesEFI: me.usesEFI,
isCreate: true,
}];
me.callParent();
},
});
Ext.define('PVE.qemu.TPMDiskInputPanel', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pveTPMDiskInputPanel',
unused: false,
vmconfig: {},
onGetValues: function(values) {
var me = this;
if (me.disabled) {
return {};
}
var confid = 'tpmstate0';
if (values.hdimage) {
me.drive.file = values.hdimage;
} else {
// size is constant, so just use 1
me.drive.file = values.hdstorage + ":1";
}
me.drive.version = values.version;
var params = {};
params[confid] = PVE.Parser.printQemuDrive(me.drive);
return params;
},
setNodename: function(nodename) {
var me = this;
me.down('#hdstorage').setNodename(nodename);
me.down('#hdimage').setStorage(undefined, nodename);
},
setDisabled: function(disabled) {
let me = this;
me.down('pveDiskStorageSelector').setDisabled(disabled);
me.down('proxmoxKVComboBox[name=version]').setDisabled(disabled);
me.callParent(arguments);
},
initComponent: function() {
var me = this;
me.drive = {};
me.items = [
{
xtype: 'pveDiskStorageSelector',
name: me.disktype + '0',
storageLabel: gettext('TPM Storage'),
storageContent: 'images',
nodename: me.nodename,
disabled: me.disabled,
hideSize: true,
hideFormat: true,
},
{
xtype: 'proxmoxKVComboBox',
name: 'version',
value: 'v2.0',
fieldLabel: gettext('Version'),
deleteEmpty: false,
disabled: me.disabled,
comboItems: [
['v1.2', 'v1.2'],
['v2.0', 'v2.0'],
],
},
];
me.callParent();
},
});
Ext.define('PVE.qemu.TPMDiskEdit', {
extend: 'Proxmox.window.Edit',
isAdd: true,
subject: gettext('TPM State'),
width: 450,
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
me.items = [{
xtype: 'pveTPMDiskInputPanel',
//onlineHelp: 'qm_tpm', FIXME: add once available
confid: me.confid,
nodename: nodename,
isCreate: true,
}];
me.callParent();
},
});
Ext.define('PVE.window.HDMove', {
extend: 'Proxmox.window.Edit',
mixins: ['Proxmox.Mixin.CBind'],
resizable: false,
modal: true,
width: 350,
border: false,
layout: 'fit',
showReset: false,
showTaskViewer: true,
method: 'POST',
cbindData: function() {
let me = this;
return {
disk: me.disk,
isQemu: me.type === 'qemu',
nodename: me.nodename,
url: () => {
let endpoint = me.type === 'qemu' ? 'move_disk' : 'move_volume';
return `/nodes/${me.nodename}/${me.type}/${me.vmid}/${endpoint}`;
},
};
},
cbind: {
title: get => get('isQemu') ? gettext("Move disk") : gettext('Move Volume'),
submitText: get => get('title'),
qemu: '{isQemu}',
url: '{url}',
},
getValues: function() {
let me = this;
let values = me.formPanel.getForm().getValues();
let params = {
storage: values.hdstorage,
};
params[me.qemu ? 'disk' : 'volume'] = me.disk;
if (values.diskformat && me.qemu) {
params.format = values.diskformat;
}
if (values.deleteDisk) {
params.delete = 1;
}
return params;
},
items: [
{
xtype: 'form',
reference: 'moveFormPanel',
border: false,
fieldDefaults: {
labelWidth: 100,
anchor: '100%',
},
items: [
{
xtype: 'displayfield',
cbind: {
name: get => get('isQemu') ? 'disk' : 'volume',
fieldLabel: get => get('isQemu') ? gettext('Disk') : gettext('Mount Point'),
value: '{disk}',
},
allowBlank: false,
},
{
xtype: 'pveDiskStorageSelector',
storageLabel: gettext('Target Storage'),
cbind: {
nodename: '{nodename}',
storageContent: get => get('isQemu') ? 'images' : 'rootdir',
hideFormat: get => get('disk') === 'tpmstate0',
},
hideSize: true,
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Delete source'),
name: 'deleteDisk',
uncheckedValue: 0,
checked: false,
},
],
},
],
initComponent: function() {
let me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (!me.vmid) {
throw "no VM ID specified";
}
if (!me.type) {
throw "no type specified";
}
me.callParent();
},
});
Ext.define('PVE.window.HDResize', {
extend: 'Ext.window.Window',
resizable: false,
resize_disk: function(disk, size) {
var me = this;
var params = { disk: disk, size: '+' + size + 'G' };
Proxmox.Utils.API2Request({
params: params,
url: '/nodes/' + me.nodename + '/qemu/' + me.vmid + '/resize',
waitMsgTarget: me,
method: 'PUT',
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
success: function(response, options) {
Ext.create('Proxmox.window.TaskProgress', {
autoShow: true,
upid: response.result.data,
});
me.close();
},
});
},
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (!me.vmid) {
throw "no VM ID specified";
}
var items = [
{
xtype: 'displayfield',
name: 'disk',
value: me.disk,
fieldLabel: gettext('Disk'),
vtype: 'StorageId',
allowBlank: false,
},
];
me.hdsizesel = Ext.createWidget('numberfield', {
name: 'size',
minValue: 0,
maxValue: 128*1024,
decimalPrecision: 3,
value: '0',
fieldLabel: `${gettext('Size Increment')} (${gettext('GiB')})`,
allowBlank: false,
});
items.push(me.hdsizesel);
me.formPanel = Ext.create('Ext.form.Panel', {
bodyPadding: 10,
border: false,
fieldDefaults: {
labelWidth: 140,
anchor: '100%',
},
items: items,
});
var form = me.formPanel.getForm();
var submitBtn;
me.title = gettext('Resize disk');
submitBtn = Ext.create('Ext.Button', {
text: gettext('Resize disk'),
handler: function() {
if (form.isValid()) {
var values = form.getValues();
me.resize_disk(me.disk, values.size);
}
},
});
Ext.apply(me, {
modal: true,
width: 250,
height: 150,
border: false,
layout: 'fit',
buttons: [submitBtn],
items: [me.formPanel],
});
me.callParent();
},
});
Ext.define('PVE.qemu.HardwareView', {
extend: 'Proxmox.grid.PendingObjectGrid',
alias: ['widget.PVE.qemu.HardwareView'],
onlineHelp: 'qm_virtual_machines_settings',
renderKey: function(key, metaData, rec, rowIndex, colIndex, store) {
var me = this;
var rows = me.rows;
var rowdef = rows[key] || {};
var iconCls = rowdef.iconCls;
var icon = '';
var txt = rowdef.header || key;
metaData.tdAttr = "valign=middle";
if (rowdef.isOnStorageBus) {
var value = me.getObjectValue(key, '', false);
if (value === '') {
value = me.getObjectValue(key, '', true);
}
if (value.match(/vm-.*-cloudinit/)) {
iconCls = 'cloud';
txt = rowdef.cloudheader;
} else if (value.match(/media=cdrom/)) {
metaData.tdCls = 'pve-itype-icon-cdrom';
return rowdef.cdheader;
}
}
if (rowdef.tdCls) {
metaData.tdCls = rowdef.tdCls;
} else if (iconCls) {
icon = "<i class='pve-grid-fa fa fa-fw fa-" + iconCls + "'></i>";
metaData.tdCls += " pve-itype-fa";
}
// only return icons in grid but not remove dialog
if (rowIndex !== undefined) {
return icon + txt;
} else {
return txt;
}
},
initComponent: function() {
var me = this;
const { node: nodename, vmid } = me.pveSelNode.data;
if (!nodename) {
throw "no node name specified";
} else if (!vmid) {
throw "no VM ID specified";
}
const caps = Ext.state.Manager.get('GuiCap');
const diskCap = caps.vms['VM.Config.Disk'];
const cdromCap = caps.vms['VM.Config.CDROM'];
let isCloudInitKey = v => v && v.toString().match(/vm-.*-cloudinit/);
const nodeInfo = PVE.data.ResourceStore.getNodes().find(node => node.node === nodename);
let processorEditor = {
xtype: 'pveQemuProcessorEdit',
cgroupMode: nodeInfo['cgroup-mode'],
};
let rows = {
memory: {
header: gettext('Memory'),
editor: caps.vms['VM.Config.Memory'] ? 'PVE.qemu.MemoryEdit' : undefined,
never_delete: true,
defaultValue: '512',
tdCls: 'pve-itype-icon-memory',
group: 2,
multiKey: ['memory', 'balloon', 'shares'],
renderer: function(value, metaData, record, ri, ci, store, pending) {
var res = '';
var max = me.getObjectValue('memory', 512, pending);
var balloon = me.getObjectValue('balloon', undefined, pending);
var shares = me.getObjectValue('shares', undefined, pending);
res = Proxmox.Utils.format_size(max*1024*1024);
if (balloon !== undefined && balloon > 0) {
res = Proxmox.Utils.format_size(balloon*1024*1024) + "/" + res;
if (shares) {
res += ' [shares=' + shares +']';
}
} else if (balloon === 0) {
res += ' [balloon=0]';
}
return res;
},
},
sockets: {
header: gettext('Processors'),
never_delete: true,
editor: caps.vms['VM.Config.CPU'] || caps.vms['VM.Config.HWType']
? processorEditor : undefined,
tdCls: 'pve-itype-icon-cpu',
group: 3,
defaultValue: '1',
multiKey: ['sockets', 'cpu', 'cores', 'numa', 'vcpus', 'cpulimit', 'cpuunits', 'affinity'],
renderer: function(value, metaData, record, rowIndex, colIndex, store, pending) {
var sockets = me.getObjectValue('sockets', 1, pending);
var model = me.getObjectValue('cpu', undefined, pending);
var cores = me.getObjectValue('cores', 1, pending);
var numa = me.getObjectValue('numa', undefined, pending);
var vcpus = me.getObjectValue('vcpus', undefined, pending);
var cpulimit = me.getObjectValue('cpulimit', undefined, pending);
var cpuunits = me.getObjectValue('cpuunits', undefined, pending);
var cpuaffinity = me.getObjectValue('affinity', undefined, pending);
let res = Ext.String.format(
'{0} ({1} sockets, {2} cores)', sockets * cores, sockets, cores);
if (model) {
res += ' [' + model + ']';
}
if (numa) {
res += ' [numa=' + numa +']';
}
if (vcpus) {
res += ' [vcpus=' + vcpus +']';
}
if (cpulimit) {
res += ' [cpulimit=' + cpulimit +']';
}
if (cpuunits) {
res += ' [cpuunits=' + cpuunits +']';
}
if (cpuaffinity) {
res += ' [cpuaffinity=' + cpuaffinity + ']';
}
return res;
},
},
bios: {
header: 'BIOS',
group: 4,
never_delete: true,
editor: caps.vms['VM.Config.Options'] ? 'PVE.qemu.BiosEdit' : undefined,
defaultValue: '',
iconCls: 'microchip',
renderer: PVE.Utils.render_qemu_bios,
},
vga: {
header: gettext('Display'),
editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.DisplayEdit' : undefined,
never_delete: true,
iconCls: 'desktop',
group: 5,
defaultValue: '',
renderer: PVE.Utils.render_kvm_vga_driver,
},
machine: {
header: gettext('Machine'),
editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.MachineEdit' : undefined,
iconCls: 'cogs',
never_delete: true,
group: 6,
defaultValue: '',
renderer: function(value, metaData, record, rowIndex, colIndex, store, pending) {
let ostype = me.getObjectValue('ostype', undefined, pending);
if (PVE.Utils.is_windows(ostype) &&
(!value || value === 'pc' || value === 'q35')) {
return value === 'q35' ? 'pc-q35-5.1' : 'pc-i440fx-5.1';
}
return PVE.Utils.render_qemu_machine(value);
},
},
scsihw: {
header: gettext('SCSI Controller'),
iconCls: 'database',
editor: caps.vms['VM.Config.Options'] ? 'PVE.qemu.ScsiHwEdit' : undefined,
renderer: PVE.Utils.render_scsihw,
group: 7,
never_delete: true,
defaultValue: '',
},
vmstate: {
header: gettext('Hibernation VM State'),
iconCls: 'download',
del_extra_msg: gettext('The saved VM state will be permanently lost.'),
group: 100,
},
cores: {
visible: false,
},
cpu: {
visible: false,
},
numa: {
visible: false,
},
balloon: {
visible: false,
},
hotplug: {
visible: false,
},
vcpus: {
visible: false,
},
cpuunits: {
visible: false,
},
cpulimit: {
visible: false,
},
shares: {
visible: false,
},
ostype: {
visible: false,
},
affinity: {
visible: false,
},
};
PVE.Utils.forEachBus(undefined, function(type, id) {
let confid = type + id;
rows[confid] = {
group: 10,
iconCls: 'hdd-o',
editor: 'PVE.qemu.HDEdit',
isOnStorageBus: true,
header: gettext('Hard Disk') + ' (' + confid +')',
cdheader: gettext('CD/DVD Drive') + ' (' + confid +')',
cloudheader: gettext('CloudInit Drive') + ' (' + confid + ')',
renderer: Ext.htmlEncode,
};
});
for (let i = 0; i < PVE.Utils.hardware_counts.net; i++) {
let confid = "net" + i.toString();
rows[confid] = {
group: 15,
order: i,
iconCls: 'exchange',
editor: caps.vms['VM.Config.Network'] ? 'PVE.qemu.NetworkEdit' : undefined,
never_delete: !caps.vms['VM.Config.Network'],
header: gettext('Network Device') + ' (' + confid +')',
};
}
rows.efidisk0 = {
group: 20,
iconCls: 'hdd-o',
editor: null,
never_delete: !caps.vms['VM.Config.Disk'],
header: gettext('EFI Disk'),
renderer: Ext.htmlEncode,
};
rows.tpmstate0 = {
group: 22,
iconCls: 'hdd-o',
editor: null,
never_delete: !caps.vms['VM.Config.Disk'],
header: gettext('TPM State'),
renderer: Ext.htmlEncode,
};
for (let i = 0; i < PVE.Utils.hardware_counts.usb; i++) {
let confid = "usb" + i.toString();
rows[confid] = {
group: 25,
order: i,
iconCls: 'usb',
editor: caps.nodes['Sys.Console'] || caps.mapping['Mapping.Use'] ? 'PVE.qemu.USBEdit' : undefined,
never_delete: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'],
header: gettext('USB Device') + ' (' + confid + ')',
};
}
for (let i = 0; i < PVE.Utils.hardware_counts.hostpci; i++) {
let confid = "hostpci" + i.toString();
rows[confid] = {
group: 30,
order: i,
tdCls: 'pve-itype-icon-pci',
never_delete: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'],
editor: caps.nodes['Sys.Console'] || caps.mapping['Mapping.Use'] ? 'PVE.qemu.PCIEdit' : undefined,
header: gettext('PCI Device') + ' (' + confid + ')',
};
}
for (let i = 0; i < PVE.Utils.hardware_counts.serial; i++) {
let confid = "serial" + i.toString();
rows[confid] = {
group: 35,
order: i,
tdCls: 'pve-itype-icon-serial',
never_delete: !caps.nodes['Sys.Console'],
header: gettext('Serial Port') + ' (' + confid + ')',
};
}
rows.audio0 = {
group: 40,
iconCls: 'volume-up',
editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.AudioEdit' : undefined,
never_delete: !caps.vms['VM.Config.HWType'],
header: gettext('Audio Device'),
};
for (let i = 0; i < 256; i++) {
rows["unused" + i.toString()] = {
group: 99,
order: i,
iconCls: 'hdd-o',
del_extra_msg: gettext('This will permanently erase all data.'),
editor: caps.vms['VM.Config.Disk'] ? 'PVE.qemu.HDEdit' : undefined,
header: gettext('Unused Disk') + ' ' + i.toString(),
};
}
rows.rng0 = {
group: 45,
tdCls: 'pve-itype-icon-die',
editor: caps.nodes['Sys.Console'] ? 'PVE.qemu.RNGEdit' : undefined,
never_delete: !caps.nodes['Sys.Console'],
header: gettext("VirtIO RNG"),
};
var sorterFn = function(rec1, rec2) {
var v1 = rec1.data.key;
var v2 = rec2.data.key;
var g1 = rows[v1].group || 0;
var g2 = rows[v2].group || 0;
var order1 = rows[v1].order || 0;
var order2 = rows[v2].order || 0;
if (g1 - g2 !== 0) {
return g1 - g2;
}
if (order1 - order2 !== 0) {
return order1 - order2;
}
if (v1 > v2) {
return 1;
} else if (v1 < v2) {
return -1;
} else {
return 0;
}
};
let baseurl = `nodes/${nodename}/qemu/${vmid}/config`;
let sm = Ext.create('Ext.selection.RowModel', {});
let run_editor = function() {
let rec = sm.getSelection()[0];
if (!rec || !rows[rec.data.key]?.editor) {
return;
}
let rowdef = rows[rec.data.key];
let editor = rowdef.editor;
if (rowdef.isOnStorageBus) {
let value = me.getObjectValue(rec.data.key, '', true);
if (isCloudInitKey(value)) {
return;
} else if (value.match(/media=cdrom/)) {
editor = 'PVE.qemu.CDEdit';
} else if (!diskCap) {
return;
}
}
let commonOpts = {
autoShow: true,
pveSelNode: me.pveSelNode,
confid: rec.data.key,
url: `/api2/extjs/${baseurl}`,
listeners: {
destroy: () => me.reload(),
},
};
if (Ext.isString(editor)) {
Ext.create(editor, commonOpts);
} else {
let win = Ext.createWidget(rowdef.editor.xtype, Ext.apply(commonOpts, rowdef.editor));
win.load();
}
};
let edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
selModel: sm,
disabled: true,
handler: run_editor,
});
let move_menuitem = new Ext.menu.Item({
text: gettext('Move Storage'),
tooltip: gettext('Move disk to another storage'),
iconCls: 'fa fa-database',
selModel: sm,
handler: () => {
let rec = sm.getSelection()[0];
if (!rec) {
return;
}
Ext.create('PVE.window.HDMove', {
autoShow: true,
disk: rec.data.key,
nodename: nodename,
vmid: vmid,
type: 'qemu',
listeners: {
destroy: () => me.reload(),
},
});
},
});
let reassign_menuitem = new Ext.menu.Item({
text: gettext('Reassign Owner'),
tooltip: gettext('Reassign disk to another VM'),
iconCls: 'fa fa-desktop',
selModel: sm,
handler: () => {
let rec = sm.getSelection()[0];
if (!rec) {
return;
}
Ext.create('PVE.window.GuestDiskReassign', {
autoShow: true,
disk: rec.data.key,
nodename: nodename,
vmid: vmid,
type: 'qemu',
listeners: {
destroy: () => me.reload(),
},
});
},
});
let resize_menuitem = new Ext.menu.Item({
text: gettext('Resize'),
iconCls: 'fa fa-plus',
selModel: sm,
handler: () => {
let rec = sm.getSelection()[0];
if (!rec) {
return;
}
Ext.create('PVE.window.HDResize', {
autoShow: true,
disk: rec.data.key,
nodename: nodename,
vmid: vmid,
listeners: {
destroy: () => me.reload(),
},
});
},
});
let diskaction_btn = new Proxmox.button.Button({
text: gettext('Disk Action'),
disabled: true,
menu: {
items: [
move_menuitem,
reassign_menuitem,
resize_menuitem,
],
},
});
let remove_btn = new Proxmox.button.Button({
text: gettext('Remove'),
defaultText: gettext('Remove'),
altText: gettext('Detach'),
selModel: sm,
disabled: true,
dangerous: true,
RESTMethod: 'PUT',
confirmMsg: function(rec) {
let warn = gettext('Are you sure you want to remove entry {0}');
if (this.text === this.altText) {
warn = gettext('Are you sure you want to detach entry {0}');
}
let rendered = me.renderKey(rec.data.key, {}, rec);
let msg = Ext.String.format(warn, `'${rendered}'`);
if (rows[rec.data.key].del_extra_msg) {
msg += '<br>' + rows[rec.data.key].del_extra_msg;
}
return msg;
},
handler: function(btn, e, rec) {
let params = { 'delete': rec.data.key };
if (btn.RESTMethod === 'POST') {
params.background_delay = 5;
}
Proxmox.Utils.API2Request({
url: '/api2/extjs/' + baseurl,
waitMsgTarget: me,
method: btn.RESTMethod,
params: params,
callback: () => me.reload(),
failure: response => Ext.Msg.alert('Error', response.htmlStatus),
success: function(response, options) {
if (btn.RESTMethod === 'POST' && response.result.data !== null) {
Ext.create('Proxmox.window.TaskProgress', {
autoShow: true,
upid: response.result.data,
listeners: {
destroy: () => me.reload(),
},
});
}
},
});
},
listeners: {
render: function(btn) {
// hack: calculate the max button width on first display to prevent the whole
// toolbar to move when we switch between the "Remove" and "Detach" labels
var def = btn.getSize().width;
btn.setText(btn.altText);
var alt = btn.getSize().width;
btn.setText(btn.defaultText);
var optimal = alt > def ? alt : def;
btn.setSize({ width: optimal });
},
},
});
let revert_btn = new PVE.button.PendingRevert({
apiurl: '/api2/extjs/' + baseurl,
});
let efidisk_menuitem = Ext.create('Ext.menu.Item', {
text: gettext('EFI Disk'),
iconCls: 'fa fa-fw fa-hdd-o black',
disabled: !caps.vms['VM.Config.Disk'],
handler: function() {
let { data: bios } = me.rstore.getData().map.bios || {};
Ext.create('PVE.qemu.EFIDiskEdit', {
autoShow: true,
url: '/api2/extjs/' + baseurl,
pveSelNode: me.pveSelNode,
usesEFI: bios?.value === 'ovmf' || bios?.pending === 'ovmf',
listeners: {
destroy: () => me.reload(),
},
});
},
});
let counts = {};
let isAtLimit = (type) => counts[type] >= PVE.Utils.hardware_counts[type];
let isAtUsbLimit = () => {
let ostype = me.getObjectValue('ostype');
let machine = me.getObjectValue('machine');
return counts.usb >= PVE.Utils.get_max_usb_count(ostype, machine);
};
let set_button_status = function() {
let selection_model = me.getSelectionModel();
let rec = selection_model.getSelection()[0];
counts = {}; // en/disable hardwarebuttons
let hasCloudInit = false;
me.rstore.getData().items.forEach(function({ id, data }) {
if (!hasCloudInit && (isCloudInitKey(data.value) || isCloudInitKey(data.pending))) {
hasCloudInit = true;
return;
}
let match = id.match(/^([^\d]+)\d+$/);
if (match && PVE.Utils.hardware_counts[match[1]] !== undefined) {
let type = match[1];
counts[type] = (counts[type] || 0) + 1;
}
});
// heuristic only for disabling some stuff, the backend has the final word.
const noSysConsolePerm = !caps.nodes['Sys.Console'];
const noHWPerm = !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'];
const noVMConfigHWTypePerm = !caps.vms['VM.Config.HWType'];
const noVMConfigNetPerm = !caps.vms['VM.Config.Network'];
const noVMConfigDiskPerm = !caps.vms['VM.Config.Disk'];
const noVMConfigCDROMPerm = !caps.vms['VM.Config.CDROM'];
const noVMConfigCloudinitPerm = !caps.vms['VM.Config.Cloudinit'];
me.down('#addUsb').setDisabled(noHWPerm || isAtUsbLimit());
me.down('#addPci').setDisabled(noHWPerm || isAtLimit('hostpci'));
me.down('#addAudio').setDisabled(noVMConfigHWTypePerm || isAtLimit('audio'));
me.down('#addSerial').setDisabled(noVMConfigHWTypePerm || isAtLimit('serial'));
me.down('#addNet').setDisabled(noVMConfigNetPerm || isAtLimit('net'));
me.down('#addRng').setDisabled(noSysConsolePerm || isAtLimit('rng'));
efidisk_menuitem.setDisabled(noVMConfigDiskPerm || isAtLimit('efidisk'));
me.down('#addTpmState').setDisabled(noVMConfigDiskPerm || isAtLimit('tpmstate'));
me.down('#addCloudinitDrive').setDisabled(noVMConfigCDROMPerm || noVMConfigCloudinitPerm || hasCloudInit);
if (!rec) {
remove_btn.disable();
edit_btn.disable();
diskaction_btn.disable();
revert_btn.disable();
return;
}
const { key, value } = rec.data;
const row = rows[key];
const deleted = !!rec.data.delete;
const pending = deleted || me.hasPendingChanges(key);
const isRunning = me.pveSelNode.data.running;
const isCloudInit = isCloudInitKey(value);
const isCDRom = value && !!value.toString().match(/media=cdrom/);
const isUnusedDisk = key.match(/^unused\d+/);
const isUsedDisk = !isUnusedDisk && row.isOnStorageBus && !isCDRom;
const isDisk = isUnusedDisk || isUsedDisk;
const isEfi = key === 'efidisk0';
const tpmMoveable = key === 'tpmstate0' && !isRunning;
let cannotDelete = deleted || row.never_delete;
cannotDelete ||= isCDRom && !cdromCap;
cannotDelete ||= isDisk && !diskCap;
cannotDelete ||= isCloudInit && noVMConfigCloudinitPerm;
remove_btn.setDisabled(cannotDelete);
remove_btn.setText(isUsedDisk && !isCloudInit ? remove_btn.altText : remove_btn.defaultText);
remove_btn.RESTMethod = isUnusedDisk || (isDisk && isRunning) ? 'POST' : 'PUT';
edit_btn.setDisabled(
deleted || !row.editor || isCloudInit || (isCDRom && !cdromCap) || (isDisk && !diskCap));
diskaction_btn.setDisabled(
pending ||
!diskCap ||
isCloudInit ||
!(isDisk || isEfi || tpmMoveable),
);
reassign_menuitem.setDisabled(pending || (isEfi || tpmMoveable));
resize_menuitem.setDisabled(pending || !isUsedDisk);
revert_btn.setDisabled(!pending);
};
let editorFactory = (classPath, extraOptions) => {
extraOptions = extraOptions || {};
return () => Ext.create(`PVE.qemu.${classPath}`, {
autoShow: true,
url: `/api2/extjs/${baseurl}`,
pveSelNode: me.pveSelNode,
listeners: {
destroy: () => me.reload(),
},
isAdd: true,
isCreate: true,
...extraOptions,
});
};
Ext.apply(me, {
url: `/api2/json/nodes/${nodename}/qemu/${vmid}/pending`,
interval: 5000,
selModel: sm,
run_editor: run_editor,
tbar: [
{
text: gettext('Add'),
menu: new Ext.menu.Menu({
cls: 'pve-add-hw-menu',
items: [
{
text: gettext('Hard Disk'),
iconCls: 'fa fa-fw fa-hdd-o black',
disabled: !caps.vms['VM.Config.Disk'],
handler: editorFactory('HDEdit'),
},
{
text: gettext('CD/DVD Drive'),
iconCls: 'pve-itype-icon-cdrom',
disabled: !caps.vms['VM.Config.CDROM'],
handler: editorFactory('CDEdit'),
},
{
text: gettext('Network Device'),
itemId: 'addNet',
iconCls: 'fa fa-fw fa-exchange black',
disabled: !caps.vms['VM.Config.Network'],
handler: editorFactory('NetworkEdit'),
},
efidisk_menuitem,
{
text: gettext('TPM State'),
itemId: 'addTpmState',
iconCls: 'fa fa-fw fa-hdd-o black',
disabled: !caps.vms['VM.Config.Disk'],
handler: editorFactory('TPMDiskEdit'),
},
{
text: gettext('USB Device'),
itemId: 'addUsb',
iconCls: 'fa fa-fw fa-usb black',
disabled: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'],
handler: editorFactory('USBEdit'),
},
{
text: gettext('PCI Device'),
itemId: 'addPci',
iconCls: 'pve-itype-icon-pci',
disabled: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'],
handler: editorFactory('PCIEdit'),
},
{
text: gettext('Serial Port'),
itemId: 'addSerial',
iconCls: 'pve-itype-icon-serial',
disabled: !caps.vms['VM.Config.Options'],
handler: editorFactory('SerialEdit'),
},
{
text: gettext('CloudInit Drive'),
itemId: 'addCloudinitDrive',
iconCls: 'fa fa-fw fa-cloud black',
disabled: !caps.vms['VM.Config.CDROM'] || !caps.vms['VM.Config.Cloudinit'],
handler: editorFactory('CIDriveEdit'),
},
{
text: gettext('Audio Device'),
itemId: 'addAudio',
iconCls: 'fa fa-fw fa-volume-up black',
disabled: !caps.vms['VM.Config.HWType'],
handler: editorFactory('AudioEdit'),
},
{
text: gettext("VirtIO RNG"),
itemId: 'addRng',
iconCls: 'pve-itype-icon-die',
disabled: !caps.nodes['Sys.Console'],
handler: editorFactory('RNGEdit'),
},
],
}),
},
remove_btn,
edit_btn,
diskaction_btn,
revert_btn,
],
rows: rows,
sorterFn: sorterFn,
listeners: {
itemdblclick: run_editor,
selectionchange: set_button_status,
},
});
me.callParent();
me.on('activate', me.rstore.startUpdate, me.rstore);
me.on('destroy', me.rstore.stopUpdate, me.rstore);
me.mon(me.getStore(), 'datachanged', set_button_status, me);
},
});
Ext.define('PVE.qemu.IPConfigPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveIPConfigPanel',
insideWizard: false,
vmconfig: {},
onGetValues: function(values) {
var me = this;
if (values.ipv4mode !== 'static') {
values.ip = values.ipv4mode;
}
if (values.ipv6mode !== 'static') {
values.ip6 = values.ipv6mode;
}
var params = {};
var cfg = PVE.Parser.printIPConfig(values);
if (cfg === '') {
params.delete = [me.confid];
} else {
params[me.confid] = cfg;
}
return params;
},
setVMConfig: function(config) {
var me = this;
me.vmconfig = config;
},
setIPConfig: function(confid, data) {
var me = this;
me.confid = confid;
if (data.ip === 'dhcp') {
data.ipv4mode = data.ip;
data.ip = '';
} else {
data.ipv4mode = 'static';
}
if (data.ip6 === 'dhcp' || data.ip6 === 'auto') {
data.ipv6mode = data.ip6;
data.ip6 = '';
} else {
data.ipv6mode = 'static';
}
me.ipconfig = data;
me.setValues(me.ipconfig);
},
initComponent: function() {
var me = this;
me.ipconfig = {};
me.column1 = [
{
xtype: 'displayfield',
fieldLabel: gettext('Network Device'),
value: me.netid,
},
{
layout: {
type: 'hbox',
align: 'middle',
},
border: false,
margin: '0 0 5 0',
items: [
{
xtype: 'label',
text: gettext('IPv4') + ':',
},
{
xtype: 'radiofield',
boxLabel: gettext('Static'),
name: 'ipv4mode',
inputValue: 'static',
checked: false,
margin: '0 0 0 10',
listeners: {
change: function(cb, value) {
me.down('field[name=ip]').setDisabled(!value);
me.down('field[name=gw]').setDisabled(!value);
},
},
},
{
xtype: 'radiofield',
boxLabel: gettext('DHCP'),
name: 'ipv4mode',
inputValue: 'dhcp',
checked: false,
margin: '0 0 0 10',
},
],
},
{
xtype: 'textfield',
name: 'ip',
vtype: 'IPCIDRAddress',
value: '',
disabled: true,
fieldLabel: gettext('IPv4/CIDR'),
},
{
xtype: 'textfield',
name: 'gw',
value: '',
vtype: 'IPAddress',
disabled: true,
fieldLabel: gettext('Gateway') + ' (' + gettext('IPv4') +')',
},
];
me.column2 = [
{
xtype: 'displayfield',
},
{
layout: {
type: 'hbox',
align: 'middle',
},
border: false,
margin: '0 0 5 0',
items: [
{
xtype: 'label',
text: gettext('IPv6') + ':',
},
{
xtype: 'radiofield',
boxLabel: gettext('Static'),
name: 'ipv6mode',
inputValue: 'static',
checked: false,
margin: '0 0 0 10',
listeners: {
change: function(cb, value) {
me.down('field[name=ip6]').setDisabled(!value);
me.down('field[name=gw6]').setDisabled(!value);
},
},
},
{
xtype: 'radiofield',
boxLabel: gettext('DHCP'),
name: 'ipv6mode',
inputValue: 'dhcp',
checked: false,
margin: '0 0 0 10',
},
{
xtype: 'radiofield',
boxLabel: gettext('SLAAC'),
name: 'ipv6mode',
inputValue: 'auto',
checked: false,
margin: '0 0 0 10',
},
],
},
{
xtype: 'textfield',
name: 'ip6',
value: '',
vtype: 'IP6CIDRAddress',
disabled: true,
fieldLabel: gettext('IPv6/CIDR'),
},
{
xtype: 'textfield',
name: 'gw6',
vtype: 'IP6Address',
value: '',
disabled: true,
fieldLabel: gettext('Gateway') + ' (' + gettext('IPv6') +')',
},
];
me.callParent();
},
});
Ext.define('PVE.qemu.IPConfigEdit', {
extend: 'Proxmox.window.Edit',
isAdd: true,
initComponent: function() {
var me = this;
// convert confid from netX to ipconfigX
var match = me.confid.match(/^net(\d+)$/);
if (match) {
me.netid = me.confid;
me.confid = 'ipconfig' + match[1];
}
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
me.isCreate = !me.confid;
var ipanel = Ext.create('PVE.qemu.IPConfigPanel', {
confid: me.confid,
netid: me.netid,
nodename: nodename,
});
Ext.applyIf(me, {
subject: gettext('Network Config'),
items: ipanel,
});
me.callParent();
me.load({
success: function(response, options) {
me.vmconfig = response.result.data;
var ipconfig = {};
var value = me.vmconfig[me.confid];
if (value) {
ipconfig = PVE.Parser.parseIPConfig(me.confid, value);
if (!ipconfig) {
Ext.Msg.alert(gettext('Error'), gettext('Unable to parse network configuration'));
me.close();
return;
}
}
ipanel.setIPConfig(me.confid, ipconfig);
ipanel.setVMConfig(me.vmconfig);
},
});
},
});
Ext.define('PVE.qemu.KeyboardEdit', {
extend: 'Proxmox.window.Edit',
initComponent: function() {
var me = this;
Ext.applyIf(me, {
subject: gettext('Keyboard Layout'),
items: {
xtype: 'VNCKeyboardSelector',
name: 'keyboard',
value: '__default__',
fieldLabel: gettext('Keyboard Layout'),
},
});
me.callParent();
me.load();
},
});
Ext.define('PVE.qemu.MachineInputPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveMachineInputPanel',
onlineHelp: 'qm_machine_type',
viewModel: {
data: {
type: '__default__',
},
formulas: {
q35: get => get('type') === 'q35',
},
},
controller: {
xclass: 'Ext.app.ViewController',
control: {
'combobox[name=machine]': {
change: 'onMachineChange',
},
},
onMachineChange: function(field, value) {
let me = this;
let version = me.lookup('version');
let store = version.getStore();
let oldRec = store.findRecord('id', version.getValue(), 0, false, false, true);
let type = value === 'q35' ? 'q35' : 'i440fx';
store.clearFilter();
store.addFilter(val => val.data.id === 'latest' || val.data.type === type);
if (!me.getView().isWindows) {
version.setValue('latest');
} else {
store.isWindows = true;
if (!oldRec) {
return;
}
let oldVers = oldRec.data.version;
// we already filtered by correct type, so just check version property
let rec = store.findRecord('version', oldVers, 0, false, false, true);
if (rec) {
version.select(rec);
}
}
},
},
onGetValues: function(values) {
if (values.delete === 'machine' && values.viommu) {
delete values.delete;
values.machine = 'pc';
}
if (values.version && values.version !== 'latest') {
values.machine = values.version;
delete values.delete;
}
delete values.version;
if (values.delete === 'machine' && !values.viommu) {
return values;
}
let ret = {};
ret.machine = PVE.Parser.printPropertyString(values, 'machine');
return ret;
},
setValues: function(values) {
let me = this;
let machineConf = PVE.Parser.parsePropertyString(values.machine, 'type');
values.machine = machineConf.type;
me.isWindows = values.isWindows;
if (values.machine === 'pc') {
values.machine = '__default__';
}
if (me.isWindows) {
if (values.machine === '__default__') {
values.version = 'pc-i440fx-5.1';
} else if (values.machine === 'q35') {
values.version = 'pc-q35-5.1';
}
}
values.viommu = machineConf.viommu || '__default__';
if (values.machine !== '__default__' && values.machine !== 'q35') {
values.version = values.machine;
values.machine = values.version.match(/q35/) ? 'q35' : '__default__';
// avoid hiding a pinned version
me.setAdvancedVisible(true);
}
this.callParent(arguments);
},
items: {
xtype: 'proxmoxKVComboBox',
name: 'machine',
reference: 'machine',
fieldLabel: gettext('Machine'),
comboItems: [
['__default__', PVE.Utils.render_qemu_machine('')],
['q35', 'q35'],
],
bind: {
value: '{type}',
},
},
advancedItems: [
{
xtype: 'combobox',
name: 'version',
reference: 'version',
fieldLabel: gettext('Version'),
emptyText: gettext('Latest'),
value: 'latest',
editable: false,
valueField: 'id',
displayField: 'version',
queryParam: false,
store: {
autoLoad: true,
fields: ['id', 'type', 'version'],
proxy: {
type: 'proxmox',
url: "/api2/json/nodes/localhost/capabilities/qemu/machines",
},
listeners: {
load: function(records) {
if (!this.isWindows) {
this.insert(0, { id: 'latest', type: 'any', version: gettext('Latest') });
}
},
},
},
},
{
xtype: 'displayfield',
fieldLabel: gettext('Note'),
value: gettext('Machine version change may affect hardware layout and settings in the guest OS.'),
},
{
xtype: 'proxmoxKVComboBox',
name: 'viommu',
fieldLabel: gettext('vIOMMU'),
reference: 'viommu-q35',
deleteEmpty: false,
value: '__default__',
comboItems: [
['__default__', Proxmox.Utils.defaultText + ' (None)'],
['intel', gettext('Intel (AMD Compatible)')],
['virtio', 'VirtIO'],
],
bind: {
hidden: '{!q35}',
disabled: '{!q35}',
},
},
{
xtype: 'proxmoxKVComboBox',
name: 'viommu',
fieldLabel: gettext('vIOMMU'),
reference: 'viommu-i440fx',
deleteEmpty: false,
value: '__default__',
comboItems: [
['__default__', Proxmox.Utils.defaultText + ' (None)'],
['virtio', 'VirtIO'],
],
bind: {
hidden: '{q35}',
disabled: '{q35}',
},
},
],
});
Ext.define('PVE.qemu.MachineEdit', {
extend: 'Proxmox.window.Edit',
subject: gettext('Machine'),
items: {
xtype: 'pveMachineInputPanel',
},
width: 400,
initComponent: function() {
let me = this;
me.callParent();
me.load({
success: function(response) {
let conf = response.result.data;
let values = {
machine: conf.machine || '__default__',
};
values.isWindows = PVE.Utils.is_windows(conf.ostype);
me.setValues(values);
},
});
},
});
Ext.define('PVE.qemu.MemoryInputPanel', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pveQemuMemoryPanel',
onlineHelp: 'qm_memory',
insideWizard: false,
viewModel: {}, // inherit data from createWizard if insideWizard
controller: {
xclass: 'Ext.app.ViewController',
control: {
'#': {
afterrender: 'setMemory',
},
},
setMemory: function() {
let me = this;
let view = me.getView(), viewModel = me.getViewModel();
if (view.insideWizard) {
let memory = view.down('pveMemoryField[name=memory]');
// NOTE: we only set memory but that then sets balloon in its change handler
if (viewModel.get('current.ostype') === 'win11') {
memory.setValue('4096');
} else {
memory.setValue('2048');
}
}
},
},
onGetValues: function(values) {
var me = this;
var res = {};
res.memory = values.memory;
res.balloon = values.balloon;
if (!values.ballooning) {
res.balloon = 0;
res.delete = 'shares';
} else if (values.memory === values.balloon) {
delete res.balloon;
res.delete = 'balloon,shares';
} else if (Ext.isDefined(values.shares) && values.shares !== "") {
res.shares = values.shares;
} else {
res.delete = "shares";
}
return res;
},
initComponent: function() {
var me = this;
var labelWidth = 160;
me.items= [
{
xtype: 'pveMemoryField',
labelWidth: labelWidth,
fieldLabel: gettext('Memory') + ' (MiB)',
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();
bf.setMaxValue(value);
if (balloon === old) {
bf.setValue(value);
}
bf.validate();
},
},
},
];
me.advancedItems= [
{
xtype: 'pveMemoryField',
name: 'balloon',
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)',
hotplug: me.hotplug,
labelWidth: labelWidth,
allowBlank: false,
listeners: {
change: function(f, value) {
var memory = me.down('field[name=memory]').getValue();
var shares = me.down('field[name=shares]');
shares.setDisabled(value === memory);
},
},
},
{
xtype: 'proxmoxintegerfield',
name: 'shares',
disabled: true,
minValue: 0,
maxValue: 50000,
value: '',
step: 10,
fieldLabel: gettext('Shares'),
labelWidth: labelWidth,
allowBlank: true,
emptyText: Proxmox.Utils.defaultText + ' (1000)',
submitEmptyText: false,
},
{
xtype: 'proxmoxcheckbox',
labelWidth: labelWidth,
value: '1',
name: 'ballooning',
fieldLabel: gettext('Ballooning Device'),
listeners: {
change: function(f, value) {
var bf = me.down('field[name=balloon]');
var shares = me.down('field[name=shares]');
var memory = me.down('field[name=memory]');
bf.setDisabled(!value);
shares.setDisabled(!value || bf.getValue() === memory.getValue());
},
},
},
];
if (me.insideWizard) {
me.column1 = me.items;
me.items = undefined;
me.advancedColumn1 = me.advancedItems;
me.advancedItems = undefined;
}
me.callParent();
},
});
Ext.define('PVE.qemu.MemoryEdit', {
extend: 'Proxmox.window.Edit',
initComponent: function() {
var me = this;
var memoryhotplug;
if (me.hotplug) {
Ext.each(me.hotplug.split(','), function(el) {
if (el === 'memory') {
memoryhotplug = 1;
}
});
}
var ipanel = Ext.create('PVE.qemu.MemoryInputPanel', {
hotplug: memoryhotplug,
});
Ext.apply(me, {
subject: gettext('Memory'),
items: [ipanel],
// uncomment the following to use the async configiguration API
// backgroundDelay: 5,
width: 400,
});
me.callParent();
me.load({
success: function(response, options) {
var data = response.result.data;
var values = {
ballooning: data.balloon === 0 ? '0' : '1',
shares: data.shares,
memory: data.memory || '512',
balloon: data.balloon > 0 ? data.balloon : data.memory || '512',
};
ipanel.setValues(values);
},
});
},
});
Ext.define('PVE.qemu.Monitor', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveQemuMonitor',
// start to trim saved command output once there are *both*, more than `commandLimit` commands
// executed and the total of saved in+output is over `lineLimit` lines; repeat by dropping one
// full command output until either condition is false again
commandLimit: 10,
lineLimit: 5000,
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
var vmid = me.pveSelNode.data.vmid;
if (!vmid) {
throw "no VM ID specified";
}
var history = [];
var histNum = -1;
let commands = [];
var textbox = Ext.createWidget('panel', {
region: 'center',
xtype: 'panel',
autoScroll: true,
border: true,
margins: '5 5 5 5',
bodyStyle: 'font-family: monospace;',
});
var scrollToEnd = function() {
var el = textbox.getTargetEl();
var dom = Ext.getDom(el);
var clientHeight = dom.clientHeight;
// BrowserBug: clientHeight reports 0 in IE9 StrictMode
// Instead we are using offsetHeight and hardcoding borders
if (Ext.isIE9 && Ext.isStrict) {
clientHeight = dom.offsetHeight + 2;
}
dom.scrollTop = dom.scrollHeight - clientHeight;
};
var refresh = function() {
textbox.update(`<pre>${commands.flat(2).join('\n')}</pre>`);
scrollToEnd();
};
let recordInput = line => {
commands.push([line]);
// drop oldest commands and their output until we're not over both limits anymore
while (commands.length > me.commandLimit && commands.flat(2).length > me.lineLimit) {
commands.shift();
}
};
let addResponse = lines => commands[commands.length - 1].push(lines);
var executeCmd = function(cmd) {
recordInput("# " + Ext.htmlEncode(cmd), true);
if (cmd) {
history.unshift(cmd);
if (history.length > 20) {
history.splice(20);
}
}
histNum = -1;
refresh();
Proxmox.Utils.API2Request({
params: { command: cmd },
url: '/nodes/' + nodename + '/qemu/' + vmid + "/monitor",
method: 'POST',
waitMsgTarget: me,
success: function(response, opts) {
var res = response.result.data;
addResponse(res.split('\n').map(line => Ext.htmlEncode(line)));
refresh();
},
failure: function(response, opts) {
Ext.Msg.alert('Error', response.htmlStatus);
},
});
};
Ext.apply(me, {
layout: { type: 'border' },
border: false,
items: [
textbox,
{
region: 'south',
margins: '0 5 5 5',
border: false,
xtype: 'textfield',
name: 'cmd',
value: '',
fieldStyle: 'font-family: monospace;',
allowBlank: true,
listeners: {
afterrender: function(f) {
f.focus(false);
recordInput("Type 'help' for help.");
refresh();
},
specialkey: function(f, e) {
var key = e.getKey();
switch (key) {
case e.ENTER:
var cmd = f.getValue();
f.setValue('');
executeCmd(cmd);
break;
case e.PAGE_UP:
textbox.scrollBy(0, -0.9*textbox.getHeight(), false);
break;
case e.PAGE_DOWN:
textbox.scrollBy(0, 0.9*textbox.getHeight(), false);
break;
case e.UP:
if (histNum + 1 < history.length) {
f.setValue(history[++histNum]);
}
e.preventDefault();
break;
case e.DOWN:
if (histNum > 0) {
f.setValue(history[--histNum]);
}
e.preventDefault();
break;
default:
break;
}
},
},
},
],
listeners: {
show: function() {
var field = me.query('textfield[name="cmd"]')[0];
field.focus(false, true);
},
},
});
me.callParent();
},
});
Ext.define('PVE.qemu.MultiHDPanel', {
extend: 'PVE.panel.MultiDiskPanel',
alias: 'widget.pveMultiHDPanel',
onlineHelp: 'qm_hard_disk',
controller: {
xclass: 'Ext.app.ViewController',
// maxCount is the sum of all controller ids - 1 (ide2 is fixed in the wizard)
maxCount: Object.values(PVE.Utils.diskControllerMaxIDs)
.reduce((previous, current) => previous+current, 0) - 1,
getNextFreeDisk: function(vmconfig) {
let clist = PVE.Utils.sortByPreviousUsage(vmconfig);
return PVE.Utils.nextFreeDisk(clist, vmconfig);
},
addPanel: function(itemId, vmconfig, nextFreeDisk) {
let me = this;
return me.getView().add({
vmconfig,
border: false,
showAdvanced: Ext.state.Manager.getProvider().get('proxmox-advanced-cb'),
xtype: 'pveQemuHDInputPanel',
bind: {
nodename: '{nodename}',
},
padding: '0 0 0 5',
itemId,
isCreate: true,
insideWizard: true,
});
},
getBaseVMConfig: function() {
let me = this;
let vm = me.getViewModel();
let res = {
ide2: 'media=cdrom',
scsihw: vm.get('current.scsihw'),
ostype: vm.get('current.ostype'),
};
if (vm.get('current.ide0') === "some") {
res.ide0 = "media=cdrom";
}
return res;
},
diskSorter: {
sorterFn: function(rec1, rec2) {
let [, name1, id1] = PVE.Utils.bus_match.exec(rec1.data.name);
let [, name2, id2] = PVE.Utils.bus_match.exec(rec2.data.name);
if (name1 === name2) {
return parseInt(id1, 10) - parseInt(id2, 10);
}
return name1 < name2 ? -1 : 1;
},
},
deleteDisabled: () => false,
},
});
Ext.define('PVE.qemu.NetworkInputPanel', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pveQemuNetworkInputPanel',
onlineHelp: 'qm_network_device',
insideWizard: false,
onGetValues: function(values) {
var me = this;
me.network.model = values.model;
if (values.nonetwork) {
return {};
} else {
me.network.bridge = values.bridge;
me.network.tag = values.tag;
me.network.firewall = values.firewall;
}
me.network.macaddr = values.macaddr;
me.network.disconnect = values.disconnect;
me.network.queues = values.queues;
me.network.mtu = values.mtu;
if (values.rate) {
me.network.rate = values.rate;
} else {
delete me.network.rate;
}
var params = {};
params[me.confid] = PVE.Parser.printQemuNetwork(me.network);
return params;
},
viewModel: {
data: {
networkModel: undefined,
mtu: '',
},
formulas: {
isVirtio: get => get('networkModel') === 'virtio',
showMtuHint: get => get('mtu') === 1,
},
},
setNetwork: function(confid, data) {
var me = this;
me.confid = confid;
if (data) {
data.networkmode = data.bridge ? 'bridge' : 'nat';
} else {
data = {};
data.networkmode = 'bridge';
}
me.network = data;
me.setValues(me.network);
},
setNodename: function(nodename) {
var me = this;
me.bridgesel.setNodename(nodename);
},
initComponent: function() {
var me = this;
me.network = {};
me.confid = 'net0';
me.column1 = [];
me.column2 = [];
me.bridgesel = Ext.create('PVE.form.BridgeSelector', {
name: 'bridge',
fieldLabel: gettext('Bridge'),
nodename: me.nodename,
autoSelect: true,
allowBlank: false,
});
me.column1 = [
me.bridgesel,
{
xtype: 'pveVlanField',
name: 'tag',
value: '',
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Firewall'),
name: 'firewall',
checked: me.insideWizard || me.isCreate,
},
];
me.advancedColumn1 = [
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Disconnect'),
name: 'disconnect',
},
{
xtype: 'proxmoxintegerfield',
name: 'mtu',
fieldLabel: 'MTU',
bind: {
disabled: '{!isVirtio}',
value: '{mtu}',
},
emptyText: '1500 (1 = bridge MTU)',
minValue: 1,
maxValue: 65520,
allowBlank: true,
validator: val => val === '' || val >= 576 || val === '1'
? true
: gettext('MTU needs to be >= 576 or 1 to inherit the MTU from the underlying bridge.'),
},
];
if (me.insideWizard) {
me.column1.unshift({
xtype: 'checkbox',
name: 'nonetwork',
inputValue: 'none',
boxLabel: gettext('No network device'),
listeners: {
change: function(cb, value) {
var fields = [
'disconnect',
'bridge',
'tag',
'firewall',
'model',
'macaddr',
'rate',
'queues',
'mtu',
];
fields.forEach(function(fieldname) {
me.down('field[name='+fieldname+']').setDisabled(value);
});
me.down('field[name=bridge]').validate();
},
},
});
me.column2.unshift({
xtype: 'displayfield',
});
}
me.column2.push(
{
xtype: 'pveNetworkCardSelector',
name: 'model',
fieldLabel: gettext('Model'),
bind: '{networkModel}',
value: PVE.qemu.OSDefaults.generic.networkCard,
allowBlank: false,
},
{
xtype: 'textfield',
name: 'macaddr',
fieldLabel: gettext('MAC address'),
vtype: 'MacAddress',
allowBlank: true,
emptyText: 'auto',
});
me.advancedColumn2 = [
{
xtype: 'numberfield',
name: 'rate',
fieldLabel: gettext('Rate limit') + ' (MB/s)',
minValue: 0,
maxValue: 10*1024,
value: '',
emptyText: 'unlimited',
allowBlank: true,
},
{
xtype: 'proxmoxintegerfield',
name: 'queues',
fieldLabel: 'Multiqueue',
minValue: 1,
maxValue: 64,
value: '',
allowBlank: true,
},
];
me.advancedColumnB = [
{
xtype: 'displayfield',
userCls: 'pmx-hint',
value: gettext("Use the special value '1' to inherit the MTU value from the underlying bridge"),
bind: {
hidden: '{!showMtuHint}',
},
},
];
me.callParent();
},
});
Ext.define('PVE.qemu.NetworkEdit', {
extend: 'Proxmox.window.Edit',
isAdd: true,
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
me.isCreate = !me.confid;
var ipanel = Ext.create('PVE.qemu.NetworkInputPanel', {
confid: me.confid,
nodename: nodename,
isCreate: me.isCreate,
});
Ext.applyIf(me, {
subject: gettext('Network Device'),
items: ipanel,
});
me.callParent();
me.load({
success: function(response, options) {
var i, confid;
me.vmconfig = response.result.data;
if (!me.isCreate) {
var value = me.vmconfig[me.confid];
var network = PVE.Parser.parseQemuNetwork(me.confid, value);
if (!network) {
Ext.Msg.alert(gettext('Error'), 'Unable to parse network options');
me.close();
return;
}
ipanel.setNetwork(me.confid, network);
} else {
for (i = 0; i < 100; i++) {
confid = 'net' + i.toString();
if (!Ext.isDefined(me.vmconfig[confid])) {
me.confid = confid;
break;
}
}
let ostype = me.vmconfig.ostype;
let defaults = PVE.qemu.OSDefaults.getDefaults(ostype);
let data = {
model: defaults.networkCard,
};
ipanel.setNetwork(me.confid, data);
}
},
});
},
});
/*
* This class holds performance *recommended* settings for the PVE Qemu wizards
* the *mandatory* settings are set in the PVE::QemuServer
* config_to_command sub
* We store this here until we get the data from the API server
*/
// this is how you would add an hypothetic FreeBSD > 10 entry
//
//virtio-blk is stable but virtIO net still
// problematic as of 10.3
// see https://bugs.freebsd.org/bugzilla/show_bug.cgi?id=165059
// addOS({
// parent: 'generic', // inherits defaults
// pveOS: 'freebsd10', // must match a radiofield in OSTypeEdit.js
// busType: 'virtio' // must match a pveBusController value
// // networkCard muss match a pveNetworkCardSelector
Ext.define('PVE.qemu.OSDefaults', {
singleton: true, // will also force creation when loaded
constructor: function() {
let me = this;
let addOS = function(settings) {
if (Object.prototype.hasOwnProperty.call(settings, 'parent')) {
var child = Ext.clone(me[settings.parent]);
me[settings.pveOS] = Ext.apply(child, settings);
} else {
throw "Could not find your genitor";
}
};
// default values
me.generic = {
busType: 'ide',
networkCard: 'e1000',
busPriority: {
ide: 4,
sata: 3,
scsi: 2,
virtio: 1,
},
scsihw: 'virtio-scsi-single',
cputype: 'x86-64-v2-AES',
};
// virtio-net is in kernel since 2.6.25
// virtio-scsi since 3.2 but backported in RHEL with 2.6 kernel
addOS({
pveOS: 'l26',
parent: 'generic',
busType: 'scsi',
busPriority: {
scsi: 4,
virtio: 3,
sata: 2,
ide: 1,
},
networkCard: 'virtio',
});
// recommendation from http://wiki.qemu.org/Windows2000
addOS({
pveOS: 'w2k',
parent: 'generic',
networkCard: 'rtl8139',
scsihw: '',
});
// https://pve.proxmox.com/wiki/Windows_XP_Guest_Notes
addOS({
pveOS: 'wxp',
parent: 'w2k',
});
me.getDefaults = function(ostype) {
if (PVE.qemu.OSDefaults[ostype]) {
return PVE.qemu.OSDefaults[ostype];
} else {
return PVE.qemu.OSDefaults.generic;
}
};
},
});
Ext.define('PVE.qemu.OSTypeInputPanel', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pveQemuOSTypePanel',
onlineHelp: 'qm_os_settings',
insideWizard: false,
controller: {
xclass: 'Ext.app.ViewController',
control: {
'combobox[name=osbase]': {
change: 'onOSBaseChange',
},
'combobox[name=ostype]': {
afterrender: 'onOSTypeChange',
change: 'onOSTypeChange',
},
'checkbox[reference=enableSecondCD]': {
change: 'onSecondCDChange',
},
},
onOSBaseChange: function(field, value) {
let me = this;
me.lookup('ostype').getStore().setData(PVE.Utils.kvm_ostypes[value]);
if (me.getView().insideWizard) {
let isWindows = value === 'Microsoft Windows';
let enableSecondCD = me.lookup('enableSecondCD');
enableSecondCD.setVisible(isWindows);
if (!isWindows) {
enableSecondCD.setValue(false);
}
}
},
onOSTypeChange: function(field) {
var me = this, ostype = field.getValue();
if (!me.getView().insideWizard) {
return;
}
var targetValues = PVE.qemu.OSDefaults.getDefaults(ostype);
me.setWidget('pveBusSelector', targetValues.busType);
me.setWidget('pveNetworkCardSelector', targetValues.networkCard);
me.setWidget('CPUModelSelector', targetValues.cputype);
var scsihw = targetValues.scsihw || '__default__';
this.getViewModel().set('current.scsihw', scsihw);
this.getViewModel().set('current.ostype', ostype);
},
setWidget: function(widget, newValue) {
// changing a widget is safe only if ComponentQuery.query returns us
// a single value array
var widgets = Ext.ComponentQuery.query('pveQemuCreateWizard ' + widget);
if (widgets.length === 1) {
widgets[0].setValue(newValue);
} else {
// ignore multiple disks, we only want to set the type if there is a single disk
}
},
onSecondCDChange: function(widget, value, lastValue) {
let me = this;
let vm = me.getViewModel();
let updateVMConfig = function() {
let widgets = Ext.ComponentQuery.query('pveMultiHDPanel');
if (widgets.length === 1) {
widgets[0].getController().updateVMConfig();
}
};
if (value) {
// only for windows
vm.set('current.ide0', "some");
vm.notify();
updateVMConfig();
me.setWidget('pveBusSelector', 'scsi');
me.setWidget('pveNetworkCardSelector', 'virtio');
} else {
vm.set('current.ide0', "");
vm.notify();
updateVMConfig();
me.setWidget('pveBusSelector', 'scsi');
let ostype = me.lookup('ostype').getValue();
var targetValues = PVE.qemu.OSDefaults.getDefaults(ostype);
me.setWidget('pveBusSelector', targetValues.busType);
}
},
},
setNodename: function(nodename) {
var me = this;
me.lookup('isoSelector').setNodename(nodename);
},
onGetValues: function(values) {
if (values.ide0) {
let drive = {
media: 'cdrom',
file: values.ide0,
};
values.ide0 = PVE.Parser.printQemuDrive(drive);
}
return values;
},
initComponent: function() {
var me = this;
me.items = [
{
xtype: 'displayfield',
value: gettext('Guest OS') + ':',
hidden: !me.insideWizard,
},
{
xtype: 'combobox',
submitValue: false,
name: 'osbase',
fieldLabel: gettext('Type'),
editable: false,
queryMode: 'local',
value: 'Linux',
store: Object.keys(PVE.Utils.kvm_ostypes),
},
{
xtype: 'combobox',
name: 'ostype',
reference: 'ostype',
fieldLabel: gettext('Version'),
value: 'l26',
allowBlank: false,
editable: false,
queryMode: 'local',
valueField: 'val',
displayField: 'desc',
store: {
fields: ['desc', 'val'],
data: PVE.Utils.kvm_ostypes.Linux,
listeners: {
datachanged: function(store) {
var ostype = me.lookup('ostype');
var old_val = ostype.getValue();
if (!me.insideWizard && old_val && store.find('val', old_val) !== -1) {
ostype.setValue(old_val);
} else {
ostype.setValue(store.getAt(0));
}
},
},
},
},
];
if (me.insideWizard) {
me.items.push(
{
xtype: 'proxmoxcheckbox',
reference: 'enableSecondCD',
isFormField: false,
hidden: true,
checked: false,
boxLabel: gettext('Add additional drive for VirtIO drivers'),
listeners: {
change: function(cb, value) {
me.lookup('isoSelector').setDisabled(!value);
me.lookup('isoSelector').setHidden(!value);
},
},
},
{
xtype: 'pveIsoSelector',
reference: 'isoSelector',
name: 'ide0',
nodename: me.nodename,
insideWizard: true,
hidden: true,
disabled: true,
},
);
}
me.callParent();
},
});
Ext.define('PVE.qemu.OSTypeEdit', {
extend: 'Proxmox.window.Edit',
subject: 'OS Type',
items: [{ xtype: 'pveQemuOSTypePanel' }],
initComponent: function() {
var me = this;
me.callParent();
me.load({
success: function(response, options) {
var value = response.result.data.ostype || 'other';
var osinfo = PVE.Utils.get_kvm_osinfo(value);
me.setValues({ ostype: value, osbase: osinfo.base });
},
});
},
});
Ext.define('PVE.qemu.Options', {
extend: 'Proxmox.grid.PendingObjectGrid',
alias: ['widget.PVE.qemu.Options'],
onlineHelp: 'qm_options',
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
var vmid = me.pveSelNode.data.vmid;
if (!vmid) {
throw "no VM ID specified";
}
var caps = Ext.state.Manager.get('GuiCap');
var rows = {
name: {
required: true,
defaultValue: me.pveSelNode.data.name,
header: gettext('Name'),
editor: caps.vms['VM.Config.Options'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('Name'),
items: {
xtype: 'inputpanel',
items: {
xtype: 'textfield',
name: 'name',
vtype: 'DnsName',
value: '',
fieldLabel: gettext('Name'),
allowBlank: true,
},
onGetValues: function(values) {
var params = values;
if (values.name === undefined ||
values.name === null ||
values.name === '') {
params = { 'delete': 'name' };
}
return params;
},
},
} : undefined,
},
onboot: {
header: gettext('Start at boot'),
defaultValue: '',
renderer: Proxmox.Utils.format_boolean,
editor: caps.vms['VM.Config.Options'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('Start at boot'),
items: {
xtype: 'proxmoxcheckbox',
name: 'onboot',
uncheckedValue: 0,
defaultValue: 0,
deleteDefaultValue: true,
fieldLabel: gettext('Start at boot'),
},
} : undefined,
},
startup: {
header: gettext('Start/Shutdown order'),
defaultValue: '',
renderer: PVE.Utils.render_kvm_startup,
editor: caps.vms['VM.Config.Options'] && caps.nodes['Sys.Modify']
? {
xtype: 'pveWindowStartupEdit',
onlineHelp: 'qm_startup_and_shutdown',
} : undefined,
},
ostype: {
header: gettext('OS Type'),
editor: caps.vms['VM.Config.Options'] ? 'PVE.qemu.OSTypeEdit' : undefined,
renderer: PVE.Utils.render_kvm_ostype,
defaultValue: 'other',
},
bootdisk: {
visible: false,
},
boot: {
header: gettext('Boot Order'),
defaultValue: 'cdn',
editor: caps.vms['VM.Config.Disk'] ? 'PVE.qemu.BootOrderEdit' : undefined,
multiKey: ['boot', 'bootdisk'],
renderer: function(order, metaData, record, rowIndex, colIndex, store, pending) {
if (/^\s*$/.test(order)) {
return gettext('(No boot device selected)');
}
let boot = PVE.Parser.parsePropertyString(order, "legacy");
if (boot.order) {
let list = boot.order.split(';');
let ret = '';
list.forEach(dev => {
if (ret) {
ret += ', ';
}
ret += dev;
});
return ret;
}
// legacy style and fallback
let i;
var text = '';
var bootdisk = me.getObjectValue('bootdisk', undefined, pending);
order = boot.legacy || 'cdn';
for (i = 0; i < order.length; i++) {
if (text) {
text += ', ';
}
var sel = order.substring(i, i + 1);
if (sel === 'c') {
if (bootdisk) {
text += bootdisk;
} else {
text += gettext('first disk');
}
} else if (sel === 'n') {
text += gettext('any net');
} else if (sel === 'a') {
text += gettext('Floppy');
} else if (sel === 'd') {
text += gettext('any CD-ROM');
} else {
text += sel;
}
}
return text;
},
},
tablet: {
header: gettext('Use tablet for pointer'),
defaultValue: true,
renderer: Proxmox.Utils.format_boolean,
editor: caps.vms['VM.Config.HWType'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('Use tablet for pointer'),
items: {
xtype: 'proxmoxcheckbox',
name: 'tablet',
checked: true,
uncheckedValue: 0,
defaultValue: 1,
deleteDefaultValue: true,
fieldLabel: gettext('Enabled'),
},
} : undefined,
},
hotplug: {
header: gettext('Hotplug'),
defaultValue: 'disk,network,usb',
renderer: PVE.Utils.render_hotplug_features,
editor: caps.vms['VM.Config.HWType'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('Hotplug'),
items: {
xtype: 'pveHotplugFeatureSelector',
name: 'hotplug',
value: '',
multiSelect: true,
fieldLabel: gettext('Hotplug'),
allowBlank: true,
},
} : undefined,
},
acpi: {
header: gettext('ACPI support'),
defaultValue: true,
renderer: Proxmox.Utils.format_boolean,
editor: caps.vms['VM.Config.HWType'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('ACPI support'),
items: {
xtype: 'proxmoxcheckbox',
name: 'acpi',
checked: true,
uncheckedValue: 0,
defaultValue: 1,
deleteDefaultValue: true,
fieldLabel: gettext('Enabled'),
},
} : undefined,
},
kvm: {
header: gettext('KVM hardware virtualization'),
defaultValue: true,
renderer: Proxmox.Utils.format_boolean,
editor: caps.vms['VM.Config.HWType'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('KVM hardware virtualization'),
items: {
xtype: 'proxmoxcheckbox',
name: 'kvm',
checked: true,
uncheckedValue: 0,
defaultValue: 1,
deleteDefaultValue: true,
fieldLabel: gettext('Enabled'),
},
} : undefined,
},
freeze: {
header: gettext('Freeze CPU at startup'),
defaultValue: false,
renderer: Proxmox.Utils.format_boolean,
editor: caps.vms['VM.PowerMgmt'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('Freeze CPU at startup'),
items: {
xtype: 'proxmoxcheckbox',
name: 'freeze',
uncheckedValue: 0,
defaultValue: 0,
deleteDefaultValue: true,
labelWidth: 140,
fieldLabel: gettext('Freeze CPU at startup'),
},
} : undefined,
},
localtime: {
header: gettext('Use local time for RTC'),
defaultValue: '__default__',
renderer: PVE.Utils.render_localtime,
editor: caps.vms['VM.Config.Options'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('Use local time for RTC'),
width: 400,
items: {
xtype: 'proxmoxKVComboBox',
name: 'localtime',
value: '__default__',
comboItems: [
['__default__', PVE.Utils.render_localtime('__default__')],
[1, PVE.Utils.render_localtime(1)],
[0, PVE.Utils.render_localtime(0)],
],
labelWidth: 140,
fieldLabel: gettext('Use local time for RTC'),
},
} : undefined,
},
startdate: {
header: gettext('RTC start date'),
defaultValue: 'now',
editor: caps.vms['VM.Config.Options'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('RTC start date'),
items: {
xtype: 'proxmoxtextfield',
name: 'startdate',
deleteEmpty: true,
value: 'now',
fieldLabel: gettext('RTC start date'),
vtype: 'QemuStartDate',
allowBlank: true,
},
} : undefined,
},
smbios1: {
header: gettext('SMBIOS settings (type1)'),
defaultValue: '',
renderer: Ext.String.htmlEncode,
editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.Smbios1Edit' : undefined,
},
agent: {
header: 'QEMU Guest Agent',
defaultValue: false,
renderer: PVE.Utils.render_qga_features,
editor: caps.vms['VM.Config.Options'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('Qemu Agent'),
width: 350,
onlineHelp: 'qm_qemu_agent',
items: {
xtype: 'pveAgentFeatureSelector',
name: 'agent',
},
} : undefined,
},
protection: {
header: gettext('Protection'),
defaultValue: false,
renderer: Proxmox.Utils.format_boolean,
editor: caps.vms['VM.Config.Options'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('Protection'),
items: {
xtype: 'proxmoxcheckbox',
name: 'protection',
uncheckedValue: 0,
defaultValue: 0,
deleteDefaultValue: true,
fieldLabel: gettext('Enabled'),
},
} : undefined,
},
spice_enhancements: {
header: gettext('Spice Enhancements'),
defaultValue: false,
renderer: PVE.Utils.render_spice_enhancements,
editor: caps.vms['VM.Config.Options'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('Spice Enhancements'),
onlineHelp: 'qm_spice_enhancements',
items: {
xtype: 'pveSpiceEnhancementSelector',
name: 'spice_enhancements',
},
} : undefined,
},
vmstatestorage: {
header: gettext('VM State storage'),
defaultValue: '',
renderer: val => val || gettext('Automatic'),
editor: caps.vms['VM.Config.Options'] ? {
xtype: 'proxmoxWindowEdit',
subject: gettext('VM State storage'),
onlineHelp: 'chapter_virtual_machines', // FIXME: use 'qm_vmstatestorage' once available
width: 350,
items: {
xtype: 'pveStorageSelector',
storageContent: 'images',
allowBlank: true,
emptyText: gettext("Automatic (Storage used by the VM, or 'local')"),
autoSelect: false,
deleteEmpty: true,
skipEmptyText: true,
nodename: nodename,
name: 'vmstatestorage',
},
} : undefined,
},
'amd-sev': {
header: gettext('AMD SEV'),
editor: caps.vms['VM.Config.HWType'] ? 'PVE.qemu.SevEdit' : undefined,
defaultValue: Proxmox.Utils.defaultText + ' (' + Proxmox.Utils.disabledText + ')',
renderer: function(value, metaData, record, ri, ci, store, pending) {
let amd_sev = PVE.Parser.parsePropertyString(value, "type");
if (amd_sev.type === 'std') return 'AMD SEV (' + value + ')';
if (amd_sev.type === 'es') return 'AMD SEV-ES (' + value + ')';
return value;
},
},
hookscript: {
header: gettext('Hookscript'),
},
};
var baseurl = 'nodes/' + nodename + '/qemu/' + vmid + '/config';
var edit_btn = new Ext.Button({
text: gettext('Edit'),
disabled: true,
handler: function() { me.run_editor(); },
});
var revert_btn = new PVE.button.PendingRevert();
var set_button_status = function() {
var sm = me.getSelectionModel();
var rec = sm.getSelection()[0];
if (!rec) {
edit_btn.disable();
return;
}
var key = rec.data.key;
var pending = rec.data.delete || me.hasPendingChanges(key);
var rowdef = rows[key];
edit_btn.setDisabled(!rowdef.editor);
revert_btn.setDisabled(!pending);
};
Ext.apply(me, {
url: "/api2/json/nodes/" + nodename + "/qemu/" + vmid + "/pending",
interval: 5000,
cwidth1: 250,
tbar: [edit_btn, revert_btn],
rows: rows,
editorConfig: {
url: "/api2/extjs/" + baseurl,
},
listeners: {
itemdblclick: me.run_editor,
selectionchange: set_button_status,
},
});
me.callParent();
me.on('activate', () => me.rstore.startUpdate());
me.on('destroy', () => me.rstore.stopUpdate());
me.on('deactivate', () => me.rstore.stopUpdate());
me.mon(me.getStore(), 'datachanged', function() {
set_button_status();
});
},
});
Ext.define('PVE.qemu.PCIInputPanel', {
extend: 'Proxmox.panel.InputPanel',
onlineHelp: 'qm_pci_passthrough_vm_config',
controller: {
xclass: 'Ext.app.ViewController',
setVMConfig: function(vmconfig) {
let me = this;
let view = me.getView();
me.vmconfig = vmconfig;
let hostpci = me.vmconfig[view.confid] || '';
let values = PVE.Parser.parsePropertyString(hostpci, 'host');
if (values.host) {
if (!values.host.match(/^[0-9a-f]{4}:/i)) { // add optional domain
values.host = "0000:" + values.host;
}
if (values.host.length < 11) { // 0000:00:00 format not 0000:00:00.0
values.host += ".0";
values.multifunction = true;
}
values.type = 'raw';
} else if (values.mapping) {
values.type = 'mapped';
}
values['x-vga'] = PVE.Parser.parseBoolean(values['x-vga'], 0);
values.pcie = PVE.Parser.parseBoolean(values.pcie, 0);
values.rombar = PVE.Parser.parseBoolean(values.rombar, 1);
view.setValues(values);
if (!me.vmconfig.machine || me.vmconfig.machine.indexOf('q35') === -1) {
// machine is not set to some variant of q35, so we disable pcie
let pcie = me.lookup('pcie');
pcie.setDisabled(true);
pcie.setBoxLabel(gettext('Q35 only'));
}
if (values.romfile) {
me.lookup('romfile').setVisible(true);
}
},
selectorEnable: function(selector) {
let me = this;
me.pciDevChange(selector, selector.getValue());
},
pciDevChange: function(pcisel, value) {
let me = this;
let mdevfield = me.lookup('mdev');
if (!value) {
if (!pcisel.isDisabled()) {
mdevfield.setDisabled(true);
}
return;
}
let pciDev = pcisel.getStore().getById(value);
mdevfield.setDisabled(!pciDev || !pciDev.data.mdev);
if (!pciDev) {
return;
}
let path = value;
if (pciDev.data.map) {
path = pciDev.data.id;
}
if (pciDev.data.mdev) {
mdevfield.setPciIdOrMapping(path);
}
if (pcisel.reference === 'selector') {
let iommu = pciDev.data.iommugroup;
if (iommu === -1) {
return;
}
// try to find out if there are more devices in that iommu group
let id = path.substring(0, 5); // 00:00
let count = 0;
pcisel.getStore().each(({ data }) => {
if (data.iommugroup === iommu && data.id.substring(0, 5) !== id) {
count++;
return false;
}
return true;
});
me.lookup('group_warning').setVisible(count > 0);
}
},
onGetValues: function(values) {
let me = this;
let view = me.getView();
if (!view.confid) {
for (let i = 0; i < PVE.Utils.hardware_counts.hostpci; i++) {
if (!me.vmconfig['hostpci' + i.toString()]) {
view.confid = 'hostpci' + i.toString();
break;
}
}
// FIXME: what if no confid was found??
}
values.host?.replace(/^0000:/, ''); // remove optional '0000' domain
if (values.multifunction && values.host) {
values.host = values.host.substring(0, values.host.indexOf('.')); // skip the '.X'
delete values.multifunction;
}
if (values.rombar) {
delete values.rombar;
} else {
values.rombar = 0;
}
if (!values.romfile) {
delete values.romfile;
}
delete values.type;
let ret = {};
ret[view.confid] = PVE.Parser.printPropertyString(values, 'host');
return ret;
},
},
viewModel: {
data: {
isMapped: true,
},
},
setVMConfig: function(vmconfig) {
return this.getController().setVMConfig(vmconfig);
},
onGetValues: function(values) {
return this.getController().onGetValues(values);
},
initComponent: function() {
let me = this;
me.nodename = me.pveSelNode.data.node;
if (!me.nodename) {
throw "no node name specified";
}
me.columnT = [
{
xtype: 'displayfield',
reference: 'iommu_warning',
hidden: true,
columnWidth: 1,
padding: '0 0 10 0',
value: 'No IOMMU detected, please activate it.' +
'See Documentation for further information.',
userCls: 'pmx-hint',
},
{
xtype: 'displayfield',
reference: 'group_warning',
hidden: true,
columnWidth: 1,
padding: '0 0 10 0',
itemId: 'iommuwarning',
value: 'The selected Device is not in a separate IOMMU group, make sure this is intended.',
userCls: 'pmx-hint',
},
];
me.column1 = [
{
xtype: 'radiofield',
name: 'type',
inputValue: 'mapped',
boxLabel: gettext('Mapped Device'),
bind: {
value: '{isMapped}',
},
},
{
xtype: 'pvePCIMapSelector',
fieldLabel: gettext('Device'),
reference: 'mapped_selector',
name: 'mapping',
labelAlign: 'right',
nodename: me.nodename,
allowBlank: false,
bind: {
disabled: '{!isMapped}',
},
listeners: {
change: 'pciDevChange',
enable: 'selectorEnable',
},
},
{
xtype: 'radiofield',
name: 'type',
inputValue: 'raw',
checked: true,
boxLabel: gettext('Raw Device'),
},
{
xtype: 'pvePCISelector',
fieldLabel: gettext('Device'),
name: 'host',
reference: 'selector',
nodename: me.nodename,
labelAlign: 'right',
allowBlank: false,
disabled: true,
bind: {
disabled: '{isMapped}',
},
onLoadCallBack: function(store, records, success) {
if (!success || !records.length) {
return;
}
me.lookup('iommu_warning').setVisible(
records.every((val) => val.data.iommugroup === -1),
);
},
listeners: {
change: 'pciDevChange',
enable: 'selectorEnable',
},
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('All Functions'),
reference: 'all_functions',
disabled: true,
labelAlign: 'right',
name: 'multifunction',
bind: {
disabled: '{isMapped}',
},
},
];
me.column2 = [
{
xtype: 'pveMDevSelector',
name: 'mdev',
reference: 'mdev',
disabled: true,
fieldLabel: gettext('MDev Type'),
nodename: me.nodename,
listeners: {
change: function(field, value) {
let multiFunction = me.down('field[name=multifunction]');
if (value) {
multiFunction.setValue(false);
}
multiFunction.setDisabled(!!value);
},
},
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Primary GPU'),
name: 'x-vga',
},
];
me.advancedColumn1 = [
{
xtype: 'proxmoxcheckbox',
fieldLabel: 'ROM-Bar',
name: 'rombar',
},
{
xtype: 'displayfield',
submitValue: true,
hidden: true,
fieldLabel: 'ROM-File',
reference: 'romfile',
name: 'romfile',
},
{
xtype: 'textfield',
name: 'vendor-id',
fieldLabel: Ext.String.format(gettext('{0} ID'), gettext('Vendor')),
emptyText: gettext('From Device'),
vtype: 'PciId',
allowBlank: true,
submitEmpty: false,
},
{
xtype: 'textfield',
name: 'device-id',
fieldLabel: Ext.String.format(gettext('{0} ID'), gettext('Device')),
emptyText: gettext('From Device'),
vtype: 'PciId',
allowBlank: true,
submitEmpty: false,
},
];
me.advancedColumn2 = [
{
xtype: 'proxmoxcheckbox',
fieldLabel: 'PCI-Express',
reference: 'pcie',
name: 'pcie',
},
{
xtype: 'textfield',
name: 'sub-vendor-id',
fieldLabel: Ext.String.format(gettext('{0} ID'), gettext('Sub-Vendor')),
emptyText: gettext('From Device'),
vtype: 'PciId',
allowBlank: true,
submitEmpty: false,
},
{
xtype: 'textfield',
name: 'sub-device-id',
fieldLabel: Ext.String.format(gettext('{0} ID'), gettext('Sub-Device')),
emptyText: gettext('From Device'),
vtype: 'PciId',
allowBlank: true,
submitEmpty: false,
},
];
me.callParent();
},
});
Ext.define('PVE.qemu.PCIEdit', {
extend: 'Proxmox.window.Edit',
subject: gettext('PCI Device'),
vmconfig: undefined,
isAdd: true,
initComponent: function() {
let me = this;
me.isCreate = !me.confid;
let ipanel = Ext.create('PVE.qemu.PCIInputPanel', {
confid: me.confid,
pveSelNode: me.pveSelNode,
});
Ext.apply(me, {
items: [ipanel],
});
me.callParent();
me.load({
success: ({ result }) => ipanel.setVMConfig(result.data),
});
},
});
// The view model of the parent should contain a 'cgroupMode' variable (or params for v2 are used).
Ext.define('PVE.qemu.ProcessorInputPanel', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.pveQemuProcessorPanel',
onlineHelp: 'qm_cpu',
insideWizard: false,
viewModel: {
data: {
socketCount: 1,
coreCount: 1,
showCustomModelPermWarning: false,
userIsRoot: false,
},
formulas: {
totalCoreCount: get => get('socketCount') * get('coreCount'),
cpuunitsDefault: (get) => get('cgroupMode') === 1 ? 1024 : 100,
cpuunitsMin: (get) => get('cgroupMode') === 1 ? 2 : 1,
cpuunitsMax: (get) => get('cgroupMode') === 1 ? 262144 : 10000,
},
},
controller: {
xclass: 'Ext.app.ViewController',
init: function() {
let me = this;
let viewModel = me.getViewModel();
viewModel.set('userIsRoot', Proxmox.UserName === 'root@pam');
},
},
onGetValues: function(values) {
let me = this;
let cpuunitsDefault = me.getViewModel().get('cpuunitsDefault');
if (Array.isArray(values.delete)) {
values.delete = values.delete.join(',');
}
PVE.Utils.delete_if_default(values, 'cpulimit', '0', me.insideWizard);
PVE.Utils.delete_if_default(values, 'cpuunits', `${cpuunitsDefault}`, me.insideWizard);
// build the cpu options:
me.cpu.cputype = values.cputype;
if (values.flags) {
me.cpu.flags = values.flags;
} else {
delete me.cpu.flags;
}
delete values.cputype;
delete values.flags;
var cpustring = PVE.Parser.printQemuCpu(me.cpu);
// remove cputype delete request:
var del = values.delete;
delete values.delete;
if (del) {
del = del.split(',');
Ext.Array.remove(del, 'cputype');
} else {
del = [];
}
if (cpustring) {
values.cpu = cpustring;
} else {
del.push('cpu');
}
var delarr = del.join(',');
if (delarr) {
values.delete = delarr;
}
return values;
},
setValues: function(values) {
let me = this;
let type = values.cputype;
let typeSelector = me.lookupReference('cputype');
let typeStore = typeSelector.getStore();
typeStore.on('load', (store, records, success) => {
if (!success || !type || records.some(x => x.data.name === type)) {
return;
}
// if we get here, a custom CPU model is selected for the VM but we
// don't have permission to configure it - it will not be in the
// list retrieved from the API, so add it manually to allow changing
// other processor options
typeStore.add({
name: type,
displayname: type.replace(/^custom-/, ''),
custom: 1,
vendor: gettext("Unknown"),
});
typeSelector.select(type);
});
me.callParent([values]);
},
cpu: {},
column1: [
{
xtype: 'proxmoxintegerfield',
name: 'sockets',
minValue: 1,
maxValue: 4,
value: '1',
fieldLabel: gettext('Sockets'),
allowBlank: false,
bind: {
value: '{socketCount}',
},
},
{
xtype: 'proxmoxintegerfield',
name: 'cores',
minValue: 1,
maxValue: 256,
value: '1',
fieldLabel: gettext('Cores'),
allowBlank: false,
bind: {
value: '{coreCount}',
},
},
],
column2: [
{
xtype: 'CPUModelSelector',
name: 'cputype',
reference: 'cputype',
fieldLabel: gettext('Type'),
},
{
xtype: 'displayfield',
fieldLabel: gettext('Total cores'),
name: 'totalcores',
isFormField: false,
bind: {
value: '{totalCoreCount}',
},
},
],
columnB: [
{
xtype: 'displayfield',
userCls: 'pmx-hint',
value: gettext('WARNING: You do not have permission to configure custom CPU types, if you change the type you will not be able to go back!'),
hidden: true,
bind: {
hidden: '{!showCustomModelPermWarning}',
},
},
],
advancedColumn1: [
{
xtype: 'proxmoxintegerfield',
name: 'vcpus',
minValue: 1,
maxValue: 1,
value: '',
fieldLabel: gettext('VCPUs'),
deleteEmpty: true,
allowBlank: true,
emptyText: '1',
bind: {
emptyText: '{totalCoreCount}',
maxValue: '{totalCoreCount}',
},
},
{
xtype: 'numberfield',
name: 'cpulimit',
minValue: 0,
maxValue: 128, // api maximum
value: '',
step: 1,
fieldLabel: gettext('CPU limit'),
allowBlank: true,
emptyText: gettext('unlimited'),
},
{
xtype: 'proxmoxtextfield',
name: 'affinity',
vtype: 'CpuSet',
value: '',
fieldLabel: gettext('CPU Affinity'),
allowBlank: true,
emptyText: gettext("All Cores"),
deleteEmpty: true,
bind: {
disabled: '{!userIsRoot}',
},
},
],
advancedColumn2: [
{
xtype: 'proxmoxintegerfield',
name: 'cpuunits',
fieldLabel: gettext('CPU units'),
minValue: '1',
maxValue: '10000',
value: '',
emptyText: '100',
bind: {
minValue: '{cpuunitsMin}',
maxValue: '{cpuunitsMax}',
emptyText: '{cpuunitsDefault}',
},
deleteEmpty: true,
allowBlank: true,
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Enable NUMA'),
name: 'numa',
uncheckedValue: 0,
},
],
advancedColumnB: [
{
xtype: 'label',
text: 'Extra CPU Flags:',
},
{
xtype: 'vmcpuflagselector',
name: 'flags',
},
],
});
Ext.define('PVE.qemu.ProcessorEdit', {
extend: 'Proxmox.window.Edit',
alias: 'widget.pveQemuProcessorEdit',
width: 700,
viewModel: {
data: {
cgroupMode: 2,
},
},
initComponent: function() {
let me = this;
me.getViewModel().set('cgroupMode', me.cgroupMode);
var ipanel = Ext.create('PVE.qemu.ProcessorInputPanel');
Ext.apply(me, {
subject: gettext('Processors'),
items: ipanel,
});
me.callParent();
me.load({
success: function(response, options) {
var data = response.result.data;
var value = data.cpu;
if (value) {
var cpu = PVE.Parser.parseQemuCpu(value);
ipanel.cpu = cpu;
data.cputype = cpu.cputype;
if (cpu.flags) {
data.flags = cpu.flags;
}
let caps = Ext.state.Manager.get('GuiCap');
if (data.cputype.indexOf('custom-') === 0 &&
!caps.nodes['Sys.Audit']) {
let vm = ipanel.getViewModel();
vm.set("showCustomModelPermWarning", true);
}
}
me.setValues(data);
},
});
},
});
Ext.define('PVE.qemu.BiosEdit', {
extend: 'Proxmox.window.Edit',
alias: 'widget.pveQemuBiosEdit',
onlineHelp: 'qm_bios_and_uefi',
subject: 'BIOS',
autoLoad: true,
viewModel: {
data: {
bios: '__default__',
efidisk0: false,
},
formulas: {
showEFIDiskHint: (get) => get('bios') === 'ovmf' && !get('efidisk0'),
},
},
items: [
{
xtype: 'pveQemuBiosSelector',
onlineHelp: 'qm_bios_and_uefi',
name: 'bios',
value: '__default__',
bind: '{bios}',
fieldLabel: 'BIOS',
},
{
xtype: 'displayfield',
name: 'efidisk0',
bind: '{efidisk0}',
hidden: true,
},
{
xtype: 'displayfield',
userCls: 'pmx-hint',
value: gettext('You need to add an EFI disk for storing the EFI settings. See the online help for details.'),
bind: {
hidden: '{!showEFIDiskHint}',
},
},
],
});
Ext.define('PVE.qemu.RNGInputPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveRNGInputPanel',
onlineHelp: 'qm_virtio_rng',
onGetValues: function(values) {
if (values.max_bytes === "") {
values.max_bytes = "0";
} else if (values.max_bytes === "1024" && values.period === "") {
delete values.max_bytes;
}
var ret = PVE.Parser.printPropertyString(values);
return {
rng0: ret,
};
},
setValues: function(values) {
if (values.max_bytes === 0) {
values.max_bytes = null;
}
this.callParent(arguments);
},
controller: {
xclass: 'Ext.app.ViewController',
control: {
'#max_bytes': {
change: function(el, newVal) {
let limitWarning = this.lookupReference('limitWarning');
limitWarning.setHidden(!!newVal);
},
},
'#source': {
change: function(el, newVal) {
let limitWarning = this.lookupReference('sourceWarning');
limitWarning.setHidden(newVal !== '/dev/random');
},
},
},
},
items: [{
itemId: 'source',
name: 'source',
xtype: 'proxmoxKVComboBox',
value: '/dev/urandom',
fieldLabel: gettext('Entropy source'),
labelWidth: 130,
comboItems: [
['/dev/urandom', '/dev/urandom'],
['/dev/random', '/dev/random'],
['/dev/hwrng', '/dev/hwrng'],
],
},
{
xtype: 'numberfield',
itemId: 'max_bytes',
name: 'max_bytes',
minValue: 0,
step: 1,
value: 1024,
fieldLabel: gettext('Limit (Bytes/Period)'),
labelWidth: 130,
emptyText: gettext('unlimited'),
},
{
xtype: 'numberfield',
name: 'period',
minValue: 1,
step: 1,
fieldLabel: gettext('Period') + ' (ms)',
labelWidth: 130,
emptyText: '1000',
},
{
xtype: 'displayfield',
reference: 'sourceWarning',
value: gettext('Using /dev/random as entropy source is discouraged, as it can lead to host entropy starvation. /dev/urandom is preferred, and does not lead to a decrease in security in practice.'),
userCls: 'pmx-hint',
hidden: true,
},
{
xtype: 'displayfield',
reference: 'limitWarning',
value: gettext('Disabling the limiter can potentially allow a guest to overload the host. Proceed with caution.'),
userCls: 'pmx-hint',
hidden: true,
}],
});
Ext.define('PVE.qemu.RNGEdit', {
extend: 'Proxmox.window.Edit',
subject: gettext('VirtIO RNG'),
items: [{
xtype: 'pveRNGInputPanel',
}],
initComponent: function() {
var me = this;
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response) {
me.vmconfig = response.result.data;
var rng0 = me.vmconfig.rng0;
if (rng0) {
me.setValues(PVE.Parser.parsePropertyString(rng0));
}
},
});
}
},
});
Ext.define('PVE.qemu.SSHKeyInputPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveQemuSSHKeyInputPanel',
insideWizard: false,
onGetValues: function(values) {
var me = this;
if (values.sshkeys) {
values.sshkeys.trim();
}
if (!values.sshkeys.length) {
values = {};
values.delete = 'sshkeys';
return values;
} else {
values.sshkeys = encodeURIComponent(values.sshkeys);
}
return values;
},
items: [
{
xtype: 'textarea',
itemId: 'sshkeys',
name: 'sshkeys',
height: 250,
},
{
xtype: 'filebutton',
itemId: 'filebutton',
name: 'file',
text: gettext('Load SSH Key File'),
fieldLabel: 'test',
listeners: {
change: function(btn, e, value) {
let view = this.up('inputpanel');
e = e.event;
Ext.Array.each(e.target.files, function(file) {
PVE.Utils.loadSSHKeyFromFile(file, function(res) {
let keysField = view.down('#sshkeys');
var old = keysField.getValue();
keysField.setValue(old + res);
});
});
btn.reset();
},
},
},
],
initComponent: function() {
var me = this;
me.callParent();
if (!window.FileReader) {
me.down('#filebutton').setVisible(false);
}
},
});
Ext.define('PVE.qemu.SSHKeyEdit', {
extend: 'Proxmox.window.Edit',
width: 800,
initComponent: function() {
var me = this;
var ipanel = Ext.create('PVE.qemu.SSHKeyInputPanel');
Ext.apply(me, {
subject: gettext('SSH Keys'),
items: [ipanel],
});
me.callParent();
if (!me.create) {
me.load({
success: function(response, options) {
var data = response.result.data;
if (data.sshkeys) {
data.sshkeys = decodeURIComponent(data.sshkeys);
ipanel.setValues(data);
}
},
});
}
},
});
Ext.define('PVE.qemu.ScsiHwEdit', {
extend: 'Proxmox.window.Edit',
initComponent: function() {
var me = this;
Ext.applyIf(me, {
subject: gettext('SCSI Controller Type'),
items: {
xtype: 'pveScsiHwSelector',
name: 'scsihw',
value: '__default__',
fieldLabel: gettext('Type'),
},
});
me.callParent();
me.load();
},
});
Ext.define('PVE.qemu.SerialnputPanel', {
extend: 'Proxmox.panel.InputPanel',
autoComplete: false,
setVMConfig: function(vmconfig) {
var me = this, i;
me.vmconfig = vmconfig;
for (i = 0; i < 4; i++) {
var port = 'serial' + i.toString();
if (!me.vmconfig[port]) {
me.down('field[name=serialid]').setValue(i);
break;
}
}
},
onGetValues: function(values) {
var me = this;
var id = 'serial' + values.serialid;
delete values.serialid;
values[id] = 'socket';
return values;
},
items: [
{
xtype: 'proxmoxintegerfield',
name: 'serialid',
fieldLabel: gettext('Serial Port'),
minValue: 0,
maxValue: 3,
allowBlank: false,
validator: function(id) {
if (!this.rendered) {
return true;
}
let view = this.up('panel');
if (view.vmconfig !== undefined && Ext.isDefined(view.vmconfig['serial' + id])) {
return "This device is already in use.";
}
return true;
},
},
],
});
Ext.define('PVE.qemu.SerialEdit', {
extend: 'Proxmox.window.Edit',
vmconfig: undefined,
isAdd: true,
subject: gettext('Serial Port'),
initComponent: function() {
var me = this;
// for now create of (socket) serial port only
me.isCreate = true;
var ipanel = Ext.create('PVE.qemu.SerialnputPanel', {});
Ext.apply(me, {
items: [ipanel],
});
me.callParent();
me.load({
success: function(response, options) {
ipanel.setVMConfig(response.result.data);
},
});
},
});
Ext.define('PVE.qemu.SevInputPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveSevInputPanel',
onlineHelp: 'qm_memory', // TODO: change to 'qm_memory_encryption' one available
viewModel: {
data: {
type: '__default__',
},
formulas: {
sevEnabled: get => get('type') !== '__default__',
},
},
onGetValues: function(values) {
if (values.delete === 'type') {
values.delete = 'amd-sev';
return values;
}
if (!values.debug) {
values["no-debug"] = 1;
}
if (!values["key-sharing"]) {
values["no-key-sharing"] = 1;
}
delete values.debug;
delete values["key-sharing"];
let ret = {};
ret['amd-sev'] = PVE.Parser.printPropertyString(values, 'type');
return ret;
},
setValues: function(values) {
if (PVE.Parser.parseBoolean(values["no-debug"])) {
values.debug = 0;
}
if (PVE.Parser.parseBoolean(values["no-key-sharing"])) {
values["key-sharing"] = 0;
}
this.callParent(arguments);
},
items: {
xtype: 'proxmoxKVComboBox',
fieldLabel: gettext('AMD SEV Type'),
labelWidth: 150,
name: 'type',
value: '__default__',
comboItems: [
['__default__', Proxmox.Utils.defaultText + ' (' + Proxmox.Utils.disabledText + ')'],
['std', 'AMD SEV'],
['es', 'AMD SEV-ES (highly experimental)'],
],
bind: {
value: '{type}',
},
},
advancedItems: [
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Allow Debugging'),
labelWidth: 150,
name: 'debug',
value: 1,
bind: {
hidden: '{!sevEnabled}',
disabled: '{!sevEnabled}',
},
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Allow Key-Sharing'),
labelWidth: 150,
name: 'key-sharing',
value: 1,
bind: {
hidden: '{!sevEnabled}',
disabled: '{!sevEnabled}',
},
},
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Enable Kernel Hashes'),
labelWidth: 150,
name: 'kernel-hashes',
deleteDefaultValue: false,
bind: {
hidden: '{!sevEnabled}',
disabled: '{!sevEnabled}',
},
},
],
});
Ext.define('PVE.qemu.SevEdit', {
extend: 'Proxmox.window.Edit',
subject: 'AMD Secure Encrypted Virtualization (SEV)',
items: {
xtype: 'pveSevInputPanel',
},
width: 400,
initComponent: function() {
let me = this;
me.callParent();
me.load({
success: function(response) {
let conf = response.result.data;
let amd_sev = conf['amd-sev'] || '__default__';
me.setValues(PVE.Parser.parsePropertyString(amd_sev, 'type'));
},
});
},
});
Ext.define('PVE.qemu.Smbios1InputPanel', {
extend: 'Proxmox.panel.InputPanel',
alias: 'widget.PVE.qemu.Smbios1InputPanel',
insideWizard: false,
smbios1: {},
onGetValues: function(values) {
var me = this;
var params = {
smbios1: PVE.Parser.printQemuSmbios1(values),
};
return params;
},
setSmbios1: function(data) {
var me = this;
me.smbios1 = data;
me.setValues(me.smbios1);
},
items: [
{
xtype: 'textfield',
fieldLabel: 'UUID',
regex: /^[a-fA-F0-9]{8}(?:-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}$/,
name: 'uuid',
},
{
xtype: 'textareafield',
fieldLabel: gettext('Manufacturer'),
fieldStyle: {
height: '2em',
minHeight: '2em',
},
name: 'manufacturer',
},
{
xtype: 'textareafield',
fieldLabel: gettext('Product'),
fieldStyle: {
height: '2em',
minHeight: '2em',
},
name: 'product',
},
{
xtype: 'textareafield',
fieldLabel: gettext('Version'),
fieldStyle: {
height: '2em',
minHeight: '2em',
},
name: 'version',
},
{
xtype: 'textareafield',
fieldLabel: gettext('Serial'),
fieldStyle: {
height: '2em',
minHeight: '2em',
},
name: 'serial',
},
{
xtype: 'textareafield',
fieldLabel: 'SKU',
fieldStyle: {
height: '2em',
minHeight: '2em',
},
name: 'sku',
},
{
xtype: 'textareafield',
fieldLabel: gettext('Family'),
fieldStyle: {
height: '2em',
minHeight: '2em',
},
name: 'family',
},
],
});
Ext.define('PVE.qemu.Smbios1Edit', {
extend: 'Proxmox.window.Edit',
initComponent: function() {
var me = this;
var ipanel = Ext.create('PVE.qemu.Smbios1InputPanel', {});
Ext.applyIf(me, {
subject: gettext('SMBIOS settings (type1)'),
width: 450,
items: ipanel,
});
me.callParent();
me.load({
success: function(response, options) {
me.vmconfig = response.result.data;
var value = me.vmconfig.smbios1;
if (value) {
var data = PVE.Parser.parseQemuSmbios1(value);
if (!data) {
Ext.Msg.alert(gettext('Error'), 'Unable to parse smbios options');
me.close();
return;
}
ipanel.setSmbios1(data);
}
},
});
},
});
Ext.define('PVE.qemu.SystemInputPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pveQemuSystemPanel',
onlineHelp: 'qm_system_settings',
viewModel: {
data: {
efi: false,
addefi: true,
},
formulas: {
efidisk: function(get) {
return get('efi') && get('addefi');
},
},
},
onGetValues: function(values) {
if (values.vga && values.vga.substr(0, 6) === 'serial') {
values['serial' + values.vga.substr(6, 1)] = 'socket';
}
delete values.hdimage;
delete values.hdstorage;
delete values.diskformat;
delete values.preEnrolledKeys; // efidisk
delete values.version; // tpmstate
return values;
},
controller: {
xclass: 'Ext.app.ViewController',
scsihwChange: function(field, value) {
var me = this;
if (me.getView().insideWizard) {
me.getViewModel().set('current.scsihw', value);
}
},
biosChange: function(field, value) {
var me = this;
if (me.getView().insideWizard) {
me.getViewModel().set('efi', value === 'ovmf');
}
},
control: {
'pveScsiHwSelector': {
change: 'scsihwChange',
},
'pveQemuBiosSelector': {
change: 'biosChange',
},
'#': {
afterrender: 'setMachine',
},
},
setMachine: function() {
let me = this;
let vm = this.getViewModel();
let ostype = vm.get('current.ostype');
if (ostype === 'win11') {
me.lookup('machine').setValue('q35');
me.lookup('bios').setValue('ovmf');
me.lookup('addtpmbox').setValue(true);
}
},
},
column1: [
{
xtype: 'proxmoxKVComboBox',
value: '__default__',
deleteEmpty: false,
fieldLabel: gettext('Graphic card'),
name: 'vga',
comboItems: Object.entries(PVE.Utils.kvm_vga_drivers),
},
{
xtype: 'proxmoxKVComboBox',
name: 'machine',
reference: 'machine',
value: '__default__',
fieldLabel: gettext('Machine'),
comboItems: [
['__default__', PVE.Utils.render_qemu_machine('')],
['q35', 'q35'],
],
},
{
xtype: 'displayfield',
value: gettext('Firmware'),
},
{
xtype: 'pveQemuBiosSelector',
name: 'bios',
reference: 'bios',
value: '__default__',
fieldLabel: 'BIOS',
},
{
xtype: 'proxmoxcheckbox',
bind: {
value: '{addefi}',
hidden: '{!efi}',
disabled: '{!efi}',
},
hidden: true,
submitValue: false,
disabled: true,
fieldLabel: gettext('Add EFI Disk'),
},
{
xtype: 'pveEFIDiskInputPanel',
name: 'efidisk0',
storageContent: 'images',
bind: {
nodename: '{nodename}',
hidden: '{!efi}',
disabled: '{!efidisk}',
},
autoSelect: false,
disabled: true,
hidden: true,
hideSize: true,
usesEFI: true,
},
],
column2: [
{
xtype: 'pveScsiHwSelector',
name: 'scsihw',
value: '__default__',
bind: {
value: '{current.scsihw}',
},
fieldLabel: gettext('SCSI Controller'),
},
{
xtype: 'proxmoxcheckbox',
name: 'agent',
uncheckedValue: 0,
defaultValue: 0,
deleteDefaultValue: true,
fieldLabel: gettext('Qemu Agent'),
},
{
// fake for spacing
xtype: 'displayfield',
value: ' ',
},
{
xtype: 'proxmoxcheckbox',
reference: 'addtpmbox',
bind: {
value: '{addtpm}',
},
submitValue: false,
fieldLabel: gettext('Add TPM'),
},
{
xtype: 'pveTPMDiskInputPanel',
name: 'tpmstate0',
storageContent: 'images',
bind: {
nodename: '{nodename}',
hidden: '{!addtpm}',
disabled: '{!addtpm}',
},
disabled: true,
hidden: true,
},
],
});
Ext.define('PVE.qemu.USBInputPanel', {
extend: 'Proxmox.panel.InputPanel',
mixins: ['Proxmox.Mixin.CBind'],
autoComplete: false,
onlineHelp: 'qm_usb_passthrough',
cbindData: function(initialConfig) {
let me = this;
if (!me.pveSelNode) {
throw "no pveSelNode given";
}
return { nodename: me.pveSelNode.data.node };
},
viewModel: {
data: {},
},
setVMConfig: function(vmconfig) {
var me = this;
me.vmconfig = vmconfig;
let max_usb = PVE.Utils.get_max_usb_count(me.vmconfig.ostype, me.vmconfig.machine);
if (max_usb > PVE.Utils.hardware_counts.usb_old) {
me.down('field[name=usb3]').setDisabled(true);
}
},
onGetValues: function(values) {
var me = this;
if (!me.confid) {
let max_usb = PVE.Utils.get_max_usb_count(me.vmconfig.ostype, me.vmconfig.machine);
for (let i = 0; i < max_usb; i++) {
let id = 'usb' + i.toString();
if (!me.vmconfig[id]) {
me.confid = id;
break;
}
}
}
var val = "";
var type = me.down('radiofield').getGroupValue();
switch (type) {
case 'spice':
val = 'spice';
break;
case 'mapped':
val = `mapping=${values[type]}`;
delete values.mapped;
break;
case 'hostdevice':
case 'port':
val = 'host=' + values[type];
delete values[type];
break;
default:
throw "invalid type selected";
}
if (values.usb3) {
delete values.usb3;
val += ',usb3=1';
}
values[me.confid] = val;
return values;
},
items: [
{
xtype: 'fieldcontainer',
defaultType: 'radiofield',
layout: 'fit',
items: [
{
name: 'usb',
inputValue: 'spice',
boxLabel: gettext('Spice Port'),
submitValue: false,
checked: true,
},
{
name: 'usb',
inputValue: 'mapped',
boxLabel: gettext('Use mapped Device'),
reference: 'mapped',
submitValue: false,
},
{
xtype: 'pveUSBMapSelector',
disabled: true,
name: 'mapped',
cbind: { nodename: '{nodename}' },
bind: { disabled: '{!mapped.checked}' },
allowBlank: false,
fieldLabel: gettext('Choose Device'),
labelAlign: 'right',
},
{
name: 'usb',
inputValue: 'hostdevice',
boxLabel: gettext('Use USB Vendor/Device ID'),
reference: 'hostdevice',
submitValue: false,
},
{
xtype: 'pveUSBSelector',
disabled: true,
type: 'device',
name: 'hostdevice',
cbind: { pveSelNode: '{pveSelNode}' },
bind: { disabled: '{!hostdevice.checked}' },
editable: true,
allowBlank: false,
fieldLabel: gettext('Choose Device'),
labelAlign: 'right',
},
{
name: 'usb',
inputValue: 'port',
boxLabel: gettext('Use USB Port'),
reference: 'port',
submitValue: false,
},
{
xtype: 'pveUSBSelector',
disabled: true,
name: 'port',
cbind: { pveSelNode: '{pveSelNode}' },
bind: { disabled: '{!port.checked}' },
editable: true,
type: 'port',
allowBlank: false,
fieldLabel: gettext('Choose Port'),
labelAlign: 'right',
},
{
xtype: 'checkbox',
name: 'usb3',
inputValue: true,
checked: true,
reference: 'usb3',
fieldLabel: gettext('Use USB3'),
},
],
},
],
});
Ext.define('PVE.qemu.USBEdit', {
extend: 'Proxmox.window.Edit',
vmconfig: undefined,
isAdd: true,
width: 400,
subject: gettext('USB Device'),
initComponent: function() {
var me = this;
me.isCreate = !me.confid;
var ipanel = Ext.create('PVE.qemu.USBInputPanel', {
confid: me.confid,
pveSelNode: me.pveSelNode,
});
Ext.apply(me, {
items: [ipanel],
});
me.callParent();
me.load({
success: function(response, options) {
ipanel.setVMConfig(response.result.data);
if (me.isCreate) {
return;
}
let data = PVE.Parser.parsePropertyString(response.result.data[me.confid], 'host');
let port, hostdevice, mapped, usb3 = false;
let usb;
if (data.host) {
if (/^(0x)?[a-zA-Z0-9]{4}:(0x)?[a-zA-Z0-9]{4}$/.test(data.host)) {
hostdevice = data.host.replace('0x', '');
usb = 'hostdevice';
} else if (/^(\d+)-(\d+(\.\d+)*)$/.test(data.host)) {
port = data.host;
usb = 'port';
} else if (/^spice$/i.test(data.host)) {
usb = 'spice';
}
} else if (data.mapping) {
mapped = data.mapping;
usb = 'mapped';
}
usb3 = data.usb3 ?? false;
var values = {
usb,
hostdevice,
port,
usb3,
mapped,
};
ipanel.setValues(values);
},
});
},
});
Ext.define('PVE.sdn.Browser', {
extend: 'PVE.panel.Config',
alias: 'widget.PVE.sdn.Browser',
onlineHelp: 'chapter_pvesdn',
initComponent: function() {
let me = this;
let nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
let sdnId = me.pveSelNode.data.sdn;
if (!sdnId) {
throw "no sdn ID specified";
}
me.items = [];
Ext.apply(me, {
title: Ext.String.format(gettext("Zone {0} on node {1}"), `'${sdnId}'`, `'${nodename}'`),
hstateid: 'sdntab',
});
const caps = Ext.state.Manager.get('GuiCap');
me.items.push({
nodename: nodename,
zone: sdnId,
xtype: 'pveSDNZoneContentPanel',
title: gettext('Content'),
iconCls: 'fa fa-th',
itemId: 'content',
});
if (caps.sdn['Permissions.Modify']) {
me.items.push({
xtype: 'pveACLView',
title: gettext('Permissions'),
iconCls: 'fa fa-unlock',
itemId: 'permissions',
path: `/sdn/zones/${sdnId}`,
});
}
me.callParent();
},
});
Ext.define('PVE.sdn.ControllerView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveSDNControllerView'],
onlineHelp: 'pvesdn_config_controllers',
stateful: true,
stateId: 'grid-sdn-controller',
createSDNControllerEditWindow: function(type, sid) {
var schema = PVE.Utils.sdncontrollerSchema[type];
if (!schema || !schema.ipanel) {
throw "no editor registered for controller type: " + type;
}
Ext.create('PVE.sdn.controllers.BaseEdit', {
paneltype: 'PVE.sdn.controllers.' + schema.ipanel,
type: type,
controllerid: sid,
autoShow: true,
listeners: {
destroy: this.reloadStore,
},
});
},
initComponent: function() {
var me = this;
var store = new Ext.data.Store({
model: 'pve-sdn-controller',
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/sdn/controllers?pending=1",
},
sorters: {
property: 'controller',
direction: 'ASC',
},
});
let sm = Ext.create('Ext.selection.RowModel', {});
let run_editor = function() {
let rec = sm.getSelection()[0];
if (!rec) {
return;
}
let type = rec.data.type, controller = rec.data.controller;
me.createSDNControllerEditWindow(type, controller);
};
let edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
selModel: sm,
handler: run_editor,
});
let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
selModel: sm,
baseurl: '/cluster/sdn/controllers/',
callback: () => store.load(),
});
// else we cannot dynamically generate the add menu handlers
let addHandleGenerator = function(type) {
return function() { me.createSDNControllerEditWindow(type); };
};
let addMenuItems = [];
for (const [type, controller] of Object.entries(PVE.Utils.sdncontrollerSchema)) {
if (controller.hideAdd) {
continue;
}
addMenuItems.push({
text: PVE.Utils.format_sdncontroller_type(type),
iconCls: 'fa fa-fw fa-' + controller.faIcon,
handler: addHandleGenerator(type),
});
}
Ext.apply(me, {
store: store,
reloadStore: () => store.load(),
selModel: sm,
viewConfig: {
trackOver: false,
},
tbar: [
{
text: gettext('Add'),
menu: new Ext.menu.Menu({
items: addMenuItems,
}),
},
remove_btn,
edit_btn,
],
columns: [
{
header: 'ID',
flex: 2,
sortable: true,
dataIndex: 'controller',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'controller', 1);
},
},
{
header: gettext('Type'),
flex: 1,
sortable: true,
dataIndex: 'type',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'type', 1);
},
},
{
header: gettext('Node'),
flex: 1,
sortable: true,
dataIndex: 'node',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'node', 1);
},
},
{
header: gettext('State'),
width: 100,
dataIndex: 'state',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending_state(rec, value);
},
},
],
listeners: {
activate: () => store.load(),
itemdblclick: run_editor,
},
});
store.load();
me.callParent();
},
});
Ext.define('PVE.sdn.Status', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveSDNStatus',
onlineHelp: 'chapter_pvesdn',
layout: {
type: 'vbox',
align: 'stretch',
},
initComponent: function() {
var me = this;
me.rstore = Ext.create('Proxmox.data.ObjectStore', {
interval: me.interval,
model: 'pve-sdn-status',
storeid: 'pve-store-' + ++Ext.idSeed,
groupField: 'type',
proxy: {
type: 'proxmox',
url: '/api2/json/cluster/resources',
},
});
me.items = [{
xtype: 'pveSDNStatusView',
title: gettext('Status'),
rstore: me.rstore,
border: 0,
collapsible: true,
padding: '0 0 20 0',
}];
me.callParent();
me.on('activate', me.rstore.startUpdate);
},
});
Ext.define('PVE.sdn.StatusView', {
extend: 'Ext.grid.GridPanel',
alias: 'widget.pveSDNStatusView',
sortPriority: {
sdn: 1,
node: 2,
status: 3,
},
initComponent: function() {
var me = this;
if (!me.rstore) {
throw "no rstore given";
}
Proxmox.Utils.monStoreErrors(me, me.rstore);
var store = Ext.create('Proxmox.data.DiffStore', {
rstore: me.rstore,
sortAfterUpdate: true,
sorters: [{
sorterFn: function(rec1, rec2) {
var p1 = me.sortPriority[rec1.data.type];
var p2 = me.sortPriority[rec2.data.type];
return p1 !== p2 ? p1 > p2 ? 1 : -1 : 0;
},
}],
filters: {
property: 'type',
value: 'sdn',
operator: '==',
},
});
Ext.apply(me, {
store: store,
stateful: false,
tbar: [
{
text: gettext('Apply'),
handler: function() {
Ext.Msg.show({
title: gettext('Confirm'),
icon: Ext.Msg.QUESTION,
msg: gettext('Applying pending SDN changes will also apply any pending local node network changes. Proceed?'),
buttons: Ext.Msg.YESNO,
callback: function(btn) {
if (btn === 'yes') {
Proxmox.Utils.API2Request({
url: '/cluster/sdn/',
method: 'PUT',
waitMsgTarget: me,
failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
}
},
});
},
},
],
viewConfig: {
trackOver: false,
},
columns: [
{
header: 'SDN',
width: 80,
dataIndex: 'sdn',
},
{
header: gettext('Node'),
width: 80,
dataIndex: 'node',
},
{
header: gettext('Status'),
width: 80,
flex: 1,
dataIndex: 'status',
},
],
});
me.callParent();
me.on('activate', me.rstore.startUpdate);
me.on('destroy', me.rstore.stopUpdate);
},
}, function() {
Ext.define('pve-sdn-status', {
extend: 'Ext.data.Model',
fields: [
'id', 'type', 'node', 'status', 'sdn',
],
idProperty: 'id',
});
});
Ext.define('PVE.sdn.VnetInputPanel', {
extend: 'Proxmox.panel.InputPanel',
mixins: ['Proxmox.Mixin.CBind'],
onGetValues: function(values) {
let me = this;
if (me.isCreate) {
values.type = 'vnet';
}
return values;
},
initComponent: function() {
let me = this;
me.callParent();
me.setZoneType(undefined);
},
items: [
{
xtype: 'pmxDisplayEditField',
name: 'vnet',
cbind: {
editable: '{isCreate}',
},
maxLength: 8,
flex: 1,
allowBlank: false,
fieldLabel: gettext('Name'),
},
{
xtype: 'proxmoxtextfield',
name: 'alias',
fieldLabel: gettext('Alias'),
allowBlank: true,
skipEmptyText: true,
cbind: {
deleteEmpty: "{!isCreate}",
},
},
{
xtype: 'pveSDNZoneSelector',
fieldLabel: gettext('Zone'),
name: 'zone',
value: '',
allowBlank: false,
listeners: {
change: function() {
let me = this;
let record = me.findRecordByValue(me.value);
let zoneType = record?.data?.type;
let panel = me.up('panel');
panel.setZoneType(zoneType);
},
},
},
{
xtype: 'proxmoxintegerfield',
itemId: 'sdnVnetTagField',
name: 'tag',
minValue: 1,
maxValue: 16777216,
fieldLabel: gettext('Tag'),
allowBlank: true,
cbind: {
deleteEmpty: "{!isCreate}",
},
},
],
advancedItems: [
{
xtype: 'proxmoxcheckbox',
name: 'isolate-ports',
uncheckedValue: null,
checked: false,
fieldLabel: gettext('Isolate Ports'),
cbind: {
deleteEmpty: "{!isCreate}",
},
},
{
xtype: 'proxmoxcheckbox',
itemId: 'sdnVnetVlanAwareField',
name: 'vlanaware',
uncheckedValue: null,
checked: false,
fieldLabel: gettext('VLAN Aware'),
cbind: {
deleteEmpty: "{!isCreate}",
},
},
],
setZoneType: function(zoneType) {
let me = this;
let tagField = me.down('#sdnVnetTagField');
if (!zoneType || zoneType === 'simple') {
tagField.setVisible(false);
tagField.setValue('');
} else {
tagField.setVisible(true);
}
let vlanField = me.down('#sdnVnetVlanAwareField');
if (!zoneType || zoneType === 'evpn') {
vlanField.setVisible(false);
vlanField.setValue('');
} else {
vlanField.setVisible(true);
}
},
});
Ext.define('PVE.sdn.VnetEdit', {
extend: 'Proxmox.window.Edit',
subject: gettext('VNet'),
vnet: undefined,
width: 350,
initComponent: function() {
var me = this;
me.isCreate = me.vnet === undefined;
if (me.isCreate) {
me.url = '/api2/extjs/cluster/sdn/vnets';
me.method = 'POST';
} else {
me.url = '/api2/extjs/cluster/sdn/vnets/' + me.vnet;
me.method = 'PUT';
}
let ipanel = Ext.create('PVE.sdn.VnetInputPanel', {
isCreate: me.isCreate,
});
Ext.apply(me, {
items: [
ipanel,
],
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
let values = response.result.data;
ipanel.setValues(values);
},
});
}
},
});
Ext.define('PVE.sdn.VnetView', {
extend: 'Ext.grid.GridPanel',
alias: 'widget.pveSDNVnetView',
onlineHelp: 'pvesdn_config_vnet',
emptyText: gettext('No VNet configured.'),
stateful: true,
stateId: 'grid-sdn-vnet',
subnetview_panel: undefined,
initComponent: function() {
let me = this;
let store = new Ext.data.Store({
model: 'pve-sdn-vnet',
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/sdn/vnets?pending=1",
},
sorters: {
property: 'vnet',
direction: 'ASC',
},
});
let reload = () => store.load();
let sm = Ext.create('Ext.selection.RowModel', {});
let run_editor = function() {
let rec = sm.getSelection()[0];
let win = Ext.create('PVE.sdn.VnetEdit', {
autoShow: true,
onlineHelp: 'pvesdn_config_vnet',
vnet: rec.data.vnet,
});
win.on('destroy', reload);
};
let edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
selModel: sm,
handler: run_editor,
});
let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
selModel: sm,
baseurl: '/cluster/sdn/vnets/',
callback: reload,
});
let set_button_status = function() {
var rec = me.selModel.getSelection()[0];
if (!rec || rec.data.state === 'deleted') {
edit_btn.disable();
remove_btn.disable();
}
};
Ext.apply(me, {
store: store,
reloadStore: reload,
selModel: sm,
viewConfig: {
trackOver: false,
},
tbar: [
{
text: gettext('Create'),
handler: function() {
let win = Ext.create('PVE.sdn.VnetEdit', {
autoShow: true,
onlineHelp: 'pvesdn_config_vnet',
type: 'vnet',
});
win.on('destroy', reload);
},
},
remove_btn,
edit_btn,
],
columns: [
{
header: 'ID',
flex: 2,
dataIndex: 'vnet',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'vnet', 1);
},
},
{
header: gettext('Alias'),
flex: 1,
dataIndex: 'alias',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'alias');
},
},
{
header: gettext('Zone'),
flex: 1,
dataIndex: 'zone',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'zone');
},
},
{
header: gettext('Tag'),
flex: 1,
dataIndex: 'tag',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'tag');
},
},
{
header: gettext('VLAN Aware'),
flex: 1,
dataIndex: 'vlanaware',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'vlanaware');
},
},
{
header: gettext('State'),
width: 100,
dataIndex: 'state',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending_state(rec, value);
},
},
],
listeners: {
activate: reload,
itemdblclick: run_editor,
selectionchange: set_button_status,
show: reload,
select: function(_sm, rec) {
let url = `/cluster/sdn/vnets/${rec.data.vnet}/subnets`;
me.subnetview_panel.setBaseUrl(url);
},
deselect: function() {
me.subnetview_panel.setBaseUrl(undefined);
},
},
});
store.load();
me.callParent();
},
});
Ext.define('PVE.sdn.VnetACLAdd', {
extend: 'Proxmox.window.Edit',
alias: ['widget.pveSDNVnetACLAdd'],
url: '/access/acl',
method: 'PUT',
isAdd: true,
isCreate: true,
width: 400,
initComponent: function() {
let me = this;
let items = [
{
xtype: 'hiddenfield',
name: 'path',
value: me.path,
allowBlank: false,
fieldLabel: gettext('Path'),
},
];
if (me.aclType === 'group') {
me.subject = gettext("Group Permission");
items.push({
xtype: 'pveGroupSelector',
name: 'groups',
fieldLabel: gettext('Group'),
});
} else if (me.aclType === 'user') {
me.subject = gettext("User Permission");
items.push({
xtype: 'pmxUserSelector',
name: 'users',
fieldLabel: gettext('User'),
});
} else if (me.aclType === 'token') {
me.subject = gettext("API Token Permission");
items.push({
xtype: 'pveTokenSelector',
name: 'tokens',
fieldLabel: gettext('API Token'),
});
} else {
throw "unknown ACL type";
}
items.push({
xtype: 'pmxRoleSelector',
name: 'roles',
value: 'NoAccess',
fieldLabel: gettext('Role'),
});
items.push({
xtype: 'proxmoxintegerfield',
name: 'vlan',
minValue: 1,
maxValue: 4096,
allowBlank: true,
fieldLabel: 'VLAN',
emptyText: gettext('All'),
});
let ipanel = Ext.create('Proxmox.panel.InputPanel', {
items: items,
onlineHelp: 'pveum_permission_management',
onGetValues: function(values) {
if (values.vlan) {
values.path = values.path + "/" + values.vlan;
delete values.vlan;
}
return values;
},
});
Ext.apply(me, {
items: [ipanel],
});
me.callParent();
},
});
Ext.define('PVE.sdn.VnetACLView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveSDNVnetACLView'],
onlineHelp: 'chapter_user_management',
stateful: true,
stateId: 'grid-acls',
// use fixed path
path: undefined,
setPath: function(path) {
let me = this;
me.path = path;
if (path === undefined) {
me.down('#groupmenu').setDisabled(true);
me.down('#usermenu').setDisabled(true);
me.down('#tokenmenu').setDisabled(true);
} else {
me.down('#groupmenu').setDisabled(false);
me.down('#usermenu').setDisabled(false);
me.down('#tokenmenu').setDisabled(false);
me.store.load();
}
},
initComponent: function() {
let me = this;
let store = Ext.create('Ext.data.Store', {
model: 'pve-acl',
proxy: {
type: 'proxmox',
url: "/api2/json/access/acl",
},
sorters: {
property: 'path',
direction: 'ASC',
},
});
store.addFilter(Ext.create('Ext.util.Filter', {
filterFn: item => item.data.path.replace(/(\/sdn\/zones\/(.*)\/(.*))\/[0-9]*$/, '$1') === me.path,
}));
let render_ugid = function(ugid, metaData, record) {
if (record.data.type === 'group') {
return '@' + ugid;
}
return Ext.String.htmlEncode(ugid);
};
let render_vlan = function(path, metaData, record) {
let vlan = 'any';
const match = path.match(/(\/sdn\/zones\/)(.*)\/(.*)\/([0-9]*)$/);
if (match) {
vlan = match[4];
}
return Ext.String.htmlEncode(vlan);
};
let columns = [
{
header: gettext('User') + '/' + gettext('Group') + '/' + gettext('API Token'),
flex: 1,
sortable: true,
renderer: render_ugid,
dataIndex: 'ugid',
},
{
header: gettext('Role'),
flex: 1,
sortable: true,
dataIndex: 'roleid',
},
{
header: gettext('VLAN'),
flex: 1,
sortable: true,
renderer: render_vlan,
dataIndex: 'path',
},
];
let sm = Ext.create('Ext.selection.RowModel', {});
let remove_btn = new Proxmox.button.Button({
text: gettext('Remove'),
disabled: true,
selModel: sm,
confirmMsg: gettext('Are you sure you want to remove this entry'),
handler: function(btn, event, rec) {
var params = {
'delete': 1,
path: rec.data.path,
roles: rec.data.roleid,
};
if (rec.data.type === 'group') {
params.groups = rec.data.ugid;
} else if (rec.data.type === 'user') {
params.users = rec.data.ugid;
} else if (rec.data.type === 'token') {
params.tokens = rec.data.ugid;
} else {
throw 'unknown data type';
}
Proxmox.Utils.API2Request({
url: '/access/acl',
params: params,
method: 'PUT',
waitMsgTarget: me,
callback: () => store.load(),
failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
},
});
Proxmox.Utils.monStoreErrors(me, store);
Ext.apply(me, {
store: store,
selModel: sm,
tbar: [
{
text: gettext('Add'),
menu: {
xtype: 'menu',
items: [
{
text: gettext('Group Permission'),
disabled: !me.path,
itemId: 'groupmenu',
iconCls: 'fa fa-fw fa-group',
handler: function() {
var win = Ext.create('PVE.sdn.VnetACLAdd', {
aclType: 'group',
path: me.path,
});
win.on('destroy', () => store.load());
win.show();
},
},
{
text: gettext('User Permission'),
disabled: !me.path,
itemId: 'usermenu',
iconCls: 'fa fa-fw fa-user',
handler: function() {
var win = Ext.create('PVE.sdn.VnetACLAdd', {
aclType: 'user',
path: me.path,
});
win.on('destroy', () => store.load());
win.show();
},
},
{
text: gettext('API Token Permission'),
disabled: !me.path,
itemId: 'tokenmenu',
iconCls: 'fa fa-fw fa-user-o',
handler: function() {
let win = Ext.create('PVE.sdn.VnetACLAdd', {
aclType: 'token',
path: me.path,
});
win.on('destroy', () => store.load());
win.show();
},
},
],
},
},
remove_btn,
],
viewConfig: {
trackOver: false,
},
columns: columns,
listeners: {
},
});
me.callParent();
},
}, function() {
Ext.define('pve-acl-vnet', {
extend: 'Ext.data.Model',
fields: [
'path', 'type', 'ugid', 'roleid',
{
name: 'propagate',
type: 'boolean',
},
],
});
});
Ext.define('PVE.sdn.Vnet', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveSDNVnet',
title: 'VNet',
onlineHelp: 'pvesdn_config_vnet',
initComponent: function() {
var me = this;
var subnetview_panel = Ext.createWidget('pveSDNSubnetView', {
title: gettext('Subnets'),
region: 'center',
border: false,
});
var vnetview_panel = Ext.createWidget('pveSDNVnetView', {
title: 'VNets',
region: 'west',
subnetview_panel: subnetview_panel,
width: '50%',
border: false,
split: true,
});
Ext.apply(me, {
layout: 'border',
items: [vnetview_panel, subnetview_panel],
listeners: {
show: function() {
subnetview_panel.fireEvent('show', subnetview_panel);
},
},
});
me.callParent();
},
});
Ext.define('PVE.sdn.SubnetInputPanel', {
extend: 'Proxmox.panel.InputPanel',
mixins: ['Proxmox.Mixin.CBind'],
onGetValues: function(values) {
let me = this;
if (me.isCreate) {
values.type = 'subnet';
values.subnet = values.cidr;
delete values.cidr;
}
return values;
},
items: [
{
xtype: 'pmxDisplayEditField',
name: 'cidr',
cbind: {
editable: '{isCreate}',
},
flex: 1,
allowBlank: false,
fieldLabel: gettext('Subnet'),
},
{
xtype: 'proxmoxtextfield',
name: 'gateway',
vtype: 'IP64Address',
fieldLabel: gettext('Gateway'),
allowBlank: true,
skipEmptyText: true,
cbind: {
deleteEmpty: "{!isCreate}",
},
},
{
xtype: 'proxmoxcheckbox',
name: 'snat',
uncheckedValue: null,
checked: false,
fieldLabel: 'SNAT',
cbind: {
deleteEmpty: "{!isCreate}",
},
},
{
xtype: 'proxmoxtextfield',
name: 'dnszoneprefix',
skipEmptyText: true,
fieldLabel: gettext('DNS Zone Prefix'),
allowBlank: true,
cbind: {
deleteEmpty: "{!isCreate}",
},
},
],
});
Ext.define('PVE.sdn.SubnetDhcpRangePanel', {
extend: 'Ext.form.FieldContainer',
mixins: ['Ext.form.field.Field'],
initComponent: function() {
let me = this;
me.callParent();
me.initField();
},
// since value is an array of objects we need to override isEquals here
isEqual: function(value1, value2) {
return JSON.stringify(value1) === JSON.stringify(value2);
},
getValue: function() {
let me = this;
let store = me.lookup('grid').getStore();
let value = [];
store.getData()
.each((item) => {
// needs a deep copy otherwise we run in to ExtJS reference
// shenaningans
value.push({
'start-address': item.data['start-address'],
'end-address': item.data['end-address'],
});
});
return value;
},
getSubmitData: function() {
let me = this;
let data = {};
let value = me.getValue()
.map((item) => `start-address=${item['start-address']},end-address=${item['end-address']}`);
if (value.length) {
data[me.getName()] = value;
} else if (!me.isCreate) {
data.delete = me.getName();
}
return data;
},
setValue: function(dhcpRanges) {
let me = this;
let store = me.lookup('grid').getStore();
let data = [];
dhcpRanges.forEach((item) => {
// needs a deep copy otherwise we run in to ExtJS reference
// shenaningans
data.push({
'start-address': item['start-address'],
'end-address': item['end-address'],
});
});
store.setData(data);
},
getErrors: function() {
let me = this;
let errors = [];
return errors;
},
controller: {
xclass: 'Ext.app.ViewController',
addRange: function() {
let me = this;
me.lookup('grid').getStore().add({});
me.getView().checkChange();
},
removeRange: function(field) {
let me = this;
let record = field.getWidgetRecord();
me.lookup('grid').getStore().remove(record);
me.getView().checkChange();
},
onValueChange: function(field, value) {
let me = this;
let record = field.getWidgetRecord();
let column = field.getWidgetColumn();
record.set(column.dataIndex, value);
record.commit();
me.getView().checkChange();
},
control: {
'grid button': {
click: 'removeRange',
},
'field': {
change: 'onValueChange',
},
},
},
items: [
{
xtype: 'grid',
reference: 'grid',
scrollable: true,
store: {
fields: ['start-address', 'end-address'],
},
columns: [
{
text: gettext('Start Address'),
xtype: 'widgetcolumn',
dataIndex: 'start-address',
flex: 1,
widget: {
xtype: 'textfield',
vtype: 'IP64Address',
},
},
{
text: gettext('End Address'),
xtype: 'widgetcolumn',
dataIndex: 'end-address',
flex: 1,
widget: {
xtype: 'textfield',
vtype: 'IP64Address',
},
},
{
xtype: 'widgetcolumn',
width: 40,
widget: {
xtype: 'button',
iconCls: 'fa fa-trash-o',
},
},
],
},
{
xtype: 'container',
layout: {
type: 'hbox',
},
items: [
{
xtype: 'button',
text: gettext('Add'),
iconCls: 'fa fa-plus-circle',
handler: 'addRange',
},
],
},
],
});
Ext.define('PVE.sdn.SubnetEdit', {
extend: 'Proxmox.window.Edit',
subject: gettext('Subnet'),
subnet: undefined,
width: 350,
base_url: undefined,
bodyPadding: 0,
initComponent: function() {
var me = this;
me.isCreate = me.subnet === undefined;
if (me.isCreate) {
me.url = me.base_url;
me.method = 'POST';
} else {
me.url = me.base_url + '/' + me.subnet;
me.method = 'PUT';
}
let ipanel = Ext.create('PVE.sdn.SubnetInputPanel', {
isCreate: me.isCreate,
title: gettext('General'),
});
let dhcpPanel = Ext.create('PVE.sdn.SubnetDhcpRangePanel', {
isCreate: me.isCreate,
title: gettext('DHCP Ranges'),
name: 'dhcp-range',
});
Ext.apply(me, {
items: [
{
xtype: 'tabpanel',
bodyPadding: 10,
items: [ipanel, dhcpPanel],
},
],
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
me.setValues(response.result.data);
},
});
}
},
});
Ext.define('PVE.sdn.SubnetView', {
extend: 'Ext.grid.GridPanel',
alias: 'widget.pveSDNSubnetView',
stateful: true,
stateId: 'grid-sdn-subnet',
base_url: undefined,
remove_btn: undefined,
setBaseUrl: function(url) {
let me = this;
me.base_url = url;
if (url === undefined) {
me.store.removeAll();
me.create_btn.disable();
} else {
me.remove_btn.baseurl = url + '/';
me.store.setProxy({
type: 'proxmox',
url: '/api2/json/' + url + '?pending=1',
});
me.create_btn.enable();
me.store.load();
}
},
initComponent: function() {
let me = this;
let store = new Ext.data.Store({
model: 'pve-sdn-subnet',
});
let reload = function() {
store.load();
};
let sm = Ext.create('Ext.selection.RowModel', {});
let run_editor = function() {
let rec = sm.getSelection()[0];
let win = Ext.create('PVE.sdn.SubnetEdit', {
autoShow: true,
subnet: rec.data.subnet,
base_url: me.base_url,
});
win.on('destroy', reload);
};
me.create_btn = new Proxmox.button.Button({
text: gettext('Create'),
disabled: true,
handler: function() {
let win = Ext.create('PVE.sdn.SubnetEdit', {
autoShow: true,
base_url: me.base_url,
type: 'subnet',
});
win.on('destroy', reload);
},
});
let edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
selModel: sm,
handler: run_editor,
});
me.remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
selModel: sm,
baseurl: me.base_url + '/',
callback: () => store.load(),
});
let set_button_status = function() {
var rec = me.selModel.getSelection()[0];
if (!rec || rec.data.state === 'deleted') {
edit_btn.disable();
me.remove_btn.disable();
}
};
Ext.apply(me, {
store: store,
reloadStore: reload,
selModel: sm,
viewConfig: {
trackOver: false,
},
tbar: [
me.create_btn,
me.remove_btn,
edit_btn,
],
columns: [
{
header: gettext('Subnet'),
flex: 2,
dataIndex: 'cidr',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'cidr', 1);
},
},
{
header: gettext('Gateway'),
flex: 1,
dataIndex: 'gateway',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'gateway');
},
},
{
header: 'SNAT',
flex: 1,
dataIndex: 'snat',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'snat');
},
},
{
header: gettext('DNS Prefix'),
flex: 1,
dataIndex: 'dnszoneprefix',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'dnszoneprefix');
},
},
{
header: gettext('State'),
width: 100,
dataIndex: 'state',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending_state(rec, value);
},
},
],
listeners: {
activate: reload,
itemdblclick: run_editor,
selectionchange: set_button_status,
},
});
me.callParent();
if (me.base_url) {
me.setBaseUrl(me.base_url); // load
}
},
}, function() {
Ext.define('pve-sdn-subnet', {
extend: 'Ext.data.Model',
fields: [
'cidr',
'gateway',
'snat',
],
idProperty: 'subnet',
});
});
Ext.define('PVE.sdn.ZoneContentView', {
extend: 'Ext.grid.GridPanel',
alias: 'widget.pveSDNZoneContentView',
stateful: true,
stateId: 'grid-sdnzone-content',
viewConfig: {
trackOver: false,
loadMask: false,
},
features: [
{
ftype: 'grouping',
groupHeaderTpl: '{name} ({rows.length} Item{[values.rows.length > 1 ? "s" : ""]})',
},
],
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no node name specified";
}
if (!me.zone) {
throw "no zone ID specified";
}
var baseurl = "/nodes/" + me.nodename + "/sdn/zones/" + me.zone + "/content";
if (me.zone === 'localnetwork') {
baseurl = "/nodes/" + me.nodename + "/network?type=any_local_bridge";
}
var store = Ext.create('Ext.data.Store', {
model: 'pve-sdnzone-content',
groupField: 'content',
proxy: {
type: 'proxmox',
url: '/api2/json' + baseurl,
},
sorters: {
property: 'vnet',
direction: 'ASC',
},
});
var sm = Ext.create('Ext.selection.RowModel', {});
var reload = function() {
store.load();
};
Proxmox.Utils.monStoreErrors(me, store);
Ext.apply(me, {
store: store,
selModel: sm,
tbar: [
],
columns: [
{
header: 'VNet',
width: 100,
sortable: true,
dataIndex: 'vnet',
},
{
header: 'Alias',
width: 300,
sortable: true,
dataIndex: 'alias',
},
{
header: gettext('Status'),
width: 100,
sortable: true,
dataIndex: 'status',
},
{
header: gettext('Details'),
flex: 1,
dataIndex: 'statusmsg',
},
],
listeners: {
activate: reload,
show: reload,
select: function(_sm, rec) {
let path = `/sdn/zones/${me.zone}/${rec.data.vnet}`;
me.permissions_panel.setPath(path);
},
deselect: function() {
me.permissions_panel.setPath(undefined);
},
},
});
store.load();
me.callParent();
},
}, function() {
Ext.define('pve-sdnzone-content', {
extend: 'Ext.data.Model',
fields: [
{
name: 'iface',
convert: function(value, record) {
//map local vmbr to vnet
if (record.data.iface) {
record.data.vnet = record.data.iface;
}
return value;
},
},
{
name: 'comments',
convert: function(value, record) {
//map local vmbr comments to vnet alias
if (record.data.comments) {
record.data.alias = record.data.comments;
}
return value;
},
},
'vnet',
'status',
'statusmsg',
{
name: 'text',
convert: function(value, record) {
// check for volid, because if you click on a grouping header,
// it calls convert (but with an empty volid)
if (value || record.data.vnet === null) {
return value;
}
return PVE.Utils.format_sdnvnet_type(value, {}, record);
},
},
],
idProperty: 'vnet',
});
});
Ext.define('PVE.sdn.ZoneContentPanel', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveSDNZoneContentPanel',
title: 'VNet',
onlineHelp: 'pvesdn_config_vnet',
initComponent: function() {
var me = this;
var permissions_panel = Ext.createWidget('pveSDNVnetACLView', {
title: gettext('VNet Permissions'),
region: 'center',
border: false,
});
var vnetview_panel = Ext.createWidget('pveSDNZoneContentView', {
title: 'VNets',
region: 'west',
permissions_panel: permissions_panel,
nodename: me.nodename,
zone: me.zone,
width: '50%',
border: false,
split: true,
});
Ext.apply(me, {
layout: 'border',
items: [vnetview_panel, permissions_panel],
listeners: {
show: function() {
permissions_panel.fireEvent('show', permissions_panel);
},
},
});
me.callParent();
},
});
Ext.define('PVE.sdn.FirewallPanel', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveSDNFirewall',
title: 'VNet',
onlineHelp: 'pvesdn_firewall_integration',
initComponent: function() {
let me = this;
let tabPanel = Ext.create('Ext.TabPanel', {
fullscreen: true,
region: 'center',
border: false,
split: true,
disabled: true,
flex: 2,
items: [
{
xtype: 'pveFirewallRules',
title: gettext('Rules'),
list_refs_url: '/cluster/firewall/refs',
firewall_type: 'vnet',
},
{
xtype: 'pveFirewallOptions',
title: gettext('Options'),
fwtype: 'vnet',
},
],
});
let vnetPanel = Ext.createWidget('pveSDNFirewallVnetView', {
title: 'VNets',
region: 'west',
border: false,
split: true,
forceFit: true,
flex: 1,
tabPanel,
});
Ext.apply(me, {
layout: 'border',
items: [vnetPanel, tabPanel],
});
me.callParent();
},
});
Ext.define('PVE.sdn.FirewallVnetView', {
extend: 'Ext.grid.GridPanel',
alias: 'widget.pveSDNFirewallVnetView',
stateful: true,
stateId: 'grid-sdn-vnet-firewall',
tabPanel: undefined,
emptyText: gettext('No VNet configured.'),
getRulesPanel: function() {
let me = this;
return me.tabPanel.items.getAt(0);
},
getOptionsPanel: function() {
let me = this;
return me.tabPanel.items.getAt(1);
},
initComponent: function() {
let me = this;
let store = new Ext.data.Store({
model: 'pve-sdn-vnet',
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/sdn/vnets",
},
sorters: {
property: ['zone', 'vnet'],
direction: 'ASC',
},
});
let reload = () => store.load();
let sm = Ext.create('Ext.selection.RowModel', {});
Ext.apply(me, {
store: store,
reloadStore: reload,
selModel: sm,
viewConfig: {
trackOver: false,
},
columns: [
{
header: 'ID',
flex: 1,
dataIndex: 'vnet',
},
{
header: gettext('Zone'),
flex: 1,
dataIndex: 'zone',
renderer: Ext.htmlEncode,
},
{
header: gettext('Alias'),
flex: 1,
dataIndex: 'alias',
renderer: Ext.htmlEncode,
},
],
listeners: {
activate: reload,
show: reload,
select: function(_sm, rec) {
me.tabPanel.setDisabled(false);
me.getRulesPanel().setBaseUrl(`/cluster/sdn/vnets/${rec.id}/firewall/rules`);
me.getOptionsPanel().setBaseUrl(`/cluster/sdn/vnets/${rec.id}/firewall/options`);
},
},
});
store.load();
me.callParent();
},
});
Ext.define('PVE.sdn.ZoneView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveSDNZoneView'],
onlineHelp: 'pvesdn_config_zone',
emptyText: gettext('No zone configured.'),
stateful: true,
stateId: 'grid-sdn-zone',
createSDNEditWindow: function(type, sid) {
let schema = PVE.Utils.sdnzoneSchema[type];
if (!schema || !schema.ipanel) {
throw "no editor registered for zone type: " + type;
}
Ext.create('PVE.sdn.zones.BaseEdit', {
paneltype: 'PVE.sdn.zones.' + schema.ipanel,
type: type,
zone: sid,
autoShow: true,
listeners: {
destroy: this.reloadStore,
},
});
},
initComponent: function() {
let me = this;
let store = new Ext.data.Store({
model: 'pve-sdn-zone',
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/sdn/zones?pending=1",
},
sorters: {
property: 'zone',
direction: 'ASC',
},
});
let reload = function() {
store.load();
};
let sm = Ext.create('Ext.selection.RowModel', {});
let run_editor = function() {
let rec = sm.getSelection()[0];
if (!rec) {
return;
}
let type = rec.data.type,
zone = rec.data.zone;
me.createSDNEditWindow(type, zone);
};
let edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
selModel: sm,
handler: run_editor,
});
let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
selModel: sm,
baseurl: '/cluster/sdn/zones/',
callback: reload,
});
let set_button_status = function() {
var rec = me.selModel.getSelection()[0];
if (!rec || rec.data.state === 'deleted') {
edit_btn.disable();
remove_btn.disable();
}
};
// else we cannot dynamically generate the add menu handlers
let addHandleGenerator = function(type) {
return function() { me.createSDNEditWindow(type); };
};
let addMenuItems = [];
for (const [type, zone] of Object.entries(PVE.Utils.sdnzoneSchema)) {
if (zone.hideAdd) {
continue;
}
addMenuItems.push({
text: PVE.Utils.format_sdnzone_type(type),
iconCls: 'fa fa-fw fa-' + zone.faIcon,
handler: addHandleGenerator(type),
});
}
Ext.apply(me, {
store: store,
reloadStore: reload,
selModel: sm,
viewConfig: {
trackOver: false,
},
tbar: [
{
text: gettext('Add'),
menu: new Ext.menu.Menu({
items: addMenuItems,
}),
},
remove_btn,
edit_btn,
],
columns: [
{
header: 'ID',
width: 100,
dataIndex: 'zone',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'zone', 1);
},
},
{
header: gettext('Type'),
width: 100,
dataIndex: 'type',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'type', 1);
},
},
{
header: 'MTU',
width: 50,
dataIndex: 'mtu',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'mtu');
},
},
{
header: 'IPAM',
flex: 3,
dataIndex: 'ipam',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'ipam');
},
},
{
header: gettext('Domain'),
flex: 3,
dataIndex: 'dnszone',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'dnszone');
},
},
{
header: gettext('DNS'),
flex: 3,
dataIndex: 'dns',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'dns');
},
},
{
header: gettext('Reverse DNS'),
flex: 3,
dataIndex: 'reversedns',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'reversedns');
},
},
{
header: gettext('Nodes'),
flex: 3,
dataIndex: 'nodes',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending(rec, value, 'nodes');
},
},
{
header: gettext('State'),
width: 100,
dataIndex: 'state',
renderer: function(value, metaData, rec) {
return PVE.Utils.render_sdn_pending_state(rec, value);
},
},
],
listeners: {
activate: reload,
itemdblclick: run_editor,
selectionchange: set_button_status,
},
});
me.callParent();
},
});
Ext.define('PVE.sdn.IpamEditInputPanel', {
extend: 'Proxmox.panel.InputPanel',
mixins: ['Proxmox.Mixin.CBind'],
isCreate: false,
onGetValues: function(values) {
let me = this;
if (!values.vmid) {
delete values.vmid;
}
return values;
},
items: [
{
xtype: 'pmxDisplayEditField',
name: 'vmid',
fieldLabel: 'VMID',
allowBlank: false,
editable: false,
cbind: {
hidden: '{isCreate}',
},
},
{
xtype: 'pmxDisplayEditField',
name: 'mac',
fieldLabel: 'MAC',
allowBlank: false,
cbind: {
editable: '{isCreate}',
},
},
{
xtype: 'proxmoxtextfield',
name: 'ip',
fieldLabel: gettext('IP Address'),
allowBlank: false,
},
],
});
Ext.define('PVE.sdn.IpamEdit', {
extend: 'Proxmox.window.Edit',
subject: gettext('DHCP Mapping'),
width: 350,
isCreate: false,
mapping: {},
url: '/cluster/sdn/vnets',
submitUrl: function(url, values) {
return `${url}/${values.vnet}/ips`;
},
initComponent: function() {
var me = this;
me.method = me.isCreate ? 'POST' : 'PUT';
let ipanel = Ext.create('PVE.sdn.IpamEditInputPanel', {
isCreate: me.isCreate,
});
Ext.apply(me, {
items: [
ipanel,
],
});
me.callParent();
ipanel.setValues(me.mapping);
},
});
Ext.define('PVE.sdn.Options', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveSDNOptions',
title: 'Options',
layout: {
type: 'vbox',
align: 'stretch',
},
onlineHelp: 'pvesdn_config_controllers',
items: [
{
xtype: 'pveSDNControllerView',
title: gettext('Controllers'),
flex: 1,
padding: '0 0 20 0',
border: 0,
},
{
xtype: 'pveSDNIpamView',
title: 'IPAM',
flex: 1,
padding: '0 0 20 0',
border: 0,
}, {
xtype: 'pveSDNDnsView',
title: 'DNS',
flex: 1,
border: 0,
},
],
});
Ext.define('PVE.panel.SDNControllerBase', {
extend: 'Proxmox.panel.InputPanel',
type: '',
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.type = me.type;
} else {
delete values.controller;
}
return values;
},
});
Ext.define('PVE.sdn.controllers.BaseEdit', {
extend: 'Proxmox.window.Edit',
initComponent: function() {
var me = this;
me.isCreate = !me.controllerid;
if (me.isCreate) {
me.url = '/api2/extjs/cluster/sdn/controllers';
me.method = 'POST';
} else {
me.url = '/api2/extjs/cluster/sdn/controllers/' + me.controllerid;
me.method = 'PUT';
}
var ipanel = Ext.create(me.paneltype, {
type: me.type,
isCreate: me.isCreate,
controllerid: me.controllerid,
});
Ext.apply(me, {
subject: PVE.Utils.format_sdncontroller_type(me.type),
isAdd: true,
items: [ipanel],
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
var values = response.result.data;
var ctypes = values.content || '';
values.content = ctypes.split(',');
if (values.nodes) {
values.nodes = values.nodes.split(',');
}
values.enable = values.disable ? 0 : 1;
ipanel.setValues(values);
},
});
}
},
});
Ext.define('PVE.sdn.controllers.EvpnInputPanel', {
extend: 'PVE.panel.SDNControllerBase',
onlineHelp: 'pvesdn_controller_plugin_evpn',
initComponent: function() {
var me = this;
me.items = [
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'controller',
maxLength: 8,
value: me.controllerid || '',
fieldLabel: 'ID',
allowBlank: false,
},
{
xtype: 'proxmoxintegerfield',
name: 'asn',
minValue: 1,
maxValue: 4294967295,
value: 65000,
fieldLabel: 'ASN #',
allowBlank: false,
},
{
xtype: 'textfield',
name: 'peers',
fieldLabel: gettext('Peers'),
allowBlank: false,
},
];
me.callParent();
},
});
Ext.define('PVE.sdn.controllers.BgpInputPanel', {
extend: 'PVE.panel.SDNControllerBase',
onlineHelp: 'pvesdn_controller_plugin_evpn',
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.type = me.type;
values.controller = 'bgp' + values.node;
} else {
delete values.controller;
}
return values;
},
initComponent: function() {
var me = this;
me.items = [
{
xtype: 'pveNodeSelector',
name: 'node',
fieldLabel: gettext('Node'),
multiSelect: false,
autoSelect: false,
allowBlank: false,
},
{
xtype: 'proxmoxintegerfield',
name: 'asn',
minValue: 1,
maxValue: 4294967295,
value: 65000,
fieldLabel: 'ASN #',
allowBlank: false,
},
{
xtype: 'textfield',
name: 'peers',
fieldLabel: gettext('Peers'),
allowBlank: false,
},
{
xtype: 'proxmoxcheckbox',
name: 'ebgp',
uncheckedValue: 0,
checked: false,
fieldLabel: 'EBGP',
},
];
me.advancedItems = [
{
xtype: 'textfield',
name: 'loopback',
fieldLabel: gettext('Loopback Interface'),
},
{
xtype: 'proxmoxintegerfield',
name: 'ebgp-multihop',
minValue: 1,
maxValue: 100,
fieldLabel: 'ebgp-multihop',
allowBlank: true,
},
{
xtype: 'proxmoxcheckbox',
name: 'bgp-multipath-as-path-relax',
uncheckedValue: 0,
checked: false,
fieldLabel: 'bgp-multipath-as-path-relax',
},
];
me.callParent();
},
});
Ext.define('PVE.sdn.controllers.IsisInputPanel', {
extend: 'PVE.panel.SDNControllerBase',
onlineHelp: 'pvesdn_controller_plugin_evpn',
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.type = me.type;
values.controller = 'isis' + values.node;
} else {
delete values.controller;
}
return values;
},
initComponent: function() {
var me = this;
me.items = [
{
xtype: 'pveNodeSelector',
name: 'node',
fieldLabel: gettext('Node'),
multiSelect: false,
autoSelect: false,
allowBlank: false,
},
{
xtype: 'textfield',
name: 'isis-domain',
fieldLabel: 'Domain',
allowBlank: false,
},
{
xtype: 'textfield',
name: 'isis-net',
fieldLabel: 'Network entity title',
allowBlank: false,
},
{
xtype: 'textfield',
name: 'isis-ifaces',
fieldLabel: gettext('Interfaces'),
allowBlank: false,
},
];
me.advancedItems = [
{
xtype: 'textfield',
name: 'loopback',
fieldLabel: gettext('Loopback Interface'),
},
];
me.callParent();
},
});
Ext.define('PVE.sdn.IpamView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveSDNIpamView'],
stateful: true,
stateId: 'grid-sdn-ipam',
createSDNEditWindow: function(type, sid) {
let schema = PVE.Utils.sdnipamSchema[type];
if (!schema || !schema.ipanel) {
throw "no editor registered for ipam type: " + type;
}
Ext.create('PVE.sdn.ipams.BaseEdit', {
paneltype: 'PVE.sdn.ipams.' + schema.ipanel,
type: type,
ipam: sid,
autoShow: true,
listeners: {
destroy: this.reloadStore,
},
});
},
initComponent: function() {
let me = this;
let store = new Ext.data.Store({
model: 'pve-sdn-ipam',
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/sdn/ipams",
},
sorters: {
property: 'ipam',
direction: 'ASC',
},
});
let sm = Ext.create('Ext.selection.RowModel', {});
let run_editor = function() {
let rec = sm.getSelection()[0];
if (!rec) {
return;
}
let type = rec.data.type, ipam = rec.data.ipam;
me.createSDNEditWindow(type, ipam);
};
let edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
selModel: sm,
handler: run_editor,
});
let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
selModel: sm,
baseurl: '/cluster/sdn/ipams/',
callback: () => store.load(),
});
// else we cannot dynamically generate the add menu handlers
let addHandleGenerator = function(type) {
return function() { me.createSDNEditWindow(type); };
};
let addMenuItems = [];
for (const [type, ipam] of Object.entries(PVE.Utils.sdnipamSchema)) {
if (ipam.hideAdd) {
continue;
}
addMenuItems.push({
text: PVE.Utils.format_sdnipam_type(type),
iconCls: 'fa fa-fw fa-' + ipam.faIcon,
handler: addHandleGenerator(type),
});
}
Ext.apply(me, {
store: store,
reloadStore: () => store.load(),
selModel: sm,
viewConfig: {
trackOver: false,
},
tbar: [
{
text: gettext('Add'),
menu: new Ext.menu.Menu({
items: addMenuItems,
}),
},
remove_btn,
edit_btn,
],
columns: [
{
header: 'ID',
flex: 2,
dataIndex: 'ipam',
renderer: Ext.htmlEncode,
},
{
header: gettext('Type'),
flex: 1,
dataIndex: 'type',
renderer: PVE.Utils.format_sdnipam_type,
},
{
header: 'url',
flex: 1,
dataIndex: 'url',
renderer: Ext.htmlEncode,
},
],
listeners: {
activate: () => store.load(),
itemdblclick: run_editor,
},
});
store.load();
me.callParent();
},
});
Ext.define('PVE.panel.SDNIpamBase', {
extend: 'Proxmox.panel.InputPanel',
type: '',
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.type = me.type;
} else {
delete values.ipam;
}
return values;
},
initComponent: function() {
var me = this;
me.callParent();
},
});
Ext.define('PVE.sdn.ipams.BaseEdit', {
extend: 'Proxmox.window.Edit',
initComponent: function() {
var me = this;
me.isCreate = !me.ipam;
if (me.isCreate) {
me.url = '/api2/extjs/cluster/sdn/ipams';
me.method = 'POST';
} else {
me.url = '/api2/extjs/cluster/sdn/ipams/' + me.ipam;
me.method = 'PUT';
}
var ipanel = Ext.create(me.paneltype, {
type: me.type,
isCreate: me.isCreate,
ipam: me.ipam,
});
Ext.apply(me, {
subject: PVE.Utils.format_sdnipam_type(me.type),
isAdd: true,
items: [ipanel],
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
var values = response.result.data;
var ctypes = values.content || '';
values.content = ctypes.split(',');
if (values.nodes) {
values.nodes = values.nodes.split(',');
}
values.enable = values.disable ? 0 : 1;
ipanel.setValues(values);
},
});
}
},
});
Ext.define('PVE.sdn.ipams.NetboxInputPanel', {
extend: 'PVE.panel.SDNIpamBase',
onlineHelp: 'pvesdn_ipam_plugin_netbox',
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.type = me.type;
} else {
delete values.ipam;
}
return values;
},
initComponent: function() {
var me = this;
me.column1 = [
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'ipam',
maxLength: 10,
value: me.zone || '',
fieldLabel: 'ID',
allowBlank: false,
},
{
xtype: 'textfield',
name: 'token',
fieldLabel: gettext('Token'),
allowBlank: false,
},
];
me.column2 = [
{
xtype: 'textfield',
name: 'url',
fieldLabel: gettext('URL'),
allowBlank: false,
},
];
me.columnB = [
{
xtype: 'pmxFingerprintField',
name: 'fingerprint',
value: me.isCreate ? null : undefined,
deleteEmpty: !me.isCreate,
},
];
me.callParent();
},
});
Ext.define('PVE.sdn.ipams.PVEIpamInputPanel', {
extend: 'PVE.panel.SDNIpamBase',
onlineHelp: 'pvesdn_ipam_plugin_pveipam',
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.type = me.type;
} else {
delete values.ipam;
}
return values;
},
initComponent: function() {
var me = this;
me.items = [
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'ipam',
maxLength: 10,
value: me.zone || '',
fieldLabel: 'ID',
allowBlank: false,
},
];
me.callParent();
},
});
Ext.define('PVE.sdn.ipams.PhpIpamInputPanel', {
extend: 'PVE.panel.SDNIpamBase',
onlineHelp: 'pvesdn_ipam_plugin_phpipam',
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.type = me.type;
} else {
delete values.ipam;
}
return values;
},
initComponent: function() {
var me = this;
me.column1 = [
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'ipam',
maxLength: 10,
value: me.zone || '',
fieldLabel: 'ID',
allowBlank: false,
},
{
xtype: 'textfield',
name: 'token',
fieldLabel: gettext('Token'),
allowBlank: false,
},
];
me.column2 = [
{
xtype: 'textfield',
name: 'url',
fieldLabel: gettext('URL'),
allowBlank: false,
},
{
xtype: 'textfield',
name: 'section',
fieldLabel: gettext('Section'),
allowBlank: false,
},
];
me.columnB = [
{
xtype: 'pmxFingerprintField',
name: 'fingerprint',
value: me.isCreate ? null : undefined,
deleteEmpty: !me.isCreate,
},
];
me.callParent();
},
});
Ext.define('PVE.sdn.DnsView', {
extend: 'Ext.grid.GridPanel',
alias: ['widget.pveSDNDnsView'],
stateful: true,
stateId: 'grid-sdn-dns',
createSDNEditWindow: function(type, sid) {
let schema = PVE.Utils.sdndnsSchema[type];
if (!schema || !schema.ipanel) {
throw "no editor registered for dns type: " + type;
}
Ext.create('PVE.sdn.dns.BaseEdit', {
paneltype: 'PVE.sdn.dns.' + schema.ipanel,
type: type,
dns: sid,
autoShow: true,
listeners: {
destroy: this.reloadStore,
},
});
},
initComponent: function() {
let me = this;
let store = new Ext.data.Store({
model: 'pve-sdn-dns',
proxy: {
type: 'proxmox',
url: "/api2/json/cluster/sdn/dns",
},
sorters: {
property: 'dns',
direction: 'ASC',
},
});
let sm = Ext.create('Ext.selection.RowModel', {});
let run_editor = function() {
let rec = sm.getSelection()[0];
if (!rec) {
return;
}
let type = rec.data.type,
dns = rec.data.dns;
me.createSDNEditWindow(type, dns);
};
let edit_btn = new Proxmox.button.Button({
text: gettext('Edit'),
disabled: true,
selModel: sm,
handler: run_editor,
});
let remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
selModel: sm,
baseurl: '/cluster/sdn/dns/',
callback: () => store.load(),
});
// else we cannot dynamically generate the add menu handlers
let addHandleGenerator = function(type) {
return function() { me.createSDNEditWindow(type); };
};
let addMenuItems = [];
for (const [type, dns] of Object.entries(PVE.Utils.sdndnsSchema)) {
if (dns.hideAdd) {
continue;
}
addMenuItems.push({
text: PVE.Utils.format_sdndns_type(type),
iconCls: 'fa fa-fw fa-' + dns.faIcon,
handler: addHandleGenerator(type),
});
}
Ext.apply(me, {
store: store,
reloadStore: () => store.load(),
selModel: sm,
viewConfig: {
trackOver: false,
},
tbar: [
{
text: gettext('Add'),
menu: new Ext.menu.Menu({
items: addMenuItems,
}),
},
remove_btn,
edit_btn,
],
columns: [
{
header: 'ID',
flex: 2,
dataIndex: 'dns',
renderer: Ext.htmlEncode,
},
{
header: gettext('Type'),
flex: 1,
dataIndex: 'type',
renderer: PVE.Utils.format_sdndns_type,
},
{
header: 'url',
flex: 1,
dataIndex: 'url',
renderer: Ext.htmlEncode,
},
],
listeners: {
activate: () => store.load(),
itemdblclick: run_editor,
},
});
store.load();
me.callParent();
},
});
Ext.define('PVE.panel.SDNDnsBase', {
extend: 'Proxmox.panel.InputPanel',
type: '',
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.type = me.type;
} else {
delete values.dns;
}
return values;
},
initComponent: function() {
var me = this;
me.callParent();
},
});
Ext.define('PVE.sdn.dns.BaseEdit', {
extend: 'Proxmox.window.Edit',
initComponent: function() {
var me = this;
me.isCreate = !me.dns;
if (me.isCreate) {
me.url = '/api2/extjs/cluster/sdn/dns';
me.method = 'POST';
} else {
me.url = '/api2/extjs/cluster/sdn/dns/' + me.dns;
me.method = 'PUT';
}
var ipanel = Ext.create(me.paneltype, {
type: me.type,
isCreate: me.isCreate,
dns: me.dns,
});
Ext.apply(me, {
subject: PVE.Utils.format_sdndns_type(me.type),
isAdd: true,
items: [ipanel],
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
var values = response.result.data;
var ctypes = values.content || '';
values.content = ctypes.split(',');
if (values.nodes) {
values.nodes = values.nodes.split(',');
}
values.enable = values.disable ? 0 : 1;
ipanel.setValues(values);
},
});
}
},
});
Ext.define('PVE.sdn.dns.PowerdnsInputPanel', {
extend: 'PVE.panel.SDNDnsBase',
onlineHelp: 'pvesdn_dns_plugin_powerdns',
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.type = me.type;
} else {
delete values.dns;
}
return values;
},
initComponent: function() {
var me = this;
me.column1 = [
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'dns',
maxLength: 10,
value: me.dns || '',
fieldLabel: 'ID',
allowBlank: false,
},
{
xtype: 'textfield',
name: 'key',
fieldLabel: gettext('API Key'),
allowBlank: false,
},
];
me.column2 = [
{
xtype: 'textfield',
name: 'url',
fieldLabel: 'URL',
allowBlank: false,
},
{
xtype: 'proxmoxintegerfield',
name: 'ttl',
fieldLabel: 'TTL',
allowBlank: true,
},
];
me.columnB = [
{
xtype: 'pmxFingerprintField',
name: 'fingerprint',
value: me.isCreate ? null : undefined,
deleteEmpty: !me.isCreate,
},
];
me.callParent();
},
});
Ext.define('PVE.panel.SDNZoneBase', {
extend: 'Proxmox.panel.InputPanel',
type: '',
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.type = me.type;
} else {
delete values.zone;
}
return values;
},
initComponent: function() {
var me = this;
me.items.unshift({
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'zone',
maxLength: 8,
value: me.zone || '',
fieldLabel: 'ID',
allowBlank: false,
});
me.items.push(
{
xtype: 'proxmoxintegerfield',
name: 'mtu',
minValue: 100,
maxValue: 65000,
fieldLabel: 'MTU',
allowBlank: true,
emptyText: 'auto',
deleteEmpty: !me.isCreate,
},
{
xtype: 'pveNodeSelector',
name: 'nodes',
fieldLabel: gettext('Nodes'),
emptyText: gettext('All') + ' (' + gettext('No restrictions') +')',
multiSelect: true,
autoSelect: false,
},
{
xtype: 'pveSDNIpamSelector',
fieldLabel: gettext('IPAM'),
name: 'ipam',
value: me.ipam || 'pve',
allowBlank: false,
},
);
me.advancedItems = me.advancedItems ?? [];
me.advancedItems.unshift(
{
xtype: 'pveSDNDnsSelector',
fieldLabel: gettext('DNS Server'),
name: 'dns',
value: '',
allowBlank: true,
},
{
xtype: 'pveSDNDnsSelector',
fieldLabel: gettext('Reverse DNS Server'),
name: 'reversedns',
value: '',
allowBlank: true,
},
{
xtype: 'proxmoxtextfield',
name: 'dnszone',
skipEmptyText: true,
fieldLabel: gettext('DNS Zone'),
allowBlank: true,
deleteEmpty: !me.isCreate,
},
);
me.callParent();
},
});
Ext.define('PVE.sdn.zones.BaseEdit', {
extend: 'Proxmox.window.Edit',
width: 400,
initComponent: function() {
var me = this;
me.isCreate = !me.zone;
if (me.isCreate) {
me.url = '/api2/extjs/cluster/sdn/zones';
me.method = 'POST';
} else {
me.url = '/api2/extjs/cluster/sdn/zones/' + me.zone;
me.method = 'PUT';
}
var ipanel = Ext.create(me.paneltype, {
type: me.type,
isCreate: me.isCreate,
zone: me.zone,
});
Ext.apply(me, {
subject: PVE.Utils.format_sdnzone_type(me.type),
isAdd: true,
items: [ipanel],
});
me.callParent();
if (!me.isCreate) {
me.load({
success: function(response, options) {
var values = response.result.data;
var ctypes = values.content || '';
values.content = ctypes.split(',');
if (values.nodes) {
values.nodes = values.nodes.split(',');
}
if (values.exitnodes) {
values.exitnodes = values.exitnodes.split(',');
}
values.enable = values.disable ? 0 : 1;
ipanel.setValues(values);
},
});
}
},
});
Ext.define('PVE.sdn.zones.EvpnInputPanel', {
extend: 'PVE.panel.SDNZoneBase',
onlineHelp: 'pvesdn_zone_plugin_evpn',
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.type = me.type;
}
return values;
},
initComponent: function() {
var me = this;
me.items = [
{
xtype: 'pveSDNControllerSelector',
fieldLabel: gettext('Controller'),
name: 'controller',
value: '',
allowBlank: false,
},
{
xtype: 'proxmoxintegerfield',
name: 'vrf-vxlan',
minValue: 1,
maxValue: 16000000,
fieldLabel: 'VRF-VXLAN Tag',
allowBlank: false,
},
{
xtype: 'proxmoxtextfield',
name: 'mac',
fieldLabel: gettext('VNet MAC Address'),
vtype: 'MacAddress',
allowBlank: true,
emptyText: 'auto',
deleteEmpty: !me.isCreate,
},
{
xtype: 'pveNodeSelector',
name: 'exitnodes',
fieldLabel: gettext('Exit Nodes'),
multiSelect: true,
autoSelect: false,
},
{
xtype: 'pveNodeSelector',
name: 'exitnodes-primary',
fieldLabel: gettext('Primary Exit Node'),
multiSelect: false,
autoSelect: false,
skipEmptyText: true,
deleteEmpty: !me.isCreate,
},
{
xtype: 'proxmoxcheckbox',
name: 'exitnodes-local-routing',
uncheckedValue: null,
checked: false,
fieldLabel: gettext('Exit Nodes Local Routing'),
deleteEmpty: !me.isCreate,
},
{
xtype: 'proxmoxcheckbox',
name: 'advertise-subnets',
uncheckedValue: null,
checked: false,
fieldLabel: gettext('Advertise Subnets'),
deleteEmpty: !me.isCreate,
},
{
xtype: 'proxmoxcheckbox',
name: 'disable-arp-nd-suppression',
uncheckedValue: null,
checked: false,
fieldLabel: gettext('Disable ARP-nd Suppression'),
deleteEmpty: !me.isCreate,
},
{
xtype: 'proxmoxtextfield',
name: 'rt-import',
fieldLabel: gettext('Route Target Import'),
allowBlank: true,
deleteEmpty: !me.isCreate,
},
];
me.callParent();
},
});
Ext.define('PVE.sdn.zones.QinQInputPanel', {
extend: 'PVE.panel.SDNZoneBase',
onlineHelp: 'pvesdn_zone_plugin_qinq',
onGetValues: function(values) {
let me = this;
if (me.isCreate) {
values.type = me.type;
} else {
delete values.sdn;
}
return values;
},
initComponent: function() {
let me = this;
me.items = [
{
xtype: 'textfield',
name: 'bridge',
fieldLabel: 'Bridge',
allowBlank: false,
vtype: 'BridgeName',
minLength: 1,
maxLength: 10,
},
{
xtype: 'proxmoxintegerfield',
name: 'tag',
minValue: 0,
maxValue: 4096,
fieldLabel: gettext('Service VLAN'),
allowBlank: false,
},
{
xtype: 'proxmoxKVComboBox',
name: 'vlan-protocol',
fieldLabel: gettext('Service VLAN Protocol'),
allowBlank: true,
value: '802.1q',
comboItems: [
['802.1q', '802.1q'],
['802.1ad', '802.1ad'],
],
},
];
me.callParent();
},
});
Ext.define('PVE.sdn.zones.SimpleInputPanel', {
extend: 'PVE.panel.SDNZoneBase',
onlineHelp: 'pvesdn_zone_plugin_simple',
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.type = me.type;
} else {
delete values.zone;
}
return values;
},
initComponent: function() {
var me = this;
me.items = [];
me.advancedItems = [
{
xtype: 'proxmoxcheckbox',
name: 'dhcp',
inputValue: 'dnsmasq',
uncheckedValue: null,
checked: false,
fieldLabel: gettext('automatic DHCP'),
deleteEmpty: !me.isCreate,
},
];
me.callParent();
},
});
Ext.define('PVE.sdn.zones.VlanInputPanel', {
extend: 'PVE.panel.SDNZoneBase',
onlineHelp: 'pvesdn_zone_plugin_vlan',
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.type = me.type;
} else {
delete values.zone;
}
return values;
},
initComponent: function() {
var me = this;
me.items = [
{
xtype: 'textfield',
name: 'bridge',
fieldLabel: 'Bridge',
allowBlank: false,
vtype: 'BridgeName',
minLength: 1,
maxLength: 10,
},
];
me.callParent();
},
});
Ext.define('PVE.sdn.zones.VxlanInputPanel', {
extend: 'PVE.panel.SDNZoneBase',
onlineHelp: 'pvesdn_zone_plugin_vxlan',
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.type = me.type;
} else {
delete values.zone;
}
delete values.mode;
return values;
},
initComponent: function() {
var me = this;
me.items = [
{
xtype: 'textfield',
name: 'peers',
fieldLabel: gettext('Peer Address List'),
allowBlank: false,
},
];
me.callParent();
},
});
Ext.define('PVE.storage.ContentView', {
extend: 'Ext.grid.GridPanel',
alias: 'widget.pveStorageContentView',
itemdblclick: Ext.emptyFn,
viewConfig: {
trackOver: false,
loadMask: false,
},
initComponent: function() {
var me = this;
if (!me.nodename) {
me.nodename = me.pveSelNode.data.node;
if (!me.nodename) {
throw "no node name specified";
}
}
const nodename = me.nodename;
if (!me.storage) {
me.storage = me.pveSelNode.data.storage;
if (!me.storage) {
throw "no storage ID specified";
}
}
const storage = me.storage;
var content = me.content;
if (!content) {
throw "no content type specified";
}
const baseurl = `/nodes/${nodename}/storage/${storage}/content`;
let store = me.store = Ext.create('Ext.data.Store', {
model: 'pve-storage-content',
proxy: {
type: 'proxmox',
url: '/api2/json' + baseurl,
extraParams: {
content: content,
},
},
sorters: [
(a, b) => a.data.text.toString().localeCompare(
b.data.text.toString(), undefined, { numeric: true }),
],
});
if (!me.sm) {
me.sm = Ext.create('Ext.selection.RowModel', {});
}
let sm = me.sm;
let reload = () => store.load();
Proxmox.Utils.monStoreErrors(me, store);
let tbar = me.tbar ? [...me.tbar] : [];
if (me.useUploadButton) {
tbar.unshift(
{
xtype: 'button',
text: gettext('Upload'),
disabled: !me.enableUploadButton,
handler: function() {
Ext.create('PVE.window.UploadToStorage', {
nodename: nodename,
storage: storage,
content: content,
autoShow: true,
taskDone: () => reload(),
});
},
},
{
xtype: 'button',
text: gettext('Download from URL'),
disabled: !me.enableDownloadUrlButton,
handler: function() {
Ext.create('PVE.window.DownloadUrlToStorage', {
nodename: nodename,
storage: storage,
content: content,
autoShow: true,
taskDone: () => reload(),
});
},
},
'-',
);
}
if (!me.useCustomRemoveButton) {
tbar.push({
xtype: 'proxmoxStdRemoveButton',
selModel: sm,
enableFn: rec => !rec?.data?.protected,
delay: 5,
callback: () => reload(),
baseurl: baseurl + '/',
});
}
tbar.push(
'->',
gettext('Search') + ':',
' ',
{
xtype: 'textfield',
width: 200,
enableKeyEvents: true,
emptyText: content === 'backup' ? gettext('Name, Format, Notes') : gettext('Name, Format'),
listeners: {
keyup: {
buffer: 500,
fn: function(field) {
let needle = field.getValue().toLocaleLowerCase();
store.clearFilter(true);
store.filter([
{
filterFn: ({ data }) =>
data.text?.toLocaleLowerCase().includes(needle) ||
data.notes?.toLocaleLowerCase().includes(needle),
},
]);
},
},
change: function(field, newValue, oldValue) {
if (newValue !== this.originalValue) {
this.triggers.clear.setVisible(true);
}
},
},
triggers: {
clear: {
cls: 'pmx-clear-trigger',
weight: -1,
hidden: true,
handler: function() {
this.triggers.clear.setVisible(false);
this.setValue(this.originalValue);
store.clearFilter();
},
},
},
},
);
let availableColumns = {
'name': {
header: gettext('Name'),
flex: 2,
sortable: true,
renderer: PVE.Utils.render_storage_content,
sorter: (a, b) => a.data.text.toString().localeCompare(
b.data.text.toString(), undefined, { numeric: true }),
dataIndex: 'text',
},
'notes': {
header: gettext('Notes'),
flex: 1,
renderer: Ext.htmlEncode,
dataIndex: 'notes',
},
'protected': {
header: `<i class="fa fa-shield"></i>`,
tooltip: gettext('Protected'),
width: 30,
renderer: v => v ? `<i data-qtip="${gettext('Protected')}" class="fa fa-shield"></i>` : '',
sorter: (a, b) => (b.data.protected || 0) - (a.data.protected || 0),
dataIndex: 'protected',
},
'date': {
header: gettext('Date'),
width: 150,
dataIndex: 'vdate',
},
'format': {
header: gettext('Format'),
width: 100,
dataIndex: 'format',
},
'size': {
header: gettext('Size'),
width: 100,
renderer: Proxmox.Utils.format_size,
dataIndex: 'size',
},
};
let showColumns = me.showColumns || ['name', 'date', 'format', 'size'];
Object.keys(availableColumns).forEach(function(key) {
if (!showColumns.includes(key)) {
delete availableColumns[key];
}
});
if (me.extraColumns && typeof me.extraColumns === 'object') {
Object.assign(availableColumns, me.extraColumns);
}
const columns = Object.values(availableColumns);
Ext.apply(me, {
store,
selModel: sm,
tbar,
columns,
listeners: {
activate: reload,
itemdblclick: (view, record) => me.itemdblclick(view, record),
},
});
me.callParent();
},
}, function() {
Ext.define('pve-storage-content', {
extend: 'Ext.data.Model',
fields: [
'volid', 'content', 'format', 'size', 'used', 'vmid',
'channel', 'id', 'lun', 'notes', 'verification',
{
name: 'text',
convert: function(value, record) {
// check for volid, because if you click on a grouping header,
// it calls convert (but with an empty volid)
if (value || record.data.volid === null) {
return value;
}
return PVE.Utils.render_storage_content(value, {}, record);
},
},
{
name: 'vdate',
convert: function(value, record) {
// check for volid, because if you click on a grouping header,
// it calls convert (but with an empty volid)
if (value || record.data.volid === null) {
return value;
}
let t = record.data.content;
if (t === "backup") {
let v = record.data.volid;
let match = v.match(/(\d{4}_\d{2}_\d{2})-(\d{2}_\d{2}_\d{2})/);
if (match) {
let date = match[1].replace(/_/g, '-');
let time = match[2].replace(/_/g, ':');
return date + " " + time;
}
}
if (record.data.ctime) {
let ctime = new Date(record.data.ctime * 1000);
return Ext.Date.format(ctime, 'Y-m-d H:i:s');
}
return '';
},
},
],
idProperty: 'volid',
});
});
Ext.define('PVE.storage.BackupView', {
extend: 'PVE.storage.ContentView',
onlineHelp: 'chapter_vzdump',
alias: 'widget.pveStorageBackupView',
showColumns: ['name', 'notes', 'protected', 'date', 'format', 'size'],
initComponent: function() {
let me = this;
let nodename = me.nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
let storage = me.storage = me.pveSelNode.data.storage;
if (!storage) {
throw "no storage ID specified";
}
me.content = 'backup';
let sm = me.sm = Ext.create('Ext.selection.RowModel', {});
let pruneButton = Ext.create('Proxmox.button.Button', {
text: gettext('Prune group'),
disabled: true,
selModel: sm,
setBackupGroup: function(backup) {
if (backup) {
let name = backup.text;
let vmid = backup.vmid;
let format = backup.format;
let vmtype;
if (name.startsWith('vzdump-lxc-') || format === "pbs-ct") {
vmtype = 'lxc';
} else if (name.startsWith('vzdump-qemu-') || format === "pbs-vm") {
vmtype = 'qemu';
}
if (vmid && vmtype) {
this.setText(gettext('Prune group') + ` ${vmtype}/${vmid}`);
this.vmid = vmid;
this.vmtype = vmtype;
this.setDisabled(false);
return;
}
}
this.setText(gettext('Prune group'));
this.vmid = null;
this.vmtype = null;
this.setDisabled(true);
},
handler: function(b, e, rec) {
Ext.create('PVE.window.Prune', {
autoShow: true,
nodename,
storage,
backup_id: this.vmid,
backup_type: this.vmtype,
listeners: {
destroy: () => me.store.load(),
},
});
},
});
me.on('selectionchange', function(model, srecords, eOpts) {
if (srecords.length === 1) {
pruneButton.setBackupGroup(srecords[0].data);
} else {
pruneButton.setBackupGroup(null);
}
});
let isPBS = me.pluginType === 'pbs';
me.tbar = [
{
xtype: 'proxmoxButton',
text: gettext('Restore'),
selModel: sm,
disabled: true,
handler: function(b, e, rec) {
let vmtype;
if (PVE.Utils.volume_is_qemu_backup(rec.data.volid, rec.data.format)) {
vmtype = 'qemu';
} else if (PVE.Utils.volume_is_lxc_backup(rec.data.volid, rec.data.format)) {
vmtype = 'lxc';
} else {
return;
}
Ext.create('PVE.window.Restore', {
autoShow: true,
nodename,
volid: rec.data.volid,
volidText: PVE.Utils.render_storage_content(rec.data.volid, {}, rec),
vmtype,
isPBS,
listeners: {
destroy: () => me.store.load(),
},
});
},
},
];
if (isPBS) {
me.tbar.push({
xtype: 'proxmoxButton',
text: gettext('File Restore'),
disabled: true,
selModel: sm,
handler: function(b, e, rec) {
let isVMArchive = PVE.Utils.volume_is_qemu_backup(rec.data.volid, rec.data.format);
Ext.create('Proxmox.window.FileBrowser', {
title: gettext('File Restore') + " - " + rec.data.text,
listURL: `/api2/json/nodes/localhost/storage/${me.storage}/file-restore/list`,
downloadURL: `/api2/json/nodes/localhost/storage/${me.storage}/file-restore/download`,
extraParams: {
volume: rec.data.volid,
},
archive: isVMArchive ? 'all' : undefined,
autoShow: true,
});
},
});
}
me.tbar.push(
{
xtype: 'proxmoxButton',
text: gettext('Show Configuration'),
disabled: true,
selModel: sm,
handler: function(b, e, rec) {
Ext.create('PVE.window.BackupConfig', {
autoShow: true,
volume: rec.data.volid,
pveSelNode: me.pveSelNode,
});
},
},
{
xtype: 'proxmoxButton',
text: gettext('Edit Notes'),
disabled: true,
selModel: sm,
handler: function(b, e, rec) {
let volid = rec.data.volid;
Ext.create('Proxmox.window.Edit', {
autoShow: true,
autoLoad: true,
width: 600,
height: 400,
resizable: true,
title: gettext('Notes'),
url: `/api2/extjs/nodes/${nodename}/storage/${me.storage}/content/${volid}`,
layout: 'fit',
items: [
{
xtype: 'textarea',
layout: 'fit',
name: 'notes',
height: '100%',
},
],
listeners: {
destroy: () => me.store.load(),
},
});
},
},
{
xtype: 'proxmoxButton',
text: gettext('Change Protection'),
disabled: true,
handler: function(button, event, record) {
const volid = record.data.volid;
Proxmox.Utils.API2Request({
url: `/api2/extjs/nodes/${nodename}/storage/${me.storage}/content/${volid}`,
method: 'PUT',
waitMsgTarget: me,
params: { 'protected': record.data.protected ? 0 : 1 },
failure: response => Ext.Msg.alert('Error', response.htmlStatus),
success: () => {
me.store.load({
callback: () => sm.fireEvent('selectionchange', sm, [record]),
});
},
});
},
},
'-',
pruneButton,
);
me.extraColumns = {};
if (isPBS) {
me.extraColumns.encrypted = {
header: gettext('Encrypted'),
dataIndex: 'encrypted',
renderer: PVE.Utils.render_backup_encryption,
sorter: {
property: 'encrypted',
transform: encrypted => encrypted ? 1 : 0,
},
};
me.extraColumns.verification = {
header: gettext('Verify State'),
dataIndex: 'verification',
renderer: PVE.Utils.render_backup_verification,
sorter: {
property: 'verification',
transform: value => {
let state = value?.state ?? 'none';
let order = PVE.Utils.verificationStateOrder;
return order[state] ?? order.__default__;
},
},
};
}
me.extraColumns.vmid = {
header: 'VMID',
dataIndex: 'vmid',
hidden: true,
sorter: (a, b) => (a.data.vmid ?? 0) - (b.data.vmid ?? 0),
};
me.callParent();
me.store.getSorters().clear();
me.store.setSorters([
{
property: 'vdate',
direction: 'DESC',
},
]);
},
});
Ext.define('PVE.panel.StorageBase', {
extend: 'Proxmox.panel.InputPanel',
controller: 'storageEdit',
type: '',
onGetValues: function(values) {
let me = this;
if (me.isCreate) {
values.type = me.type;
} else {
delete values.storage;
}
values.disable = values.enable ? 0 : 1;
delete values.enable;
return values;
},
initComponent: function() {
let me = this;
me.column1.unshift({
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'storage',
value: me.storageId || '',
fieldLabel: 'ID',
vtype: 'StorageId',
allowBlank: false,
});
me.column2 = me.column2 || [];
me.column2.unshift(
{
xtype: 'pveNodeSelector',
name: 'nodes',
reference: 'storageNodeRestriction',
disabled: me.storageId === 'local',
fieldLabel: gettext('Nodes'),
emptyText: gettext('All') + ' (' + gettext('No restrictions') +')',
multiSelect: true,
autoSelect: false,
},
{
xtype: 'proxmoxcheckbox',
name: 'enable',
checked: true,
uncheckedValue: 0,
fieldLabel: gettext('Enable'),
},
);
const qemuImgStorageTypes = ['dir', 'btrfs', 'nfs', 'cifs', 'glusterfs'];
if (qemuImgStorageTypes.includes(me.type)) {
const preallocSelector = {
xtype: 'pvePreallocationSelector',
name: 'preallocation',
fieldLabel: gettext('Preallocation'),
allowBlank: false,
deleteEmpty: !me.isCreate,
value: '__default__',
};
me.advancedColumn1 = me.advancedColumn1 || [];
me.advancedColumn2 = me.advancedColumn2 || [];
if (me.advancedColumn2.length < me.advancedColumn1.length) {
me.advancedColumn2.unshift(preallocSelector);
} else {
me.advancedColumn1.unshift(preallocSelector);
}
}
me.callParent();
},
});
Ext.define('PVE.storage.BaseEdit', {
extend: 'Proxmox.window.Edit',
apiCallDone: function(success, response, options) {
let me = this;
if (typeof me.ipanel.apiCallDone === "function") {
me.ipanel.apiCallDone(success, response, options);
}
},
initComponent: function() {
let me = this;
me.isCreate = !me.storageId;
if (me.isCreate) {
me.url = '/api2/extjs/storage';
me.method = 'POST';
} else {
me.url = '/api2/extjs/storage/' + me.storageId;
me.method = 'PUT';
}
me.ipanel = Ext.create(me.paneltype, {
title: gettext('General'),
type: me.type,
isCreate: me.isCreate,
storageId: me.storageId,
});
Ext.apply(me, {
subject: PVE.Utils.format_storage_type(me.type),
isAdd: true,
bodyPadding: 0,
items: {
xtype: 'tabpanel',
region: 'center',
layout: 'fit',
bodyPadding: 10,
items: [
me.ipanel,
{
xtype: 'pveBackupJobPrunePanel',
title: gettext('Backup Retention'),
hasMaxProtected: true,
isCreate: me.isCreate,
keepAllDefaultForCreate: true,
showPBSHint: me.ipanel.isPBS,
fallbackHintHtml: gettext('Without any keep option, the node\'s vzdump.conf or `keep-all` is used as fallback for backup jobs'),
},
],
},
});
if (me.ipanel.extraTabs) {
me.ipanel.extraTabs.forEach(panel => {
panel.isCreate = me.isCreate;
me.items.items.push(panel);
});
}
me.callParent();
if (!me.canDoBackups) {
// cannot mask now, not fully rendered until activated
me.down('pmxPruneInputPanel').needMask = true;
}
if (!me.isCreate) {
me.load({
success: function(response, options) {
let values = response.result.data;
let ctypes = values.content || '';
values.content = ctypes.split(',');
if (values.nodes) {
values.nodes = values.nodes.split(',');
}
values.enable = values.disable ? 0 : 1;
if (values['prune-backups']) {
let retention = PVE.Parser.parsePropertyString(values['prune-backups']);
delete values['prune-backups'];
Object.assign(values, retention);
} else if (values.maxfiles !== undefined) {
if (values.maxfiles > 0) {
values['keep-last'] = values.maxfiles;
}
delete values.maxfiles;
}
me.query('inputpanel').forEach(panel => {
panel.setValues(values);
});
},
});
}
},
});
Ext.define('PVE.storage.Browser', {
extend: 'PVE.panel.Config',
alias: 'widget.PVE.storage.Browser',
onlineHelp: 'chapter_storage',
initComponent: function() {
let me = this;
let nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
let storeid = me.pveSelNode.data.storage;
if (!storeid) {
throw "no storage ID specified";
}
let storageInfo = PVE.data.ResourceStore.findRecord(
'id',
`storage/${nodename}/${storeid}`,
0, // startIndex
false, // anyMatch
true, // caseSensitive
true, // exactMatch
);
let res = storageInfo.data;
let plugin = res.plugintype;
let isEsxi = plugin === 'esxi';
me.items = !isEsxi ? [
{
title: gettext('Summary'),
xtype: 'pveStorageSummary',
iconCls: 'fa fa-book',
itemId: 'summary',
},
] : [];
let caps = Ext.state.Manager.get('GuiCap');
Ext.apply(me, {
title: Ext.String.format(gettext("Storage {0} on node {1}"), `'${storeid}'`, `'${nodename}'`),
hstateid: 'storagetab',
});
if (
caps.storage['Datastore.Allocate'] ||
caps.storage['Datastore.AllocateSpace'] ||
caps.storage['Datastore.Audit']
) {
let contents = res.content.split(',');
let enableUpload = !!caps.storage['Datastore.AllocateTemplate'];
let enableDownloadUrl = enableUpload && (
!!(caps.nodes['Sys.Audit'] && caps.nodes['Sys.Modify']) || // for backward compat
!!caps.nodes['Sys.AccessNetwork'] // new explicit priv for querying (local) networks
);
if (contents.includes('backup')) {
me.items.push({
xtype: 'pveStorageBackupView',
title: gettext('Backups'),
iconCls: 'fa fa-floppy-o',
itemId: 'contentBackup',
pluginType: plugin,
});
}
if (contents.includes('images')) {
me.items.push({
xtype: 'pveStorageImageView',
title: gettext('VM Disks'),
iconCls: 'fa fa-hdd-o',
itemId: 'contentImages',
content: 'images',
pluginType: plugin,
});
}
if (contents.includes('rootdir')) {
me.items.push({
xtype: 'pveStorageImageView',
title: gettext('CT Volumes'),
iconCls: 'fa fa-hdd-o lxc',
itemId: 'contentRootdir',
content: 'rootdir',
pluginType: plugin,
});
}
if (contents.includes('iso')) {
me.items.push({
xtype: 'pveStorageContentView',
title: gettext('ISO Images'),
iconCls: 'pve-itype-treelist-item-icon-cdrom',
itemId: 'contentIso',
content: 'iso',
pluginType: plugin,
enableUploadButton: enableUpload,
enableDownloadUrlButton: enableDownloadUrl,
useUploadButton: true,
});
}
if (contents.includes('vztmpl')) {
me.items.push({
xtype: 'pveStorageTemplateView',
title: gettext('CT Templates'),
iconCls: 'fa fa-file-o lxc',
itemId: 'contentVztmpl',
pluginType: plugin,
enableUploadButton: enableUpload,
enableDownloadUrlButton: enableDownloadUrl,
useUploadButton: true,
});
}
if (contents.includes('snippets')) {
me.items.push({
xtype: 'pveStorageContentView',
title: gettext('Snippets'),
iconCls: 'fa fa-file-code-o',
itemId: 'contentSnippets',
content: 'snippets',
pluginType: plugin,
});
}
if (contents.includes('import')) {
let isImportable = format => ['ova', 'ovf', 'vmx'].indexOf(format) !== -1;
let createGuestImportWindow = (selection) => {
if (!selection) {
return;
}
let volumeName = selection.data.volid.replace(/^.*?:/, '');
Ext.create('PVE.window.GuestImport', {
storage: storeid,
volumeName,
nodename,
autoShow: true,
});
};
me.items.push({
xtype: 'pveStorageContentView',
// each gettext needs to be in a separate line
title: isEsxi ? gettext('Virtual Guests')
: gettext('Import'),
iconCls: isEsxi ? 'fa fa-desktop' : 'fa fa-cloud-download',
itemId: 'contentImport',
content: 'import',
useCustomRemoveButton: isEsxi, // hide default remove button for esxi
showColumns: isEsxi ? ['name', 'format'] : ['name', 'size', 'format'],
enableUploadButton: enableUpload && !isEsxi,
enableDownloadUrlButton: enableDownloadUrl && !isEsxi,
useUploadButton: !isEsxi,
itemdblclick: (view, record) => {
if (isImportable(record.data.format)) {
createGuestImportWindow(record);
}
},
tbar: [
{
xtype: 'proxmoxButton',
disabled: true,
text: gettext('Import'),
iconCls: 'fa fa-cloud-download',
enableFn: rec => isImportable(rec.data.format),
handler: function() {
let grid = this.up('pveStorageContentView');
let selection = grid.getSelection()?.[0];
createGuestImportWindow(selection);
},
},
],
pluginType: plugin,
});
}
}
if (caps.storage['Permissions.Modify']) {
me.items.push({
xtype: 'pveACLView',
title: gettext('Permissions'),
iconCls: 'fa fa-unlock',
itemId: 'permissions',
path: `/storage/${storeid}`,
});
}
me.callParent();
},
});
Ext.define('PVE.storage.CIFSScan', {
extend: 'Ext.form.field.ComboBox',
alias: 'widget.pveCIFSScan',
queryParam: 'server',
valueField: 'share',
displayField: 'share',
matchFieldWidth: false,
listConfig: {
loadingText: gettext('Scanning...'),
width: 350,
},
doRawQuery: Ext.emptyFn,
onTriggerClick: function() {
var me = this;
if (!me.queryCaching || me.lastQuery !== me.cifsServer) {
me.store.removeAll();
}
var params = {};
if (me.cifsUsername) {
params.username = me.cifsUsername;
}
if (me.cifsPassword) {
params.password = me.cifsPassword;
}
if (me.cifsDomain) {
params.domain = me.cifsDomain;
}
me.store.getProxy().setExtraParams(params);
me.allQuery = me.cifsServer;
me.callParent();
},
resetProxy: function() {
let me = this;
me.lastQuery = null;
if (!me.readOnly && !me.disabled) {
if (me.isExpanded) {
me.collapse();
}
}
},
setServer: function(server) {
if (this.cifsServer !== server) {
this.cifsServer = server;
this.resetProxy();
}
},
setUsername: function(username) {
if (this.cifsUsername !== username) {
this.cifsUsername = username;
this.resetProxy();
}
},
setPassword: function(password) {
if (this.cifsPassword !== password) {
this.cifsPassword = password;
this.resetProxy();
}
},
setDomain: function(domain) {
if (this.cifsDomain !== domain) {
this.cifsDomain = domain;
this.resetProxy();
}
},
initComponent: function() {
var me = this;
if (!me.nodename) {
me.nodename = 'localhost';
}
let store = Ext.create('Ext.data.Store', {
fields: ['description', 'share'],
proxy: {
type: 'proxmox',
url: '/api2/json/nodes/' + me.nodename + '/scan/cifs',
},
});
store.sort('share', 'ASC');
Ext.apply(me, {
store: store,
});
me.callParent();
let picker = me.getPicker();
// don't use monStoreErrors directly, it doesn't copes well with comboboxes
picker.mon(store, 'beforeload', function(s, operation, eOpts) {
picker.unmask();
delete picker.minHeight;
});
picker.mon(store.proxy, 'afterload', function(proxy, request, success) {
if (success) {
Proxmox.Utils.setErrorMask(picker, false);
return;
}
let error = request._operation.getError();
let msg = Proxmox.Utils.getResponseErrorMessage(error);
if (msg) {
picker.minHeight = 100;
}
Proxmox.Utils.setErrorMask(picker, msg);
});
},
});
Ext.define('PVE.storage.CIFSInputPanel', {
extend: 'PVE.panel.StorageBase',
onlineHelp: 'storage_cifs',
onGetValues: function(values) {
let me = this;
if (values.password?.length === 0) {
delete values.password;
}
if (values.username?.length === 0) {
delete values.username;
}
if (values.subdir?.length === 0) {
delete values.subdir;
}
return me.callParent([values]);
},
initComponent: function() {
var me = this;
me.column1 = [
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'server',
value: '',
fieldLabel: gettext('Server'),
allowBlank: false,
listeners: {
change: function(f, value) {
if (me.isCreate) {
var exportField = me.down('field[name=share]');
exportField.setServer(value);
}
},
},
},
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'username',
value: '',
fieldLabel: gettext('Username'),
emptyText: gettext('Guest user'),
listeners: {
change: function(f, value) {
if (!me.isCreate) {
return;
}
var exportField = me.down('field[name=share]');
exportField.setUsername(value);
},
},
},
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
inputType: 'password',
name: 'password',
value: me.isCreate ? '' : '********',
emptyText: me.isCreate ? gettext('None') : '',
fieldLabel: gettext('Password'),
minLength: 1,
listeners: {
change: function(f, value) {
let exportField = me.down('field[name=share]');
exportField.setPassword(value);
},
},
},
{
xtype: me.isCreate ? 'pveCIFSScan' : 'displayfield',
name: 'share',
value: '',
fieldLabel: 'Share',
allowBlank: false,
},
];
me.column2 = [
{
xtype: 'pveContentTypeSelector',
name: 'content',
value: 'images',
multiSelect: true,
fieldLabel: gettext('Content'),
allowBlank: false,
},
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'domain',
value: me.isCreate ? '' : undefined,
fieldLabel: gettext('Domain'),
allowBlank: true,
listeners: {
change: function(f, value) {
if (me.isCreate) {
let exportField = me.down('field[name=share]');
exportField.setDomain(value);
}
},
},
},
{
xtype: 'pmxDisplayEditField',
editable: me.isCreate,
name: 'subdir',
fieldLabel: gettext('Subdirectory'),
allowBlank: true,
emptyText: gettext('/some/path'),
},
];
me.callParent();
},
});
Ext.define('PVE.storage.CephFSInputPanel', {
extend: 'PVE.panel.StorageBase',
controller: 'cephstorage',
onlineHelp: 'storage_cephfs',
viewModel: {
type: 'cephstorage',
},
setValues: function(values) {
if (values.monhost) {
this.viewModel.set('pveceph', false);
this.lookupReference('pvecephRef').setValue(false);
this.lookupReference('pvecephRef').resetOriginalValue();
}
this.callParent([values]);
},
initComponent: function() {
var me = this;
if (!me.nodename) {
me.nodename = 'localhost';
}
me.type = 'cephfs';
me.column1 = [];
me.column1.push(
{
xtype: 'textfield',
name: 'monhost',
vtype: 'HostList',
value: '',
bind: {
disabled: '{pveceph}',
submitValue: '{!pveceph}',
hidden: '{pveceph}',
},
fieldLabel: 'Monitor(s)',
allowBlank: false,
},
{
xtype: 'displayfield',
reference: 'monhost',
bind: {
disabled: '{!pveceph}',
hidden: '{!pveceph}',
},
value: '',
fieldLabel: 'Monitor(s)',
},
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'username',
value: 'admin',
bind: {
disabled: '{pveceph}',
submitValue: '{!pveceph}',
},
fieldLabel: gettext('User name'),
allowBlank: true,
},
);
if (me.isCreate) {
me.column1.push({
xtype: 'pveCephFSSelector',
nodename: me.nodename,
name: 'fs-name',
bind: {
disabled: '{!pveceph}',
submitValue: '{pveceph}',
hidden: '{!pveceph}',
},
fieldLabel: gettext('FS Name'),
allowBlank: false,
}, {
xtype: 'textfield',
nodename: me.nodename,
name: 'fs-name',
bind: {
disabled: '{pveceph}',
submitValue: '{!pveceph}',
hidden: '{pveceph}',
},
fieldLabel: gettext('FS Name'),
});
}
me.column2 = [
{
xtype: 'pveContentTypeSelector',
cts: ['backup', 'iso', 'vztmpl', 'snippets', 'import'],
fieldLabel: gettext('Content'),
name: 'content',
value: 'backup',
multiSelect: true,
allowBlank: false,
},
];
me.columnB = [
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'keyring',
fieldLabel: gettext('Secret Key'),
value: me.isCreate ? '' : '***********',
allowBlank: false,
bind: {
hidden: '{pveceph}',
disabled: '{pveceph}',
},
},
{
xtype: 'proxmoxcheckbox',
name: 'pveceph',
reference: 'pvecephRef',
bind: {
disabled: '{!pvecephPossible}',
value: '{pveceph}',
},
checked: true,
uncheckedValue: 0,
submitValue: false,
hidden: !me.isCreate,
boxLabel: gettext('Use Proxmox VE managed hyper-converged cephFS'),
},
];
me.callParent();
},
});
Ext.define('PVE.storage.DirInputPanel', {
extend: 'PVE.panel.StorageBase',
onlineHelp: 'storage_directory',
initComponent: function() {
var me = this;
me.column1 = [
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'path',
value: '',
fieldLabel: gettext('Directory'),
allowBlank: false,
},
{
xtype: 'pveContentTypeSelector',
name: 'content',
value: 'images',
multiSelect: true,
fieldLabel: gettext('Content'),
allowBlank: false,
},
];
me.column2 = [
{
xtype: 'proxmoxcheckbox',
name: 'shared',
uncheckedValue: 0,
fieldLabel: gettext('Shared'),
autoEl: {
tag: 'div',
'data-qtip': gettext('Enable if the underlying file system is already shared between nodes.'),
},
},
];
me.callParent();
},
});
Ext.define('PVE.storage.GlusterFsScan', {
extend: 'Ext.form.field.ComboBox',
alias: 'widget.pveGlusterFsScan',
queryParam: 'server',
valueField: 'volname',
displayField: 'volname',
matchFieldWidth: false,
listConfig: {
loadingText: 'Scanning...',
width: 350,
},
doRawQuery: function() {
// nothing
},
onTriggerClick: function() {
var me = this;
if (!me.queryCaching || me.lastQuery !== me.glusterServer) {
me.store.removeAll();
}
me.allQuery = me.glusterServer;
me.callParent();
},
setServer: function(server) {
var me = this;
me.glusterServer = server;
},
initComponent: function() {
var me = this;
if (!me.nodename) {
me.nodename = 'localhost';
}
var store = Ext.create('Ext.data.Store', {
fields: ['volname'],
proxy: {
type: 'proxmox',
url: '/api2/json/nodes/' + me.nodename + '/scan/glusterfs',
},
});
store.sort('volname', 'ASC');
Ext.apply(me, {
store: store,
});
me.callParent();
},
});
Ext.define('PVE.storage.GlusterFsInputPanel', {
extend: 'PVE.panel.StorageBase',
onlineHelp: 'storage_glusterfs',
initComponent: function() {
var me = this;
me.column1 = [
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'server',
value: '',
fieldLabel: gettext('Server'),
allowBlank: false,
listeners: {
change: function(f, value) {
if (me.isCreate) {
var volumeField = me.down('field[name=volume]');
volumeField.setServer(value);
volumeField.setValue('');
}
},
},
},
{
xtype: me.isCreate ? 'proxmoxtextfield' : 'displayfield',
name: 'server2',
value: '',
fieldLabel: gettext('Second Server'),
allowBlank: true,
},
{
xtype: me.isCreate ? 'pveGlusterFsScan' : 'displayfield',
name: 'volume',
value: '',
fieldLabel: 'Volume name',
allowBlank: false,
},
{
xtype: 'pveContentTypeSelector',
cts: ['images', 'iso', 'backup', 'vztmpl', 'snippets', 'import'],
name: 'content',
value: 'images',
multiSelect: true,
fieldLabel: gettext('Content'),
allowBlank: false,
},
];
me.callParent();
},
});
Ext.define('PVE.storage.ImageView', {
extend: 'PVE.storage.ContentView',
alias: 'widget.pveStorageImageView',
initComponent: function() {
var me = this;
var nodename = me.nodename = me.pveSelNode.data.node;
if (!me.nodename) {
throw "no node name specified";
}
var storage = me.storage = me.pveSelNode.data.storage;
if (!me.storage) {
throw "no storage ID specified";
}
if (!me.content || (me.content !== 'images' && me.content !== 'rootdir')) {
throw "content needs to be either 'images' or 'rootdir'";
}
var sm = me.sm = Ext.create('Ext.selection.RowModel', {});
var reload = function() {
me.store.load();
};
me.tbar = [
{
xtype: 'proxmoxButton',
selModel: sm,
text: gettext('Remove'),
disabled: true,
handler: function(btn, event, rec) {
let url = `/nodes/${nodename}/storage/${storage}/content/${rec.data.volid}`;
var vmid = rec.data.vmid;
var store = PVE.data.ResourceStore;
if (vmid && store.findVMID(vmid)) {
var guest_node = store.guestNode(vmid);
var storage_path = 'storage/' + nodename + '/' + storage;
// allow to delete local backed images if a VMID exists on another node.
if (store.storageIsShared(storage_path) || guest_node === nodename) {
var msg = Ext.String.format(
gettext("Cannot remove image, a guest with VMID '{0}' exists!"), vmid);
msg += '<br />' + gettext("You can delete the image from the guest's hardware pane");
Ext.Msg.show({
title: gettext('Cannot remove disk image.'),
icon: Ext.Msg.ERROR,
msg: msg,
});
return;
}
}
var win = Ext.create('Proxmox.window.SafeDestroy', {
title: Ext.String.format(gettext("Destroy '{0}'"), rec.data.volid),
showProgress: true,
url: url,
item: { type: 'Image', id: vmid },
taskName: 'unknownimgdel',
}).show();
win.on('destroy', reload);
},
},
];
me.useCustomRemoveButton = true;
me.callParent();
},
});
Ext.define('PVE.storage.IScsiScan', {
extend: 'PVE.form.ComboBoxSetStoreNode',
alias: 'widget.pveIScsiScan',
queryParam: 'portal',
valueField: 'target',
displayField: 'target',
matchFieldWidth: false,
allowBlank: false,
listConfig: {
width: 350,
columns: [
{
dataIndex: 'target',
flex: 1,
},
],
emptyText: PVE.Utils.renderNotFound(gettext('iSCSI Target')),
},
config: {
apiSuffix: '/scan/iscsi',
},
showNodeSelector: true,
reload: function() {
let me = this;
if (!me.isDisabled()) {
me.getStore().load();
}
},
setPortal: function(portal) {
let me = this;
me.portal = portal;
me.getStore().getProxy().setExtraParams({ portal });
me.reload();
},
setNodeName: function(value) {
let me = this;
me.callParent([value]);
me.reload();
},
initComponent: function() {
let me = this;
if (!me.nodename) {
me.nodename = 'localhost';
}
let store = Ext.create('Ext.data.Store', {
fields: ['target', 'portal'],
proxy: {
type: 'proxmox',
url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`,
},
});
store.sort('target', 'ASC');
Ext.apply(me, {
store: store,
});
me.callParent();
},
});
Ext.define('PVE.storage.IScsiInputPanel', {
extend: 'PVE.panel.StorageBase',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'storage_open_iscsi',
onGetValues: function(values) {
let me = this;
values.content = values.luns ? 'images' : 'none';
delete values.luns;
return me.callParent([values]);
},
setValues: function(values) {
values.luns = values.content.indexOf('images') !== -1;
this.callParent([values]);
},
column1: [
{
xtype: 'pmxDisplayEditField',
cbind: {
editable: '{isCreate}',
},
name: 'portal',
value: '',
fieldLabel: 'Portal',
allowBlank: false,
editConfig: {
listeners: {
change: {
fn: function(f, value) {
let panel = this.up('inputpanel');
let exportField = panel.lookup('iScsiTargetScan');
if (exportField) {
exportField.setDisabled(!value);
exportField.setPortal(value);
exportField.setValue('');
}
},
buffer: 500,
},
},
},
},
{
cbind: {
xtype: (get) => get('isCreate') ? 'pveIScsiScan' : 'displayfield',
readOnly: '{!isCreate}',
disabled: '{isCreate}',
},
name: 'target',
value: '',
fieldLabel: gettext('Target'),
allowBlank: false,
reference: 'iScsiTargetScan',
listeners: {
nodechanged: function(value) {
this.up('inputpanel').lookup('storageNodeRestriction').setValue(value);
},
},
},
],
column2: [
{
xtype: 'checkbox',
name: 'luns',
checked: true,
fieldLabel: gettext('Use LUNs directly'),
},
],
});
Ext.define('PVE.storage.VgSelector', {
extend: 'PVE.form.ComboBoxSetStoreNode',
alias: 'widget.pveVgSelector',
valueField: 'vg',
displayField: 'vg',
queryMode: 'local',
editable: false,
listConfig: {
columns: [
{
dataIndex: 'vg',
flex: 1,
},
],
emptyText: PVE.Utils.renderNotFound('VGs'),
},
config: {
apiSuffix: '/scan/lvm',
},
showNodeSelector: true,
setNodeName: function(value) {
let me = this;
me.callParent([value]);
me.getStore().load();
},
initComponent: function() {
let me = this;
if (!me.nodename) {
me.nodename = 'localhost';
}
let store = Ext.create('Ext.data.Store', {
autoLoad: {}, // true,
fields: ['vg', 'size', 'free'],
proxy: {
type: 'proxmox',
url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`,
},
});
store.sort('vg', 'ASC');
Ext.apply(me, {
store: store,
});
me.callParent();
},
});
Ext.define('PVE.storage.BaseStorageSelector', {
extend: 'Ext.form.field.ComboBox',
alias: 'widget.pveBaseStorageSelector',
existingGroupsText: gettext("Existing volume groups"),
queryMode: 'local',
editable: false,
value: '',
valueField: 'storage',
displayField: 'text',
initComponent: function() {
let me = this;
let store = Ext.create('Ext.data.Store', {
autoLoad: {
addRecords: true,
params: {
type: 'iscsi',
},
},
fields: ['storage', 'type', 'content',
{
name: 'text',
convert: function(value, record) {
if (record.data.storage) {
return record.data.storage + " (iSCSI)";
} else {
return me.existingGroupsText;
}
},
}],
proxy: {
type: 'proxmox',
url: '/api2/json/storage/',
},
});
store.loadData([{ storage: '' }], true);
store.sort('storage', 'ASC');
Ext.apply(me, {
store: store,
});
me.callParent();
},
});
Ext.define('PVE.storage.LunSelector', {
extend: 'PVE.form.FileSelector',
alias: 'widget.pveStorageLunSelector',
nodename: 'localhost',
storageContent: 'images',
allowBlank: false,
initComponent: function() {
let me = this;
if (!PVE.Utils.isStandaloneNode()) {
me.errorHeight = 140;
Ext.apply(me.listConfig ?? {}, {
tbar: {
xtype: 'toolbar',
items: [
{
xtype: "pveStorageScanNodeSelector",
autoSelect: false,
fieldLabel: gettext('Node to scan'),
listeners: {
change: (_field, value) => me.setNodename(value),
},
},
],
},
emptyText: me.listConfig?.emptyText ?? PVE.Utils.renderNotFound(gettext('Volume')),
});
}
me.callParent();
},
});
Ext.define('PVE.storage.LVMInputPanel', {
extend: 'PVE.panel.StorageBase',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'storage_lvm',
column1: [
{
xtype: 'pveBaseStorageSelector',
name: 'basesel',
fieldLabel: gettext('Base storage'),
cbind: {
disabled: '{!isCreate}',
hidden: '{!isCreate}',
},
submitValue: false,
listeners: {
change: function(f, value) {
let me = this;
let vgField = me.up('inputpanel').lookup('volumeGroupSelector');
let vgNameField = me.up('inputpanel').lookup('vgName');
let baseField = me.up('inputpanel').lookup('lunSelector');
vgField.setVisible(!value);
vgField.setDisabled(!!value);
baseField.setVisible(!!value);
baseField.setDisabled(!value);
baseField.setStorage(value);
vgNameField.setVisible(!!value);
vgNameField.setDisabled(!value);
},
},
},
{
xtype: 'pveStorageLunSelector',
name: 'base',
fieldLabel: gettext('Base volume'),
reference: 'lunSelector',
hidden: true,
disabled: true,
},
{
xtype: 'pveVgSelector',
name: 'vgname',
fieldLabel: gettext('Volume group'),
reference: 'volumeGroupSelector',
cbind: {
disabled: '{!isCreate}',
hidden: '{!isCreate}',
},
allowBlank: false,
listeners: {
nodechanged: function(value) {
this.up('inputpanel').lookup('storageNodeRestriction').setValue(value);
},
},
},
{
name: 'vgname',
fieldLabel: gettext('Volume group'),
reference: 'vgName',
cbind: {
xtype: (get) => get('isCreate') ? 'textfield' : 'displayfield',
hidden: '{isCreate}',
disabled: '{isCreate}',
},
value: '',
allowBlank: false,
},
{
xtype: 'pveContentTypeSelector',
cts: ['images', 'rootdir'],
fieldLabel: gettext('Content'),
name: 'content',
value: ['images', 'rootdir'],
multiSelect: true,
allowBlank: false,
},
],
column2: [
{
xtype: 'proxmoxcheckbox',
name: 'shared',
uncheckedValue: 0,
fieldLabel: gettext('Shared'),
autoEl: {
tag: 'div',
'data-qtip': gettext('Enable if the LVM is located on a shared LUN.'),
},
},
{
xtype: 'proxmoxcheckbox',
name: 'saferemove',
uncheckedValue: 0,
fieldLabel: gettext('Wipe Removed Volumes'),
},
],
});
Ext.define('PVE.storage.TPoolSelector', {
extend: 'PVE.form.ComboBoxSetStoreNode',
alias: 'widget.pveTPSelector',
queryParam: 'vg',
valueField: 'lv',
displayField: 'lv',
editable: false,
allowBlank: false,
listConfig: {
emptyText: PVE.Utils.renderNotFound('Thin-Pool'),
columns: [
{
dataIndex: 'lv',
flex: 1,
},
],
},
config: {
apiSuffix: '/scan/lvmthin',
},
reload: function() {
let me = this;
if (!me.isDisabled()) {
me.getStore().load();
}
},
setVG: function(myvg) {
let me = this;
me.vg = myvg;
me.getStore().getProxy().setExtraParams({ vg: myvg });
me.reload();
},
setNodeName: function(value) {
let me = this;
me.callParent([value]);
me.reload();
},
initComponent: function() {
let me = this;
if (!me.nodename) {
me.nodename = 'localhost';
}
let store = Ext.create('Ext.data.Store', {
fields: ['lv'],
proxy: {
type: 'proxmox',
url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`,
},
});
store.sort('lv', 'ASC');
Ext.apply(me, {
store: store,
});
me.callParent();
},
});
Ext.define('PVE.storage.BaseVGSelector', {
extend: 'PVE.form.ComboBoxSetStoreNode',
alias: 'widget.pveBaseVGSelector',
valueField: 'vg',
displayField: 'vg',
queryMode: 'local',
editable: false,
allowBlank: false,
listConfig: {
columns: [
{
dataIndex: 'vg',
flex: 1,
},
],
},
showNodeSelector: true,
config: {
apiSuffix: '/scan/lvm',
},
setNodeName: function(value) {
let me = this;
me.callParent([value]);
me.getStore().load();
},
initComponent: function() {
let me = this;
if (!me.nodename) {
me.nodename = 'localhost';
}
let store = Ext.create('Ext.data.Store', {
autoLoad: {},
fields: ['vg', 'size', 'free'],
proxy: {
type: 'proxmox',
url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`,
},
});
Ext.apply(me, {
store: store,
});
me.callParent();
},
});
Ext.define('PVE.storage.LvmThinInputPanel', {
extend: 'PVE.panel.StorageBase',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'storage_lvmthin',
column1: [
{
xtype: 'pmxDisplayEditField',
cbind: {
editable: '{isCreate}',
},
name: 'vgname',
fieldLabel: gettext('Volume group'),
editConfig: {
xtype: 'pveBaseVGSelector',
listeners: {
nodechanged: function(value) {
let panel = this.up('inputpanel');
panel.lookup('thinPoolSelector').setNodeName(value);
panel.lookup('storageNodeRestriction').setValue(value);
},
change: function(f, value) {
let vgField = this.up('inputpanel').lookup('thinPoolSelector');
if (vgField && !f.isDisabled()) {
vgField.setDisabled(!value);
vgField.setVG(value);
vgField.setValue('');
}
},
},
},
},
{
xtype: 'pmxDisplayEditField',
cbind: {
editable: '{isCreate}',
},
name: 'thinpool',
fieldLabel: gettext('Thin Pool'),
allowBlank: false,
editConfig: {
xtype: 'pveTPSelector',
reference: 'thinPoolSelector',
disabled: true,
},
},
{
xtype: 'pveContentTypeSelector',
cts: ['images', 'rootdir'],
fieldLabel: gettext('Content'),
name: 'content',
value: ['images', 'rootdir'],
multiSelect: true,
allowBlank: false,
},
],
});
Ext.define('PVE.storage.BTRFSInputPanel', {
extend: 'PVE.panel.StorageBase',
onlineHelp: 'storage_btrfs',
initComponent: function() {
let me = this;
me.column1 = [
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'path',
value: '',
fieldLabel: gettext('Path'),
allowBlank: false,
},
{
xtype: 'pveContentTypeSelector',
name: 'content',
value: ['images', 'rootdir'],
multiSelect: true,
fieldLabel: gettext('Content'),
allowBlank: false,
},
];
me.columnB = [
{
xtype: 'displayfield',
userCls: 'pmx-hint',
value: `BTRFS integration is currently a technology preview.`,
},
];
me.callParent();
},
});
Ext.define('PVE.storage.NFSScan', {
extend: 'Ext.form.field.ComboBox',
alias: 'widget.pveNFSScan',
queryParam: 'server',
valueField: 'path',
displayField: 'path',
matchFieldWidth: false,
listConfig: {
loadingText: gettext('Scanning...'),
width: 350,
},
doRawQuery: function() {
// do nothing
},
onTriggerClick: function() {
var me = this;
if (!me.queryCaching || me.lastQuery !== me.nfsServer) {
me.store.removeAll();
}
me.allQuery = me.nfsServer;
me.callParent();
},
setServer: function(server) {
var me = this;
me.nfsServer = server;
},
initComponent: function() {
var me = this;
if (!me.nodename) {
me.nodename = 'localhost';
}
var store = Ext.create('Ext.data.Store', {
fields: ['path', 'options'],
proxy: {
type: 'proxmox',
url: '/api2/json/nodes/' + me.nodename + '/scan/nfs',
},
});
store.sort('path', 'ASC');
Ext.apply(me, {
store: store,
});
me.callParent();
},
});
Ext.define('PVE.storage.NFSInputPanel', {
extend: 'PVE.panel.StorageBase',
onlineHelp: 'storage_nfs',
options: [],
onGetValues: function(values) {
var me = this;
var i;
var res = [];
for (i = 0; i < me.options.length; i++) {
var item = me.options[i];
if (!item.match(/^vers=(.*)$/)) {
res.push(item);
}
}
if (values.nfsversion && values.nfsversion !== '__default__') {
res.push('vers=' + values.nfsversion);
}
delete values.nfsversion;
values.options = res.join(',');
if (values.options === '') {
delete values.options;
if (!me.isCreate) {
values.delete = "options";
}
}
return me.callParent([values]);
},
setValues: function(values) {
var me = this;
if (values.options) {
me.options = values.options.split(',');
me.options.forEach(function(item) {
var match = item.match(/^vers=(.*)$/);
if (match) {
values.nfsversion = match[1];
}
});
}
return me.callParent([values]);
},
initComponent: function() {
var me = this;
me.column1 = [
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'server',
value: '',
fieldLabel: gettext('Server'),
allowBlank: false,
listeners: {
change: function(f, value) {
if (me.isCreate) {
var exportField = me.down('field[name=export]');
exportField.setServer(value);
exportField.setValue('');
}
},
},
},
{
xtype: me.isCreate ? 'pveNFSScan' : 'displayfield',
name: 'export',
value: '',
fieldLabel: 'Export',
allowBlank: false,
},
{
xtype: 'pveContentTypeSelector',
name: 'content',
value: 'images',
multiSelect: true,
fieldLabel: gettext('Content'),
allowBlank: false,
},
];
me.advancedColumn2 = [
{
xtype: 'proxmoxKVComboBox',
fieldLabel: gettext('NFS Version'),
name: 'nfsversion',
value: '__default__',
deleteEmpty: false,
comboItems: [
['__default__', Proxmox.Utils.defaultText],
['3', '3'],
['4', '4'],
['4.1', '4.1'],
['4.2', '4.2'],
],
},
];
me.callParent();
},
});
/*global QRCode*/
Ext.define('PVE.Storage.PBSKeyShow', {
extend: 'Ext.window.Window',
xtype: 'pvePBSKeyShow',
mixins: ['Proxmox.Mixin.CBind'],
width: 600,
modal: true,
resizable: false,
title: gettext('Important: Save your Encryption Key'),
// avoid that esc closes this by mistake, force user to more manual action
onEsc: Ext.emptyFn,
closable: false,
items: [
{
xtype: 'form',
layout: {
type: 'vbox',
align: 'stretch',
},
bodyPadding: 10,
border: false,
defaults: {
anchor: '100%',
border: false,
padding: '10 0 0 0',
},
items: [
{
xtype: 'textfield',
fieldLabel: gettext('Key'),
labelWidth: 80,
inputId: 'encryption-key-value',
cbind: {
value: '{key}',
},
editable: false,
},
{
xtype: 'component',
html: gettext('Keep your encryption key safe, but easily accessible for disaster recovery.')
+ '<br>' + gettext('We recommend the following safe-keeping strategy:'),
},
{
xtyp: 'container',
layout: 'hbox',
items: [
{
xtype: 'component',
html: '1. ' + gettext('Save the key in your password manager.'),
flex: 1,
},
{
xtype: 'button',
text: gettext('Copy Key'),
iconCls: 'fa fa-clipboard x-btn-icon-el-default-toolbar-small',
cls: 'x-btn-default-toolbar-small proxmox-inline-button',
width: 110,
handler: function(b) {
document.getElementById('encryption-key-value').select();
document.execCommand("copy");
},
},
],
},
{
xtype: 'container',
layout: 'hbox',
items: [
{
xtype: 'component',
html: '2. ' + gettext('Download the key to a USB (pen) drive, placed in secure vault.'),
flex: 1,
},
{
xtype: 'button',
text: gettext('Download'),
iconCls: 'fa fa-download x-btn-icon-el-default-toolbar-small',
cls: 'x-btn-default-toolbar-small proxmox-inline-button',
width: 110,
handler: function(b) {
let win = this.up('window');
let pveID = PVE.ClusterName || window.location.hostname;
let name = `pve-${pveID}-storage-${win.sid}.enc`;
let hiddenElement = document.createElement('a');
hiddenElement.href = 'data:attachment/text,' + encodeURI(win.key);
hiddenElement.target = '_blank';
hiddenElement.download = name;
hiddenElement.click();
},
},
],
},
{
xtype: 'container',
layout: 'hbox',
items: [
{
xtype: 'component',
html: '3. ' + gettext('Print as paperkey, laminated and placed in secure vault.'),
flex: 1,
},
{
xtype: 'button',
text: gettext('Print Key'),
iconCls: 'fa fa-print x-btn-icon-el-default-toolbar-small',
cls: 'x-btn-default-toolbar-small proxmox-inline-button',
width: 110,
handler: function(b) {
let win = this.up('window');
win.paperkey(win.key);
},
},
],
},
],
},
{
xtype: 'component',
border: false,
padding: '10 10 10 10',
userCls: 'pmx-hint',
html: gettext('Please save the encryption key - losing it will render any backup created with it unusable'),
},
],
buttons: [
{
text: gettext('Close'),
handler: function(b) {
let win = this.up('window');
win.close();
},
},
],
paperkey: function(keyString) {
let me = this;
const key = JSON.parse(keyString);
const qrwidth = 500;
let qrdiv = document.createElement('div');
let qrcode = new QRCode(qrdiv, {
width: qrwidth,
height: qrwidth,
correctLevel: QRCode.CorrectLevel.H,
});
qrcode.makeCode(keyString);
let shortKeyFP = '';
if (key.fingerprint) {
shortKeyFP = PVE.Utils.render_pbs_fingerprint(key.fingerprint);
}
let printFrame = document.createElement("iframe");
Object.assign(printFrame.style, {
position: "fixed",
right: "0",
bottom: "0",
width: "0",
height: "0",
border: "0",
});
const prettifiedKey = JSON.stringify(key, null, 2);
const keyQrBase64 = qrdiv.children[0].toDataURL("image/png");
const html = `<html><head><script>
window.addEventListener('DOMContentLoaded', (ev) => window.print());
</script><style>@media print and (max-height: 150mm) {
h4, p { margin: 0; font-size: 1em; }
}</style></head><body style="padding: 5px;">
<h4>Encryption Key - Storage '${me.sid}' (${shortKeyFP})</h4>
<p style="font-size:1.2em;font-family:monospace;white-space:pre-wrap;overflow-wrap:break-word;">
-----BEGIN PROXMOX BACKUP KEY-----
${prettifiedKey}
-----END PROXMOX BACKUP KEY-----</p>
<center><img style="width: 100%; max-width: ${qrwidth}px;" src="${keyQrBase64}"></center>
</body></html>`;
printFrame.src = "data:text/html;base64," + btoa(html);
document.body.appendChild(printFrame);
me.on('destroy', () => document.body.removeChild(printFrame));
},
});
Ext.define('PVE.panel.PBSEncryptionKeyTab', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pvePBSEncryptionKeyTab',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'storage_pbs_encryption',
onGetValues: function(form) {
let values = {};
if (form.cryptMode === 'upload') {
values['encryption-key'] = form['crypt-key-upload'];
} else if (form.cryptMode === 'autogenerate') {
values['encryption-key'] = 'autogen';
} else if (form.cryptMode === 'none') {
if (!this.isCreate) {
values.delete = ['encryption-key'];
}
}
return values;
},
setValues: function(values) {
let me = this;
let vm = me.getViewModel();
let cryptKeyInfo = values['encryption-key'];
if (cryptKeyInfo) {
let icon = '<span class="fa fa-lock good"></span> ';
if (cryptKeyInfo.match(/^[a-fA-F0-9]{2}:/)) { // new style fingerprint
let shortKeyFP = PVE.Utils.render_pbs_fingerprint(cryptKeyInfo);
values['crypt-key-fp'] = icon + `${gettext('Active')} - ${gettext('Fingerprint')} ${shortKeyFP}`;
} else {
// old key without FP
values['crypt-key-fp'] = icon + gettext('Active');
}
values.cryptMode = 'keep';
values['crypt-allow-edit'] = false;
} else {
values['crypt-key-fp'] = gettext('None');
let cryptModeNone = me.down('radiofield[inputValue=none]');
cryptModeNone.setBoxLabel(gettext('Do not encrypt backups'));
values.cryptMode = 'none';
values['crypt-allow-edit'] = true;
}
vm.set('keepCryptVisible', !!cryptKeyInfo);
vm.set('allowEdit', !cryptKeyInfo);
me.callParent([values]);
},
viewModel: {
data: {
allowEdit: true,
keepCryptVisible: false,
},
formulas: {
showDangerousHint: get => {
let allowEdit = get('allowEdit');
return get('keepCryptVisible') && allowEdit;
},
},
},
items: [
{
xtype: 'displayfield',
name: 'crypt-key-fp',
fieldLabel: gettext('Encryption Key'),
padding: '2 0',
},
{
xtype: 'checkbox',
name: 'crypt-allow-edit',
boxLabel: gettext('Edit existing encryption key (dangerous!)'),
hidden: true,
submitValue: false,
isDirty: () => false,
bind: {
hidden: '{!keepCryptVisible}',
value: '{allowEdit}',
},
},
{
xtype: 'radiofield',
name: 'cryptMode',
inputValue: 'keep',
boxLabel: gettext('Keep encryption key'),
padding: '0 0 0 25',
cbind: {
hidden: '{isCreate}',
},
bind: {
hidden: '{!keepCryptVisible}',
disabled: '{!allowEdit}',
},
},
{
xtype: 'radiofield',
name: 'cryptMode',
inputValue: 'none',
checked: true,
padding: '0 0 0 25',
cbind: {
disabled: '{!isCreate}',
checked: '{isCreate}',
boxLabel: get => get('isCreate')
? gettext('Do not encrypt backups')
: gettext('Delete existing encryption key'),
},
bind: {
disabled: '{!allowEdit}',
},
},
{
xtype: 'radiofield',
name: 'cryptMode',
inputValue: 'autogenerate',
boxLabel: gettext('Auto-generate a client encryption key'),
padding: '0 0 0 25',
cbind: {
disabled: '{!isCreate}',
},
bind: {
disabled: '{!allowEdit}',
},
},
{
xtype: 'radiofield',
name: 'cryptMode',
inputValue: 'upload',
boxLabel: gettext('Upload an existing client encryption key'),
padding: '0 0 0 25',
cbind: {
disabled: '{!isCreate}',
},
bind: {
disabled: '{!allowEdit}',
},
listeners: {
change: function(f, value) {
let panel = this.up('inputpanel');
if (!panel.rendered) {
return;
}
let uploadKeyField = panel.down('field[name=crypt-key-upload]');
uploadKeyField.setDisabled(!value);
uploadKeyField.setHidden(!value);
let uploadKeyButton = panel.down('filebutton[name=crypt-upload-button]');
uploadKeyButton.setDisabled(!value);
uploadKeyButton.setHidden(!value);
if (value) {
uploadKeyField.validate();
} else {
uploadKeyField.reset();
}
},
},
},
{
xtype: 'fieldcontainer',
layout: 'hbox',
items: [
{
xtype: 'proxmoxtextfield',
name: 'crypt-key-upload',
fieldLabel: gettext('Key'),
value: '',
disabled: true,
hidden: true,
allowBlank: false,
labelAlign: 'right',
flex: 1,
emptyText: gettext('You can drag-and-drop a key file here.'),
validator: function(value) {
if (value.length) {
let key;
try {
key = JSON.parse(value);
} catch (e) {
return "Failed to parse key - " + e;
}
if (key.data === undefined) {
return "Does not seems like a valid Proxmox Backup key!";
}
}
return true;
},
afterRender: function() {
if (!window.FileReader) {
// No FileReader support in this browser
return;
}
let cancel = function(ev) {
ev = ev.event;
if (ev.preventDefault) {
ev.preventDefault();
}
};
this.inputEl.on('dragover', cancel);
this.inputEl.on('dragenter', cancel);
this.inputEl.on('drop', ev => {
cancel(ev);
let files = ev.event.dataTransfer.files;
PVE.Utils.loadTextFromFile(files[0], v => this.setValue(v));
});
},
},
{
xtype: 'filebutton',
name: 'crypt-upload-button',
iconCls: 'fa fa-fw fa-folder-open-o x-btn-icon-el-default-toolbar-small',
cls: 'x-btn-default-toolbar-small proxmox-inline-button',
margin: '0 0 0 4',
disabled: true,
hidden: true,
listeners: {
change: function(btn, e, value) {
let ev = e.event;
let field = btn.up().down('proxmoxtextfield[name=crypt-key-upload]');
PVE.Utils.loadTextFromFile(ev.target.files[0], v => field.setValue(v));
btn.reset();
},
},
},
],
},
{
xtype: 'component',
border: false,
padding: '5 2',
userCls: 'pmx-hint',
html: // `<b style="color:red;font-weight:600;">${gettext('Warning')}</b>: ` +
`<span class="fa fa-exclamation-triangle" style="color:red;font-size:14px;"></span> ` +
gettext('Deleting or replacing the encryption key will break restoring backups created with it!'),
hidden: true,
bind: {
hidden: '{!showDangerousHint}',
},
},
],
});
Ext.define('PVE.storage.PBSInputPanel', {
extend: 'PVE.panel.StorageBase',
onlineHelp: 'storage_pbs',
apiCallDone: function(success, response, options) {
let res = response.result.data;
if (!(res && res.config && res.config['encryption-key'])) {
return;
}
let key = res.config['encryption-key'];
Ext.create('PVE.Storage.PBSKeyShow', {
autoShow: true,
sid: res.storage,
key: key,
});
},
isPBS: true, // HACK
extraTabs: [
{
xtype: 'pvePBSEncryptionKeyTab',
title: gettext('Encryption'),
},
],
setValues: function(values) {
let me = this;
let server = values.server;
if (values.port !== undefined) {
if (Proxmox.Utils.IP6_match.test(server)) {
server = `[${server}]`;
}
server += `:${values.port}`;
}
values.hostport = server;
return me.callParent([values]);
},
initComponent: function() {
var me = this;
me.column1 = [
{
xtype: me.isCreate ? 'proxmoxtextfield' : 'displayfield',
fieldLabel: gettext('Server'),
allowBlank: false,
name: 'hostport',
submitValue: false,
vtype: 'HostPort',
listeners: {
change: function(field, newvalue) {
let server = newvalue;
let port;
let match = Proxmox.Utils.HostPort_match.exec(newvalue);
if (match === null) {
match = Proxmox.Utils.HostPortBrackets_match.exec(newvalue);
if (match === null) {
match = Proxmox.Utils.IP6_dotnotation_match.exec(newvalue);
}
}
if (match !== null) {
server = match[1];
if (match[2] !== undefined) {
port = match[2];
}
}
field.up('inputpanel').down('field[name=server]').setValue(server);
field.up('inputpanel').down('field[name=port]').setValue(port);
},
},
},
{
xtype: 'proxmoxtextfield',
hidden: true,
name: 'server',
submitValue: me.isCreate, // it is fixed
},
{
xtype: 'proxmoxtextfield',
hidden: true,
deleteEmpty: !me.isCreate,
name: 'port',
},
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'username',
value: '',
emptyText: gettext('Example') + ': admin@pbs',
fieldLabel: gettext('Username'),
regex: /\S+@\w+/,
regexText: gettext('Example') + ': admin@pbs',
allowBlank: false,
},
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
inputType: 'password',
name: 'password',
value: me.isCreate ? '' : '********',
emptyText: me.isCreate ? gettext('None') : '',
fieldLabel: gettext('Password'),
allowBlank: false,
},
];
me.column2 = [
{
xtype: 'displayfield',
name: 'content',
value: 'backup',
submitValue: true,
fieldLabel: gettext('Content'),
},
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'datastore',
value: '',
fieldLabel: 'Datastore',
allowBlank: false,
},
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'namespace',
value: '',
emptyText: gettext('Root'),
fieldLabel: gettext('Namespace'),
allowBlank: true,
},
];
me.columnB = [
{
xtype: 'pmxFingerprintField',
name: 'fingerprint',
value: me.isCreate ? null : undefined,
deleteEmpty: !me.isCreate,
},
];
me.callParent();
},
});
Ext.define('PVE.storage.Ceph.Model', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.cephstorage',
data: {
pveceph: true,
pvecephPossible: true,
namespacePresent: false,
},
});
Ext.define('PVE.storage.Ceph.Controller', {
extend: 'PVE.controller.StorageEdit',
alias: 'controller.cephstorage',
control: {
'#': {
afterrender: 'queryMonitors',
},
'textfield[name=username]': {
disable: 'resetField',
},
'displayfield[name=monhost]': {
enable: 'queryMonitors',
},
'textfield[name=monhost]': {
disable: 'resetField',
enable: 'resetField',
},
'textfield[name=namespace]': {
change: 'updateNamespaceHint',
},
},
resetField: function(field) {
field.reset();
},
updateNamespaceHint: function(field, newVal, oldVal) {
this.getViewModel().set('namespacePresent', newVal);
},
queryMonitors: function(field, newVal, oldVal) {
// we get called with two signatures, the above one for a field
// change event and the afterrender from the view, this check only
// can be true for the field change one and omit the API request if
// pveceph got unchecked - as it's not needed there.
if (field && !newVal && oldVal) {
return;
}
var view = this.getView();
var vm = this.getViewModel();
if (!(view.isCreate || vm.get('pveceph'))) {
return; // only query on create or if editing a pveceph store
}
var monhostField = this.lookupReference('monhost');
Proxmox.Utils.API2Request({
url: '/api2/json/nodes/localhost/ceph/mon',
method: 'GET',
scope: this,
callback: function(options, success, response) {
var data = response.result.data;
if (response.status === 200) {
if (data.length > 0) {
var monhost = Ext.Array.pluck(data, 'name').sort().join(',');
monhostField.setValue(monhost);
monhostField.resetOriginalValue();
if (view.isCreate) {
vm.set('pvecephPossible', true);
}
} else {
vm.set('pveceph', false);
}
} else {
vm.set('pveceph', false);
vm.set('pvecephPossible', false);
}
},
});
},
});
Ext.define('PVE.storage.RBDInputPanel', {
extend: 'PVE.panel.StorageBase',
controller: 'cephstorage',
onlineHelp: 'ceph_rados_block_devices',
viewModel: {
type: 'cephstorage',
},
setValues: function(values) {
if (values.monhost) {
this.viewModel.set('pveceph', false);
this.lookupReference('pvecephRef').setValue(false);
this.lookupReference('pvecephRef').resetOriginalValue();
}
if (values.namespace) {
this.getViewModel().set('namespacePresent', true);
}
this.callParent([values]);
},
initComponent: function() {
var me = this;
if (!me.nodename) {
me.nodename = 'localhost';
}
me.type = 'rbd';
me.column1 = [];
if (me.isCreate) {
me.column1.push({
xtype: 'pveCephPoolSelector',
nodename: me.nodename,
name: 'pool',
bind: {
disabled: '{!pveceph}',
submitValue: '{pveceph}',
hidden: '{!pveceph}',
},
fieldLabel: gettext('Pool'),
allowBlank: false,
}, {
xtype: 'textfield',
name: 'pool',
value: 'rbd',
bind: {
disabled: '{pveceph}',
submitValue: '{!pveceph}',
hidden: '{pveceph}',
},
fieldLabel: gettext('Pool'),
allowBlank: false,
});
} else {
me.column1.push({
xtype: 'displayfield',
nodename: me.nodename,
name: 'pool',
fieldLabel: gettext('Pool'),
allowBlank: false,
});
}
me.column1.push(
{
xtype: 'textfield',
name: 'monhost',
vtype: 'HostList',
bind: {
disabled: '{pveceph}',
submitValue: '{!pveceph}',
hidden: '{pveceph}',
},
value: '',
fieldLabel: 'Monitor(s)',
allowBlank: false,
},
{
xtype: 'displayfield',
reference: 'monhost',
bind: {
disabled: '{!pveceph}',
hidden: '{!pveceph}',
},
value: '',
fieldLabel: 'Monitor(s)',
},
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'username',
bind: {
disabled: '{pveceph}',
submitValue: '{!pveceph}',
},
value: 'admin',
fieldLabel: gettext('User name'),
allowBlank: true,
},
);
me.column2 = [
{
xtype: 'pveContentTypeSelector',
cts: ['images', 'rootdir'],
fieldLabel: gettext('Content'),
name: 'content',
value: ['images'],
multiSelect: true,
allowBlank: false,
},
{
xtype: 'proxmoxcheckbox',
name: 'krbd',
uncheckedValue: 0,
fieldLabel: 'KRBD',
},
];
me.columnB = [
{
xtype: me.isCreate ? 'textarea' : 'displayfield',
name: 'keyring',
fieldLabel: 'Keyring',
value: me.isCreate ? '' : '***********',
allowBlank: false,
bind: {
hidden: '{pveceph}',
disabled: '{pveceph}',
},
},
{
xtype: 'proxmoxcheckbox',
name: 'pveceph',
reference: 'pvecephRef',
bind: {
disabled: '{!pvecephPossible}',
value: '{pveceph}',
},
checked: true,
uncheckedValue: 0,
submitValue: false,
hidden: !me.isCreate,
boxLabel: gettext('Use Proxmox VE managed hyper-converged ceph pool'),
},
];
me.advancedColumn1 = [
{
xtype: 'pmxDisplayEditField',
editable: me.isCreate,
name: 'namespace',
value: '',
fieldLabel: gettext('Namespace'),
allowBlank: true,
},
];
me.advancedColumn2 = [
{
xtype: 'displayfield',
name: 'namespace-hint',
userCls: 'pmx-hint',
value: gettext('RBD namespaces must be created manually!'),
bind: {
hidden: '{!namespacePresent}',
},
},
];
me.callParent();
},
});
Ext.define('PVE.storage.StatusView', {
extend: 'Proxmox.panel.StatusView',
alias: 'widget.pveStorageStatusView',
height: 230,
title: gettext('Status'),
layout: {
type: 'vbox',
align: 'stretch',
},
defaults: {
xtype: 'pmxInfoWidget',
padding: '0 30 5 30',
},
items: [
{
xtype: 'box',
height: 30,
},
{
itemId: 'enabled',
title: gettext('Enabled'),
printBar: false,
textField: 'disabled',
renderer: Proxmox.Utils.format_neg_boolean,
},
{
itemId: 'active',
title: gettext('Active'),
printBar: false,
textField: 'active',
renderer: Proxmox.Utils.format_boolean,
},
{
itemId: 'content',
title: gettext('Content'),
printBar: false,
textField: 'content',
renderer: PVE.Utils.format_content_types,
},
{
itemId: 'type',
title: gettext('Type'),
printBar: false,
textField: 'type',
renderer: PVE.Utils.format_storage_type,
},
{
xtype: 'box',
height: 10,
},
{
itemId: 'usage',
title: gettext('Usage'),
valueField: 'used',
maxField: 'total',
renderer: (val, max) => {
if (max === undefined) {
return val;
}
return Proxmox.Utils.render_size_usage(val, max, true);
},
},
],
updateTitle: function() {
// nothing
},
});
Ext.define('PVE.storage.Summary', {
extend: 'Ext.panel.Panel',
alias: 'widget.pveStorageSummary',
scrollable: true,
bodyPadding: 5,
tbar: [
'->',
{
xtype: 'proxmoxRRDTypeSelector',
},
],
layout: {
type: 'column',
},
defaults: {
padding: 5,
columnWidth: 1,
},
initComponent: function() {
var me = this;
var nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
var storage = me.pveSelNode.data.storage;
if (!storage) {
throw "no storage ID specified";
}
var rstore = Ext.create('Proxmox.data.ObjectStore', {
url: "/api2/json/nodes/" + nodename + "/storage/" + storage + "/status",
interval: 1000,
});
var rrdstore = Ext.create('Proxmox.data.RRDStore', {
rrdurl: "/api2/json/nodes/" + nodename + "/storage/" + storage + "/rrddata",
model: 'pve-rrd-storage',
});
Ext.apply(me, {
items: [
{
xtype: 'pveStorageStatusView',
pveSelNode: me.pveSelNode,
rstore: rstore,
},
{
xtype: 'proxmoxRRDChart',
title: gettext('Usage'),
fields: ['total', 'used'],
fieldTitles: ['Total Size', 'Used Size'],
store: rrdstore,
},
],
listeners: {
activate: function() { rstore.startUpdate(); rrdstore.startUpdate(); },
destroy: function() { rstore.stopUpdate(); rrdstore.stopUpdate(); },
},
});
me.callParent();
},
});
Ext.define('PVE.grid.TemplateSelector', {
extend: 'Ext.grid.GridPanel',
alias: 'widget.pveTemplateSelector',
stateful: true,
stateId: 'grid-template-selector',
viewConfig: {
trackOver: false,
},
initComponent: function() {
var me = this;
if (!me.nodename) {
throw "no node name specified";
}
var baseurl = "/nodes/" + me.nodename + "/aplinfo";
var store = new Ext.data.Store({
model: 'pve-aplinfo',
groupField: 'section',
proxy: {
type: 'proxmox',
url: '/api2/json' + baseurl,
},
});
var sm = Ext.create('Ext.selection.RowModel', {});
var groupingFeature = Ext.create('Ext.grid.feature.Grouping', {
groupHeaderTpl: '{[ "Section: " + values.name ]} ({rows.length} Item{[values.rows.length > 1 ? "s" : ""]})',
});
var reload = function() {
store.load();
};
Proxmox.Utils.monStoreErrors(me, store);
Ext.apply(me, {
store: store,
selModel: sm,
tbar: [
'->',
gettext('Search'),
{
xtype: 'textfield',
width: 200,
enableKeyEvents: true,
listeners: {
buffer: 500,
keyup: function(field) {
var value = field.getValue().toLowerCase();
store.clearFilter(true);
store.filterBy(function(rec) {
return rec.data.package.toLowerCase().indexOf(value) !== -1 ||
rec.data.headline.toLowerCase().indexOf(value) !== -1;
});
},
},
},
],
features: [groupingFeature],
columns: [
{
header: gettext('Type'),
width: 80,
dataIndex: 'type',
},
{
header: gettext('Package'),
flex: 1,
dataIndex: 'package',
},
{
header: gettext('Version'),
width: 80,
dataIndex: 'version',
},
{
header: gettext('Description'),
flex: 1.5,
renderer: Ext.String.htmlEncode,
dataIndex: 'headline',
},
],
listeners: {
afterRender: reload,
},
});
me.callParent();
},
}, function() {
Ext.define('pve-aplinfo', {
extend: 'Ext.data.Model',
fields: [
'template', 'type', 'package', 'version', 'headline', 'infopage',
'description', 'os', 'section',
],
idProperty: 'template',
});
});
Ext.define('PVE.storage.TemplateDownload', {
extend: 'Ext.window.Window',
alias: 'widget.pveTemplateDownload',
modal: true,
title: gettext('Templates'),
layout: 'fit',
width: 900,
height: 600,
initComponent: function() {
var me = this;
var grid = Ext.create('PVE.grid.TemplateSelector', {
border: false,
scrollable: true,
nodename: me.nodename,
});
var sm = grid.getSelectionModel();
var submitBtn = Ext.create('Proxmox.button.Button', {
text: gettext('Download'),
disabled: true,
selModel: sm,
handler: function(button, event, rec) {
Proxmox.Utils.API2Request({
url: '/nodes/' + me.nodename + '/aplinfo',
params: {
storage: me.storage,
template: rec.data.template,
},
method: 'POST',
failure: function(response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
success: function(response, options) {
var upid = response.result.data;
Ext.create('Proxmox.window.TaskViewer', {
upid: upid,
listeners: {
destroy: me.reloadGrid,
},
}).show();
me.close();
},
});
},
});
Ext.apply(me, {
items: grid,
buttons: [submitBtn],
});
me.callParent();
},
});
Ext.define('PVE.storage.TemplateView', {
extend: 'PVE.storage.ContentView',
alias: 'widget.pveStorageTemplateView',
initComponent: function() {
var me = this;
var nodename = me.nodename = me.pveSelNode.data.node;
if (!nodename) {
throw "no node name specified";
}
var storage = me.storage = me.pveSelNode.data.storage;
if (!storage) {
throw "no storage ID specified";
}
me.content = 'vztmpl';
var reload = function() {
me.store.load();
};
var templateButton = Ext.create('Proxmox.button.Button', {
itemId: 'tmpl-btn',
text: gettext('Templates'),
handler: function() {
var win = Ext.create('PVE.storage.TemplateDownload', {
nodename: nodename,
storage: storage,
reloadGrid: reload,
});
win.show();
},
});
me.tbar = [templateButton];
me.useUploadButton = true;
me.callParent();
},
});
Ext.define('PVE.storage.ZFSInputPanel', {
extend: 'PVE.panel.StorageBase',
viewModel: {
parent: null,
data: {
isLIO: false,
isComstar: true,
hasWriteCacheOption: true,
},
},
controller: {
xclass: 'Ext.app.ViewController',
control: {
'field[name=iscsiprovider]': {
change: 'changeISCSIProvider',
},
},
changeISCSIProvider: function(f, newVal, oldVal) {
var vm = this.getViewModel();
vm.set('isLIO', newVal === 'LIO');
vm.set('isComstar', newVal === 'comstar');
vm.set('hasWriteCacheOption', newVal === 'comstar' || newVal === 'istgt');
},
},
onGetValues: function(values) {
var me = this;
if (me.isCreate) {
values.content = 'images';
}
values.nowritecache = values.writecache ? 0 : 1;
delete values.writecache;
return me.callParent([values]);
},
setValues: function(values) {
values.writecache = values.nowritecache ? 0 : 1;
this.callParent([values]);
},
initComponent: function() {
var me = this;
me.column1 = [
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'portal',
value: '',
fieldLabel: gettext('Portal'),
allowBlank: false,
},
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'pool',
value: '',
fieldLabel: gettext('Pool'),
allowBlank: false,
},
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'blocksize',
value: '4k',
fieldLabel: gettext('Block Size'),
allowBlank: false,
},
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'target',
value: '',
fieldLabel: gettext('Target'),
allowBlank: false,
},
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'comstar_tg',
value: '',
fieldLabel: gettext('Target group'),
bind: me.isCreate ? { disabled: '{!isComstar}' } : { hidden: '{!isComstar}' },
allowBlank: true,
},
];
me.column2 = [
{
xtype: me.isCreate ? 'pveiScsiProviderSelector' : 'displayfield',
name: 'iscsiprovider',
value: 'comstar',
fieldLabel: gettext('iSCSI Provider'),
allowBlank: false,
},
{
xtype: 'proxmoxcheckbox',
name: 'sparse',
checked: false,
uncheckedValue: 0,
fieldLabel: gettext('Thin provision'),
},
{
xtype: 'proxmoxcheckbox',
name: 'writecache',
checked: true,
bind: me.isCreate ? { disabled: '{!hasWriteCacheOption}' } : { hidden: '{!hasWriteCacheOption}' },
uncheckedValue: 0,
fieldLabel: gettext('Write cache'),
},
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'comstar_hg',
value: '',
bind: me.isCreate ? { disabled: '{!isComstar}' } : { hidden: '{!isComstar}' },
fieldLabel: gettext('Host group'),
allowBlank: true,
},
{
xtype: me.isCreate ? 'textfield' : 'displayfield',
name: 'lio_tpg',
value: '',
bind: me.isCreate ? { disabled: '{!isLIO}' } : { hidden: '{!isLIO}' },
allowBlank: false,
fieldLabel: gettext('Target portal group'),
},
];
me.callParent();
},
});
Ext.define('PVE.storage.ZFSPoolSelector', {
extend: 'PVE.form.ComboBoxSetStoreNode',
alias: 'widget.pveZFSPoolSelector',
valueField: 'pool',
displayField: 'pool',
queryMode: 'local',
editable: false,
allowBlank: false,
listConfig: {
columns: [
{
dataIndex: 'pool',
flex: 1,
},
],
emptyText: PVE.Utils.renderNotFound(gettext('ZFS Pool')),
},
config: {
apiSuffix: '/scan/zfs',
},
showNodeSelector: true,
setNodeName: function(value) {
let me = this;
me.callParent([value]);
me.getStore().load();
},
initComponent: function() {
let me = this;
if (!me.nodename) {
me.nodename = 'localhost';
}
let store = Ext.create('Ext.data.Store', {
autoLoad: {}, // true,
fields: ['pool', 'size', 'free'],
proxy: {
type: 'proxmox',
url: `${me.apiBaseUrl}${me.nodename}${me.apiSuffix}`,
},
});
store.sort('pool', 'ASC');
Ext.apply(me, {
store: store,
});
me.callParent();
},
});
Ext.define('PVE.storage.ZFSPoolInputPanel', {
extend: 'PVE.panel.StorageBase',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'storage_zfspool',
column1: [
{
xtype: 'pmxDisplayEditField',
cbind: {
editable: '{isCreate}',
},
name: 'pool',
fieldLabel: gettext('ZFS Pool'),
allowBlank: false,
editConfig: {
xtype: 'pveZFSPoolSelector',
reference: 'zfsPoolSelector',
listeners: {
nodechanged: function(value) {
this.up('inputpanel').lookup('storageNodeRestriction').setValue(value);
},
},
},
},
{
xtype: 'pveContentTypeSelector',
cts: ['images', 'rootdir'],
fieldLabel: gettext('Content'),
name: 'content',
value: ['images', 'rootdir'],
multiSelect: true,
allowBlank: false,
},
],
column2: [
{
xtype: 'proxmoxcheckbox',
name: 'sparse',
checked: false,
uncheckedValue: 0,
fieldLabel: gettext('Thin provision'),
},
{
xtype: 'textfield',
name: 'blocksize',
emptyText: '16k',
fieldLabel: gettext('Block Size'),
allowBlank: true,
},
],
});
Ext.define('PVE.storage.ESXIInputPanel', {
extend: 'PVE.panel.StorageBase',
setValues: function(values) {
let me = this;
let server = values.server;
if (values.port !== undefined) {
if (Proxmox.Utils.IP6_match.test(server)) {
server = `[${server}]`;
}
server += `:${values.port}`;
}
values.server = server;
return me.callParent([values]);
},
onGetValues: function(values) {
let me = this;
if (values.password?.length === 0) {
delete values.password;
}
if (values.username?.length === 0) {
delete values.username;
}
if (me.isCreate) {
let serverPortMatch = Proxmox.Utils.HostPort_match.exec(values.server);
if (serverPortMatch === null) {
serverPortMatch = Proxmox.Utils.HostPortBrackets_match.exec(values.server);
if (serverPortMatch === null) {
serverPortMatch = Proxmox.Utils.IP6_dotnotation_match.exec(values.server);
}
}
if (serverPortMatch !== null) {
values.server = serverPortMatch[1];
if (serverPortMatch[2] !== undefined) {
values.port = serverPortMatch[2];
}
}
}
return me.callParent([values]);
},
initComponent: function() {
var me = this;
me.column1 = [
{
xtype: 'pmxDisplayEditField',
name: 'server',
fieldLabel: gettext('Server'),
editable: me.isCreate,
emptyText: gettext('IP address or hostname'),
allowBlank: false,
},
{
xtype: 'textfield',
name: 'username',
fieldLabel: gettext('Username'),
allowBlank: false,
},
{
xtype: 'proxmoxtextfield',
name: 'password',
fieldLabel: gettext('Password'),
inputType: 'password',
emptyText: gettext('Unchanged'),
minLength: 1,
allowBlank: !me.isCreate,
},
];
me.column2 = [
{
xtype: 'proxmoxcheckbox',
name: 'skip-cert-verification',
fieldLabel: gettext('Skip Certificate Verification'),
value: false,
uncheckedValue: 0,
defaultValue: 0,
deleteDefaultValue: !me.isCreate,
},
];
me.callParent();
},
});
/*
* Workspace base class
*
* popup login window when auth fails (call onLogin handler)
* update (re-login) ticket every 15 minutes
*
*/
Ext.define('PVE.Workspace', {
extend: 'Ext.container.Viewport',
title: 'Proxmox Virtual Environment',
loginData: null, // Data from last login call
onLogin: function(loginData) {
// override me
},
// private
updateLoginData: function(loginData) {
let me = this;
me.loginData = loginData;
Proxmox.Utils.setAuthData(loginData);
let rt = me.down('pveResourceTree');
rt.setDatacenterText(loginData.clustername);
PVE.ClusterName = loginData.clustername;
if (loginData.cap) {
Ext.state.Manager.set('GuiCap', loginData.cap);
}
me.response401count = 0;
me.onLogin(loginData);
},
// private
showLogin: function() {
let me = this;
Proxmox.Utils.authClear();
Ext.state.Manager.clear('GuiCap');
Proxmox.UserName = null;
me.loginData = null;
if (!me.login) {
me.login = Ext.create('PVE.window.LoginWindow', {
handler: function(data) {
me.login = null;
me.updateLoginData(data);
Proxmox.Utils.checked_command(Ext.emptyFn); // display subscription status
},
});
}
me.onLogin(null);
me.login.show();
},
initComponent: function() {
let me = this;
Ext.tip.QuickTipManager.init();
// fixme: what about other errors
Ext.Ajax.on('requestexception', function(conn, response, options) {
if ((response.status === 401 || response.status === '401') && !PVE.Utils.silenceAuthFailures) { // auth failure
// don't immediately show as logged out to cope better with some big
// upgrades, which may temporarily produce a false positive 401 err
me.response401count++;
if (me.response401count > 5) {
me.showLogin();
}
}
});
me.callParent();
if (!Proxmox.Utils.authOK()) {
me.showLogin();
} else if (me.loginData) {
me.onLogin(me.loginData);
}
Ext.TaskManager.start({
run: function() {
let ticket = Proxmox.Utils.authOK();
if (!ticket || !Proxmox.UserName) {
return;
}
Ext.Ajax.request({
params: {
username: Proxmox.UserName,
password: ticket,
},
url: '/api2/json/access/ticket',
method: 'POST',
success: function(response, opts) {
let obj = Ext.decode(response.responseText);
me.updateLoginData(obj.data);
},
});
},
interval: 15 * 60 * 1000,
});
},
});
Ext.define('PVE.StdWorkspace', {
extend: 'PVE.Workspace',
alias: ['widget.pveStdWorkspace'],
// private
setContent: function(comp) {
let me = this;
let view = me.child('#content');
let layout = view.getLayout();
let current = layout.getActiveItem();
if (comp) {
Proxmox.Utils.setErrorMask(view, false);
comp.border = false;
view.add(comp);
if (current !== null && layout.getNext()) {
layout.next();
let task = Ext.create('Ext.util.DelayedTask', function() {
view.remove(current);
});
task.delay(10);
}
} else {
view.removeAll(); // helper for cleaning the content when logging out
}
},
selectById: function(nodeid) {
let me = this;
me.down('pveResourceTree').selectById(nodeid);
},
onLogin: function(loginData) {
let me = this;
me.updateUserInfo();
if (loginData) {
PVE.data.ResourceStore.startUpdate();
Proxmox.Utils.API2Request({
url: '/version',
method: 'GET',
success: function(response) {
PVE.VersionInfo = response.result.data;
me.updateVersionInfo();
},
});
PVE.UIOptions.update();
Proxmox.Utils.API2Request({
url: '/cluster/sdn',
method: 'GET',
success: function(response) {
PVE.SDNInfo = response.result.data;
},
failure: function(response) {
PVE.SDNInfo = null;
let ui = Ext.ComponentQuery.query('treelistitem[text="SDN"]')[0];
if (ui) {
ui.addCls('x-hidden-display');
}
},
});
Proxmox.Utils.API2Request({
url: '/access/domains',
method: 'GET',
success: function(response) {
let [_username, realm] = Proxmox.Utils.parse_userid(Proxmox.UserName);
response.result.data.forEach((domain) => {
if (domain.realm === realm) {
let schema = PVE.Utils.authSchema[domain.type];
if (schema) {
me.query('#tfaitem')[0].setHidden(!schema.tfa);
me.query('#passworditem')[0].setHidden(!schema.pwchange);
}
}
});
},
});
}
},
updateUserInfo: function() {
let me = this;
let ui = me.query('#userinfo')[0];
ui.setText(Ext.String.htmlEncode(Proxmox.UserName || ''));
ui.updateLayout();
},
updateVersionInfo: function() {
let me = this;
let ui = me.query('#versioninfo')[0];
if (PVE.VersionInfo) {
let version = PVE.VersionInfo.version;
ui.update('Virtual Environment ' + version);
} else {
ui.update('Virtual Environment');
}
ui.updateLayout();
},
initComponent: function() {
let me = this;
Ext.History.init();
let appState = Ext.create('PVE.StateProvider');
Ext.state.Manager.setProvider(appState);
let selview = Ext.create('PVE.form.ViewSelector', {
flex: 1,
padding: '0 5 0 0',
});
let rtree = Ext.createWidget('pveResourceTree', {
viewFilter: selview.getViewFilter(),
flex: 1,
selModel: {
selType: 'treemodel',
listeners: {
selectionchange: function(sm, selected) {
if (selected.length <= 0) {
return;
}
let treeNode = selected[0];
let treeTypeToClass = {
root: 'PVE.dc.Config',
node: 'PVE.node.Config',
qemu: 'PVE.qemu.Config',
lxc: 'pveLXCConfig',
storage: 'PVE.storage.Browser',
sdn: 'PVE.sdn.Browser',
pool: 'pvePoolConfig',
tag: 'pveTagConfig',
};
PVE.curSelectedNode = treeNode;
me.setContent({
xtype: treeTypeToClass[treeNode.data.type || 'root'] || 'pvePanelConfig',
showSearch: treeNode.data.id === 'root' || Ext.isDefined(treeNode.data.groupbyid),
pveSelNode: treeNode,
workspace: me,
viewFilter: selview.getViewFilter(),
});
},
},
},
});
selview.on('select', function(combo, records) {
if (records) {
let view = combo.getViewFilter();
rtree.setViewFilter(view);
}
});
let caps = appState.get('GuiCap');
let createVM = Ext.createWidget('button', {
pack: 'end',
margin: '3 5 0 0',
baseCls: 'x-btn',
iconCls: 'fa fa-desktop',
text: gettext("Create VM"),
disabled: !caps.vms['VM.Allocate'],
handler: function() {
let wiz = Ext.create('PVE.qemu.CreateWizard', {});
wiz.show();
},
});
let createCT = Ext.createWidget('button', {
pack: 'end',
margin: '3 5 0 0',
baseCls: 'x-btn',
iconCls: 'fa fa-cube',
text: gettext("Create CT"),
disabled: !caps.vms['VM.Allocate'],
handler: function() {
let wiz = Ext.create('PVE.lxc.CreateWizard', {});
wiz.show();
},
});
appState.on('statechange', function(sp, key, value) {
if (key === 'GuiCap' && value) {
caps = value;
createVM.setDisabled(!caps.vms['VM.Allocate']);
createCT.setDisabled(!caps.vms['VM.Allocate']);
}
});
Ext.apply(me, {
layout: { type: 'border' },
border: false,
items: [
{
region: 'north',
title: gettext('Header'), // for ARIA
header: false, // avoid rendering the title
layout: {
type: 'hbox',
align: 'middle',
},
baseCls: 'x-plain',
defaults: {
baseCls: 'x-plain',
},
border: false,
margin: '2 0 2 5',
items: [
{
xtype: 'proxmoxlogo',
},
{
minWidth: 150,
id: 'versioninfo',
html: 'Virtual Environment',
style: {
'font-size': '14px',
'line-height': '18px',
},
},
{
xtype: 'pveGlobalSearchField',
tree: rtree,
},
{
flex: 1,
},
{
xtype: 'proxmoxHelpButton',
hidden: false,
baseCls: 'x-btn',
iconCls: 'fa fa-book x-btn-icon-el-default-toolbar-small ',
listenToGlobalEvent: false,
onlineHelp: 'pve_documentation_index',
text: gettext('Documentation'),
margin: '0 5 0 0',
},
createVM,
createCT,
{
pack: 'end',
margin: '0 5 0 0',
id: 'userinfo',
xtype: 'button',
baseCls: 'x-btn',
style: {
// proxmox dark grey p light grey as border
backgroundColor: '#464d4d',
borderColor: '#ABBABA',
},
iconCls: 'fa fa-user',
menu: [
{
iconCls: 'fa fa-gear',
text: gettext('My Settings'),
handler: function() {
var win = Ext.create('PVE.window.Settings');
win.show();
},
},
{
text: gettext('Password'),
itemId: 'passworditem',
iconCls: 'fa fa-fw fa-key',
handler: function() {
var win = Ext.create('Proxmox.window.PasswordEdit', {
userid: Proxmox.UserName,
confirmCurrentPassword: Proxmox.UserName !== 'root@pam',
minLength: 8,
});
win.show();
},
},
{
text: 'TFA',
itemId: 'tfaitem',
iconCls: 'fa fa-fw fa-lock',
handler: function(btn, event, rec) {
Ext.state.Manager.getProvider().set('dctab', { value: 'tfa' }, true);
me.selectById('root');
},
},
{
iconCls: 'fa fa-paint-brush',
text: gettext('Color Theme'),
handler: function() {
Ext.create('Proxmox.window.ThemeEditWindow')
.show();
},
},
{
iconCls: 'fa fa-language',
text: gettext('Language'),
handler: function() {
Ext.create('Proxmox.window.LanguageEditWindow')
.show();
},
},
'-',
{
iconCls: 'fa fa-fw fa-sign-out',
text: gettext("Logout"),
handler: function() {
PVE.data.ResourceStore.loadData([], false);
me.showLogin();
me.setContent(null);
var rt = me.down('pveResourceTree');
rt.setDatacenterText(undefined);
rt.clearTree();
// empty the stores of the StatusPanel child items
var statusPanels = Ext.ComponentQuery.query('pveStatusPanel grid');
Ext.Array.forEach(statusPanels, function(comp) {
if (comp.getStore()) {
comp.getStore().loadData([], false);
}
});
},
},
],
},
],
},
{
region: 'center',
stateful: true,
stateId: 'pvecenter',
minWidth: 100,
minHeight: 100,
id: 'content',
xtype: 'container',
layout: { type: 'card' },
border: false,
margin: '0 5 0 0',
items: [],
},
{
region: 'west',
stateful: true,
stateId: 'pvewest',
itemId: 'west',
xtype: 'container',
border: false,
layout: { type: 'vbox', align: 'stretch' },
margin: '0 0 0 5',
split: true,
width: 300,
items: [
{
xtype: 'container',
layout: 'hbox',
padding: '0 0 5 0',
items: [
selview,
{
xtype: 'button',
cls: 'x-btn-default-toolbar-small',
iconCls: 'fa fa-fw fa-gear x-btn-icon-el-default-toolbar-small',
handler: () => {
Ext.create('PVE.window.TreeSettingsEdit', {
autoShow: true,
apiCallDone: () => PVE.UIOptions.fireUIConfigChanged(),
});
},
},
],
},
rtree,
],
listeners: {
resize: function(panel, width, height) {
var viewWidth = me.getSize().width;
if (width > viewWidth - 100 && viewWidth > 150) {
panel.setWidth(viewWidth - 100);
}
},
},
},
{
xtype: 'pveStatusPanel',
stateful: true,
stateId: 'pvesouth',
itemId: 'south',
region: 'south',
margin: '0 5 5 5',
title: gettext('Logs'),
collapsible: true,
header: false,
height: 200,
split: true,
listeners: {
resize: function(panel, width, height) {
var viewHeight = me.getSize().height;
if (height > viewHeight - 150 && viewHeight > 200) {
panel.setHeight(viewHeight - 150);
}
},
},
},
],
});
me.callParent();
me.updateUserInfo();
// on resize, center all modal windows
Ext.on('resize', function() {
let modalWindows = Ext.ComponentQuery.query('window[modal]');
if (modalWindows.length > 0) {
modalWindows.forEach(win => win.alignTo(me, 'c-c'));
}
});
let tagSelectors = [];
['circle', 'dense'].forEach((style) => {
['dark', 'light'].forEach((variant) => {
let selector = `.proxmox-tags-${style} :not(.proxmox-tags-full) > .proxmox-tag-${variant}`;
tagSelectors.push(selector);
});
});
Ext.create('Ext.tip.ToolTip', {
target: me.el,
delegate: tagSelectors.join(', '),
trackMouse: true,
renderTo: Ext.getBody(),
border: 0,
minWidth: 0,
padding: 0,
bodyBorder: 0,
bodyPadding: 0,
dismissDelay: 0,
userCls: 'pmx-tag-tooltip',
shadow: false,
listeners: {
beforeshow: function(tip) {
let tag = Ext.htmlEncode(tip.triggerElement.innerHTML);
let tagEl = Proxmox.Utils.getTagElement(tag, PVE.UIOptions.tagOverrides);
tip.update(`<span class="proxmox-tags-full">${tagEl}</span>`);
},
},
});
},
});