Compare commits
	
		
			14 Commits
		
	
	
		
			9334b984ae
			...
			58c3257d1d
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 58c3257d1d | |||
| 22416badcf | |||
| ef0d77f1d6 | |||
| 1d4af8c74e | |||
| ec9492c933 | |||
| fa03ae885f | |||
| ea98aaf856 | |||
| 794d16bdaa | |||
| a3ac56f849 | |||
| 6130f37336 | |||
| 6b6fef5ccc | |||
| 83df7e1b20 | |||
| a18310e04a | |||
| dd7f9176fa | 
							
								
								
									
										4
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @@ -3413,9 +3413,9 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "tokio" | ||||
| version = "1.45.0" | ||||
| version = "1.45.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" | ||||
| checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" | ||||
| dependencies = [ | ||||
|  "backtrace", | ||||
|  "bytes", | ||||
|   | ||||
| @@ -36,7 +36,7 @@ lazy-regex = "3.4.1" | ||||
| thiserror = "2.0.12" | ||||
| image = "0.25.6" | ||||
| rand = "0.9.1" | ||||
| tokio = { version = "1.45.0", features = ["rt", "time", "macros"] } | ||||
| tokio = { version = "1.45.1", features = ["rt", "time", "macros"] } | ||||
| futures = "0.3.31" | ||||
| ipnetwork = { version = "0.21.1", features = ["serde"] } | ||||
| num = "0.4.3" | ||||
|   | ||||
| @@ -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,7 @@ pub async fn convert( | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     Ok(HttpResponse::Ok().json(src)) | ||||
|     Ok(HttpResponse::Accepted().json("Successfully converted disk file")) | ||||
| } | ||||
|  | ||||
| /// Delete a disk image | ||||
| @@ -150,9 +195,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, | ||||
|   | ||||
| @@ -4,8 +4,8 @@ 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 lazy_regex::regex; | ||||
| use num::Integer; | ||||
| @@ -29,6 +29,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 +52,7 @@ pub struct Network { | ||||
|     #[serde(flatten)] | ||||
|     r#type: NetworkType, | ||||
|     mac: String, | ||||
|     model: NetworkInterfaceModelType, | ||||
|     nwfilterref: Option<NWFilterRef>, | ||||
| } | ||||
|  | ||||
| @@ -70,8 +77,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 +203,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 { | ||||
| @@ -380,7 +391,7 @@ impl VMInfo { | ||||
|  | ||||
|             memory: DomainMemoryXML { | ||||
|                 unit: "MB".to_string(), | ||||
|                 memory: self.memory, | ||||
|                 memory: self.memory.as_mb(), | ||||
|             }, | ||||
|  | ||||
|             vcpu: DomainVCPUXML { | ||||
| @@ -452,7 +463,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 +478,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) | ||||
|                         .expect("Failed to load file disk information!") | ||||
|                 }) | ||||
|                 .collect(), | ||||
|  | ||||
|             networks: domain | ||||
| @@ -515,6 +529,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( | ||||
| @@ -357,6 +356,10 @@ async fn main() -> std::io::Result<()> { | ||||
|                 "/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", | ||||
|   | ||||
| @@ -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,20 +13,13 @@ enum VMDisksError { | ||||
|     Config(&'static str), | ||||
| } | ||||
|  | ||||
| /// Type of disk allocation | ||||
| #[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq)] | ||||
| pub enum VMDiskAllocType { | ||||
|     Fixed, | ||||
|     Sparse, | ||||
| } | ||||
|  | ||||
| /// Disk allocation type | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| #[serde(tag = "format")] | ||||
| pub enum VMDiskFormat { | ||||
|     Raw { | ||||
|         /// Type of disk allocation | ||||
|         alloc_type: VMDiskAllocType, | ||||
|         /// Is raw file a sparse file? | ||||
|         is_sparse: bool, | ||||
|     }, | ||||
|     QCow2, | ||||
| } | ||||
| @@ -40,6 +33,9 @@ pub struct VMFileDisk { | ||||
|     /// 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, | ||||
| } | ||||
| @@ -61,16 +57,12 @@ 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), | ||||
|             }, | ||||
|             delete: false, | ||||
|             from_image: None, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
| @@ -90,10 +82,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 +132,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,22 @@ 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 }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Delete disk image file | ||||
|    */ | ||||
|   | ||||
| @@ -24,16 +24,17 @@ export interface BaseFileVMDisk { | ||||
|   name: string; | ||||
|   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 +60,7 @@ export type VMNetInterface = ( | ||||
|  | ||||
| export interface VMNetInterfaceBase { | ||||
|   mac: string; | ||||
|   model: "Virtio" | "E1000"; | ||||
|   nwfilterref?: VMNetInterfaceFilter; | ||||
| } | ||||
|  | ||||
| @@ -137,7 +139,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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
							
								
								
									
										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 React from "react"; | ||||
| import { DiskImage } from "../../api/DiskImageApi"; | ||||
| import { | ||||
|   FormControl, | ||||
|   InputLabel, | ||||
|   Select, | ||||
|   MenuItem, | ||||
|   SelectChangeEvent, | ||||
| } from "@mui/material"; | ||||
| 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 value={d.file_name}> | ||||
|             <FileDiskImageWidget image={d} /> | ||||
|           </MenuItem> | ||||
|         ))} | ||||
|       </Select> | ||||
|     </FormControl> | ||||
|   ); | ||||
| } | ||||
| @@ -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,4 +1,4 @@ | ||||
| import { mdiHarddisk } from "@mdi/js"; | ||||
| import { mdiHarddisk, mdiHarddiskPlus } from "@mdi/js"; | ||||
| import Icon from "@mdi/react"; | ||||
| import CheckCircleIcon from "@mui/icons-material/CheckCircle"; | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| @@ -13,17 +13,28 @@ import { | ||||
|   Tooltip, | ||||
| } from "@mui/material"; | ||||
| import { filesize } from "filesize"; | ||||
| import React from "react"; | ||||
| 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 { CheckboxInput } from "./CheckboxInput"; | ||||
| import { SelectInput } from "./SelectInput"; | ||||
| import { TextInput } from "./TextInput"; | ||||
| import { DiskImageSelect } from "./DiskImageSelect"; | ||||
| import { DiskImage } from "../../api/DiskImageApi"; | ||||
|  | ||||
| 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", | ||||
| @@ -35,6 +46,14 @@ export function VMDisksList(p: { | ||||
|     p.onChange?.(); | ||||
|   }; | ||||
|  | ||||
|   const handleBackupRequest = (disk: VMFileDisk) => { | ||||
|     setCurrBackupRequest(disk); | ||||
|   }; | ||||
|  | ||||
|   const handleFinishBackup = () => { | ||||
|     setCurrBackupRequest(undefined); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {/* disks list */} | ||||
| @@ -43,25 +62,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 () => { | ||||
| @@ -88,23 +124,37 @@ function DiskInfo(p: { | ||||
|     return ( | ||||
|       <ListItem | ||||
|         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> | ||||
| @@ -126,7 +176,9 @@ function DiskInfo(p: { | ||||
|             </> | ||||
|           } | ||||
|           secondary={`${filesize(p.disk.size)} - ${p.disk.format}${ | ||||
|             p.disk.format == "Raw" ? " - " + p.disk.alloc_type : "" | ||||
|             p.disk.format == "Raw" | ||||
|               ? " - " + (p.disk.is_sparse ? "Sparse" : "Fixed") | ||||
|               : "" | ||||
|           }`} | ||||
|         /> | ||||
|       </ListItem> | ||||
| @@ -151,6 +203,35 @@ 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?.(); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       {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 +247,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,68 @@ 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: "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[]; | ||||
| @@ -279,14 +287,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) | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|   | ||||
							
								
								
									
										21
									
								
								virtweb_frontend/src/widgets/vms/VMDiskFileWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								virtweb_frontend/src/widgets/vms/VMDiskFileWidget.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| 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"; | ||||
|  | ||||
| export function VMDiskFileWidget(p: { disk: VMFileDisk }): React.ReactElement { | ||||
|   return ( | ||||
|     <ListItem> | ||||
|       <ListItemAvatar> | ||||
|         <Avatar> | ||||
|           <Icon path={mdiHarddisk} /> | ||||
|         </Avatar> | ||||
|       </ListItemAvatar> | ||||
|       <ListItemText | ||||
|         primary={p.disk.name} | ||||
|         secondary={`${p.disk.format} - ${filesize(p.disk.size)}`} | ||||
|       /> | ||||
|     </ListItem> | ||||
|   ); | ||||
| } | ||||
		Reference in New Issue
	
	Block a user