Compare commits
	
		
			20 Commits
		
	
	
		
			9334b984ae
			...
			20250531v2
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d1ca9aee39 | |||
| f850ca5cb7 | |||
| 4ee01cad4b | |||
| 5518b45219 | |||
| 0279907ca9 | |||
| 5fe481ffed | |||
| c7cc15d8d0 | |||
| 22416badcf | |||
| ef0d77f1d6 | |||
| 1d4af8c74e | |||
| ec9492c933 | |||
| fa03ae885f | |||
| ea98aaf856 | |||
| 794d16bdaa | |||
| a3ac56f849 | |||
| 6130f37336 | |||
| 6b6fef5ccc | |||
| 83df7e1b20 | |||
| a18310e04a | |||
| dd7f9176fa | 
| @@ -255,6 +255,11 @@ impl AppConfig { | ||||
|         self.storage_path().join("disk_images") | ||||
|     } | ||||
|  | ||||
|     /// Get the path of a disk image file | ||||
|     pub fn disk_images_file_path(&self, name: &str) -> PathBuf { | ||||
|         self.disk_images_storage_path().join(name) | ||||
|     } | ||||
|  | ||||
|     /// Get VM vnc sockets directory | ||||
|     pub fn vnc_sockets_path(&self) -> PathBuf { | ||||
|         self.storage_path().join("vnc") | ||||
|   | ||||
| @@ -27,20 +27,23 @@ pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [ | ||||
| ]; | ||||
|  | ||||
| /// ISO max size | ||||
| pub const ISO_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000; | ||||
| pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10); | ||||
|  | ||||
| /// Allowed uploaded disk images formats | ||||
| pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 2] = | ||||
|     ["application/x-qemu-disk", "application/gzip"]; | ||||
| pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 3] = [ | ||||
|     "application/x-qemu-disk", | ||||
|     "application/gzip", | ||||
|     "application/octet-stream", | ||||
| ]; | ||||
|  | ||||
| /// Disk image max size | ||||
| pub const DISK_IMAGE_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000 * 1000; | ||||
| pub const DISK_IMAGE_MAX_SIZE: FileSize = FileSize::from_gb(10 * 1000); | ||||
|  | ||||
| /// Min VM memory size (MB) | ||||
| pub const MIN_VM_MEMORY: usize = 100; | ||||
| /// Min VM memory size | ||||
| pub const MIN_VM_MEMORY: FileSize = FileSize::from_mb(100); | ||||
|  | ||||
| /// Max VM memory size (MB) | ||||
| pub const MAX_VM_MEMORY: usize = 64000; | ||||
| /// Max VM memory size | ||||
| pub const MAX_VM_MEMORY: FileSize = FileSize::from_gb(64); | ||||
|  | ||||
| /// Disk name min length | ||||
| pub const DISK_NAME_MIN_LEN: usize = 2; | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| use crate::app_config::AppConfig; | ||||
| use crate::constants; | ||||
| use crate::controllers::HttpResult; | ||||
| use crate::controllers::{HttpResult, LibVirtReq}; | ||||
| use crate::libvirt_lib_structures::XMLUuid; | ||||
| use crate::libvirt_rest_structures::vm::VMInfo; | ||||
| use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo}; | ||||
| use crate::utils::files_utils; | ||||
| use actix_files::NamedFile; | ||||
| @@ -24,7 +26,7 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>) | ||||
|     let file = form.files.remove(0); | ||||
|  | ||||
|     // Check uploaded file size | ||||
|     if file.size > constants::DISK_IMAGE_MAX_SIZE { | ||||
|     if file.size > constants::DISK_IMAGE_MAX_SIZE.as_bytes() { | ||||
|         return Ok(HttpResponse::BadRequest().json("Disk image max size exceeded!")); | ||||
|     } | ||||
|  | ||||
| @@ -47,7 +49,7 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>) | ||||
|     } | ||||
|  | ||||
|     // Check if a file with the same name already exists | ||||
|     let dest_path = AppConfig::get().disk_images_storage_path().join(file_name); | ||||
|     let dest_path = AppConfig::get().disk_images_file_path(&file_name); | ||||
|     if dest_path.is_file() { | ||||
|         return Ok(HttpResponse::Conflict().json("A file with the same name already exists!")); | ||||
|     } | ||||
| @@ -80,9 +82,7 @@ pub async fn download(p: web::Path<DiskFilePath>, req: HttpRequest) -> HttpResul | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid file name!")); | ||||
|     } | ||||
|  | ||||
|     let file_path = AppConfig::get() | ||||
|         .disk_images_storage_path() | ||||
|         .join(&p.filename); | ||||
|     let file_path = AppConfig::get().disk_images_file_path(&p.filename); | ||||
|  | ||||
|     if !file_path.exists() { | ||||
|         return Ok(HttpResponse::NotFound().json("Disk image does not exists!")); | ||||
| @@ -107,6 +107,59 @@ pub async fn convert( | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid src file name!")); | ||||
|     } | ||||
|  | ||||
|     let src_file_path = AppConfig::get().disk_images_file_path(&p.filename); | ||||
|  | ||||
|     let src = DiskFileInfo::load_file(&src_file_path)?; | ||||
|  | ||||
|     handle_convert_request(src, &req).await | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct BackupVMDiskPath { | ||||
|     uid: XMLUuid, | ||||
|     diskid: String, | ||||
| } | ||||
|  | ||||
| /// Perform disk backup | ||||
| pub async fn backup_disk( | ||||
|     client: LibVirtReq, | ||||
|     path: web::Path<BackupVMDiskPath>, | ||||
|     req: web::Json<ConvertDiskImageRequest>, | ||||
| ) -> HttpResult { | ||||
|     // Get the VM information | ||||
|     let info = match client.get_single_domain(path.uid).await { | ||||
|         Ok(i) => i, | ||||
|         Err(e) => { | ||||
|             log::error!("Failed to get domain info! {e}"); | ||||
|             return Ok(HttpResponse::InternalServerError().json(e.to_string())); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     let vm = VMInfo::from_domain(info)?; | ||||
|  | ||||
|     // Load disk information | ||||
|     let Some(disk) = vm | ||||
|         .file_disks | ||||
|         .into_iter() | ||||
|         .find(|disk| disk.name == path.diskid) | ||||
|     else { | ||||
|         return Ok(HttpResponse::NotFound() | ||||
|             .json(format!("Disk {} not found for vm {}", path.diskid, vm.name))); | ||||
|     }; | ||||
|  | ||||
|     let src_path = disk.disk_path(vm.uuid.expect("Missing VM uuid!")); | ||||
|     let src_disk = DiskFileInfo::load_file(&src_path)?; | ||||
|  | ||||
|     // Perform conversion | ||||
|     handle_convert_request(src_disk, &req).await | ||||
| } | ||||
|  | ||||
| /// Generic controller code that performs image conversion to create a disk image file | ||||
| pub async fn handle_convert_request( | ||||
|     src: DiskFileInfo, | ||||
|     req: &ConvertDiskImageRequest, | ||||
| ) -> HttpResult { | ||||
|     // Check destination file | ||||
|     if !files_utils::check_file_name(&req.dest_file_name) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid destination file name!")); | ||||
|     } | ||||
| @@ -119,15 +172,7 @@ pub async fn convert( | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!")); | ||||
|     } | ||||
|  | ||||
|     let src_file_path = AppConfig::get() | ||||
|         .disk_images_storage_path() | ||||
|         .join(&p.filename); | ||||
|  | ||||
|     let src = DiskFileInfo::load_file(&src_file_path)?; | ||||
|  | ||||
|     let dst_file_path = AppConfig::get() | ||||
|         .disk_images_storage_path() | ||||
|         .join(&req.dest_file_name); | ||||
|     let dst_file_path = AppConfig::get().disk_images_file_path(&req.dest_file_name); | ||||
|  | ||||
|     if dst_file_path.exists() { | ||||
|         return Ok(HttpResponse::Conflict().json("Specified destination file already exists!")); | ||||
| @@ -141,7 +186,47 @@ pub async fn convert( | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     Ok(HttpResponse::Ok().json(src)) | ||||
|     Ok(HttpResponse::Accepted().json("Successfully converted disk file")) | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct RenameDiskImageRequest { | ||||
|     name: String, | ||||
| } | ||||
|  | ||||
| /// Rename disk image | ||||
| pub async fn rename( | ||||
|     p: web::Path<DiskFilePath>, | ||||
|     req: web::Json<RenameDiskImageRequest>, | ||||
| ) -> HttpResult { | ||||
|     // Check source | ||||
|     if !files_utils::check_file_name(&p.filename) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid src file name!")); | ||||
|     } | ||||
|     let src_path = AppConfig::get().disk_images_file_path(&p.filename); | ||||
|     if !src_path.exists() { | ||||
|         return Ok(HttpResponse::NotFound().json("Disk image does not exists!")); | ||||
|     } | ||||
|  | ||||
|     // Check destination | ||||
|     if !files_utils::check_file_name(&req.name) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid dst file name!")); | ||||
|     } | ||||
|     let dst_path = AppConfig::get().disk_images_file_path(&req.name); | ||||
|     if dst_path.exists() { | ||||
|         return Ok(HttpResponse::Conflict().json("Destination name already exists!")); | ||||
|     } | ||||
|  | ||||
|     // Check extension | ||||
|     let disk = DiskFileInfo::load_file(&src_path)?; | ||||
|     if !disk.format.ext().iter().any(|e| req.name.ends_with(e)) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!")); | ||||
|     } | ||||
|  | ||||
|     // Perform rename | ||||
|     std::fs::rename(&src_path, &dst_path)?; | ||||
|  | ||||
|     Ok(HttpResponse::Accepted().finish()) | ||||
| } | ||||
|  | ||||
| /// Delete a disk image | ||||
| @@ -150,9 +235,7 @@ pub async fn delete(p: web::Path<DiskFilePath>) -> HttpResult { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid file name!")); | ||||
|     } | ||||
|  | ||||
|     let file_path = AppConfig::get() | ||||
|         .disk_images_storage_path() | ||||
|         .join(&p.filename); | ||||
|     let file_path = AppConfig::get().disk_images_file_path(&p.filename); | ||||
|  | ||||
|     if !file_path.exists() { | ||||
|         return Ok(HttpResponse::NotFound().json("Disk image does not exists!")); | ||||
|   | ||||
| @@ -26,7 +26,7 @@ pub async fn upload_file(MultipartForm(mut form): MultipartForm<UploadIsoForm>) | ||||
|  | ||||
|     let file = form.files.remove(0); | ||||
|  | ||||
|     if file.size > constants::ISO_MAX_SIZE { | ||||
|     if file.size > constants::ISO_MAX_SIZE.as_bytes() { | ||||
|         log::error!("Uploaded ISO file is too large!"); | ||||
|         return Ok(HttpResponse::BadRequest().json("File is too large!")); | ||||
|     } | ||||
| @@ -88,7 +88,7 @@ pub async fn upload_from_url(req: web::Json<DownloadFromURLReq>) -> HttpResult { | ||||
|     let response = reqwest::get(&req.url).await?; | ||||
|  | ||||
|     if let Some(len) = response.content_length() { | ||||
|         if len > constants::ISO_MAX_SIZE as u64 { | ||||
|         if len > constants::ISO_MAX_SIZE.as_bytes() as u64 { | ||||
|             return Ok(HttpResponse::BadRequest().json("File is too large!")); | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -71,8 +71,8 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { | ||||
|         builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES, | ||||
|         nwfilter_chains: &constants::NETWORK_CHAINS, | ||||
|         constraints: ServerConstraints { | ||||
|             iso_max_size: constants::ISO_MAX_SIZE, | ||||
|             disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE, | ||||
|             iso_max_size: constants::ISO_MAX_SIZE.as_bytes(), | ||||
|             disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE.as_bytes(), | ||||
|  | ||||
|             vnc_token_duration: VNC_TOKEN_LIFETIME, | ||||
|  | ||||
| @@ -80,8 +80,8 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { | ||||
|             vm_title_size: LenConstraints { min: 0, max: 50 }, | ||||
|             group_id_size: LenConstraints { min: 3, max: 50 }, | ||||
|             memory_size: LenConstraints { | ||||
|                 min: constants::MIN_VM_MEMORY, | ||||
|                 max: constants::MAX_VM_MEMORY, | ||||
|                 min: constants::MIN_VM_MEMORY.as_bytes(), | ||||
|                 max: constants::MAX_VM_MEMORY.as_bytes(), | ||||
|             }, | ||||
|             disk_name_size: LenConstraints { | ||||
|                 min: DISK_NAME_MIN_LEN, | ||||
|   | ||||
| @@ -22,10 +22,13 @@ pub struct DomainMetadataXML { | ||||
| #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | ||||
| #[serde(rename = "os")] | ||||
| pub struct OSXML { | ||||
|     #[serde(rename = "@firmware", default)] | ||||
|     pub firmware: String, | ||||
|     #[serde(rename = "@firmware", default, skip_serializing_if = "Option::is_none")] | ||||
|     pub firmware: Option<String>, | ||||
|     pub r#type: OSTypeXML, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub loader: Option<OSLoaderXML>, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub bootmenu: Option<OSBootMenuXML>, | ||||
|     pub smbios: Option<OSSMBiosXML>, | ||||
| } | ||||
|  | ||||
| @@ -49,6 +52,16 @@ pub struct OSLoaderXML { | ||||
|     pub secure: String, | ||||
| } | ||||
|  | ||||
| /// Legacy boot menu information | ||||
| #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | ||||
| #[serde(rename = "bootmenu")] | ||||
| pub struct OSBootMenuXML { | ||||
|     #[serde(rename = "@enable")] | ||||
|     pub enable: String, | ||||
|     #[serde(rename = "@timeout")] | ||||
|     pub timeout: usize, | ||||
| } | ||||
|  | ||||
| /// SMBIOS System information | ||||
| #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | ||||
| #[serde(rename = "smbios")] | ||||
|   | ||||
| @@ -4,9 +4,9 @@ use crate::libvirt_lib_structures::XMLUuid; | ||||
| use crate::libvirt_lib_structures::domain::*; | ||||
| use crate::libvirt_rest_structures::LibVirtStructError; | ||||
| use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction; | ||||
| use crate::utils::file_size_utils::FileSize; | ||||
| use crate::utils::files_utils; | ||||
| use crate::utils::files_utils::convert_size_unit_to_mb; | ||||
| use crate::utils::vm_file_disks_utils::{VMDiskFormat, VMFileDisk}; | ||||
| use crate::utils::vm_file_disks_utils::{VMDiskBus, VMDiskFormat, VMFileDisk}; | ||||
| use lazy_regex::regex; | ||||
| use num::Integer; | ||||
|  | ||||
| @@ -17,6 +17,7 @@ pub struct VMGroupId(pub String); | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| pub enum BootType { | ||||
|     Legacy, | ||||
|     UEFI, | ||||
|     UEFISecureBoot, | ||||
| } | ||||
| @@ -29,6 +30,12 @@ pub enum VMArchitecture { | ||||
|     X86_64, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| pub enum NetworkInterfaceModelType { | ||||
|     Virtio, | ||||
|     E1000, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| pub struct NWFilterParam { | ||||
|     name: String, | ||||
| @@ -46,6 +53,7 @@ pub struct Network { | ||||
|     #[serde(flatten)] | ||||
|     r#type: NetworkType, | ||||
|     mac: String, | ||||
|     model: NetworkInterfaceModelType, | ||||
|     nwfilterref: Option<NWFilterRef>, | ||||
| } | ||||
|  | ||||
| @@ -70,8 +78,8 @@ pub struct VMInfo { | ||||
|     pub group: Option<VMGroupId>, | ||||
|     pub boot_type: BootType, | ||||
|     pub architecture: VMArchitecture, | ||||
|     /// VM allocated memory, in megabytes | ||||
|     pub memory: usize, | ||||
|     /// VM allocated RAM memory | ||||
|     pub memory: FileSize, | ||||
|     /// Number of vCPU for the VM | ||||
|     pub number_vcpu: usize, | ||||
|     /// Enable VNC access through admin console | ||||
| @@ -196,7 +204,11 @@ impl VMInfo { | ||||
|             }; | ||||
|  | ||||
|             let model = Some(NetIntModelXML { | ||||
|                 r#type: "virtio".to_string(), | ||||
|                 r#type: match n.model { | ||||
|                     NetworkInterfaceModelType::Virtio => "virtio", | ||||
|                     NetworkInterfaceModelType::E1000 => "e1000", | ||||
|                 } | ||||
|                 .to_string(), | ||||
|             }); | ||||
|  | ||||
|             let filterref = if let Some(n) = &n.nwfilterref { | ||||
| @@ -302,7 +314,11 @@ impl VMInfo { | ||||
|                         "vd{}", | ||||
|                         ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"][disks.len()] | ||||
|                     ), | ||||
|                     bus: "virtio".to_string(), | ||||
|                     bus: match disk.bus { | ||||
|                         VMDiskBus::Virtio => "virtio", | ||||
|                         VMDiskBus::SATA => "sata", | ||||
|                     } | ||||
|                     .to_string(), | ||||
|                 }, | ||||
|                 readonly: None, | ||||
|                 boot: DiskBootXML { | ||||
| @@ -336,13 +352,26 @@ impl VMInfo { | ||||
|                     machine: "q35".to_string(), | ||||
|                     body: "hvm".to_string(), | ||||
|                 }, | ||||
|                 firmware: "efi".to_string(), | ||||
|                 loader: Some(OSLoaderXML { | ||||
|                     secure: match self.boot_type { | ||||
|                         BootType::UEFI => "no".to_string(), | ||||
|                         BootType::UEFISecureBoot => "yes".to_string(), | ||||
|                     }, | ||||
|                 }), | ||||
|                 firmware: match self.boot_type { | ||||
|                     BootType::Legacy => None, | ||||
|                     _ => Some("efi".to_string()), | ||||
|                 }, | ||||
|                 loader: match self.boot_type { | ||||
|                     BootType::Legacy => None, | ||||
|                     _ => Some(OSLoaderXML { | ||||
|                         secure: match self.boot_type { | ||||
|                             BootType::UEFISecureBoot => "yes".to_string(), | ||||
|                             _ => "no".to_string(), | ||||
|                         }, | ||||
|                     }), | ||||
|                 }, | ||||
|                 bootmenu: match self.boot_type { | ||||
|                     BootType::Legacy => Some(OSBootMenuXML { | ||||
|                         enable: "yes".to_string(), | ||||
|                         timeout: 3000, | ||||
|                     }), | ||||
|                     _ => None, | ||||
|                 }, | ||||
|                 smbios: Some(OSSMBiosXML { | ||||
|                     mode: "sysinfo".to_string(), | ||||
|                 }), | ||||
| @@ -380,7 +409,7 @@ impl VMInfo { | ||||
|  | ||||
|             memory: DomainMemoryXML { | ||||
|                 unit: "MB".to_string(), | ||||
|                 memory: self.memory, | ||||
|                 memory: self.memory.as_mb(), | ||||
|             }, | ||||
|  | ||||
|             vcpu: DomainVCPUXML { | ||||
| @@ -434,9 +463,10 @@ impl VMInfo { | ||||
|                 .virtweb | ||||
|                 .group | ||||
|                 .map(VMGroupId), | ||||
|             boot_type: match domain.os.loader { | ||||
|                 None => BootType::UEFI, | ||||
|                 Some(l) => match l.secure.as_str() { | ||||
|             boot_type: match (domain.os.loader, domain.os.bootmenu) { | ||||
|                 (_, Some(_)) => BootType::Legacy, | ||||
|                 (None, _) => BootType::UEFI, | ||||
|                 (Some(l), _) => match l.secure.as_str() { | ||||
|                     "yes" => BootType::UEFISecureBoot, | ||||
|                     _ => BootType::UEFI, | ||||
|                 }, | ||||
| @@ -452,7 +482,7 @@ impl VMInfo { | ||||
|                 } | ||||
|             }, | ||||
|             number_vcpu: domain.vcpu.body, | ||||
|             memory: convert_size_unit_to_mb(&domain.memory.unit, domain.memory.memory)?, | ||||
|             memory: FileSize::from_size_unit(&domain.memory.unit, domain.memory.memory)?, | ||||
|             vnc_access: domain.devices.graphics.is_some(), | ||||
|             iso_files: domain | ||||
|                 .devices | ||||
| @@ -467,7 +497,10 @@ impl VMInfo { | ||||
|                 .disks | ||||
|                 .iter() | ||||
|                 .filter(|d| d.device == "disk") | ||||
|                 .map(|d| VMFileDisk::load_from_file(&d.source.file).unwrap()) | ||||
|                 .map(|d| { | ||||
|                     VMFileDisk::load_from_file(&d.source.file, &d.target.bus) | ||||
|                         .expect("Failed to load file disk information!") | ||||
|                 }) | ||||
|                 .collect(), | ||||
|  | ||||
|             networks: domain | ||||
| @@ -515,6 +548,18 @@ impl VMInfo { | ||||
|                                 ))); | ||||
|                             } | ||||
|                         }, | ||||
|                         model: match d.model.as_ref() { | ||||
|                             None => NetworkInterfaceModelType::Virtio, | ||||
|                             Some(model) => match model.r#type.as_str() { | ||||
|                                 "virtio" => NetworkInterfaceModelType::Virtio, | ||||
|                                 "e1000" => NetworkInterfaceModelType::E1000, | ||||
|                                 model => { | ||||
|                                     return Err(LibVirtStructError::DomainExtraction(format!( | ||||
|                                         "Unknown network interface model type: {model}! " | ||||
|                                     ))); | ||||
|                                 } | ||||
|                             }, | ||||
|                         }, | ||||
|                         nwfilterref: d.filterref.as_ref().map(|f| NWFilterRef { | ||||
|                             name: f.filter.to_string(), | ||||
|                             parameters: f | ||||
|   | ||||
| @@ -122,10 +122,9 @@ async fn main() -> std::io::Result<()> { | ||||
|             })) | ||||
|             .app_data(conn.clone()) | ||||
|             // Uploaded files | ||||
|             .app_data( | ||||
|                 MultipartFormConfig::default() | ||||
|                     .total_limit(max(constants::DISK_IMAGE_MAX_SIZE, constants::ISO_MAX_SIZE)), | ||||
|             ) | ||||
|             .app_data(MultipartFormConfig::default().total_limit( | ||||
|                 max(constants::DISK_IMAGE_MAX_SIZE, constants::ISO_MAX_SIZE).as_bytes(), | ||||
|             )) | ||||
|             .app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir)) | ||||
|             // Server controller | ||||
|             .route( | ||||
| @@ -353,10 +352,18 @@ async fn main() -> std::io::Result<()> { | ||||
|                 "/api/disk_images/{filename}/convert", | ||||
|                 web::post().to(disk_images_controller::convert), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/disk_images/{filename}/rename", | ||||
|                 web::post().to(disk_images_controller::rename), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/disk_images/{filename}", | ||||
|                 web::delete().to(disk_images_controller::delete), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/vm/{uid}/disk/{diskid}/backup", | ||||
|                 web::post().to(disk_images_controller::backup_disk), | ||||
|             ) | ||||
|             // API tokens controller | ||||
|             .route( | ||||
|                 "/api/token/create", | ||||
|   | ||||
| @@ -183,7 +183,13 @@ impl DiskFileInfo { | ||||
|             // Convert QCow2 to Raw file | ||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => { | ||||
|                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|                 cmd.arg("convert").arg(&self.file_path).arg(&temp_file); | ||||
|                 cmd.arg("convert") | ||||
|                     .arg("-f") | ||||
|                     .arg("qcow2") | ||||
|                     .arg("-O") | ||||
|                     .arg("raw") | ||||
|                     .arg(&self.file_path) | ||||
|                     .arg(&temp_file); | ||||
|  | ||||
|                 if !is_sparse { | ||||
|                     cmd.args(["-S", "0"]); | ||||
| @@ -197,6 +203,8 @@ impl DiskFileInfo { | ||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::QCow2 { .. }) => { | ||||
|                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|                 cmd.arg("convert") | ||||
|                     .arg("-f") | ||||
|                     .arg("qcow2") | ||||
|                     .arg("-O") | ||||
|                     .arg("qcow2") | ||||
|                     .arg(&self.file_path) | ||||
| @@ -207,7 +215,13 @@ impl DiskFileInfo { | ||||
|             // Convert Raw to QCow2 file | ||||
|             (DiskFileFormat::Raw { .. }, DiskFileFormat::QCow2 { .. }) => { | ||||
|                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|                 cmd.arg("convert").arg(&self.file_path).arg(&temp_file); | ||||
|                 cmd.arg("convert") | ||||
|                     .arg("-f") | ||||
|                     .arg("raw") | ||||
|                     .arg("-O") | ||||
|                     .arg("qcow2") | ||||
|                     .arg(&self.file_path) | ||||
|                     .arg(&temp_file); | ||||
|  | ||||
|                 cmd | ||||
|             } | ||||
|   | ||||
| @@ -1,3 +1,12 @@ | ||||
| use std::ops::Mul; | ||||
|  | ||||
| #[derive(thiserror::Error, Debug)] | ||||
| enum FilesSizeUtilsError { | ||||
|     #[error("UnitConvertError: {0}")] | ||||
|     UnitConvert(String), | ||||
| } | ||||
|  | ||||
| /// Holds a data size, convertible in any form | ||||
| #[derive( | ||||
|     serde::Serialize, | ||||
|     serde::Deserialize, | ||||
| @@ -25,6 +34,30 @@ impl FileSize { | ||||
|         Self(gb * 1000 * 1000 * 1000) | ||||
|     } | ||||
|  | ||||
|     /// Convert size unit to MB | ||||
|     pub fn from_size_unit(unit: &str, value: usize) -> anyhow::Result<Self> { | ||||
|         let fact = match unit { | ||||
|             "bytes" | "b" => 1f64, | ||||
|             "KB" => 1000f64, | ||||
|             "MB" => 1000f64 * 1000f64, | ||||
|             "GB" => 1000f64 * 1000f64 * 1000f64, | ||||
|             "TB" => 1000f64 * 1000f64 * 1000f64 * 1000f64, | ||||
|  | ||||
|             "k" | "KiB" => 1024f64, | ||||
|             "M" | "MiB" => 1024f64 * 1024f64, | ||||
|             "G" | "GiB" => 1024f64 * 1024f64 * 1024f64, | ||||
|             "T" | "TiB" => 1024f64 * 1024f64 * 1024f64 * 1024f64, | ||||
|  | ||||
|             _ => { | ||||
|                 return Err( | ||||
|                     FilesSizeUtilsError::UnitConvert(format!("Unknown size unit: {unit}")).into(), | ||||
|                 ); | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         Ok(Self((value as f64).mul(fact).ceil() as usize)) | ||||
|     } | ||||
|  | ||||
|     /// Get file size as bytes | ||||
|     pub fn as_bytes(&self) -> usize { | ||||
|         self.0 | ||||
| @@ -35,3 +68,24 @@ impl FileSize { | ||||
|         self.0 / (1000 * 1000) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use crate::utils::file_size_utils::FileSize; | ||||
|  | ||||
|     #[test] | ||||
|     fn convert_units_mb() { | ||||
|         assert_eq!(FileSize::from_size_unit("MB", 1).unwrap().as_mb(), 1); | ||||
|         assert_eq!(FileSize::from_size_unit("MB", 1000).unwrap().as_mb(), 1000); | ||||
|         assert_eq!( | ||||
|             FileSize::from_size_unit("GB", 1000).unwrap().as_mb(), | ||||
|             1000 * 1000 | ||||
|         ); | ||||
|         assert_eq!(FileSize::from_size_unit("GB", 1).unwrap().as_mb(), 1000); | ||||
|         assert_eq!(FileSize::from_size_unit("GiB", 3).unwrap().as_mb(), 3221); | ||||
|         assert_eq!( | ||||
|             FileSize::from_size_unit("KiB", 488281).unwrap().as_mb(), | ||||
|             499 | ||||
|         ); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,13 +1,6 @@ | ||||
| use std::ops::{Div, Mul}; | ||||
| use std::os::unix::fs::PermissionsExt; | ||||
| use std::path::Path; | ||||
|  | ||||
| #[derive(thiserror::Error, Debug)] | ||||
| enum FilesUtilsError { | ||||
|     #[error("UnitConvertError: {0}")] | ||||
|     UnitConvert(String), | ||||
| } | ||||
|  | ||||
| const INVALID_CHARS: [&str; 19] = [ | ||||
|     "@", "\\", "/", ":", ",", "<", ">", "%", "'", "\"", "?", "{", "}", "$", "*", "|", ";", "=", | ||||
|     "\t", | ||||
| @@ -35,31 +28,9 @@ pub fn set_file_permission<P: AsRef<Path>>(path: P, mode: u32) -> anyhow::Result | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Convert size unit to MB | ||||
| pub fn convert_size_unit_to_mb(unit: &str, value: usize) -> anyhow::Result<usize> { | ||||
|     let fact = match unit { | ||||
|         "bytes" | "b" => 1f64, | ||||
|         "KB" => 1000f64, | ||||
|         "MB" => 1000f64 * 1000f64, | ||||
|         "GB" => 1000f64 * 1000f64 * 1000f64, | ||||
|         "TB" => 1000f64 * 1000f64 * 1000f64 * 1000f64, | ||||
|  | ||||
|         "k" | "KiB" => 1024f64, | ||||
|         "M" | "MiB" => 1024f64 * 1024f64, | ||||
|         "G" | "GiB" => 1024f64 * 1024f64 * 1024f64, | ||||
|         "T" | "TiB" => 1024f64 * 1024f64 * 1024f64 * 1024f64, | ||||
|  | ||||
|         _ => { | ||||
|             return Err(FilesUtilsError::UnitConvert(format!("Unknown size unit: {unit}")).into()); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     Ok((value as f64).mul(fact.div((1000 * 1000) as f64)).ceil() as usize) | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod test { | ||||
|     use crate::utils::files_utils::{check_file_name, convert_size_unit_to_mb}; | ||||
|     use crate::utils::files_utils::check_file_name; | ||||
|  | ||||
|     #[test] | ||||
|     fn empty_file_name() { | ||||
| @@ -85,14 +56,4 @@ mod test { | ||||
|     fn valid_file_name() { | ||||
|         assert!(check_file_name("test.iso")); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn convert_units_mb() { | ||||
|         assert_eq!(convert_size_unit_to_mb("MB", 1).unwrap(), 1); | ||||
|         assert_eq!(convert_size_unit_to_mb("MB", 1000).unwrap(), 1000); | ||||
|         assert_eq!(convert_size_unit_to_mb("GB", 1000).unwrap(), 1000 * 1000); | ||||
|         assert_eq!(convert_size_unit_to_mb("GB", 1).unwrap(), 1000); | ||||
|         assert_eq!(convert_size_unit_to_mb("GiB", 3).unwrap(), 3222); | ||||
|         assert_eq!(convert_size_unit_to_mb("KiB", 488281).unwrap(), 500); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -13,11 +13,10 @@ enum VMDisksError { | ||||
|     Config(&'static str), | ||||
| } | ||||
|  | ||||
| /// Type of disk allocation | ||||
| #[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq)] | ||||
| pub enum VMDiskAllocType { | ||||
|     Fixed, | ||||
|     Sparse, | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| pub enum VMDiskBus { | ||||
|     Virtio, | ||||
|     SATA, | ||||
| } | ||||
|  | ||||
| /// Disk allocation type | ||||
| @@ -25,8 +24,8 @@ pub enum VMDiskAllocType { | ||||
| #[serde(tag = "format")] | ||||
| pub enum VMDiskFormat { | ||||
|     Raw { | ||||
|         /// Type of disk allocation | ||||
|         alloc_type: VMDiskAllocType, | ||||
|         /// Is raw file a sparse file? | ||||
|         is_sparse: bool, | ||||
|     }, | ||||
|     QCow2, | ||||
| } | ||||
| @@ -37,15 +36,20 @@ pub struct VMFileDisk { | ||||
|     pub name: String, | ||||
|     /// Disk size, in bytes | ||||
|     pub size: FileSize, | ||||
|     /// Disk bus | ||||
|     pub bus: VMDiskBus, | ||||
|     /// Disk format | ||||
|     #[serde(flatten)] | ||||
|     pub format: VMDiskFormat, | ||||
|     /// When creating a new disk, specify the disk image template to use | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub from_image: Option<String>, | ||||
|     /// Set this variable to true to delete the disk | ||||
|     pub delete: bool, | ||||
| } | ||||
|  | ||||
| impl VMFileDisk { | ||||
|     pub fn load_from_file(path: &str) -> anyhow::Result<Self> { | ||||
|     pub fn load_from_file(path: &str, bus: &str) -> anyhow::Result<Self> { | ||||
|         let file = Path::new(path); | ||||
|  | ||||
|         let info = DiskFileInfo::load_file(file)?; | ||||
| @@ -61,16 +65,19 @@ impl VMFileDisk { | ||||
|             }, | ||||
|  | ||||
|             format: match info.format { | ||||
|                 DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw { | ||||
|                     alloc_type: match is_sparse { | ||||
|                         true => VMDiskAllocType::Sparse, | ||||
|                         false => VMDiskAllocType::Fixed, | ||||
|                     }, | ||||
|                 }, | ||||
|                 DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw { is_sparse }, | ||||
|                 DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2, | ||||
|                 _ => anyhow::bail!("Unsupported image format: {:?}", info.format), | ||||
|             }, | ||||
|  | ||||
|             bus: match bus { | ||||
|                 "virtio" => VMDiskBus::Virtio, | ||||
|                 "sata" => VMDiskBus::SATA, | ||||
|                 _ => anyhow::bail!("Unsupported disk bus type: {bus}"), | ||||
|             }, | ||||
|  | ||||
|             delete: false, | ||||
|             from_image: None, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
| @@ -90,10 +97,23 @@ impl VMFileDisk { | ||||
|             return Err(VMDisksError::Config("Disk size is invalid!").into()); | ||||
|         } | ||||
|  | ||||
|         // Check specified disk image template | ||||
|         if let Some(disk_image) = &self.from_image { | ||||
|             if !files_utils::check_file_name(disk_image) { | ||||
|                 return Err(VMDisksError::Config("Disk image template name is not valid!").into()); | ||||
|             } | ||||
|  | ||||
|             if !AppConfig::get().disk_images_file_path(disk_image).is_file() { | ||||
|                 return Err( | ||||
|                     VMDisksError::Config("Specified disk image file does not exist!").into(), | ||||
|                 ); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Get disk path | ||||
|     /// Get disk path on file system | ||||
|     pub fn disk_path(&self, id: XMLUuid) -> PathBuf { | ||||
|         let domain_dir = AppConfig::get().vm_storage_path(id); | ||||
|         let file_name = match self.format { | ||||
| @@ -127,19 +147,27 @@ impl VMFileDisk { | ||||
|             return Ok(()); | ||||
|         } | ||||
|  | ||||
|         // Create disk file | ||||
|         DiskFileInfo::create( | ||||
|             &file, | ||||
|             match self.format { | ||||
|                 VMDiskFormat::Raw { alloc_type } => DiskFileFormat::Raw { | ||||
|                     is_sparse: alloc_type == VMDiskAllocType::Sparse, | ||||
|                 }, | ||||
|                 VMDiskFormat::QCow2 => DiskFileFormat::QCow2 { | ||||
|                     virtual_size: self.size, | ||||
|                 }, | ||||
|         let format = match self.format { | ||||
|             VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse }, | ||||
|             VMDiskFormat::QCow2 => DiskFileFormat::QCow2 { | ||||
|                 virtual_size: self.size, | ||||
|             }, | ||||
|             self.size, | ||||
|         )?; | ||||
|         }; | ||||
|  | ||||
|         // Create / Restore disk file | ||||
|         match &self.from_image { | ||||
|             // Create disk file | ||||
|             None => { | ||||
|                 DiskFileInfo::create(&file, format, self.size)?; | ||||
|             } | ||||
|  | ||||
|             // Restore disk image template | ||||
|             Some(disk_img) => { | ||||
|                 let src_file = | ||||
|                     DiskFileInfo::load_file(&AppConfig::get().disk_images_file_path(disk_img))?; | ||||
|                 src_file.convert(&file, format)?; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|   | ||||
| @@ -103,6 +103,7 @@ export class APIClient { | ||||
|         body: body, | ||||
|         headers: headers, | ||||
|         credentials: "include", | ||||
|         signal: AbortSignal.timeout(50 * 1000 * 1000), | ||||
|       }); | ||||
|  | ||||
|       // Process response | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { APIClient } from "./ApiClient"; | ||||
| import { VMFileDisk, VMInfo } from "./VMApi"; | ||||
|  | ||||
| export type DiskImageFormat = | ||||
|   | { format: "Raw"; is_sparse: boolean } | ||||
| @@ -77,6 +78,33 @@ export class DiskImageApi { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Backup VM disk into image disks library | ||||
|    */ | ||||
|   static async BackupVMDisk( | ||||
|     vm: VMInfo, | ||||
|     disk: VMFileDisk, | ||||
|     dest_file_name: string, | ||||
|     format: DiskImageFormat | ||||
|   ): Promise<void> { | ||||
|     await APIClient.exec({ | ||||
|       uri: `/vm/${vm.uuid}/disk/${disk.name}/backup`, | ||||
|       method: "POST", | ||||
|       jsonData: { ...format, dest_file_name }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Rename disk image file | ||||
|    */ | ||||
|   static async Rename(file: DiskImage, name: string): Promise<void> { | ||||
|     await APIClient.exec({ | ||||
|       method: "POST", | ||||
|       uri: `/disk_images/${file.file_name}/rename`, | ||||
|       jsonData: { name }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Delete disk image file | ||||
|    */ | ||||
|   | ||||
| @@ -19,21 +19,26 @@ export type VMState = | ||||
|  | ||||
| export type VMFileDisk = BaseFileVMDisk & (RawVMDisk | QCow2Disk); | ||||
|  | ||||
| export type DiskBusType = "Virtio" | "SATA"; | ||||
|  | ||||
| export interface BaseFileVMDisk { | ||||
|   size: number; | ||||
|   name: string; | ||||
|   bus: DiskBusType; | ||||
|  | ||||
|   delete: boolean; | ||||
|  | ||||
|   // application attribute | ||||
|   // For new disk only | ||||
|   from_image?: string; | ||||
|  | ||||
|   // application attributes | ||||
|   new?: boolean; | ||||
|   deleteType?: "keepfile" | "deletefile"; | ||||
| } | ||||
|  | ||||
| export type DiskAllocType = "Sparse" | "Fixed"; | ||||
|  | ||||
| interface RawVMDisk { | ||||
|   format: "Raw"; | ||||
|   alloc_type: DiskAllocType; | ||||
|   is_sparse: boolean; | ||||
| } | ||||
|  | ||||
| interface QCow2Disk { | ||||
| @@ -59,6 +64,7 @@ export type VMNetInterface = ( | ||||
|  | ||||
| export interface VMNetInterfaceBase { | ||||
|   mac: string; | ||||
|   model: "Virtio" | "E1000"; | ||||
|   nwfilterref?: VMNetInterfaceFilter; | ||||
| } | ||||
|  | ||||
| @@ -76,6 +82,8 @@ export interface VMNetBridge { | ||||
|   bridge: string; | ||||
| } | ||||
|  | ||||
| export type VMBootType = "UEFI" | "UEFISecureBoot" | "Legacy"; | ||||
|  | ||||
| interface VMInfoInterface { | ||||
|   name: string; | ||||
|   uuid?: string; | ||||
| @@ -83,7 +91,7 @@ interface VMInfoInterface { | ||||
|   title?: string; | ||||
|   description?: string; | ||||
|   group?: string; | ||||
|   boot_type: "UEFI" | "UEFISecureBoot"; | ||||
|   boot_type: VMBootType; | ||||
|   architecture: "i686" | "x86_64"; | ||||
|   memory: number; | ||||
|   number_vcpu: number; | ||||
| @@ -102,7 +110,7 @@ export class VMInfo implements VMInfoInterface { | ||||
|   title?: string; | ||||
|   description?: string; | ||||
|   group?: string; | ||||
|   boot_type: "UEFI" | "UEFISecureBoot"; | ||||
|   boot_type: VMBootType; | ||||
|   architecture: "i686" | "x86_64"; | ||||
|   number_vcpu: number; | ||||
|   memory: number; | ||||
| @@ -137,7 +145,7 @@ export class VMInfo implements VMInfoInterface { | ||||
|       name: "", | ||||
|       boot_type: "UEFI", | ||||
|       architecture: "x86_64", | ||||
|       memory: 1024, | ||||
|       memory: 1000 * 1000 * 1000, | ||||
|       number_vcpu: 1, | ||||
|       vnc_access: true, | ||||
|       iso_files: [], | ||||
|   | ||||
| @@ -9,56 +9,69 @@ import { | ||||
| import React from "react"; | ||||
| import { DiskImage, DiskImageApi, DiskImageFormat } from "../api/DiskImageApi"; | ||||
| import { ServerApi } from "../api/ServerApi"; | ||||
| import { VMFileDisk, VMInfo } from "../api/VMApi"; | ||||
| import { useAlert } from "../hooks/providers/AlertDialogProvider"; | ||||
| import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider"; | ||||
| import { useSnackbar } from "../hooks/providers/SnackbarProvider"; | ||||
| import { FileDiskImageWidget } from "../widgets/FileDiskImageWidget"; | ||||
| import { CheckboxInput } from "../widgets/forms/CheckboxInput"; | ||||
| import { SelectInput } from "../widgets/forms/SelectInput"; | ||||
| import { TextInput } from "../widgets/forms/TextInput"; | ||||
| import { VMDiskFileWidget } from "../widgets/vms/VMDiskFileWidget"; | ||||
|  | ||||
| export function ConvertDiskImageDialog(p: { | ||||
|   image: DiskImage; | ||||
|   onCancel: () => void; | ||||
|   onFinished: () => void; | ||||
| }): React.ReactElement { | ||||
| export function ConvertDiskImageDialog( | ||||
|   p: { | ||||
|     onCancel: () => void; | ||||
|     onFinished: () => void; | ||||
|   } & ( | ||||
|     | { backup?: false; image: DiskImage } | ||||
|     | { backup: true; disk: VMFileDisk; vm: VMInfo } | ||||
|   ) | ||||
| ): React.ReactElement { | ||||
|   const alert = useAlert(); | ||||
|   const snackbar = useSnackbar(); | ||||
|   const loadingMessage = useLoadingMessage(); | ||||
|  | ||||
|   const [format, setFormat] = React.useState<DiskImageFormat>({ | ||||
|     format: "QCow2", | ||||
|   }); | ||||
|  | ||||
|   const [filename, setFilename] = React.useState(p.image.file_name + ".qcow2"); | ||||
|   const origFilename = p.backup ? p.disk.name : p.image.file_name; | ||||
|  | ||||
|   const [filename, setFilename] = React.useState(origFilename + ".qcow2"); | ||||
|  | ||||
|   const handleFormatChange = (value?: string) => { | ||||
|     setFormat({ format: value ?? ("QCow2" as any) }); | ||||
|  | ||||
|     if (value === "QCow2") setFilename(`${p.image.file_name}.qcow2`); | ||||
|     if (value === "CompressedQCow2") | ||||
|       setFilename(`${p.image.file_name}.qcow2.gz`); | ||||
|     if (value === "QCow2") setFilename(`${origFilename}.qcow2`); | ||||
|     if (value === "CompressedQCow2") setFilename(`${origFilename}.qcow2.gz`); | ||||
|     if (value === "Raw") { | ||||
|       setFilename(`${p.image.file_name}.raw`); | ||||
|       setFilename(`${origFilename}.raw`); | ||||
|       // Check sparse checkbox by default | ||||
|       setFormat({ format: "Raw", is_sparse: true }); | ||||
|     } | ||||
|     if (value === "CompressedRaw") setFilename(`${p.image.file_name}.raw.gz`); | ||||
|     if (value === "CompressedRaw") setFilename(`${origFilename}.raw.gz`); | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = async () => { | ||||
|     try { | ||||
|       loadingMessage.show("Converting image..."); | ||||
|       loadingMessage.show( | ||||
|         p.backup ? "Performing backup..." : "Converting image..." | ||||
|       ); | ||||
|  | ||||
|       // Perform the conversion | ||||
|       await DiskImageApi.Convert(p.image, filename, format); | ||||
|       // Perform the conversion / backup operation | ||||
|       if (p.backup) | ||||
|         await DiskImageApi.BackupVMDisk(p.vm, p.disk, filename, format); | ||||
|       else await DiskImageApi.Convert(p.image, filename, format); | ||||
|  | ||||
|       p.onFinished(); | ||||
|  | ||||
|       snackbar("Conversion successful!"); | ||||
|       alert(p.backup ? "Backup successful!" : "Conversion successful!"); | ||||
|     } catch (e) { | ||||
|       console.error("Failed to convert image!", e); | ||||
|       alert(`Failed to convert image! ${e}`); | ||||
|       console.error("Failed to perform backup/conversion!", e); | ||||
|       alert( | ||||
|         p.backup | ||||
|           ? `Failed to perform backup! ${e}` | ||||
|           : `Failed to convert image! ${e}` | ||||
|       ); | ||||
|     } finally { | ||||
|       loadingMessage.hide(); | ||||
|     } | ||||
| @@ -66,13 +79,21 @@ export function ConvertDiskImageDialog(p: { | ||||
|  | ||||
|   return ( | ||||
|     <Dialog open onClose={p.onCancel}> | ||||
|       <DialogTitle>Convert disk image</DialogTitle> | ||||
|       <DialogTitle> | ||||
|         {p.backup ? `Backup disk ${p.disk.name}` : "Convert disk image"} | ||||
|       </DialogTitle> | ||||
|  | ||||
|       <DialogContent> | ||||
|         <DialogContentText> | ||||
|           Select the destination format for this image: | ||||
|         </DialogContentText> | ||||
|         <FileDiskImageWidget image={p.image} /> | ||||
|  | ||||
|         {/* Show details of of the image */} | ||||
|         {p.backup ? ( | ||||
|           <VMDiskFileWidget {...p} /> | ||||
|         ) : ( | ||||
|           <FileDiskImageWidget {...p} /> | ||||
|         )} | ||||
|  | ||||
|         {/* New image format */} | ||||
|         <SelectInput | ||||
| @@ -109,13 +130,13 @@ export function ConvertDiskImageDialog(p: { | ||||
|             setFilename(s ?? ""); | ||||
|           }} | ||||
|           size={ServerApi.Config.constraints.disk_image_name_size} | ||||
|           helperText="The image name shall contain the proper file extension" | ||||
|           helperText="The image name shall contain the proper file extension for the selected target format" | ||||
|         /> | ||||
|       </DialogContent> | ||||
|       <DialogActions> | ||||
|         <Button onClick={p.onCancel}>Cancel</Button> | ||||
|         <Button onClick={handleSubmit} autoFocus> | ||||
|           Convert image | ||||
|           {p.backup ? "Perform backup" : "Convert image"} | ||||
|         </Button> | ||||
|       </DialogActions> | ||||
|     </Dialog> | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import DownloadIcon from "@mui/icons-material/Download"; | ||||
| import LoopIcon from "@mui/icons-material/Loop"; | ||||
| import MoreVertIcon from "@mui/icons-material/MoreVert"; | ||||
| import RefreshIcon from "@mui/icons-material/Refresh"; | ||||
| import { | ||||
|   Alert, | ||||
| @@ -8,6 +9,10 @@ import { | ||||
|   CircularProgress, | ||||
|   IconButton, | ||||
|   LinearProgress, | ||||
|   ListItemIcon, | ||||
|   ListItemText, | ||||
|   Menu, | ||||
|   MenuItem, | ||||
|   Tooltip, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| @@ -164,15 +169,11 @@ function DiskImageList(p: { | ||||
|   const confirm = useConfirm(); | ||||
|   const loadingMessage = useLoadingMessage(); | ||||
|  | ||||
|   const [dlProgress, setDlProgress] = React.useState<undefined | number>(); | ||||
|  | ||||
|   const [currConversion, setCurrConversion] = React.useState< | ||||
|     DiskImage | undefined | ||||
|   >(); | ||||
|   const [dlProgress, setDlProgress] = React.useState<undefined | number>(); | ||||
|  | ||||
|   // Convert disk image file | ||||
|   const convertDiskImage = (entry: DiskImage) => { | ||||
|     setCurrConversion(entry); | ||||
|   }; | ||||
|  | ||||
|   // Download disk image file | ||||
|   const downloadDiskImage = async (entry: DiskImage) => { | ||||
| @@ -190,6 +191,11 @@ function DiskImageList(p: { | ||||
|     setDlProgress(undefined); | ||||
|   }; | ||||
|  | ||||
|   // Convert disk image file | ||||
|   const convertDiskImage = (entry: DiskImage) => { | ||||
|     setCurrConversion(entry); | ||||
|   }; | ||||
|  | ||||
|   // Delete disk image | ||||
|   const deleteDiskImage = async (entry: DiskImage) => { | ||||
|     if ( | ||||
| @@ -221,7 +227,7 @@ function DiskImageList(p: { | ||||
|     ); | ||||
|  | ||||
|   const columns: GridColDef<(typeof p.list)[number]>[] = [ | ||||
|     { field: "file_name", headerName: "File name", flex: 3 }, | ||||
|     { field: "file_name", headerName: "File name", flex: 3, editable: true }, | ||||
|     { | ||||
|       field: "format", | ||||
|       headerName: "Format", | ||||
| @@ -260,28 +266,21 @@ function DiskImageList(p: { | ||||
|     }, | ||||
|     { | ||||
|       field: "actions", | ||||
|       type: "actions", | ||||
|       headerName: "", | ||||
|       width: 140, | ||||
|       renderCell(params) { | ||||
|         return ( | ||||
|           <> | ||||
|             <Tooltip title="Convert disk image"> | ||||
|               <IconButton onClick={() => { convertDiskImage(params.row); }}> | ||||
|                 <LoopIcon /> | ||||
|               </IconButton> | ||||
|             </Tooltip> | ||||
|             <Tooltip title="Download disk image"> | ||||
|               <IconButton onClick={() => downloadDiskImage(params.row)}> | ||||
|                 <DownloadIcon /> | ||||
|               </IconButton> | ||||
|             </Tooltip> | ||||
|             <Tooltip title="Delete disk image"> | ||||
|               <IconButton onClick={() => deleteDiskImage(params.row)}> | ||||
|                 <DeleteIcon /> | ||||
|               </IconButton> | ||||
|             </Tooltip> | ||||
|           </> | ||||
|         ); | ||||
|       width: 55, | ||||
|       cellClassName: "actions", | ||||
|       editable: false, | ||||
|       getActions: (params) => { | ||||
|         return [ | ||||
|           <DiskImageActionMenu | ||||
|             key="menu" | ||||
|             diskImage={params.row} | ||||
|             onDownload={downloadDiskImage} | ||||
|             onConvert={convertDiskImage} | ||||
|             onDelete={deleteDiskImage} | ||||
|           />, | ||||
|         ]; | ||||
|       }, | ||||
|     }, | ||||
|   ]; | ||||
| @@ -327,7 +326,92 @@ function DiskImageList(p: { | ||||
|       )} | ||||
|  | ||||
|       {/* The table itself */} | ||||
|       <DataGrid getRowId={(c) => c.file_name} rows={p.list} columns={columns} /> | ||||
|       <DataGrid<DiskImage> | ||||
|         getRowId={(c) => c.file_name} | ||||
|         rows={p.list} | ||||
|         columns={columns} | ||||
|         processRowUpdate={async (n, o) => { | ||||
|           try { | ||||
|             await DiskImageApi.Rename(o, n.file_name); | ||||
|             return n; | ||||
|           } catch (e) { | ||||
|             console.error("Failed to rename disk image!", e); | ||||
|             alert(`Failed to rename disk image! ${e}`); | ||||
|             throw e; | ||||
|           } finally { | ||||
|             p.onReload(); | ||||
|           } | ||||
|         }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function DiskImageActionMenu(p: { | ||||
|   diskImage: DiskImage; | ||||
|   onDownload: (d: DiskImage) => void; | ||||
|   onConvert: (d: DiskImage) => void; | ||||
|   onDelete: (d: DiskImage) => void; | ||||
| }): React.ReactElement { | ||||
|   const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); | ||||
|   const open = Boolean(anchorEl); | ||||
|   const handleClick = (event: React.MouseEvent<HTMLElement>) => { | ||||
|     setAnchorEl(event.currentTarget); | ||||
|   }; | ||||
|   const handleClose = () => { | ||||
|     setAnchorEl(null); | ||||
|   }; | ||||
|   return ( | ||||
|     <> | ||||
|       <IconButton | ||||
|         aria-label="Actions" | ||||
|         aria-haspopup="true" | ||||
|         onClick={handleClick} | ||||
|       > | ||||
|         <MoreVertIcon /> | ||||
|       </IconButton> | ||||
|       <Menu anchorEl={anchorEl} open={open} onClose={handleClose}> | ||||
|         {/* Download disk image */} | ||||
|         <MenuItem | ||||
|           onClick={() => { | ||||
|             handleClose(); | ||||
|             p.onDownload(p.diskImage); | ||||
|           }} | ||||
|         > | ||||
|           <ListItemIcon> | ||||
|             <DownloadIcon /> | ||||
|           </ListItemIcon> | ||||
|           <ListItemText secondary={"Download disk image"}> | ||||
|             Download | ||||
|           </ListItemText> | ||||
|         </MenuItem> | ||||
|  | ||||
|         {/* Convert disk image */} | ||||
|         <MenuItem | ||||
|           onClick={() => { | ||||
|             handleClose(); | ||||
|             p.onConvert(p.diskImage); | ||||
|           }} | ||||
|         > | ||||
|           <ListItemIcon> | ||||
|             <LoopIcon /> | ||||
|           </ListItemIcon> | ||||
|           <ListItemText secondary={"Convert disk image"}>Convert</ListItemText> | ||||
|         </MenuItem> | ||||
|  | ||||
|         {/* Delete disk image */} | ||||
|         <MenuItem | ||||
|           onClick={() => { | ||||
|             handleClose(); | ||||
|             p.onDelete(p.diskImage); | ||||
|           }} | ||||
|         > | ||||
|           <ListItemIcon> | ||||
|             <DeleteIcon color="error" /> | ||||
|           </ListItemIcon> | ||||
|           <ListItemText secondary={"Delete disk image"}>Delete</ListItemText> | ||||
|         </MenuItem> | ||||
|       </Menu> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import { | ||||
|   TableContainer, | ||||
|   TableHead, | ||||
|   TableRow, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| @@ -58,70 +59,78 @@ export function TokensListRouteInner(p: { | ||||
|         </RouterLink> | ||||
|       } | ||||
|     > | ||||
|       <TableContainer component={Paper}> | ||||
|         <Table> | ||||
|           <TableHead> | ||||
|             <TableRow> | ||||
|               <TableCell>Name</TableCell> | ||||
|               <TableCell>Description</TableCell> | ||||
|               <TableCell>Created</TableCell> | ||||
|               <TableCell>Updated</TableCell> | ||||
|               <TableCell>Last used</TableCell> | ||||
|               <TableCell>IP restriction</TableCell> | ||||
|               <TableCell>Max inactivity</TableCell> | ||||
|               <TableCell>Rights</TableCell> | ||||
|               <TableCell>Actions</TableCell> | ||||
|             </TableRow> | ||||
|           </TableHead> | ||||
|           <TableBody> | ||||
|             {p.list.map((t) => { | ||||
|               return ( | ||||
|                 <TableRow | ||||
|                   key={t.id} | ||||
|                   hover | ||||
|                   onDoubleClick={() => navigate(APITokenURL(t))} | ||||
|                   style={{ backgroundColor: ExpiredAPIToken(t) ? "red" : "" }} | ||||
|                 > | ||||
|                   <TableCell> | ||||
|                     {t.name} {ExpiredAPIToken(t) && <i>(Expired)</i>} | ||||
|                   </TableCell> | ||||
|                   <TableCell>{t.description}</TableCell> | ||||
|                   <TableCell> | ||||
|                     <TimeWidget time={t.created} /> | ||||
|                   </TableCell> | ||||
|                   <TableCell> | ||||
|                     <TimeWidget time={t.updated} /> | ||||
|                   </TableCell> | ||||
|                   <TableCell> | ||||
|                     <TimeWidget time={t.last_used} /> | ||||
|                   </TableCell> | ||||
|                   <TableCell>{t.ip_restriction}</TableCell> | ||||
|                   <TableCell> | ||||
|                     {t.max_inactivity && timeDiff(0, t.max_inactivity)} | ||||
|                   </TableCell> | ||||
|                   <TableCell> | ||||
|                     {t.rights.map((r, n) => { | ||||
|                       return ( | ||||
|                         <div key={n}> | ||||
|                           {r.verb} {r.path} | ||||
|                         </div> | ||||
|                       ); | ||||
|                     })} | ||||
|                   </TableCell> | ||||
|       {p.list.length > 0 && ( | ||||
|         <TableContainer component={Paper}> | ||||
|           <Table> | ||||
|             <TableHead> | ||||
|               <TableRow> | ||||
|                 <TableCell>Name</TableCell> | ||||
|                 <TableCell>Description</TableCell> | ||||
|                 <TableCell>Created</TableCell> | ||||
|                 <TableCell>Updated</TableCell> | ||||
|                 <TableCell>Last used</TableCell> | ||||
|                 <TableCell>IP restriction</TableCell> | ||||
|                 <TableCell>Max inactivity</TableCell> | ||||
|                 <TableCell>Rights</TableCell> | ||||
|                 <TableCell>Actions</TableCell> | ||||
|               </TableRow> | ||||
|             </TableHead> | ||||
|             <TableBody> | ||||
|               {p.list.map((t) => { | ||||
|                 return ( | ||||
|                   <TableRow | ||||
|                     key={t.id} | ||||
|                     hover | ||||
|                     onDoubleClick={() => navigate(APITokenURL(t))} | ||||
|                     style={{ backgroundColor: ExpiredAPIToken(t) ? "red" : "" }} | ||||
|                   > | ||||
|                     <TableCell> | ||||
|                       {t.name} {ExpiredAPIToken(t) && <i>(Expired)</i>} | ||||
|                     </TableCell> | ||||
|                     <TableCell>{t.description}</TableCell> | ||||
|                     <TableCell> | ||||
|                       <TimeWidget time={t.created} /> | ||||
|                     </TableCell> | ||||
|                     <TableCell> | ||||
|                       <TimeWidget time={t.updated} /> | ||||
|                     </TableCell> | ||||
|                     <TableCell> | ||||
|                       <TimeWidget time={t.last_used} /> | ||||
|                     </TableCell> | ||||
|                     <TableCell>{t.ip_restriction}</TableCell> | ||||
|                     <TableCell> | ||||
|                       {t.max_inactivity && timeDiff(0, t.max_inactivity)} | ||||
|                     </TableCell> | ||||
|                     <TableCell> | ||||
|                       {t.rights.map((r, n) => { | ||||
|                         return ( | ||||
|                           <div key={n}> | ||||
|                             {r.verb} {r.path} | ||||
|                           </div> | ||||
|                         ); | ||||
|                       })} | ||||
|                     </TableCell> | ||||
|  | ||||
|                   <TableCell> | ||||
|                     <RouterLink to={APITokenURL(t)}> | ||||
|                       <IconButton> | ||||
|                         <VisibilityIcon /> | ||||
|                       </IconButton> | ||||
|                     </RouterLink> | ||||
|                   </TableCell> | ||||
|                 </TableRow> | ||||
|               ); | ||||
|             })} | ||||
|           </TableBody> | ||||
|         </Table> | ||||
|       </TableContainer> | ||||
|                     <TableCell> | ||||
|                       <RouterLink to={APITokenURL(t)}> | ||||
|                         <IconButton> | ||||
|                           <VisibilityIcon /> | ||||
|                         </IconButton> | ||||
|                       </RouterLink> | ||||
|                     </TableCell> | ||||
|                   </TableRow> | ||||
|                 ); | ||||
|               })} | ||||
|             </TableBody> | ||||
|           </Table> | ||||
|         </TableContainer> | ||||
|       )} | ||||
|  | ||||
|       {p.list.length === 0 && ( | ||||
|         <Typography style={{ textAlign: "center" }}> | ||||
|           No API token created yet. | ||||
|         </Typography> | ||||
|       )} | ||||
|     </VirtWebRouteContainer> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -154,7 +154,7 @@ function VMListWidget(p: { | ||||
|                         {row.name} | ||||
|                       </TableCell> | ||||
|                       <TableCell>{row.description ?? ""}</TableCell> | ||||
|                       <TableCell>{vmMemoryToHuman(row.memory)}</TableCell> | ||||
|                       <TableCell>{filesize(row.memory)}</TableCell> | ||||
|                       <TableCell>{row.number_vcpu}</TableCell> | ||||
|                       <TableCell> | ||||
|                         <VMStatusWidget | ||||
| @@ -183,13 +183,13 @@ function VMListWidget(p: { | ||||
|             <TableCell></TableCell> | ||||
|             <TableCell></TableCell> | ||||
|             <TableCell> | ||||
|               {vmMemoryToHuman( | ||||
|               {filesize( | ||||
|                 p.list | ||||
|                   .filter((v) => runningVMs.has(v.name)) | ||||
|                   .reduce((s, v) => s + v.memory, 0) | ||||
|               )} | ||||
|               {" / "} | ||||
|               {vmMemoryToHuman(p.list.reduce((s, v) => s + v.memory, 0))} | ||||
|               {filesize(p.list.reduce((s, v) => s + v.memory, 0))} | ||||
|             </TableCell> | ||||
|             <TableCell> | ||||
|               {p.list | ||||
| @@ -206,7 +206,3 @@ function VMListWidget(p: { | ||||
|     </TableContainer> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function vmMemoryToHuman(size: number): string { | ||||
|   return filesize(size * 1000 * 1000); | ||||
| } | ||||
|   | ||||
| @@ -59,6 +59,7 @@ function VMRouteBody(p: { vm: VMInfo }): React.ReactElement { | ||||
|       <VMDetails | ||||
|         vm={p.vm} | ||||
|         editable={false} | ||||
|         state={state} | ||||
|         screenshot={p.vm.vnc_access && state === "Running"} | ||||
|       /> | ||||
|     </VirtWebRouteContainer> | ||||
|   | ||||
							
								
								
									
										24
									
								
								virtweb_frontend/src/widgets/forms/DiskBusSelect.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								virtweb_frontend/src/widgets/forms/DiskBusSelect.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| import { DiskBusType } from "../../api/VMApi"; | ||||
| import { SelectInput } from "./SelectInput"; | ||||
|  | ||||
| export function DiskBusSelect(p: { | ||||
|   editable: boolean; | ||||
|   value: DiskBusType; | ||||
|   label?: string; | ||||
|   onValueChange: (value: DiskBusType) => void; | ||||
|   size?: "medium" | "small"; | ||||
|   disableUnderline?: boolean; | ||||
|   disableBottomMargin?: boolean; | ||||
| }): React.ReactElement { | ||||
|   return ( | ||||
|     <SelectInput | ||||
|       {...p} | ||||
|       label={p.label ?? "Disk bus type"} | ||||
|       options={[ | ||||
|         { label: "virtio", value: "Virtio" }, | ||||
|         { label: "sata", value: "SATA" }, | ||||
|       ]} | ||||
|       onValueChange={(v) => { p.onValueChange(v as any); }} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										40
									
								
								virtweb_frontend/src/widgets/forms/DiskImageSelect.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								virtweb_frontend/src/widgets/forms/DiskImageSelect.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| import { | ||||
|   FormControl, | ||||
|   InputLabel, | ||||
|   MenuItem, | ||||
|   Select, | ||||
|   SelectChangeEvent, | ||||
| } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { DiskImage } from "../../api/DiskImageApi"; | ||||
| import { FileDiskImageWidget } from "../FileDiskImageWidget"; | ||||
|  | ||||
| /** | ||||
|  * Select a disk image | ||||
|  */ | ||||
| export function DiskImageSelect(p: { | ||||
|   label: string; | ||||
|   value?: string; | ||||
|   onValueChange: (image: string | undefined) => void; | ||||
|   list: DiskImage[]; | ||||
| }): React.ReactElement { | ||||
|   const handleChange = (event: SelectChangeEvent) => { | ||||
|     p.onValueChange(event.target.value); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <FormControl fullWidth variant="standard"> | ||||
|       <InputLabel>{p.label}</InputLabel> | ||||
|       <Select value={p.value} label={p.label} onChange={handleChange}> | ||||
|         <MenuItem value={undefined}> | ||||
|           <i>None</i> | ||||
|         </MenuItem> | ||||
|         {p.list.map((d) => ( | ||||
|           <MenuItem key={d.file_name} value={d.file_name}> | ||||
|             <FileDiskImageWidget image={d} /> | ||||
|           </MenuItem> | ||||
|         ))} | ||||
|       </Select> | ||||
|     </FormControl> | ||||
|   ); | ||||
| } | ||||
| @@ -17,8 +17,11 @@ export function SelectInput(p: { | ||||
|   value?: string; | ||||
|   editable: boolean; | ||||
|   label?: string; | ||||
|   size?: "medium" | "small"; | ||||
|   options: SelectOption[]; | ||||
|   onValueChange: (o?: string) => void; | ||||
|   disableUnderline?: boolean; | ||||
|   disableBottomMargin?: boolean; | ||||
| }): React.ReactElement { | ||||
|   if (!p.editable && !p.value) return <></>; | ||||
|  | ||||
| @@ -28,12 +31,18 @@ export function SelectInput(p: { | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <FormControl fullWidth variant="standard" style={{ marginBottom: "15px" }}> | ||||
|     <FormControl | ||||
|       fullWidth | ||||
|       variant="standard" | ||||
|       style={{ marginBottom: p.disableBottomMargin ? "0px" : "15px" }} | ||||
|     > | ||||
|       {p.label && <InputLabel>{p.label}</InputLabel>} | ||||
|       <Select | ||||
|         {...p} | ||||
|         value={p.value ?? ""} | ||||
|         label={p.label} | ||||
|         onChange={(e) => { p.onValueChange(e.target.value); }} | ||||
|         onChange={(e) => { | ||||
|           p.onValueChange(e.target.value); | ||||
|         }} | ||||
|       > | ||||
|         {p.options.map((e) => ( | ||||
|           <MenuItem | ||||
|   | ||||
| @@ -17,6 +17,7 @@ export function TextInput(p: { | ||||
|   type?: React.HTMLInputTypeAttribute; | ||||
|   style?: React.CSSProperties; | ||||
|   helperText?: string; | ||||
|   disabled?: boolean; | ||||
| }): React.ReactElement { | ||||
|   if (!p.editable && (p.value ?? "") === "") return <></>; | ||||
|  | ||||
| @@ -35,6 +36,7 @@ export function TextInput(p: { | ||||
|  | ||||
|   return ( | ||||
|     <TextField | ||||
|       disabled={p.disabled} | ||||
|       label={p.label} | ||||
|       value={p.value ?? ""} | ||||
|       onChange={(e) => | ||||
|   | ||||
| @@ -1,33 +1,37 @@ | ||||
| import { mdiHarddisk } from "@mdi/js"; | ||||
| import { mdiHarddiskPlus } from "@mdi/js"; | ||||
| import Icon from "@mdi/react"; | ||||
| import CheckCircleIcon from "@mui/icons-material/CheckCircle"; | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import { | ||||
|   Avatar, | ||||
|   Button, | ||||
|   IconButton, | ||||
|   ListItem, | ||||
|   ListItemAvatar, | ||||
|   ListItemText, | ||||
|   Paper, | ||||
|   Tooltip, | ||||
| } from "@mui/material"; | ||||
| import { filesize } from "filesize"; | ||||
| import { Button, IconButton, Paper, Tooltip } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { DiskImage } from "../../api/DiskImageApi"; | ||||
| import { ServerApi } from "../../api/ServerApi"; | ||||
| import { VMFileDisk, VMInfo } from "../../api/VMApi"; | ||||
| import { VMFileDisk, VMInfo, VMState } from "../../api/VMApi"; | ||||
| import { ConvertDiskImageDialog } from "../../dialogs/ConvertDiskImageDialog"; | ||||
| import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider"; | ||||
| import { VMDiskFileWidget } from "../vms/VMDiskFileWidget"; | ||||
| import { CheckboxInput } from "./CheckboxInput"; | ||||
| import { DiskBusSelect } from "./DiskBusSelect"; | ||||
| import { DiskImageSelect } from "./DiskImageSelect"; | ||||
| import { SelectInput } from "./SelectInput"; | ||||
| import { TextInput } from "./TextInput"; | ||||
|  | ||||
| export function VMDisksList(p: { | ||||
|   vm: VMInfo; | ||||
|   state?: VMState; | ||||
|   onChange?: () => void; | ||||
|   editable: boolean; | ||||
|   diskImagesList: DiskImage[]; | ||||
| }): React.ReactElement { | ||||
|   const [currBackupRequest, setCurrBackupRequest] = React.useState< | ||||
|     VMFileDisk | undefined | ||||
|   >(); | ||||
|  | ||||
|   const addNewDisk = () => { | ||||
|     p.vm.file_disks.push({ | ||||
|       format: "QCow2", | ||||
|       size: 10000 * 1000 * 1000, | ||||
|       bus: "Virtio", | ||||
|       delete: false, | ||||
|       name: `disk${p.vm.file_disks.length}`, | ||||
|       new: true, | ||||
| @@ -35,6 +39,14 @@ export function VMDisksList(p: { | ||||
|     p.onChange?.(); | ||||
|   }; | ||||
|  | ||||
|   const handleBackupRequest = (disk: VMFileDisk) => { | ||||
|     setCurrBackupRequest(disk); | ||||
|   }; | ||||
|  | ||||
|   const handleFinishBackup = () => { | ||||
|     setCurrBackupRequest(undefined); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {/* disks list */} | ||||
| @@ -43,25 +55,42 @@ export function VMDisksList(p: { | ||||
|           // eslint-disable-next-line react-x/no-array-index-key | ||||
|           key={num} | ||||
|           editable={p.editable} | ||||
|           canBackup={!p.editable && !d.new && p.state !== "Running"} | ||||
|           disk={d} | ||||
|           onChange={p.onChange} | ||||
|           removeFromList={() => { | ||||
|             p.vm.file_disks.splice(num, 1); | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|           onRequestBackup={handleBackupRequest} | ||||
|           diskImagesList={p.diskImagesList} | ||||
|         /> | ||||
|       ))} | ||||
|  | ||||
|       {p.editable && <Button onClick={addNewDisk}>Add new disk</Button>} | ||||
|  | ||||
|       {/* Disk backup */} | ||||
|       {currBackupRequest && ( | ||||
|         <ConvertDiskImageDialog | ||||
|           backup | ||||
|           onCancel={handleFinishBackup} | ||||
|           onFinished={handleFinishBackup} | ||||
|           vm={p.vm} | ||||
|           disk={currBackupRequest} | ||||
|         /> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function DiskInfo(p: { | ||||
|   editable: boolean; | ||||
|   canBackup: boolean; | ||||
|   disk: VMFileDisk; | ||||
|   onChange?: () => void; | ||||
|   removeFromList: () => void; | ||||
|   onRequestBackup: (disk: VMFileDisk) => void; | ||||
|   diskImagesList: DiskImage[]; | ||||
| }): React.ReactElement { | ||||
|   const confirm = useConfirm(); | ||||
|   const deleteDisk = async () => { | ||||
| @@ -86,50 +115,42 @@ function DiskInfo(p: { | ||||
|  | ||||
|   if (!p.editable || !p.disk.new) | ||||
|     return ( | ||||
|       <ListItem | ||||
|       <VMDiskFileWidget | ||||
|         {...p} | ||||
|         secondaryAction={ | ||||
|           p.editable && ( | ||||
|             <IconButton | ||||
|               edge="end" | ||||
|               aria-label="delete disk" | ||||
|               onClick={deleteDisk} | ||||
|             > | ||||
|               {p.disk.deleteType ? ( | ||||
|                 <Tooltip title="Cancel disk removal"> | ||||
|                   <CheckCircleIcon /> | ||||
|                 </Tooltip> | ||||
|               ) : ( | ||||
|                 <Tooltip title="Remove disk"> | ||||
|                   <DeleteIcon /> | ||||
|                 </Tooltip> | ||||
|               )} | ||||
|             </IconButton> | ||||
|           ) | ||||
|           <> | ||||
|             {p.editable && ( | ||||
|               <IconButton | ||||
|                 edge="end" | ||||
|                 aria-label="delete disk" | ||||
|                 onClick={deleteDisk} | ||||
|               > | ||||
|                 {p.disk.deleteType ? ( | ||||
|                   <Tooltip title="Cancel disk removal"> | ||||
|                     <CheckCircleIcon /> | ||||
|                   </Tooltip> | ||||
|                 ) : ( | ||||
|                   <Tooltip title="Remove disk"> | ||||
|                     <DeleteIcon /> | ||||
|                   </Tooltip> | ||||
|                 )} | ||||
|               </IconButton> | ||||
|             )} | ||||
|  | ||||
|             {p.canBackup && ( | ||||
|               <Tooltip title="Backup this disk"> | ||||
|                 <IconButton | ||||
|                   onClick={() => { | ||||
|                     p.onRequestBackup(p.disk); | ||||
|                   }} | ||||
|                 > | ||||
|                   <Icon path={mdiHarddiskPlus} size={1} /> | ||||
|                 </IconButton> | ||||
|               </Tooltip> | ||||
|             )} | ||||
|           </> | ||||
|         } | ||||
|       > | ||||
|         <ListItemAvatar> | ||||
|           <Avatar> | ||||
|             <Icon path={mdiHarddisk} /> | ||||
|           </Avatar> | ||||
|         </ListItemAvatar> | ||||
|         <ListItemText | ||||
|           primary={ | ||||
|             <> | ||||
|               {p.disk.name}{" "} | ||||
|               {p.disk.deleteType && ( | ||||
|                 <span style={{ color: "red" }}> | ||||
|                   {p.disk.deleteType === "deletefile" | ||||
|                     ? "Remove, DELETING block file" | ||||
|                     : "Remove, keeping block file"} | ||||
|                 </span> | ||||
|               )} | ||||
|             </> | ||||
|           } | ||||
|           secondary={`${filesize(p.disk.size)} - ${p.disk.format}${ | ||||
|             p.disk.format == "Raw" ? " - " + p.disk.alloc_type : "" | ||||
|           }`} | ||||
|         /> | ||||
|       </ListItem> | ||||
|       /> | ||||
|     ); | ||||
|  | ||||
|   return ( | ||||
| @@ -151,6 +172,46 @@ function DiskInfo(p: { | ||||
|         </IconButton> | ||||
|       </div> | ||||
|  | ||||
|       <SelectInput | ||||
|         editable={true} | ||||
|         label="Disk format" | ||||
|         options={[ | ||||
|           { label: "Raw file", value: "Raw" }, | ||||
|           { label: "QCow2", value: "QCow2" }, | ||||
|         ]} | ||||
|         value={p.disk.format} | ||||
|         onValueChange={(v) => { | ||||
|           p.disk.format = v as any; | ||||
|  | ||||
|           if (p.disk.format === "Raw") p.disk.is_sparse = true; | ||||
|  | ||||
|           p.onChange?.(); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       {/* Bus selection */} | ||||
|       <DiskBusSelect | ||||
|         editable | ||||
|         value={p.disk.bus} | ||||
|         onValueChange={(v) => { | ||||
|           p.disk.bus = v; | ||||
|           p.onChange?.(); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       {/* Raw disk: choose sparse mode */} | ||||
|       {p.disk.format === "Raw" && ( | ||||
|         <CheckboxInput | ||||
|           editable | ||||
|           label="Sparse file" | ||||
|           checked={p.disk.is_sparse} | ||||
|           onValueChange={(v) => { | ||||
|             if (p.disk.format === "Raw") p.disk.is_sparse = v; | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       <TextInput | ||||
|         editable={true} | ||||
|         label="Disk size (GB)" | ||||
| @@ -166,37 +227,18 @@ function DiskInfo(p: { | ||||
|           p.onChange?.(); | ||||
|         }} | ||||
|         type="number" | ||||
|         disabled={!!p.disk.from_image} | ||||
|       /> | ||||
|  | ||||
|       <SelectInput | ||||
|         editable={true} | ||||
|         label="Disk format" | ||||
|         options={[ | ||||
|           { label: "Raw file", value: "Raw" }, | ||||
|           { label: "QCow2", value: "QCow2" }, | ||||
|         ]} | ||||
|         value={p.disk.format} | ||||
|       <DiskImageSelect | ||||
|         label="Use disk image as template" | ||||
|         list={p.diskImagesList} | ||||
|         value={p.disk.from_image} | ||||
|         onValueChange={(v) => { | ||||
|           p.disk.format = v as any; | ||||
|           p.disk.from_image = v; | ||||
|           p.onChange?.(); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       {p.disk.format === "Raw" && ( | ||||
|         <SelectInput | ||||
|           editable={true} | ||||
|           label="File allocation type" | ||||
|           options={[ | ||||
|             { label: "Sparse allocation", value: "Sparse" }, | ||||
|             { label: "Fixed allocation", value: "Fixed" }, | ||||
|           ]} | ||||
|           value={p.disk.alloc_type} | ||||
|           onValueChange={(v) => { | ||||
|             if (p.disk.format === "Raw") p.disk.alloc_type = v as any; | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|     </Paper> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -35,6 +35,7 @@ export function VMNetworksList(p: { | ||||
|   const addNew = () => { | ||||
|     p.vm.networks.push({ | ||||
|       type: "UserspaceSLIRPStack", | ||||
|       model: "Virtio", | ||||
|       mac: randomMacAddress(ServerApi.Config.net_mac_prefix), | ||||
|     }); | ||||
|     p.onChange?.(); | ||||
| @@ -146,6 +147,7 @@ function NetworkInfoWidget(p: { | ||||
|         /> | ||||
|       </ListItem> | ||||
|       <div style={{ marginLeft: "70px" }}> | ||||
|         {/* MAC address input */} | ||||
|         <MACInput | ||||
|           editable={p.editable} | ||||
|           label="MAC Address" | ||||
| @@ -156,6 +158,26 @@ function NetworkInfoWidget(p: { | ||||
|           }} | ||||
|         /> | ||||
|  | ||||
|         {/* NIC model */} | ||||
|         <SelectInput | ||||
|           editable={p.editable} | ||||
|           label="NIC Model" | ||||
|           value={p.network.model} | ||||
|           onValueChange={(v) => { | ||||
|             p.network.model = v as any; | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|           options={[ | ||||
|             { label: "e1000", value: "E1000" }, | ||||
|             { | ||||
|               label: "virtio", | ||||
|               value: "Virtio", | ||||
|               description: | ||||
|                 "Recommended model, but will require specific drivers on OS that do not support it.", | ||||
|             }, | ||||
|           ]} | ||||
|         /> | ||||
|  | ||||
|         {/* Defined network selection */} | ||||
|         {p.network.type === "DefinedNetwork" && ( | ||||
|           <SelectInput | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { | ||||
|   Checkbox, | ||||
|   FormControlLabel, | ||||
|   Grid, | ||||
|   Paper, | ||||
|   Table, | ||||
|   TableBody, | ||||
| @@ -59,6 +60,7 @@ export function TokenRightsEditor(p: { | ||||
|               <TableCell align="center">Get XML definition</TableCell> | ||||
|               <TableCell align="center">Get autostart</TableCell> | ||||
|               <TableCell align="center">Set autostart</TableCell> | ||||
|               <TableCell align="center">Backup disk</TableCell> | ||||
|             </TableRow> | ||||
|           </TableHead> | ||||
|           <TableBody> | ||||
| @@ -82,6 +84,10 @@ export function TokenRightsEditor(p: { | ||||
|                 {...p} | ||||
|                 right={{ verb: "PUT", path: "/api/vm/*/autostart" }} | ||||
|               /> | ||||
|               <CellRight | ||||
|                 {...p} | ||||
|                 right={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }} | ||||
|               /> | ||||
|             </TableRow> | ||||
|  | ||||
|             {/* Per VM operations */} | ||||
| @@ -117,6 +123,14 @@ export function TokenRightsEditor(p: { | ||||
|                   {...p} | ||||
|                   right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }} | ||||
|                   parent={{ verb: "PUT", path: "/api/vm/*/autostart" }} | ||||
|                 />{" "} | ||||
|                 <CellRight | ||||
|                   {...p} | ||||
|                   right={{ | ||||
|                     verb: "POST", | ||||
|                     path: `/api/vm/${v.uuid}/disk/*/backup`, | ||||
|                   }} | ||||
|                   parent={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }} | ||||
|                 /> | ||||
|               </TableRow> | ||||
|             ))} | ||||
| @@ -669,34 +683,73 @@ export function TokenRightsEditor(p: { | ||||
|         </Table> | ||||
|       </RightsSection> | ||||
|  | ||||
|       {/* ISO files */} | ||||
|       <RightsSection label="ISO files"> | ||||
|         <RouteRight | ||||
|           {...p} | ||||
|           right={{ verb: "POST", path: "/api/iso/upload" }} | ||||
|           label="Upload a new ISO file" | ||||
|         /> | ||||
|         <RouteRight | ||||
|           {...p} | ||||
|           right={{ verb: "POST", path: "/api/iso/upload_from_url" }} | ||||
|           label="Upload a new ISO file from a given URL" | ||||
|         /> | ||||
|         <RouteRight | ||||
|           {...p} | ||||
|           right={{ verb: "GET", path: "/api/iso/list" }} | ||||
|           label="Get the list of ISO files" | ||||
|         /> | ||||
|         <RouteRight | ||||
|           {...p} | ||||
|           right={{ verb: "GET", path: "/api/iso/*" }} | ||||
|           label="Download ISO files" | ||||
|         /> | ||||
|         <RouteRight | ||||
|           {...p} | ||||
|           right={{ verb: "DELETE", path: "/api/iso/*" }} | ||||
|           label="Delete ISO files" | ||||
|         /> | ||||
|       </RightsSection> | ||||
|       <Grid container> | ||||
|         <Grid size={{ md: 6 }}> | ||||
|           {/* Disk images */} | ||||
|           <RightsSection label="Disk images"> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "POST", path: "/api/disk_images/upload" }} | ||||
|               label="Upload a new disk image" | ||||
|             /> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "GET", path: "/api/disk_images/list" }} | ||||
|               label="Get the list of disk images" | ||||
|             /> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "GET", path: "/api/disk_images/*" }} | ||||
|               label="Download disk images" | ||||
|             /> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "POST", path: "/api/disk_images/*/convert" }} | ||||
|               label="Convert disk images" | ||||
|             /> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "POST", path: "/api/disk_images/*/rename" }} | ||||
|               label="Rename disk images" | ||||
|             /> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "DELETE", path: "/api/disk_images/*" }} | ||||
|               label="Delete disk images" | ||||
|             /> | ||||
|           </RightsSection> | ||||
|         </Grid> | ||||
|         <Grid size={{ md: 6 }}> | ||||
|           {/* ISO files */} | ||||
|           <RightsSection label="ISO files"> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "POST", path: "/api/iso/upload" }} | ||||
|               label="Upload a new ISO file" | ||||
|             /> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "POST", path: "/api/iso/upload_from_url" }} | ||||
|               label="Upload a new ISO file from a given URL" | ||||
|             /> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "GET", path: "/api/iso/list" }} | ||||
|               label="Get the list of ISO files" | ||||
|             /> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "GET", path: "/api/iso/*" }} | ||||
|               label="Download ISO files" | ||||
|             /> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "DELETE", path: "/api/iso/*" }} | ||||
|               label="Delete ISO files" | ||||
|             /> | ||||
|           </RightsSection> | ||||
|         </Grid> | ||||
|       </Grid> | ||||
|  | ||||
|       {/* Server general information */} | ||||
|       <RightsSection label="Server"> | ||||
|   | ||||
| @@ -10,7 +10,7 @@ import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi"; | ||||
| import { NWFilter, NWFilterApi } from "../../api/NWFilterApi"; | ||||
| import { NetworkApi, NetworkInfo } from "../../api/NetworksApi"; | ||||
| import { ServerApi } from "../../api/ServerApi"; | ||||
| import { VMApi, VMInfo } from "../../api/VMApi"; | ||||
| import { VMApi, VMInfo, VMState } from "../../api/VMApi"; | ||||
| import { useAlert } from "../../hooks/providers/AlertDialogProvider"; | ||||
| import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider"; | ||||
| import { useSnackbar } from "../../hooks/providers/SnackbarProvider"; | ||||
| @@ -27,16 +27,21 @@ import { VMDisksList } from "../forms/VMDisksList"; | ||||
| import { VMNetworksList } from "../forms/VMNetworksList"; | ||||
| import { VMSelectIsoInput } from "../forms/VMSelectIsoInput"; | ||||
| import { VMScreenshot } from "./VMScreenshot"; | ||||
| import { DiskImage, DiskImageApi } from "../../api/DiskImageApi"; | ||||
|  | ||||
| interface DetailsProps { | ||||
|   vm: VMInfo; | ||||
|   editable: boolean; | ||||
|   onChange?: () => void; | ||||
|   screenshot?: boolean; | ||||
|   state?: VMState | undefined; | ||||
| } | ||||
|  | ||||
| export function VMDetails(p: DetailsProps): React.ReactElement { | ||||
|   const [groupsList, setGroupsList] = React.useState<string[] | undefined>(); | ||||
|   const [diskImagesList, setDiskImagesList] = React.useState< | ||||
|     DiskImage[] | undefined | ||||
|   >(); | ||||
|   const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>(); | ||||
|   const [bridgesList, setBridgesList] = React.useState<string[] | undefined>(); | ||||
|   const [vcpuCombinations, setVCPUCombinations] = React.useState< | ||||
| @@ -51,6 +56,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement { | ||||
|  | ||||
|   const load = async () => { | ||||
|     setGroupsList(await GroupApi.GetList()); | ||||
|     setDiskImagesList(await DiskImageApi.GetList()); | ||||
|     setIsoList(await IsoFilesApi.GetList()); | ||||
|     setBridgesList(await ServerApi.GetNetworksBridgesList()); | ||||
|     setVCPUCombinations(await ServerApi.NumberVCPUs()); | ||||
| @@ -66,6 +72,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement { | ||||
|       build={() => ( | ||||
|         <VMDetailsInner | ||||
|           groupsList={groupsList!} | ||||
|           diskImagesList={diskImagesList!} | ||||
|           isoList={isoList!} | ||||
|           bridgesList={bridgesList!} | ||||
|           vcpuCombinations={vcpuCombinations!} | ||||
| @@ -89,6 +96,7 @@ enum VMTab { | ||||
|  | ||||
| type DetailsInnerProps = DetailsProps & { | ||||
|   groupsList: string[]; | ||||
|   diskImagesList: DiskImage[]; | ||||
|   isoList: IsoFile[]; | ||||
|   bridgesList: string[]; | ||||
|   vcpuCombinations: number[]; | ||||
| @@ -272,6 +280,7 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement { | ||||
|           options={[ | ||||
|             { label: "UEFI with Secure Boot", value: "UEFISecureBoot" }, | ||||
|             { label: "UEFI", value: "UEFI" }, | ||||
|             { label: "Legacy", value: "Legacy" }, | ||||
|           ]} | ||||
|         /> | ||||
|  | ||||
| @@ -279,14 +288,16 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement { | ||||
|           label="Memory (MB)" | ||||
|           editable={p.editable} | ||||
|           type="number" | ||||
|           value={p.vm.memory.toString()} | ||||
|           value={Math.floor(p.vm.memory / (1000 * 1000)).toString()} | ||||
|           onValueChange={(v) => { | ||||
|             p.vm.memory = Number(v ?? "0"); | ||||
|             p.vm.memory = Number(v ?? "0") * 1000 * 1000; | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|           checkValue={(v) => | ||||
|             Number(v) > ServerApi.Config.constraints.memory_size.min && | ||||
|             Number(v) < ServerApi.Config.constraints.memory_size.max | ||||
|             Number(v) > | ||||
|               ServerApi.Config.constraints.memory_size.min / (1000 * 1000) && | ||||
|             Number(v) < | ||||
|               ServerApi.Config.constraints.memory_size.max / (1000 * 1000) | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|   | ||||
							
								
								
									
										72
									
								
								virtweb_frontend/src/widgets/vms/VMDiskFileWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								virtweb_frontend/src/widgets/vms/VMDiskFileWidget.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| import { mdiHarddisk } from "@mdi/js"; | ||||
| import { Icon } from "@mdi/react"; | ||||
| import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material"; | ||||
| import { filesize } from "filesize"; | ||||
| import { VMFileDisk } from "../../api/VMApi"; | ||||
| import { DiskBusSelect } from "../forms/DiskBusSelect"; | ||||
|  | ||||
| export function VMDiskFileWidget(p: { | ||||
|   editable?: boolean; | ||||
|   disk: VMFileDisk; | ||||
|   secondaryAction?: React.ReactElement; | ||||
|   onChange?: () => void; | ||||
| }): React.ReactElement { | ||||
|   const info = [filesize(p.disk.size), p.disk.format]; | ||||
|  | ||||
|   if (p.disk.format === "Raw") info.push(p.disk.is_sparse ? "Sparse" : "Fixed"); | ||||
|  | ||||
|   if (!p.editable) info.push(p.disk.bus); | ||||
|  | ||||
|   return ( | ||||
|     <ListItem secondaryAction={p.secondaryAction}> | ||||
|       <ListItemAvatar> | ||||
|         <Avatar> | ||||
|           <Icon path={mdiHarddisk} /> | ||||
|         </Avatar> | ||||
|       </ListItemAvatar> | ||||
|       <ListItemText | ||||
|         primary={ | ||||
|           <> | ||||
|             {p.disk.name}{" "} | ||||
|             {p.disk.deleteType && ( | ||||
|               <span style={{ color: "red" }}> | ||||
|                 {p.disk.deleteType === "deletefile" | ||||
|                   ? "Remove, DELETING block file" | ||||
|                   : "Remove, keeping block file"} | ||||
|               </span> | ||||
|             )} | ||||
|           </> | ||||
|         } | ||||
|         secondary={ | ||||
|           <div style={{ display: "flex", alignItems: "center" }}> | ||||
|             {p.editable ? ( | ||||
|               <div | ||||
|                 style={{ | ||||
|                   maxWidth: "80px", | ||||
|                   display: "inline-block", | ||||
|                   marginRight: "10px", | ||||
|                 }} | ||||
|               > | ||||
|                 <DiskBusSelect | ||||
|                   onValueChange={(v) => { | ||||
|                     p.disk.bus = v; | ||||
|                     p.onChange?.(); | ||||
|                   }} | ||||
|                   label="" | ||||
|                   editable | ||||
|                   value={p.disk.bus} | ||||
|                   size="small" | ||||
|                   disableUnderline | ||||
|                   disableBottomMargin | ||||
|                 /> | ||||
|               </div> | ||||
|             ) : ( | ||||
|               "" | ||||
|             )} | ||||
|             <div style={{ height: "100%" }}>{info.join(" - ")}</div> | ||||
|           </div> | ||||
|         } | ||||
|       /> | ||||
|     </ListItem> | ||||
|   ); | ||||
| } | ||||
		Reference in New Issue
	
	Block a user