Compare commits
	
		
			1 Commits
		
	
	
		
			20250531
			...
			3d4750de21
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 3d4750de21 | 
							
								
								
									
										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" | ||||
|   | ||||
| @@ -245,21 +245,11 @@ impl AppConfig { | ||||
|         storage_path.canonicalize().unwrap() | ||||
|     } | ||||
|  | ||||
|     /// Get iso files storage directory | ||||
|     /// Get iso storage directory | ||||
|     pub fn iso_storage_path(&self) -> PathBuf { | ||||
|         self.storage_path().join("iso") | ||||
|     } | ||||
|  | ||||
|     /// Get disk images storage directory | ||||
|     pub fn disk_images_storage_path(&self) -> PathBuf { | ||||
|         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") | ||||
| @@ -270,17 +260,15 @@ impl AppConfig { | ||||
|         self.vnc_sockets_path().join(format!("vnc-{}", name)) | ||||
|     } | ||||
|  | ||||
|     /// Get VM root disks storage directory | ||||
|     pub fn root_vm_disks_storage_path(&self) -> PathBuf { | ||||
|     /// Get VM vnc sockets directory | ||||
|     pub fn disks_storage_path(&self) -> PathBuf { | ||||
|         self.storage_path().join("disks") | ||||
|     } | ||||
|  | ||||
|     /// Get specific VM disk storage directory | ||||
|     pub fn vm_storage_path(&self, id: XMLUuid) -> PathBuf { | ||||
|         self.root_vm_disks_storage_path().join(id.as_string()) | ||||
|         self.disks_storage_path().join(id.as_string()) | ||||
|     } | ||||
|  | ||||
|     /// Get the path were VM definitions are backed up | ||||
|     pub fn definitions_path(&self) -> PathBuf { | ||||
|         self.storage_path().join("definitions") | ||||
|     } | ||||
|   | ||||
| @@ -1,5 +1,3 @@ | ||||
| use crate::utils::file_size_utils::FileSize; | ||||
|  | ||||
| /// Name of the cookie that contains session information | ||||
| pub const SESSION_COOKIE_NAME: &str = "X-auth-token"; | ||||
|  | ||||
| @@ -27,23 +25,13 @@ pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [ | ||||
| ]; | ||||
|  | ||||
| /// ISO max size | ||||
| pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10); | ||||
| pub const ISO_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000; | ||||
|  | ||||
| /// Allowed uploaded disk images formats | ||||
| pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 3] = [ | ||||
|     "application/x-qemu-disk", | ||||
|     "application/gzip", | ||||
|     "application/octet-stream", | ||||
| ]; | ||||
| /// Min VM memory size (MB) | ||||
| pub const MIN_VM_MEMORY: usize = 100; | ||||
|  | ||||
| /// Disk image max size | ||||
| pub const DISK_IMAGE_MAX_SIZE: FileSize = FileSize::from_gb(10 * 1000); | ||||
|  | ||||
| /// Min VM memory size | ||||
| pub const MIN_VM_MEMORY: FileSize = FileSize::from_mb(100); | ||||
|  | ||||
| /// Max VM memory size | ||||
| pub const MAX_VM_MEMORY: FileSize = FileSize::from_gb(64); | ||||
| /// Max VM memory size (MB) | ||||
| pub const MAX_VM_MEMORY: usize = 64000; | ||||
|  | ||||
| /// Disk name min length | ||||
| pub const DISK_NAME_MIN_LEN: usize = 2; | ||||
| @@ -51,11 +39,11 @@ pub const DISK_NAME_MIN_LEN: usize = 2; | ||||
| /// Disk name max length | ||||
| pub const DISK_NAME_MAX_LEN: usize = 10; | ||||
|  | ||||
| /// Disk size min (B) | ||||
| pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50); | ||||
| /// Disk size min (MB) | ||||
| pub const DISK_SIZE_MIN: usize = 100; | ||||
|  | ||||
| /// Disk size max (B) | ||||
| pub const DISK_SIZE_MAX: FileSize = FileSize::from_gb(20000); | ||||
| /// Disk size max (MB) | ||||
| pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 2; | ||||
|  | ||||
| /// Net nat entry comment max size | ||||
| pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250; | ||||
| @@ -126,15 +114,3 @@ pub const QEMU_IMAGE_PROGRAM: &str = "/usr/bin/qemu-img"; | ||||
|  | ||||
| /// IP program path | ||||
| pub const IP_PROGRAM: &str = "/usr/sbin/ip"; | ||||
|  | ||||
| /// Copy program path | ||||
| pub const COPY_PROGRAM: &str = "/bin/cp"; | ||||
|  | ||||
| /// Gzip program path | ||||
| pub const GZIP_PROGRAM: &str = "/usr/bin/gzip"; | ||||
|  | ||||
| /// Bash program | ||||
| pub const BASH_PROGRAM: &str = "/usr/bin/bash"; | ||||
|  | ||||
| /// DD program | ||||
| pub const DD_PROGRAM: &str = "/usr/bin/dd"; | ||||
|   | ||||
| @@ -1,247 +0,0 @@ | ||||
| use crate::app_config::AppConfig; | ||||
| use crate::constants; | ||||
| 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; | ||||
| use actix_multipart::form::MultipartForm; | ||||
| use actix_multipart::form::tempfile::TempFile; | ||||
| use actix_web::{HttpRequest, HttpResponse, web}; | ||||
|  | ||||
| #[derive(Debug, MultipartForm)] | ||||
| pub struct UploadDiskImageForm { | ||||
|     #[multipart(rename = "file")] | ||||
|     files: Vec<TempFile>, | ||||
| } | ||||
|  | ||||
| /// Upload disk image file | ||||
| pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>) -> HttpResult { | ||||
|     if form.files.is_empty() { | ||||
|         log::error!("Missing uploaded disk file!"); | ||||
|         return Ok(HttpResponse::BadRequest().json("Missing file!")); | ||||
|     } | ||||
|  | ||||
|     let file = form.files.remove(0); | ||||
|  | ||||
|     // Check uploaded file size | ||||
|     if file.size > constants::DISK_IMAGE_MAX_SIZE.as_bytes() { | ||||
|         return Ok(HttpResponse::BadRequest().json("Disk image max size exceeded!")); | ||||
|     } | ||||
|  | ||||
|     // Check file mime type | ||||
|     if let Some(mime_type) = file.content_type { | ||||
|         if !constants::ALLOWED_DISK_IMAGES_MIME_TYPES.contains(&mime_type.as_ref()) { | ||||
|             return Ok(HttpResponse::BadRequest().json(format!( | ||||
|                 "Unsupported file type for disk upload: {}", | ||||
|                 mime_type | ||||
|             ))); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Extract and check file name | ||||
|     let Some(file_name) = file.file_name else { | ||||
|         return Ok(HttpResponse::BadRequest().json("Missing file name of uploaded file!")); | ||||
|     }; | ||||
|     if !files_utils::check_file_name(&file_name) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid uploaded file name!")); | ||||
|     } | ||||
|  | ||||
|     // Check if a file with the same name already exists | ||||
|     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!")); | ||||
|     } | ||||
|  | ||||
|     // Copy the file to the destination | ||||
|     file.file.persist(dest_path)?; | ||||
|  | ||||
|     Ok(HttpResponse::Ok().json("Successfully uploaded disk image!")) | ||||
| } | ||||
|  | ||||
| /// Get disk images list | ||||
| pub async fn get_list() -> HttpResult { | ||||
|     let mut list = vec![]; | ||||
|     for entry in AppConfig::get().disk_images_storage_path().read_dir()? { | ||||
|         let entry = entry?; | ||||
|         list.push(DiskFileInfo::load_file(&entry.path())?); | ||||
|     } | ||||
|  | ||||
|     Ok(HttpResponse::Ok().json(list)) | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct DiskFilePath { | ||||
|     filename: String, | ||||
| } | ||||
|  | ||||
| /// Download disk image | ||||
| pub async fn download(p: web::Path<DiskFilePath>, req: HttpRequest) -> HttpResult { | ||||
|     if !files_utils::check_file_name(&p.filename) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid file name!")); | ||||
|     } | ||||
|  | ||||
|     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!")); | ||||
|     } | ||||
|  | ||||
|     Ok(NamedFile::open(file_path)?.into_response(&req)) | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct ConvertDiskImageRequest { | ||||
|     dest_file_name: String, | ||||
|     #[serde(flatten)] | ||||
|     format: DiskFileFormat, | ||||
| } | ||||
|  | ||||
| /// Convert disk image into a new format | ||||
| pub async fn convert( | ||||
|     p: web::Path<DiskFilePath>, | ||||
|     req: web::Json<ConvertDiskImageRequest>, | ||||
| ) -> HttpResult { | ||||
|     if !files_utils::check_file_name(&p.filename) { | ||||
|         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!")); | ||||
|     } | ||||
|     if !req | ||||
|         .format | ||||
|         .ext() | ||||
|         .iter() | ||||
|         .any(|e| req.dest_file_name.ends_with(e)) | ||||
|     { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!")); | ||||
|     } | ||||
|  | ||||
|     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!")); | ||||
|     } | ||||
|  | ||||
|     // Perform conversion | ||||
|     if let Err(e) = src.convert(&dst_file_path, req.format) { | ||||
|         log::error!("Disk file conversion error: {e}"); | ||||
|         return Ok( | ||||
|             HttpResponse::InternalServerError().json(format!("Disk file conversion error: {e}")) | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     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 | ||||
| pub async fn delete(p: web::Path<DiskFilePath>) -> HttpResult { | ||||
|     if !files_utils::check_file_name(&p.filename) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid file name!")); | ||||
|     } | ||||
|  | ||||
|     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!")); | ||||
|     } | ||||
|  | ||||
|     std::fs::remove_file(file_path)?; | ||||
|  | ||||
|     Ok(HttpResponse::Accepted().finish()) | ||||
| } | ||||
| @@ -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.as_bytes() { | ||||
|     if file.size > constants::ISO_MAX_SIZE { | ||||
|         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_bytes() as u64 { | ||||
|         if len > constants::ISO_MAX_SIZE as u64 { | ||||
|             return Ok(HttpResponse::BadRequest().json("File is too large!")); | ||||
|         } | ||||
|     } | ||||
| @@ -132,12 +132,12 @@ pub async fn get_list() -> HttpResult { | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct IsoFilePath { | ||||
| pub struct DownloadFilePath { | ||||
|     filename: String, | ||||
| } | ||||
|  | ||||
| /// Download ISO file | ||||
| pub async fn download_file(p: web::Path<IsoFilePath>, req: HttpRequest) -> HttpResult { | ||||
| pub async fn download_file(p: web::Path<DownloadFilePath>, req: HttpRequest) -> HttpResult { | ||||
|     if !files_utils::check_file_name(&p.filename) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid file name!")); | ||||
|     } | ||||
| @@ -152,7 +152,7 @@ pub async fn download_file(p: web::Path<IsoFilePath>, req: HttpRequest) -> HttpR | ||||
| } | ||||
|  | ||||
| /// Delete ISO file | ||||
| pub async fn delete_file(p: web::Path<IsoFilePath>) -> HttpResult { | ||||
| pub async fn delete_file(p: web::Path<DownloadFilePath>) -> HttpResult { | ||||
|     if !files_utils::check_file_name(&p.filename) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid file name!")); | ||||
|     } | ||||
|   | ||||
| @@ -7,7 +7,6 @@ use std::fmt::{Display, Formatter}; | ||||
|  | ||||
| pub mod api_tokens_controller; | ||||
| pub mod auth_controller; | ||||
| pub mod disk_images_controller; | ||||
| pub mod groups_controller; | ||||
| pub mod iso_controller; | ||||
| pub mod network_controller; | ||||
|   | ||||
| @@ -16,7 +16,6 @@ struct StaticConfig { | ||||
|     local_auth_enabled: bool, | ||||
|     oidc_auth_enabled: bool, | ||||
|     iso_mimetypes: &'static [&'static str], | ||||
|     disk_images_mimetypes: &'static [&'static str], | ||||
|     net_mac_prefix: &'static str, | ||||
|     builtin_nwfilter_rules: &'static [&'static str], | ||||
|     nwfilter_chains: &'static [&'static str], | ||||
| @@ -38,7 +37,6 @@ struct SLenConstraints { | ||||
| #[derive(serde::Serialize)] | ||||
| struct ServerConstraints { | ||||
|     iso_max_size: usize, | ||||
|     disk_image_max_size: usize, | ||||
|     vnc_token_duration: u64, | ||||
|     vm_name_size: LenConstraints, | ||||
|     vm_title_size: LenConstraints, | ||||
| @@ -46,7 +44,6 @@ struct ServerConstraints { | ||||
|     memory_size: LenConstraints, | ||||
|     disk_name_size: LenConstraints, | ||||
|     disk_size: LenConstraints, | ||||
|     disk_image_name_size: LenConstraints, | ||||
|     net_name_size: LenConstraints, | ||||
|     net_title_size: LenConstraints, | ||||
|     net_nat_comment_size: LenConstraints, | ||||
| @@ -66,13 +63,11 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { | ||||
|         local_auth_enabled: *local_auth, | ||||
|         oidc_auth_enabled: !AppConfig::get().disable_oidc, | ||||
|         iso_mimetypes: &constants::ALLOWED_ISO_MIME_TYPES, | ||||
|         disk_images_mimetypes: &constants::ALLOWED_DISK_IMAGES_MIME_TYPES, | ||||
|         net_mac_prefix: constants::NET_MAC_ADDR_PREFIX, | ||||
|         builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES, | ||||
|         nwfilter_chains: &constants::NETWORK_CHAINS, | ||||
|         constraints: ServerConstraints { | ||||
|             iso_max_size: constants::ISO_MAX_SIZE.as_bytes(), | ||||
|             disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE.as_bytes(), | ||||
|             iso_max_size: constants::ISO_MAX_SIZE, | ||||
|  | ||||
|             vnc_token_duration: VNC_TOKEN_LIFETIME, | ||||
|  | ||||
| @@ -80,20 +75,18 @@ 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.as_bytes(), | ||||
|                 max: constants::MAX_VM_MEMORY.as_bytes(), | ||||
|                 min: constants::MIN_VM_MEMORY, | ||||
|                 max: constants::MAX_VM_MEMORY, | ||||
|             }, | ||||
|             disk_name_size: LenConstraints { | ||||
|                 min: DISK_NAME_MIN_LEN, | ||||
|                 max: DISK_NAME_MAX_LEN, | ||||
|             }, | ||||
|             disk_size: LenConstraints { | ||||
|                 min: DISK_SIZE_MIN.as_bytes(), | ||||
|                 max: DISK_SIZE_MAX.as_bytes(), | ||||
|                 min: DISK_SIZE_MIN, | ||||
|                 max: DISK_SIZE_MAX, | ||||
|             }, | ||||
|  | ||||
|             disk_image_name_size: LenConstraints { min: 5, max: 220 }, | ||||
|  | ||||
|             net_name_size: LenConstraints { min: 2, max: 50 }, | ||||
|             net_title_size: LenConstraints { min: 0, max: 50 }, | ||||
|             net_nat_comment_size: LenConstraints { | ||||
|   | ||||
| @@ -22,13 +22,10 @@ pub struct DomainMetadataXML { | ||||
| #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | ||||
| #[serde(rename = "os")] | ||||
| pub struct OSXML { | ||||
|     #[serde(rename = "@firmware", default, skip_serializing_if = "Option::is_none")] | ||||
|     pub firmware: Option<String>, | ||||
|     #[serde(rename = "@firmware", default)] | ||||
|     pub firmware: 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>, | ||||
| } | ||||
|  | ||||
| @@ -52,16 +49,6 @@ 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::file_disks_utils::{DiskFormat, FileDisk}; | ||||
| use crate::utils::files_utils; | ||||
| use crate::utils::vm_file_disks_utils::{VMDiskBus, VMDiskFormat, VMFileDisk}; | ||||
| use crate::utils::files_utils::convert_size_unit_to_mb; | ||||
| use lazy_regex::regex; | ||||
| use num::Integer; | ||||
|  | ||||
| @@ -17,7 +17,6 @@ pub struct VMGroupId(pub String); | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| pub enum BootType { | ||||
|     Legacy, | ||||
|     UEFI, | ||||
|     UEFISecureBoot, | ||||
| } | ||||
| @@ -30,12 +29,6 @@ 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, | ||||
| @@ -53,7 +46,6 @@ pub struct Network { | ||||
|     #[serde(flatten)] | ||||
|     r#type: NetworkType, | ||||
|     mac: String, | ||||
|     model: NetworkInterfaceModelType, | ||||
|     nwfilterref: Option<NWFilterRef>, | ||||
| } | ||||
|  | ||||
| @@ -78,8 +70,8 @@ pub struct VMInfo { | ||||
|     pub group: Option<VMGroupId>, | ||||
|     pub boot_type: BootType, | ||||
|     pub architecture: VMArchitecture, | ||||
|     /// VM allocated RAM memory | ||||
|     pub memory: FileSize, | ||||
|     /// VM allocated memory, in megabytes | ||||
|     pub memory: usize, | ||||
|     /// Number of vCPU for the VM | ||||
|     pub number_vcpu: usize, | ||||
|     /// Enable VNC access through admin console | ||||
| @@ -87,7 +79,7 @@ pub struct VMInfo { | ||||
|     /// Attach ISO file(s) | ||||
|     pub iso_files: Vec<String>, | ||||
|     /// File Storage - https://access.redhat.com/documentation/fr-fr/red_hat_enterprise_linux/6/html/virtualization_administration_guide/sect-virtualization-virtualized_block_devices-adding_storage_devices_to_guests#sect-Virtualization-Adding_storage_devices_to_guests-Adding_file_based_storage_to_a_guest | ||||
|     pub file_disks: Vec<VMFileDisk>, | ||||
|     pub file_disks: Vec<FileDisk>, | ||||
|     /// Network cards | ||||
|     pub networks: Vec<Network>, | ||||
|     /// Add a TPM v2.0 module | ||||
| @@ -204,11 +196,7 @@ impl VMInfo { | ||||
|             }; | ||||
|  | ||||
|             let model = Some(NetIntModelXML { | ||||
|                 r#type: match n.model { | ||||
|                     NetworkInterfaceModelType::Virtio => "virtio", | ||||
|                     NetworkInterfaceModelType::E1000 => "e1000", | ||||
|                 } | ||||
|                 .to_string(), | ||||
|                 r#type: "virtio".to_string(), | ||||
|             }); | ||||
|  | ||||
|             let filterref = if let Some(n) = &n.nwfilterref { | ||||
| @@ -301,8 +289,8 @@ impl VMInfo { | ||||
|                 driver: DiskDriverXML { | ||||
|                     name: "qemu".to_string(), | ||||
|                     r#type: match disk.format { | ||||
|                         VMDiskFormat::Raw { .. } => "raw".to_string(), | ||||
|                         VMDiskFormat::QCow2 => "qcow2".to_string(), | ||||
|                         DiskFormat::Raw { .. } => "raw".to_string(), | ||||
|                         DiskFormat::QCow2 => "qcow2".to_string(), | ||||
|                     }, | ||||
|                     cache: "none".to_string(), | ||||
|                 }, | ||||
| @@ -314,11 +302,7 @@ impl VMInfo { | ||||
|                         "vd{}", | ||||
|                         ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"][disks.len()] | ||||
|                     ), | ||||
|                     bus: match disk.bus { | ||||
|                         VMDiskBus::Virtio => "virtio", | ||||
|                         VMDiskBus::SATA => "sata", | ||||
|                     } | ||||
|                     .to_string(), | ||||
|                     bus: "virtio".to_string(), | ||||
|                 }, | ||||
|                 readonly: None, | ||||
|                 boot: DiskBootXML { | ||||
| @@ -352,26 +336,13 @@ impl VMInfo { | ||||
|                     machine: "q35".to_string(), | ||||
|                     body: "hvm".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, | ||||
|                 }, | ||||
|                 firmware: "efi".to_string(), | ||||
|                 loader: Some(OSLoaderXML { | ||||
|                     secure: match self.boot_type { | ||||
|                         BootType::UEFI => "no".to_string(), | ||||
|                         BootType::UEFISecureBoot => "yes".to_string(), | ||||
|                     }, | ||||
|                 }), | ||||
|                 smbios: Some(OSSMBiosXML { | ||||
|                     mode: "sysinfo".to_string(), | ||||
|                 }), | ||||
| @@ -409,7 +380,7 @@ impl VMInfo { | ||||
|  | ||||
|             memory: DomainMemoryXML { | ||||
|                 unit: "MB".to_string(), | ||||
|                 memory: self.memory.as_mb(), | ||||
|                 memory: self.memory, | ||||
|             }, | ||||
|  | ||||
|             vcpu: DomainVCPUXML { | ||||
| @@ -463,10 +434,9 @@ impl VMInfo { | ||||
|                 .virtweb | ||||
|                 .group | ||||
|                 .map(VMGroupId), | ||||
|             boot_type: match (domain.os.loader, domain.os.bootmenu) { | ||||
|                 (_, Some(_)) => BootType::Legacy, | ||||
|                 (None, _) => BootType::UEFI, | ||||
|                 (Some(l), _) => match l.secure.as_str() { | ||||
|             boot_type: match domain.os.loader { | ||||
|                 None => BootType::UEFI, | ||||
|                 Some(l) => match l.secure.as_str() { | ||||
|                     "yes" => BootType::UEFISecureBoot, | ||||
|                     _ => BootType::UEFI, | ||||
|                 }, | ||||
| @@ -482,7 +452,7 @@ impl VMInfo { | ||||
|                 } | ||||
|             }, | ||||
|             number_vcpu: domain.vcpu.body, | ||||
|             memory: FileSize::from_size_unit(&domain.memory.unit, domain.memory.memory)?, | ||||
|             memory: convert_size_unit_to_mb(&domain.memory.unit, domain.memory.memory)?, | ||||
|             vnc_access: domain.devices.graphics.is_some(), | ||||
|             iso_files: domain | ||||
|                 .devices | ||||
| @@ -497,10 +467,7 @@ impl VMInfo { | ||||
|                 .disks | ||||
|                 .iter() | ||||
|                 .filter(|d| d.device == "disk") | ||||
|                 .map(|d| { | ||||
|                     VMFileDisk::load_from_file(&d.source.file, &d.target.bus) | ||||
|                         .expect("Failed to load file disk information!") | ||||
|                 }) | ||||
|                 .map(|d| FileDisk::load_from_file(&d.source.file).unwrap()) | ||||
|                 .collect(), | ||||
|  | ||||
|             networks: domain | ||||
| @@ -548,18 +515,6 @@ 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 | ||||
|   | ||||
| @@ -13,7 +13,6 @@ use actix_web::middleware::Logger; | ||||
| use actix_web::web::Data; | ||||
| use actix_web::{App, HttpServer, web}; | ||||
| use light_openid::basic_state_manager::BasicStateManager; | ||||
| use std::cmp::max; | ||||
| use std::time::Duration; | ||||
| use virtweb_backend::actors::libvirt_actor::LibVirtActor; | ||||
| use virtweb_backend::actors::vnc_tokens_actor::VNCTokensManager; | ||||
| @@ -23,9 +22,8 @@ use virtweb_backend::constants::{ | ||||
|     MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME, | ||||
| }; | ||||
| use virtweb_backend::controllers::{ | ||||
|     api_tokens_controller, auth_controller, disk_images_controller, groups_controller, | ||||
|     iso_controller, network_controller, nwfilter_controller, server_controller, static_controller, | ||||
|     vm_controller, | ||||
|     api_tokens_controller, auth_controller, groups_controller, iso_controller, network_controller, | ||||
|     nwfilter_controller, server_controller, static_controller, vm_controller, | ||||
| }; | ||||
| use virtweb_backend::libvirt_client::LibVirtClient; | ||||
| use virtweb_backend::middlewares::auth_middleware::AuthChecker; | ||||
| @@ -57,11 +55,9 @@ async fn main() -> std::io::Result<()> { | ||||
|  | ||||
|     log::debug!("Create required directory, if missing"); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().iso_storage_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().disk_images_storage_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap(); | ||||
|     files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().root_vm_disks_storage_path()) | ||||
|         .unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().disks_storage_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().nat_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().definitions_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().api_tokens_path()).unwrap(); | ||||
| @@ -122,9 +118,7 @@ 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).as_bytes(), | ||||
|             )) | ||||
|             .app_data(MultipartFormConfig::default().total_limit(constants::ISO_MAX_SIZE)) | ||||
|             .app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir)) | ||||
|             // Server controller | ||||
|             .route( | ||||
| @@ -335,35 +329,6 @@ async fn main() -> std::io::Result<()> { | ||||
|                 "/api/nwfilter/{uid}", | ||||
|                 web::delete().to(nwfilter_controller::delete), | ||||
|             ) | ||||
|             // Disk images library | ||||
|             .route( | ||||
|                 "/api/disk_images/upload", | ||||
|                 web::post().to(disk_images_controller::upload), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/disk_images/list", | ||||
|                 web::get().to(disk_images_controller::get_list), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/disk_images/{filename}", | ||||
|                 web::get().to(disk_images_controller::download), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/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", | ||||
|   | ||||
| @@ -1,139 +1,169 @@ | ||||
| use crate::app_config::AppConfig; | ||||
| use crate::constants; | ||||
| use crate::utils::file_size_utils::FileSize; | ||||
| use std::fs::File; | ||||
| use crate::libvirt_lib_structures::XMLUuid; | ||||
| use crate::utils::files_utils; | ||||
| use lazy_regex::regex; | ||||
| use std::os::linux::fs::MetadataExt; | ||||
| use std::path::{Path, PathBuf}; | ||||
| use std::process::Command; | ||||
| use std::time::UNIX_EPOCH; | ||||
|  | ||||
| #[derive(thiserror::Error, Debug)] | ||||
| enum DisksError { | ||||
|     #[error("DiskParseError: {0}")] | ||||
|     Parse(&'static str), | ||||
|     #[error("DiskConfigError: {0}")] | ||||
|     Config(&'static str), | ||||
|     #[error("DiskCreateError")] | ||||
|     Create, | ||||
|     #[error("DiskConvertError: {0}")] | ||||
|     Convert(String), | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Serialize, serde::Deserialize, Copy, Clone, PartialEq, Eq)] | ||||
| /// Type of disk allocation | ||||
| #[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)] | ||||
| pub enum DiskAllocType { | ||||
|     Fixed, | ||||
|     Sparse, | ||||
| } | ||||
|  | ||||
| /// Disk allocation type | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| #[serde(tag = "format")] | ||||
| pub enum DiskFileFormat { | ||||
| pub enum DiskFormat { | ||||
|     Raw { | ||||
|         #[serde(default)] | ||||
|         is_sparse: bool, | ||||
|         /// Type of disk allocation | ||||
|         alloc_type: DiskAllocType, | ||||
|     }, | ||||
|     QCow2 { | ||||
|         #[serde(default)] | ||||
|         virtual_size: FileSize, | ||||
|     }, | ||||
|     CompressedRaw, | ||||
|     CompressedQCow2, | ||||
|     QCow2, | ||||
| } | ||||
|  | ||||
| impl DiskFileFormat { | ||||
|     pub fn ext(&self) -> &'static [&'static str] { | ||||
|         match self { | ||||
|             DiskFileFormat::Raw { .. } => &["raw", ""], | ||||
|             DiskFileFormat::QCow2 { .. } => &["qcow2"], | ||||
|             DiskFileFormat::CompressedRaw => &["raw.gz"], | ||||
|             DiskFileFormat::CompressedQCow2 => &["qcow2.gz"], | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Disk file information | ||||
| #[derive(serde::Serialize)] | ||||
| pub struct DiskFileInfo { | ||||
|     pub file_path: PathBuf, | ||||
|     pub file_size: FileSize, | ||||
|     #[serde(flatten)] | ||||
|     pub format: DiskFileFormat, | ||||
|     pub file_name: String, | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| pub struct FileDisk { | ||||
|     /// Disk name | ||||
|     pub name: String, | ||||
|     pub created: u64, | ||||
|     /// Disk size, in megabytes | ||||
|     pub size: usize, | ||||
|     /// Disk format | ||||
|     #[serde(flatten)] | ||||
|     pub format: DiskFormat, | ||||
|     /// Set this variable to true to delete the disk | ||||
|     pub delete: bool, | ||||
| } | ||||
|  | ||||
| impl DiskFileInfo { | ||||
|     /// Get disk image file information | ||||
|     pub fn load_file(file: &Path) -> anyhow::Result<Self> { | ||||
| impl FileDisk { | ||||
|     pub fn load_from_file(path: &str) -> anyhow::Result<Self> { | ||||
|         let file = Path::new(path); | ||||
|  | ||||
|         if !file.is_file() { | ||||
|             return Err(DisksError::Parse("Path is not a file!").into()); | ||||
|         } | ||||
|  | ||||
|         // Get file metadata | ||||
|         let metadata = file.metadata()?; | ||||
|         let mut name = file | ||||
|             .file_stem() | ||||
|             .and_then(|s| s.to_str()) | ||||
|             .unwrap_or("disk") | ||||
|             .to_string(); | ||||
|         let name = file.file_stem().and_then(|s| s.to_str()).unwrap_or("disk"); | ||||
|         let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("raw"); | ||||
|  | ||||
|         // Determine file format | ||||
|         // Approximate raw file estimation | ||||
|         let is_raw_sparse = metadata.len() / 512 >= metadata.st_blocks(); | ||||
|  | ||||
|         let format = match ext { | ||||
|             "qcow2" => DiskFileFormat::QCow2 { | ||||
|                 virtual_size: qcow_virt_size(file)?, | ||||
|             "qcow2" => DiskFormat::QCow2, | ||||
|             "raw" => DiskFormat::Raw { | ||||
|                 alloc_type: match is_raw_sparse { | ||||
|                     true => DiskAllocType::Sparse, | ||||
|                     false => DiskAllocType::Fixed, | ||||
|                 }, | ||||
|             }, | ||||
|             "raw" => DiskFileFormat::Raw { | ||||
|                 is_sparse: metadata.len() / 512 >= metadata.st_blocks(), | ||||
|             }, | ||||
|             "gz" if name.ends_with(".qcow2") => { | ||||
|                 name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string(); | ||||
|                 DiskFileFormat::CompressedQCow2 | ||||
|             } | ||||
|             "gz" => DiskFileFormat::CompressedRaw, | ||||
|             _ => anyhow::bail!("Unsupported disk extension: {ext}!"), | ||||
|         }; | ||||
|  | ||||
|         Ok(Self { | ||||
|             file_path: file.to_path_buf(), | ||||
|             name, | ||||
|             file_size: FileSize::from_bytes(metadata.len() as usize), | ||||
|             name: name.to_string(), | ||||
|             size: match format { | ||||
|                 DiskFormat::Raw { .. } => metadata.len() as usize / (1000 * 1000), | ||||
|                 DiskFormat::QCow2 => qcow_virt_size(path)? / (1000 * 1000), | ||||
|             }, | ||||
|             format, | ||||
|             file_name: file | ||||
|                 .file_name() | ||||
|                 .and_then(|s| s.to_str()) | ||||
|                 .unwrap_or("") | ||||
|                 .to_string(), | ||||
|             created: metadata | ||||
|                 .created()? | ||||
|                 .duration_since(UNIX_EPOCH) | ||||
|                 .unwrap() | ||||
|                 .as_secs(), | ||||
|             delete: false, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     /// Create a new empty disk | ||||
|     pub fn create(file: &Path, format: DiskFileFormat, size: FileSize) -> anyhow::Result<()> { | ||||
|     pub fn check_config(&self) -> anyhow::Result<()> { | ||||
|         if constants::DISK_NAME_MIN_LEN > self.name.len() | ||||
|             || constants::DISK_NAME_MAX_LEN < self.name.len() | ||||
|         { | ||||
|             return Err(DisksError::Config("Disk name length is invalid").into()); | ||||
|         } | ||||
|  | ||||
|         if !regex!("^[a-zA-Z0-9]+$").is_match(&self.name) { | ||||
|             return Err(DisksError::Config("Disk name contains invalid characters!").into()); | ||||
|         } | ||||
|  | ||||
|         // Check disk size | ||||
|         if !(constants::DISK_SIZE_MIN..=constants::DISK_SIZE_MAX).contains(&self.size) { | ||||
|             return Err(DisksError::Config("Disk size is invalid!").into()); | ||||
|         } | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Get disk path | ||||
|     pub fn disk_path(&self, id: XMLUuid) -> PathBuf { | ||||
|         let domain_dir = AppConfig::get().vm_storage_path(id); | ||||
|         let file_name = match self.format { | ||||
|             DiskFormat::Raw { .. } => self.name.to_string(), | ||||
|             DiskFormat::QCow2 => format!("{}.qcow2", self.name), | ||||
|         }; | ||||
|         domain_dir.join(&file_name) | ||||
|     } | ||||
|  | ||||
|     /// Apply disk configuration | ||||
|     pub fn apply_config(&self, id: XMLUuid) -> anyhow::Result<()> { | ||||
|         self.check_config()?; | ||||
|  | ||||
|         let file = self.disk_path(id); | ||||
|         files_utils::create_directory_if_missing(file.parent().unwrap())?; | ||||
|  | ||||
|         // Delete file if requested | ||||
|         if self.delete { | ||||
|             if !file.exists() { | ||||
|                 log::debug!("File {file:?} does not exists, so it was not deleted"); | ||||
|                 return Ok(()); | ||||
|             } | ||||
|  | ||||
|             log::info!("Deleting {file:?}"); | ||||
|             std::fs::remove_file(file)?; | ||||
|             return Ok(()); | ||||
|         } | ||||
|  | ||||
|         if file.exists() { | ||||
|             log::debug!("File {file:?} does not exists, so it was not touched"); | ||||
|             return Ok(()); | ||||
|         } | ||||
|  | ||||
|         // Prepare command to create file | ||||
|         let res = match format { | ||||
|             DiskFileFormat::Raw { is_sparse } => { | ||||
|         let res = match self.format { | ||||
|             DiskFormat::Raw { alloc_type } => { | ||||
|                 let mut cmd = Command::new("/usr/bin/dd"); | ||||
|                 cmd.arg("if=/dev/zero") | ||||
|                     .arg(format!("of={}", file.to_string_lossy())) | ||||
|                     .arg("bs=1M"); | ||||
|  | ||||
|                 match is_sparse { | ||||
|                     false => cmd.arg(format!("count={}", size.as_mb())), | ||||
|                     true => cmd.arg(format!("seek={}", size.as_mb())).arg("count=0"), | ||||
|                 match alloc_type { | ||||
|                     DiskAllocType::Fixed => cmd.arg(format!("count={}", self.size)), | ||||
|                     DiskAllocType::Sparse => cmd.arg(format!("seek={}", self.size)).arg("count=0"), | ||||
|                 }; | ||||
|  | ||||
|                 cmd.output()? | ||||
|             } | ||||
|  | ||||
|             DiskFileFormat::QCow2 { virtual_size } => { | ||||
|             DiskFormat::QCow2 => { | ||||
|                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|                 cmd.arg("create") | ||||
|                     .arg("-f") | ||||
|                     .arg("qcow2") | ||||
|                     .arg(file) | ||||
|                     .arg(format!("{}M", virtual_size.as_mb())); | ||||
|                     .arg(format!("{}M", self.size)); | ||||
|  | ||||
|                 cmd.output()? | ||||
|             } | ||||
|             _ => anyhow::bail!("Cannot create disk file image of this format: {format:?}!"), | ||||
|         }; | ||||
|  | ||||
|         // Execute Linux command | ||||
| @@ -148,174 +178,6 @@ impl DiskFileInfo { | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Copy / convert file disk image into a new destination with optionally a new file format | ||||
|     pub fn convert(&self, dest_file: &Path, dest_format: DiskFileFormat) -> anyhow::Result<()> { | ||||
|         // Create a temporary directory to perform the operation | ||||
|         let temp_dir = tempfile::tempdir_in(&AppConfig::get().temp_dir)?; | ||||
|         let temp_file = temp_dir | ||||
|             .path() | ||||
|             .join(format!("temp_file.{}", dest_format.ext()[0])); | ||||
|  | ||||
|         // Prepare the conversion | ||||
|         let mut cmd = match (self.format, dest_format) { | ||||
|             // Decompress QCow2 | ||||
|             (DiskFileFormat::CompressedQCow2, DiskFileFormat::QCow2 { .. }) => { | ||||
|                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--decompress") | ||||
|                     .arg("--to-stdout") | ||||
|                     .arg(&self.file_path) | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Compress QCow2 | ||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::CompressedQCow2) => { | ||||
|                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--to-stdout") | ||||
|                     .arg(&self.file_path) | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // 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); | ||||
|  | ||||
|                 if !is_sparse { | ||||
|                     cmd.args(["-S", "0"]); | ||||
|                 } | ||||
|  | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Clone a QCow file, using qemu-image instead of cp might improve "sparsification" of | ||||
|             // file | ||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::QCow2 { .. }) => { | ||||
|                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|                 cmd.arg("convert") | ||||
|                     .arg("-O") | ||||
|                     .arg("qcow2") | ||||
|                     .arg(&self.file_path) | ||||
|                     .arg(&temp_file); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // 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 | ||||
|             } | ||||
|  | ||||
|             // Render raw file non sparse | ||||
|             (DiskFileFormat::Raw { is_sparse: true }, DiskFileFormat::Raw { is_sparse: false }) => { | ||||
|                 let mut cmd = Command::new(constants::COPY_PROGRAM); | ||||
|                 cmd.arg("--sparse=never") | ||||
|                     .arg(&self.file_path) | ||||
|                     .arg(&temp_file); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Render raw file sparse | ||||
|             (DiskFileFormat::Raw { is_sparse: false }, DiskFileFormat::Raw { is_sparse: true }) => { | ||||
|                 let mut cmd = Command::new(constants::DD_PROGRAM); | ||||
|                 cmd.arg("conv=sparse") | ||||
|                     .arg(format!("if={}", self.file_path.display())) | ||||
|                     .arg(format!("of={}", temp_file.display())); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Compress Raw | ||||
|             (DiskFileFormat::Raw { .. }, DiskFileFormat::CompressedRaw) => { | ||||
|                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--to-stdout") | ||||
|                     .arg(&self.file_path) | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Decompress Raw to not sparse file | ||||
|             (DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => { | ||||
|                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--decompress") | ||||
|                     .arg("--to-stdout") | ||||
|                     .arg(&self.file_path) | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Decompress Raw to sparse file | ||||
|             // https://benou.fr/www/ben/decompressing-sparse-files.html | ||||
|             (DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => { | ||||
|                 let mut cmd = Command::new(constants::BASH_PROGRAM); | ||||
|                 cmd.arg("-c").arg(format!( | ||||
|                     "{} -d -c {} | {} conv=sparse of={}", | ||||
|                     constants::GZIP_PROGRAM, | ||||
|                     self.file_path.display(), | ||||
|                     constants::DD_PROGRAM, | ||||
|                     temp_file.display() | ||||
|                 )); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Dumb copy of file | ||||
|             (a, b) if a == b => { | ||||
|                 let mut cmd = Command::new(constants::COPY_PROGRAM); | ||||
|                 cmd.arg("--sparse=auto") | ||||
|                     .arg(&self.file_path) | ||||
|                     .arg(&temp_file); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // By default, conversion is unsupported | ||||
|             (src, dest) => { | ||||
|                 return Err(DisksError::Convert(format!( | ||||
|                     "Conversion from {src:?} to {dest:?} is not supported!" | ||||
|                 )) | ||||
|                 .into()); | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         // Execute the conversion | ||||
|         let command_s = format!( | ||||
|             "{} {}", | ||||
|             cmd.get_program().display(), | ||||
|             cmd.get_args() | ||||
|                 .map(|a| format!("'{}'", a.display())) | ||||
|                 .collect::<Vec<String>>() | ||||
|                 .join(" ") | ||||
|         ); | ||||
|         let cmd_output = cmd.output()?; | ||||
|         if !cmd_output.status.success() { | ||||
|             return Err(DisksError::Convert(format!( | ||||
|                 "Command failed:\n{command_s}\nStatus: {}\nstdout: {}\nstderr: {}", | ||||
|                 cmd_output.status, | ||||
|                 String::from_utf8_lossy(&cmd_output.stdout), | ||||
|                 String::from_utf8_lossy(&cmd_output.stderr) | ||||
|             )) | ||||
|             .into()); | ||||
|         } | ||||
|  | ||||
|         // Check the file was created | ||||
|         if !temp_file.is_file() { | ||||
|             return Err(DisksError::Convert( | ||||
|                 "Temporary was not created after execution of command!".to_string(), | ||||
|             ) | ||||
|             .into()); | ||||
|         } | ||||
|  | ||||
|         // Move the file to its final location | ||||
|         std::fs::rename(temp_file, dest_file)?; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| @@ -325,16 +187,10 @@ struct QCowInfoOutput { | ||||
| } | ||||
|  | ||||
| /// Get QCow2 virtual size | ||||
| fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> { | ||||
| fn qcow_virt_size(path: &str) -> anyhow::Result<usize> { | ||||
|     // Run qemu-img | ||||
|     let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|     cmd.args([ | ||||
|         "info", | ||||
|         path.to_str().unwrap_or(""), | ||||
|         "--output", | ||||
|         "json", | ||||
|         "--force-share", | ||||
|     ]); | ||||
|     cmd.args(["info", path, "--output", "json", "--force-share"]); | ||||
|     let output = cmd.output()?; | ||||
|     if !output.status.success() { | ||||
|         anyhow::bail!( | ||||
| @@ -348,5 +204,5 @@ fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> { | ||||
|  | ||||
|     // Decode JSON | ||||
|     let decoded: QCowInfoOutput = serde_json::from_str(&res_json)?; | ||||
|     Ok(FileSize::from_bytes(decoded.virtual_size)) | ||||
|     Ok(decoded.virtual_size) | ||||
| } | ||||
|   | ||||
| @@ -1,91 +0,0 @@ | ||||
| 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, | ||||
|     Copy, | ||||
|     Clone, | ||||
|     Debug, | ||||
|     Eq, | ||||
|     PartialEq, | ||||
|     PartialOrd, | ||||
|     Ord, | ||||
|     Default, | ||||
| )] | ||||
| pub struct FileSize(usize); | ||||
|  | ||||
| impl FileSize { | ||||
|     pub const fn from_bytes(size: usize) -> Self { | ||||
|         Self(size) | ||||
|     } | ||||
|  | ||||
|     pub const fn from_mb(mb: usize) -> Self { | ||||
|         Self(mb * 1000 * 1000) | ||||
|     } | ||||
|  | ||||
|     pub const fn from_gb(gb: usize) -> Self { | ||||
|         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 | ||||
|     } | ||||
|  | ||||
|     /// Get file size as megabytes | ||||
|     pub fn as_mb(&self) -> usize { | ||||
|         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,6 +1,13 @@ | ||||
| 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", | ||||
| @@ -28,9 +35,31 @@ 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; | ||||
|     use crate::utils::files_utils::{check_file_name, convert_size_unit_to_mb}; | ||||
|  | ||||
|     #[test] | ||||
|     fn empty_file_name() { | ||||
| @@ -56,4 +85,14 @@ 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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,9 +1,7 @@ | ||||
| pub mod exec_utils; | ||||
| pub mod file_disks_utils; | ||||
| pub mod file_size_utils; | ||||
| pub mod files_utils; | ||||
| pub mod net_utils; | ||||
| pub mod rand_utils; | ||||
| pub mod time_utils; | ||||
| pub mod url_utils; | ||||
| pub mod vm_file_disks_utils; | ||||
|   | ||||
| @@ -1,174 +0,0 @@ | ||||
| use crate::app_config::AppConfig; | ||||
| use crate::constants; | ||||
| use crate::libvirt_lib_structures::XMLUuid; | ||||
| use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo}; | ||||
| use crate::utils::file_size_utils::FileSize; | ||||
| use crate::utils::files_utils; | ||||
| use lazy_regex::regex; | ||||
| use std::path::{Path, PathBuf}; | ||||
|  | ||||
| #[derive(thiserror::Error, Debug)] | ||||
| enum VMDisksError { | ||||
|     #[error("DiskConfigError: {0}")] | ||||
|     Config(&'static str), | ||||
| } | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| pub enum VMDiskBus { | ||||
|     Virtio, | ||||
|     SATA, | ||||
| } | ||||
|  | ||||
| /// Disk allocation type | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| #[serde(tag = "format")] | ||||
| pub enum VMDiskFormat { | ||||
|     Raw { | ||||
|         /// Is raw file a sparse file? | ||||
|         is_sparse: bool, | ||||
|     }, | ||||
|     QCow2, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| pub struct VMFileDisk { | ||||
|     /// Disk name | ||||
|     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, bus: &str) -> anyhow::Result<Self> { | ||||
|         let file = Path::new(path); | ||||
|  | ||||
|         let info = DiskFileInfo::load_file(file)?; | ||||
|  | ||||
|         Ok(Self { | ||||
|             name: info.name, | ||||
|  | ||||
|             // Get only the virtual size of the file | ||||
|             size: match info.format { | ||||
|                 DiskFileFormat::Raw { .. } => info.file_size, | ||||
|                 DiskFileFormat::QCow2 { virtual_size } => virtual_size, | ||||
|                 _ => anyhow::bail!("Unsupported image format: {:?}", info.format), | ||||
|             }, | ||||
|  | ||||
|             format: match info.format { | ||||
|                 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, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     pub fn check_config(&self) -> anyhow::Result<()> { | ||||
|         if constants::DISK_NAME_MIN_LEN > self.name.len() | ||||
|             || constants::DISK_NAME_MAX_LEN < self.name.len() | ||||
|         { | ||||
|             return Err(VMDisksError::Config("Disk name length is invalid").into()); | ||||
|         } | ||||
|  | ||||
|         if !regex!("^[a-zA-Z0-9]+$").is_match(&self.name) { | ||||
|             return Err(VMDisksError::Config("Disk name contains invalid characters!").into()); | ||||
|         } | ||||
|  | ||||
|         // Check disk size | ||||
|         if !(constants::DISK_SIZE_MIN..=constants::DISK_SIZE_MAX).contains(&self.size) { | ||||
|             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 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 { | ||||
|             VMDiskFormat::Raw { .. } => self.name.to_string(), | ||||
|             VMDiskFormat::QCow2 => format!("{}.qcow2", self.name), | ||||
|         }; | ||||
|         domain_dir.join(&file_name) | ||||
|     } | ||||
|  | ||||
|     /// Apply disk configuration | ||||
|     pub fn apply_config(&self, id: XMLUuid) -> anyhow::Result<()> { | ||||
|         self.check_config()?; | ||||
|  | ||||
|         let file = self.disk_path(id); | ||||
|         files_utils::create_directory_if_missing(file.parent().unwrap())?; | ||||
|  | ||||
|         // Delete file if requested | ||||
|         if self.delete { | ||||
|             if !file.exists() { | ||||
|                 log::debug!("File {file:?} does not exists, so it was not deleted"); | ||||
|                 return Ok(()); | ||||
|             } | ||||
|  | ||||
|             log::info!("Deleting {file:?}"); | ||||
|             std::fs::remove_file(file)?; | ||||
|             return Ok(()); | ||||
|         } | ||||
|  | ||||
|         if file.exists() { | ||||
|             log::debug!("File {file:?} does not exists, so it was not touched"); | ||||
|             return Ok(()); | ||||
|         } | ||||
|  | ||||
|         let format = match self.format { | ||||
|             VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse }, | ||||
|             VMDiskFormat::QCow2 => DiskFileFormat::QCow2 { | ||||
|                 virtual_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(()) | ||||
|     } | ||||
| } | ||||
| @@ -34,7 +34,7 @@ docker compose up | ||||
| sudo mkdir /var/virtweb | ||||
| sudo chown $USER:$USER /var/virtweb | ||||
| cd virtweb_backend | ||||
| cargo fmt && cargo clippy && cargo run -- -s /var/virtweb --hypervisor-uri "qemu:///system" --website-origin "http://localhost:5173" | ||||
| cargo fmt && cargo clippy && cargo run -- -s /var/virtweb --hypervisor-uri "qemu:///system" | ||||
| ``` | ||||
|  | ||||
| 7. Run the frontend | ||||
|   | ||||
| @@ -38,7 +38,6 @@ import { LoginRoute } from "./routes/auth/LoginRoute"; | ||||
| import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute"; | ||||
| import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; | ||||
| import { BaseLoginPage } from "./widgets/BaseLoginPage"; | ||||
| import { DiskImagesRoute } from "./routes/DiskImagesRoute"; | ||||
|  | ||||
| interface AuthContext { | ||||
|   signedIn: boolean; | ||||
| @@ -64,8 +63,6 @@ export function App() { | ||||
|         <Route path="*" element={<BaseAuthenticatedPage />}> | ||||
|           <Route path="" element={<HomeRoute />} /> | ||||
|  | ||||
|           <Route path="disk_images" element={<DiskImagesRoute />} /> | ||||
|  | ||||
|           <Route path="iso" element={<IsoFilesRoute />} /> | ||||
|  | ||||
|           <Route path="vms" element={<VMListRoute />} /> | ||||
|   | ||||
| @@ -103,7 +103,6 @@ export class APIClient { | ||||
|         body: body, | ||||
|         headers: headers, | ||||
|         credentials: "include", | ||||
|         signal: AbortSignal.timeout(50 * 1000 * 1000), | ||||
|       }); | ||||
|  | ||||
|       // Process response | ||||
|   | ||||
| @@ -1,117 +0,0 @@ | ||||
| import { APIClient } from "./ApiClient"; | ||||
| import { VMFileDisk, VMInfo } from "./VMApi"; | ||||
|  | ||||
| export type DiskImageFormat = | ||||
|   | { format: "Raw"; is_sparse: boolean } | ||||
|   | { format: "QCow2"; virtual_size?: number } | ||||
|   | { format: "CompressedQCow2" } | ||||
|   | { format: "CompressedRaw" }; | ||||
|  | ||||
| export type DiskImage = { | ||||
|   file_size: number; | ||||
|   file_name: string; | ||||
|   name: string; | ||||
|   created: number; | ||||
| } & DiskImageFormat; | ||||
|  | ||||
| export class DiskImageApi { | ||||
|   /** | ||||
|    * Upload a new disk image file to the server | ||||
|    */ | ||||
|   static async Upload( | ||||
|     file: File, | ||||
|     progress: (progress: number) => void | ||||
|   ): Promise<void> { | ||||
|     const fd = new FormData(); | ||||
|     fd.append("file", file); | ||||
|  | ||||
|     await APIClient.exec({ | ||||
|       method: "POST", | ||||
|       uri: "/disk_images/upload", | ||||
|       formData: fd, | ||||
|       upProgress: progress, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get the list of disk images | ||||
|    */ | ||||
|   static async GetList(): Promise<DiskImage[]> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: "/disk_images/list", | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Download disk image file | ||||
|    */ | ||||
|   static async Download( | ||||
|     file: DiskImage, | ||||
|     progress: (p: number) => void | ||||
|   ): Promise<Blob> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: `/disk_images/${file.file_name}`, | ||||
|         downProgress(e) { | ||||
|           progress(Math.floor(100 * (e.progress / e.total))); | ||||
|         }, | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Convert disk image file | ||||
|    */ | ||||
|   static async Convert( | ||||
|     file: DiskImage, | ||||
|     dest_file_name: string, | ||||
|     dest_format: DiskImageFormat | ||||
|   ): Promise<void> { | ||||
|     await APIClient.exec({ | ||||
|       method: "POST", | ||||
|       uri: `/disk_images/${file.file_name}/convert`, | ||||
|       jsonData: { ...dest_format, dest_file_name }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * 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 | ||||
|    */ | ||||
|   static async Delete(file: DiskImage): Promise<void> { | ||||
|     await APIClient.exec({ | ||||
|       method: "DELETE", | ||||
|       uri: `/disk_images/${file.file_name}`, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| @@ -5,7 +5,6 @@ export interface ServerConfig { | ||||
|   local_auth_enabled: boolean; | ||||
|   oidc_auth_enabled: boolean; | ||||
|   iso_mimetypes: string[]; | ||||
|   disk_images_mimetypes: string[]; | ||||
|   net_mac_prefix: string; | ||||
|   builtin_nwfilter_rules: string[]; | ||||
|   nwfilter_chains: string[]; | ||||
| @@ -14,7 +13,6 @@ export interface ServerConfig { | ||||
|  | ||||
| export interface ServerConstraints { | ||||
|   iso_max_size: number; | ||||
|   disk_image_max_size: number; | ||||
|   vnc_token_duration: number; | ||||
|   vm_name_size: LenConstraint; | ||||
|   vm_title_size: LenConstraint; | ||||
| @@ -22,7 +20,6 @@ export interface ServerConstraints { | ||||
|   memory_size: LenConstraint; | ||||
|   disk_name_size: LenConstraint; | ||||
|   disk_size: LenConstraint; | ||||
|   disk_image_name_size: LenConstraint; | ||||
|   net_name_size: LenConstraint; | ||||
|   net_title_size: LenConstraint; | ||||
|   net_nat_comment_size: LenConstraint; | ||||
|   | ||||
| @@ -19,26 +19,21 @@ 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; | ||||
|  | ||||
|   // For new disk only | ||||
|   from_image?: string; | ||||
|  | ||||
|   // application attributes | ||||
|   // application attribute | ||||
|   new?: boolean; | ||||
|   deleteType?: "keepfile" | "deletefile"; | ||||
| } | ||||
|  | ||||
| export type DiskAllocType = "Sparse" | "Fixed"; | ||||
|  | ||||
| interface RawVMDisk { | ||||
|   format: "Raw"; | ||||
|   is_sparse: boolean; | ||||
|   alloc_type: DiskAllocType; | ||||
| } | ||||
|  | ||||
| interface QCow2Disk { | ||||
| @@ -64,7 +59,6 @@ export type VMNetInterface = ( | ||||
|  | ||||
| export interface VMNetInterfaceBase { | ||||
|   mac: string; | ||||
|   model: "Virtio" | "E1000"; | ||||
|   nwfilterref?: VMNetInterfaceFilter; | ||||
| } | ||||
|  | ||||
| @@ -82,8 +76,6 @@ export interface VMNetBridge { | ||||
|   bridge: string; | ||||
| } | ||||
|  | ||||
| export type VMBootType = "UEFI" | "UEFISecureBoot" | "Legacy"; | ||||
|  | ||||
| interface VMInfoInterface { | ||||
|   name: string; | ||||
|   uuid?: string; | ||||
| @@ -91,7 +83,7 @@ interface VMInfoInterface { | ||||
|   title?: string; | ||||
|   description?: string; | ||||
|   group?: string; | ||||
|   boot_type: VMBootType; | ||||
|   boot_type: "UEFI" | "UEFISecureBoot"; | ||||
|   architecture: "i686" | "x86_64"; | ||||
|   memory: number; | ||||
|   number_vcpu: number; | ||||
| @@ -110,7 +102,7 @@ export class VMInfo implements VMInfoInterface { | ||||
|   title?: string; | ||||
|   description?: string; | ||||
|   group?: string; | ||||
|   boot_type: VMBootType; | ||||
|   boot_type: "UEFI" | "UEFISecureBoot"; | ||||
|   architecture: "i686" | "x86_64"; | ||||
|   number_vcpu: number; | ||||
|   memory: number; | ||||
| @@ -145,7 +137,7 @@ export class VMInfo implements VMInfoInterface { | ||||
|       name: "", | ||||
|       boot_type: "UEFI", | ||||
|       architecture: "x86_64", | ||||
|       memory: 1000 * 1000 * 1000, | ||||
|       memory: 1024, | ||||
|       number_vcpu: 1, | ||||
|       vnc_access: true, | ||||
|       iso_files: [], | ||||
|   | ||||
| @@ -1,144 +0,0 @@ | ||||
| import { | ||||
|   Button, | ||||
|   Dialog, | ||||
|   DialogActions, | ||||
|   DialogContent, | ||||
|   DialogContentText, | ||||
|   DialogTitle, | ||||
| } from "@mui/material"; | ||||
| 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 { 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: { | ||||
|     onCancel: () => void; | ||||
|     onFinished: () => void; | ||||
|   } & ( | ||||
|     | { backup?: false; image: DiskImage } | ||||
|     | { backup: true; disk: VMFileDisk; vm: VMInfo } | ||||
|   ) | ||||
| ): React.ReactElement { | ||||
|   const alert = useAlert(); | ||||
|   const loadingMessage = useLoadingMessage(); | ||||
|  | ||||
|   const [format, setFormat] = React.useState<DiskImageFormat>({ | ||||
|     format: "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(`${origFilename}.qcow2`); | ||||
|     if (value === "CompressedQCow2") setFilename(`${origFilename}.qcow2.gz`); | ||||
|     if (value === "Raw") { | ||||
|       setFilename(`${origFilename}.raw`); | ||||
|       // Check sparse checkbox by default | ||||
|       setFormat({ format: "Raw", is_sparse: true }); | ||||
|     } | ||||
|     if (value === "CompressedRaw") setFilename(`${origFilename}.raw.gz`); | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = async () => { | ||||
|     try { | ||||
|       loadingMessage.show( | ||||
|         p.backup ? "Performing backup..." : "Converting image..." | ||||
|       ); | ||||
|  | ||||
|       // 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(); | ||||
|  | ||||
|       alert(p.backup ? "Backup successful!" : "Conversion successful!"); | ||||
|     } catch (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(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Dialog open onClose={p.onCancel}> | ||||
|       <DialogTitle> | ||||
|         {p.backup ? `Backup disk ${p.disk.name}` : "Convert disk image"} | ||||
|       </DialogTitle> | ||||
|  | ||||
|       <DialogContent> | ||||
|         <DialogContentText> | ||||
|           Select the destination format for this image: | ||||
|         </DialogContentText> | ||||
|  | ||||
|         {/* Show details of of the image */} | ||||
|         {p.backup ? ( | ||||
|           <VMDiskFileWidget {...p} /> | ||||
|         ) : ( | ||||
|           <FileDiskImageWidget {...p} /> | ||||
|         )} | ||||
|  | ||||
|         {/* New image format */} | ||||
|         <SelectInput | ||||
|           editable | ||||
|           label="Target format" | ||||
|           value={format.format} | ||||
|           onValueChange={handleFormatChange} | ||||
|           options={[ | ||||
|             { value: "QCow2" }, | ||||
|             { value: "Raw" }, | ||||
|             { value: "CompressedRaw" }, | ||||
|             { value: "CompressedQCow2" }, | ||||
|           ]} | ||||
|         /> | ||||
|  | ||||
|         {/* Check for sparse file */} | ||||
|         {format.format === "Raw" && ( | ||||
|           <CheckboxInput | ||||
|             editable | ||||
|             label="Sparse file" | ||||
|             checked={format.is_sparse} | ||||
|             onValueChange={(c) => { | ||||
|               setFormat({ format: "Raw", is_sparse: c }); | ||||
|             }} | ||||
|           /> | ||||
|         )} | ||||
|  | ||||
|         {/* New image name */} | ||||
|         <TextInput | ||||
|           editable | ||||
|           label="New image name" | ||||
|           value={filename} | ||||
|           onValueChange={(s) => { | ||||
|             setFilename(s ?? ""); | ||||
|           }} | ||||
|           size={ServerApi.Config.constraints.disk_image_name_size} | ||||
|           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> | ||||
|           {p.backup ? "Perform backup" : "Convert image"} | ||||
|         </Button> | ||||
|       </DialogActions> | ||||
|     </Dialog> | ||||
|   ); | ||||
| } | ||||
| @@ -1,417 +0,0 @@ | ||||
| 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, | ||||
|   Button, | ||||
|   CircularProgress, | ||||
|   IconButton, | ||||
|   LinearProgress, | ||||
|   ListItemIcon, | ||||
|   ListItemText, | ||||
|   Menu, | ||||
|   MenuItem, | ||||
|   Tooltip, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| import { DataGrid, GridColDef } from "@mui/x-data-grid"; | ||||
| import { filesize } from "filesize"; | ||||
| import React from "react"; | ||||
| import { DiskImage, DiskImageApi } from "../api/DiskImageApi"; | ||||
| import { ServerApi } from "../api/ServerApi"; | ||||
| import { ConvertDiskImageDialog } from "../dialogs/ConvertDiskImageDialog"; | ||||
| import { useAlert } from "../hooks/providers/AlertDialogProvider"; | ||||
| import { useConfirm } from "../hooks/providers/ConfirmDialogProvider"; | ||||
| import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider"; | ||||
| import { useSnackbar } from "../hooks/providers/SnackbarProvider"; | ||||
| import { downloadBlob } from "../utils/FilesUtils"; | ||||
| import { AsyncWidget } from "../widgets/AsyncWidget"; | ||||
| import { DateWidget } from "../widgets/DateWidget"; | ||||
| import { FileInput } from "../widgets/forms/FileInput"; | ||||
| import { VirtWebPaper } from "../widgets/VirtWebPaper"; | ||||
| import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; | ||||
|  | ||||
| export function DiskImagesRoute(): React.ReactElement { | ||||
|   const [list, setList] = React.useState<DiskImage[] | undefined>(); | ||||
|  | ||||
|   const loadKey = React.useRef(1); | ||||
|  | ||||
|   const load = async () => { | ||||
|     setList(await DiskImageApi.GetList()); | ||||
|   }; | ||||
|  | ||||
|   const reload = () => { | ||||
|     loadKey.current += 1; | ||||
|     setList(undefined); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <VirtWebRouteContainer | ||||
|       label="Disk images management" | ||||
|       actions={ | ||||
|         <span> | ||||
|           <Tooltip title="Refresh Disk images list"> | ||||
|             <IconButton onClick={reload}> | ||||
|               <RefreshIcon /> | ||||
|             </IconButton> | ||||
|           </Tooltip> | ||||
|         </span> | ||||
|       } | ||||
|     > | ||||
|       <AsyncWidget | ||||
|         loadKey={loadKey.current} | ||||
|         errMsg="Failed to load disk images list!" | ||||
|         load={load} | ||||
|         ready={list !== undefined} | ||||
|         build={() => ( | ||||
|           <> | ||||
|             <UploadDiskImageCard onFileUploaded={reload} /> | ||||
|             <DiskImageList list={list!} onReload={reload} /> | ||||
|           </> | ||||
|         )} | ||||
|       /> | ||||
|     </VirtWebRouteContainer> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function UploadDiskImageCard(p: { | ||||
|   onFileUploaded: () => void; | ||||
| }): React.ReactElement { | ||||
|   const alert = useAlert(); | ||||
|   const snackbar = useSnackbar(); | ||||
|  | ||||
|   const [value, setValue] = React.useState<File | null>(null); | ||||
|   const [uploadProgress, setUploadProgress] = React.useState<number | null>( | ||||
|     null | ||||
|   ); | ||||
|  | ||||
|   const handleChange = (newValue: File | null) => { | ||||
|     if ( | ||||
|       newValue && | ||||
|       newValue.size > ServerApi.Config.constraints.disk_image_max_size | ||||
|     ) { | ||||
|       alert( | ||||
|         `The file is too big (max size allowed: ${filesize( | ||||
|           ServerApi.Config.constraints.disk_image_max_size | ||||
|         )}` | ||||
|       ); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if ( | ||||
|       newValue && | ||||
|       newValue.type.length > 0 && | ||||
|       !ServerApi.Config.disk_images_mimetypes.includes(newValue.type) | ||||
|     ) { | ||||
|       alert(`Selected file mimetype is not allowed! (${newValue.type})`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     setValue(newValue); | ||||
|   }; | ||||
|  | ||||
|   const upload = async () => { | ||||
|     try { | ||||
|       setUploadProgress(0); | ||||
|       await DiskImageApi.Upload(value!, setUploadProgress); | ||||
|  | ||||
|       setValue(null); | ||||
|       snackbar("The file was successfully uploaded!"); | ||||
|  | ||||
|       p.onFileUploaded(); | ||||
|     } catch (e) { | ||||
|       console.error(e); | ||||
|       await alert(`Failed to perform file upload! ${e}`); | ||||
|     } | ||||
|  | ||||
|     setUploadProgress(null); | ||||
|   }; | ||||
|  | ||||
|   if (uploadProgress !== null) { | ||||
|     return ( | ||||
|       <VirtWebPaper label="File upload" noHorizontalMargin> | ||||
|         <Typography variant="body1"> | ||||
|           Upload in progress ({Math.floor(uploadProgress * 100)}%)... | ||||
|         </Typography> | ||||
|         <LinearProgress variant="determinate" value={uploadProgress * 100} /> | ||||
|       </VirtWebPaper> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <VirtWebPaper label="Disk image upload" noHorizontalMargin> | ||||
|       <div style={{ display: "flex", alignItems: "center" }}> | ||||
|         <FileInput | ||||
|           value={value} | ||||
|           onChange={handleChange} | ||||
|           style={{ flex: 1 }} | ||||
|           slotProps={{ | ||||
|             htmlInput: { | ||||
|               accept: ServerApi.Config.disk_images_mimetypes.join(","), | ||||
|             }, | ||||
|           }} | ||||
|         /> | ||||
|  | ||||
|         {value && <Button onClick={upload}>Upload</Button>} | ||||
|       </div> | ||||
|     </VirtWebPaper> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function DiskImageList(p: { | ||||
|   list: DiskImage[]; | ||||
|   onReload: () => void; | ||||
| }): React.ReactElement { | ||||
|   const alert = useAlert(); | ||||
|   const snackbar = useSnackbar(); | ||||
|   const confirm = useConfirm(); | ||||
|   const loadingMessage = useLoadingMessage(); | ||||
|  | ||||
|   const [dlProgress, setDlProgress] = React.useState<undefined | number>(); | ||||
|  | ||||
|   const [currConversion, setCurrConversion] = React.useState< | ||||
|     DiskImage | undefined | ||||
|   >(); | ||||
|  | ||||
|   // Download disk image file | ||||
|   const downloadDiskImage = async (entry: DiskImage) => { | ||||
|     setDlProgress(0); | ||||
|  | ||||
|     try { | ||||
|       const blob = await DiskImageApi.Download(entry, setDlProgress); | ||||
|  | ||||
|       downloadBlob(blob, entry.file_name); | ||||
|     } catch (e) { | ||||
|       console.error(e); | ||||
|       alert(`Failed to download disk image file! ${e}`); | ||||
|     } | ||||
|  | ||||
|     setDlProgress(undefined); | ||||
|   }; | ||||
|  | ||||
|   // Convert disk image file | ||||
|   const convertDiskImage = (entry: DiskImage) => { | ||||
|     setCurrConversion(entry); | ||||
|   }; | ||||
|  | ||||
|   // Delete disk image | ||||
|   const deleteDiskImage = async (entry: DiskImage) => { | ||||
|     if ( | ||||
|       !(await confirm( | ||||
|         `Do you really want to delete this disk image (${entry.file_name}) ?` | ||||
|       )) | ||||
|     ) | ||||
|       return; | ||||
|  | ||||
|     loadingMessage.show("Deleting disk image file..."); | ||||
|  | ||||
|     try { | ||||
|       await DiskImageApi.Delete(entry); | ||||
|       snackbar("The disk image has been successfully deleted!"); | ||||
|       p.onReload(); | ||||
|     } catch (e) { | ||||
|       console.error(e); | ||||
|       alert(`Failed to delete disk image!\n${e}`); | ||||
|     } | ||||
|  | ||||
|     loadingMessage.hide(); | ||||
|   }; | ||||
|  | ||||
|   if (p.list.length === 0) | ||||
|     return ( | ||||
|       <Typography variant="body1" style={{ textAlign: "center" }}> | ||||
|         No disk image uploaded for now. | ||||
|       </Typography> | ||||
|     ); | ||||
|  | ||||
|   const columns: GridColDef<(typeof p.list)[number]>[] = [ | ||||
|     { field: "file_name", headerName: "File name", flex: 3, editable: true }, | ||||
|     { | ||||
|       field: "format", | ||||
|       headerName: "Format", | ||||
|       flex: 1, | ||||
|       renderCell(params) { | ||||
|         let content = params.row.format; | ||||
|  | ||||
|         if (params.row.format === "Raw") { | ||||
|           content += params.row.is_sparse ? " (Sparse)" : " (Fixed)"; | ||||
|         } | ||||
|  | ||||
|         return content; | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "file_size", | ||||
|       headerName: "File size", | ||||
|       flex: 1, | ||||
|       renderCell(params) { | ||||
|         let res = filesize(params.row.file_size); | ||||
|  | ||||
|         if (params.row.format === "QCow2") { | ||||
|           res += ` (${filesize(params.row.virtual_size!)})`; | ||||
|         } | ||||
|  | ||||
|         return res; | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "created", | ||||
|       headerName: "Created", | ||||
|       flex: 1, | ||||
|       renderCell(params) { | ||||
|         return <DateWidget time={params.row.created} />; | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: "actions", | ||||
|       type: "actions", | ||||
|       headerName: "", | ||||
|       width: 55, | ||||
|       cellClassName: "actions", | ||||
|       editable: false, | ||||
|       getActions: (params) => { | ||||
|         return [ | ||||
|           <DiskImageActionMenu | ||||
|             key="menu" | ||||
|             diskImage={params.row} | ||||
|             onDownload={downloadDiskImage} | ||||
|             onConvert={convertDiskImage} | ||||
|             onDelete={deleteDiskImage} | ||||
|           />, | ||||
|         ]; | ||||
|       }, | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {/* Download notification */} | ||||
|       {dlProgress !== undefined && ( | ||||
|         <Alert severity="info"> | ||||
|           <div | ||||
|             style={{ | ||||
|               display: "flex", | ||||
|               flexDirection: "row", | ||||
|               alignItems: "center", | ||||
|               overflow: "hidden", | ||||
|             }} | ||||
|           > | ||||
|             <Typography variant="body1"> | ||||
|               Downloading... {dlProgress}% | ||||
|             </Typography> | ||||
|             <CircularProgress | ||||
|               variant="determinate" | ||||
|               size={"1.5rem"} | ||||
|               style={{ marginLeft: "10px" }} | ||||
|               value={dlProgress} | ||||
|             /> | ||||
|           </div> | ||||
|         </Alert> | ||||
|       )} | ||||
|  | ||||
|       {/* Disk image conversion dialog */} | ||||
|       {currConversion && ( | ||||
|         <ConvertDiskImageDialog | ||||
|           image={currConversion} | ||||
|           onCancel={() => { | ||||
|             setCurrConversion(undefined); | ||||
|           }} | ||||
|           onFinished={() => { | ||||
|             setCurrConversion(undefined); | ||||
|             p.onReload(); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       {/* The table itself */} | ||||
|       <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> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -3,15 +3,7 @@ import { RouterLink } from "../widgets/RouterLink"; | ||||
|  | ||||
| export function NotFoundRoute(): React.ReactElement { | ||||
|   return ( | ||||
|     <div | ||||
|       style={{ | ||||
|         textAlign: "center", | ||||
|         flex: 1, | ||||
|         justifyContent: "center", | ||||
|         display: "flex", | ||||
|         flexDirection: "column", | ||||
|       }} | ||||
|     > | ||||
|     <div style={{ textAlign: "center" }}> | ||||
|       <h1>404 Not found</h1> | ||||
|       <p>The page you requested was not found!</p> | ||||
|       <RouterLink to="/"> | ||||
|   | ||||
| @@ -10,7 +10,6 @@ import { | ||||
|   TableContainer, | ||||
|   TableHead, | ||||
|   TableRow, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| @@ -59,78 +58,70 @@ export function TokensListRouteInner(p: { | ||||
|         </RouterLink> | ||||
|       } | ||||
|     > | ||||
|       {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> | ||||
|       <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> | ||||
|       )} | ||||
|  | ||||
|       {p.list.length === 0 && ( | ||||
|         <Typography style={{ textAlign: "center" }}> | ||||
|           No API token created yet. | ||||
|         </Typography> | ||||
|       )} | ||||
|                   <TableCell> | ||||
|                     <RouterLink to={APITokenURL(t)}> | ||||
|                       <IconButton> | ||||
|                         <VisibilityIcon /> | ||||
|                       </IconButton> | ||||
|                     </RouterLink> | ||||
|                   </TableCell> | ||||
|                 </TableRow> | ||||
|               ); | ||||
|             })} | ||||
|           </TableBody> | ||||
|         </Table> | ||||
|       </TableContainer> | ||||
|     </VirtWebRouteContainer> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -154,7 +154,7 @@ function VMListWidget(p: { | ||||
|                         {row.name} | ||||
|                       </TableCell> | ||||
|                       <TableCell>{row.description ?? ""}</TableCell> | ||||
|                       <TableCell>{filesize(row.memory)}</TableCell> | ||||
|                       <TableCell>{vmMemoryToHuman(row.memory)}</TableCell> | ||||
|                       <TableCell>{row.number_vcpu}</TableCell> | ||||
|                       <TableCell> | ||||
|                         <VMStatusWidget | ||||
| @@ -183,13 +183,13 @@ function VMListWidget(p: { | ||||
|             <TableCell></TableCell> | ||||
|             <TableCell></TableCell> | ||||
|             <TableCell> | ||||
|               {filesize( | ||||
|               {vmMemoryToHuman( | ||||
|                 p.list | ||||
|                   .filter((v) => runningVMs.has(v.name)) | ||||
|                   .reduce((s, v) => s + v.memory, 0) | ||||
|               )} | ||||
|               {" / "} | ||||
|               {filesize(p.list.reduce((s, v) => s + v.memory, 0))} | ||||
|               {vmMemoryToHuman(p.list.reduce((s, v) => s + v.memory, 0))} | ||||
|             </TableCell> | ||||
|             <TableCell> | ||||
|               {p.list | ||||
| @@ -206,3 +206,7 @@ function VMListWidget(p: { | ||||
|     </TableContainer> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function vmMemoryToHuman(size: number): string { | ||||
|   return filesize(size * 1000 * 1000); | ||||
| } | ||||
|   | ||||
| @@ -59,7 +59,6 @@ function VMRouteBody(p: { vm: VMInfo }): React.ReactElement { | ||||
|       <VMDetails | ||||
|         vm={p.vm} | ||||
|         editable={false} | ||||
|         state={state} | ||||
|         screenshot={p.vm.vnc_access && state === "Running"} | ||||
|       /> | ||||
|     </VirtWebRouteContainer> | ||||
|   | ||||
| @@ -2,7 +2,6 @@ import { | ||||
|   mdiApi, | ||||
|   mdiBoxShadow, | ||||
|   mdiDisc, | ||||
|   mdiHarddisk, | ||||
|   mdiHome, | ||||
|   mdiInformation, | ||||
|   mdiLan, | ||||
| @@ -67,11 +66,6 @@ export function BaseAuthenticatedPage(): React.ReactElement { | ||||
|             uri="/nwfilter" | ||||
|             icon={<Icon path={mdiSecurityNetwork} size={1} />} | ||||
|           /> | ||||
|           <NavLink | ||||
|             label="Disk images" | ||||
|             uri="/disk_images" | ||||
|             icon={<Icon path={mdiHarddisk} size={1} />} | ||||
|           /> | ||||
|           <NavLink | ||||
|             label="ISO files" | ||||
|             uri="/iso" | ||||
|   | ||||
| @@ -1,13 +0,0 @@ | ||||
| export function DateWidget(p: { time: number }): React.ReactElement { | ||||
|   const date = new Date(p.time * 1000); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {pad(date.getDate())}/{pad(date.getMonth() + 1)}/{date.getFullYear()} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function pad(num: number): string { | ||||
|   return num.toString().padStart(2, "0"); | ||||
| } | ||||
| @@ -1,23 +0,0 @@ | ||||
| import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material"; | ||||
| import { DiskImage } from "../api/DiskImageApi"; | ||||
| import { mdiHarddisk } from "@mdi/js"; | ||||
| import { filesize } from "filesize"; | ||||
| import Icon from "@mdi/react"; | ||||
|  | ||||
| export function FileDiskImageWidget(p: { | ||||
|   image: DiskImage; | ||||
| }): React.ReactElement { | ||||
|   return ( | ||||
|     <ListItem> | ||||
|       <ListItemAvatar> | ||||
|         <Avatar> | ||||
|           <Icon path={mdiHarddisk} /> | ||||
|         </Avatar> | ||||
|       </ListItemAvatar> | ||||
|       <ListItemText | ||||
|         primary={p.image.file_name} | ||||
|         secondary={`${p.image.format} - ${filesize(p.image.file_size)}`} | ||||
|       /> | ||||
|     </ListItem> | ||||
|   ); | ||||
| } | ||||
| @@ -1,24 +0,0 @@ | ||||
| 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); }} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| @@ -1,40 +0,0 @@ | ||||
| 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> | ||||
|   ); | ||||
| } | ||||
| @@ -35,7 +35,7 @@ export function FileInput( | ||||
|               <InputAdornment position="start"> | ||||
|                 <AttachFileIcon /> | ||||
|                    | ||||
|                 {p.value ? p.value.name : "Select a file"} | ||||
|                 {p.value ? p.value.name : "Insert a file"} | ||||
|               </InputAdornment> | ||||
|             </> | ||||
|           ), | ||||
|   | ||||
| @@ -17,11 +17,8 @@ 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 <></>; | ||||
|  | ||||
| @@ -31,18 +28,12 @@ export function SelectInput(p: { | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <FormControl | ||||
|       fullWidth | ||||
|       variant="standard" | ||||
|       style={{ marginBottom: p.disableBottomMargin ? "0px" : "15px" }} | ||||
|     > | ||||
|     <FormControl fullWidth variant="standard" style={{ marginBottom: "15px" }}> | ||||
|       {p.label && <InputLabel>{p.label}</InputLabel>} | ||||
|       <Select | ||||
|         {...p} | ||||
|         value={p.value ?? ""} | ||||
|         onChange={(e) => { | ||||
|           p.onValueChange(e.target.value); | ||||
|         }} | ||||
|         label={p.label} | ||||
|         onChange={(e) => { p.onValueChange(e.target.value); }} | ||||
|       > | ||||
|         {p.options.map((e) => ( | ||||
|           <MenuItem | ||||
|   | ||||
| @@ -17,7 +17,6 @@ export function TextInput(p: { | ||||
|   type?: React.HTMLInputTypeAttribute; | ||||
|   style?: React.CSSProperties; | ||||
|   helperText?: string; | ||||
|   disabled?: boolean; | ||||
| }): React.ReactElement { | ||||
|   if (!p.editable && (p.value ?? "") === "") return <></>; | ||||
|  | ||||
| @@ -36,7 +35,6 @@ export function TextInput(p: { | ||||
|  | ||||
|   return ( | ||||
|     <TextField | ||||
|       disabled={p.disabled} | ||||
|       label={p.label} | ||||
|       value={p.value ?? ""} | ||||
|       onChange={(e) => | ||||
|   | ||||
| @@ -1,37 +1,33 @@ | ||||
| import { mdiHarddiskPlus } from "@mdi/js"; | ||||
| import { mdiHarddisk } from "@mdi/js"; | ||||
| import Icon from "@mdi/react"; | ||||
| import CheckCircleIcon from "@mui/icons-material/CheckCircle"; | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import { Button, IconButton, Paper, Tooltip } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { DiskImage } from "../../api/DiskImageApi"; | ||||
| import { | ||||
|   Avatar, | ||||
|   Button, | ||||
|   IconButton, | ||||
|   ListItem, | ||||
|   ListItemAvatar, | ||||
|   ListItemText, | ||||
|   Paper, | ||||
|   Tooltip, | ||||
| } from "@mui/material"; | ||||
| import { filesize } from "filesize"; | ||||
| import { ServerApi } from "../../api/ServerApi"; | ||||
| import { VMFileDisk, VMInfo, VMState } from "../../api/VMApi"; | ||||
| import { ConvertDiskImageDialog } from "../../dialogs/ConvertDiskImageDialog"; | ||||
| import { VMFileDisk, VMInfo } from "../../api/VMApi"; | ||||
| 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", | ||||
|       size: 10000, | ||||
|       delete: false, | ||||
|       name: `disk${p.vm.file_disks.length}`, | ||||
|       new: true, | ||||
| @@ -39,14 +35,6 @@ export function VMDisksList(p: { | ||||
|     p.onChange?.(); | ||||
|   }; | ||||
|  | ||||
|   const handleBackupRequest = (disk: VMFileDisk) => { | ||||
|     setCurrBackupRequest(disk); | ||||
|   }; | ||||
|  | ||||
|   const handleFinishBackup = () => { | ||||
|     setCurrBackupRequest(undefined); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {/* disks list */} | ||||
| @@ -55,42 +43,25 @@ 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 () => { | ||||
| @@ -115,42 +86,50 @@ function DiskInfo(p: { | ||||
|  | ||||
|   if (!p.editable || !p.disk.new) | ||||
|     return ( | ||||
|       <VMDiskFileWidget | ||||
|         {...p} | ||||
|       <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.canBackup && ( | ||||
|               <Tooltip title="Backup this disk"> | ||||
|                 <IconButton | ||||
|                   onClick={() => { | ||||
|                     p.onRequestBackup(p.disk); | ||||
|                   }} | ||||
|                 > | ||||
|                   <Icon path={mdiHarddiskPlus} size={1} /> | ||||
|                 </IconButton> | ||||
|               </Tooltip> | ||||
|             )} | ||||
|           </> | ||||
|           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> | ||||
|           ) | ||||
|         } | ||||
|       /> | ||||
|       > | ||||
|         <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 * 1000 * 1000)} - ${ | ||||
|             p.disk.format | ||||
|           }${p.disk.format == "Raw" ? " - " + p.disk.alloc_type : ""}`} | ||||
|         /> | ||||
|       </ListItem> | ||||
|     ); | ||||
|  | ||||
|   return ( | ||||
| @@ -172,6 +151,18 @@ function DiskInfo(p: { | ||||
|         </IconButton> | ||||
|       </div> | ||||
|  | ||||
|       <TextInput | ||||
|         editable={true} | ||||
|         label="Disk size (MB)" | ||||
|         size={ServerApi.Config.constraints.disk_size} | ||||
|         value={p.disk.size.toString()} | ||||
|         onValueChange={(v) => { | ||||
|           p.disk.size = Number(v ?? "0"); | ||||
|           p.onChange?.(); | ||||
|         }} | ||||
|         type="number" | ||||
|       /> | ||||
|  | ||||
|       <SelectInput | ||||
|         editable={true} | ||||
|         label="Disk format" | ||||
| @@ -182,63 +173,25 @@ function DiskInfo(p: { | ||||
|         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} | ||||
|         <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.is_sparse = v; | ||||
|             if (p.disk.format === "Raw") p.disk.alloc_type = v as any; | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       <TextInput | ||||
|         editable={true} | ||||
|         label="Disk size (GB)" | ||||
|         size={{ | ||||
|           min: | ||||
|             ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000), | ||||
|           max: | ||||
|             ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000), | ||||
|         }} | ||||
|         value={(p.disk.size / (1000 * 1000 * 1000)).toString()} | ||||
|         onValueChange={(v) => { | ||||
|           p.disk.size = Number(v ?? "0") * 1000 * 1000 * 1000; | ||||
|           p.onChange?.(); | ||||
|         }} | ||||
|         type="number" | ||||
|         disabled={!!p.disk.from_image} | ||||
|       /> | ||||
|  | ||||
|       <DiskImageSelect | ||||
|         label="Use disk image as template" | ||||
|         list={p.diskImagesList} | ||||
|         value={p.disk.from_image} | ||||
|         onValueChange={(v) => { | ||||
|           p.disk.from_image = v; | ||||
|           p.onChange?.(); | ||||
|         }} | ||||
|       /> | ||||
|     </Paper> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -35,7 +35,6 @@ export function VMNetworksList(p: { | ||||
|   const addNew = () => { | ||||
|     p.vm.networks.push({ | ||||
|       type: "UserspaceSLIRPStack", | ||||
|       model: "Virtio", | ||||
|       mac: randomMacAddress(ServerApi.Config.net_mac_prefix), | ||||
|     }); | ||||
|     p.onChange?.(); | ||||
| @@ -147,7 +146,6 @@ function NetworkInfoWidget(p: { | ||||
|         /> | ||||
|       </ListItem> | ||||
|       <div style={{ marginLeft: "70px" }}> | ||||
|         {/* MAC address input */} | ||||
|         <MACInput | ||||
|           editable={p.editable} | ||||
|           label="MAC Address" | ||||
| @@ -158,26 +156,6 @@ 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,7 +1,6 @@ | ||||
| import { | ||||
|   Checkbox, | ||||
|   FormControlLabel, | ||||
|   Grid, | ||||
|   Paper, | ||||
|   Table, | ||||
|   TableBody, | ||||
| @@ -60,7 +59,6 @@ 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> | ||||
| @@ -84,10 +82,6 @@ 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 */} | ||||
| @@ -123,14 +117,6 @@ 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> | ||||
|             ))} | ||||
| @@ -683,73 +669,34 @@ export function TokenRightsEditor(p: { | ||||
|         </Table> | ||||
|       </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> | ||||
|       {/* 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> | ||||
|  | ||||
|       {/* 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, VMState } from "../../api/VMApi"; | ||||
| import { VMApi, VMInfo } from "../../api/VMApi"; | ||||
| import { useAlert } from "../../hooks/providers/AlertDialogProvider"; | ||||
| import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider"; | ||||
| import { useSnackbar } from "../../hooks/providers/SnackbarProvider"; | ||||
| @@ -27,21 +27,16 @@ 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< | ||||
| @@ -56,7 +51,6 @@ 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()); | ||||
| @@ -72,7 +66,6 @@ export function VMDetails(p: DetailsProps): React.ReactElement { | ||||
|       build={() => ( | ||||
|         <VMDetailsInner | ||||
|           groupsList={groupsList!} | ||||
|           diskImagesList={diskImagesList!} | ||||
|           isoList={isoList!} | ||||
|           bridgesList={bridgesList!} | ||||
|           vcpuCombinations={vcpuCombinations!} | ||||
| @@ -96,7 +89,6 @@ enum VMTab { | ||||
|  | ||||
| type DetailsInnerProps = DetailsProps & { | ||||
|   groupsList: string[]; | ||||
|   diskImagesList: DiskImage[]; | ||||
|   isoList: IsoFile[]; | ||||
|   bridgesList: string[]; | ||||
|   vcpuCombinations: number[]; | ||||
| @@ -280,7 +272,6 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement { | ||||
|           options={[ | ||||
|             { label: "UEFI with Secure Boot", value: "UEFISecureBoot" }, | ||||
|             { label: "UEFI", value: "UEFI" }, | ||||
|             { label: "Legacy", value: "Legacy" }, | ||||
|           ]} | ||||
|         /> | ||||
|  | ||||
| @@ -288,16 +279,14 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement { | ||||
|           label="Memory (MB)" | ||||
|           editable={p.editable} | ||||
|           type="number" | ||||
|           value={Math.floor(p.vm.memory / (1000 * 1000)).toString()} | ||||
|           value={p.vm.memory.toString()} | ||||
|           onValueChange={(v) => { | ||||
|             p.vm.memory = Number(v ?? "0") * 1000 * 1000; | ||||
|             p.vm.memory = Number(v ?? "0"); | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|           checkValue={(v) => | ||||
|             Number(v) > | ||||
|               ServerApi.Config.constraints.memory_size.min / (1000 * 1000) && | ||||
|             Number(v) < | ||||
|               ServerApi.Config.constraints.memory_size.max / (1000 * 1000) | ||||
|             Number(v) > ServerApi.Config.constraints.memory_size.min && | ||||
|             Number(v) < ServerApi.Config.constraints.memory_size.max | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|   | ||||
| @@ -1,72 +0,0 @@ | ||||
| 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