Compare commits
	
		
			11 Commits
		
	
	
		
			a314e6b41f
			...
			ee44ad4311
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ee44ad4311 | |||
| 0de15af10e | |||
| d4bc92f562 | |||
| a1439689dd | |||
| 63126c75fa | |||
| 940302a825 | |||
| 9c374f849b | |||
| 2fa4d0e11b | |||
| d7796e1459 | |||
| 759361d9f6 | |||
| b2529c250a | 
							
								
								
									
										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" | ||||
|   | ||||
| @@ -137,6 +137,9 @@ pub const PROGRAM_COPY: &str = "/bin/cp"; | ||||
| /// Gzip program path | ||||
| pub const PROGRAM_GZIP: &str = "/usr/bin/gzip"; | ||||
|  | ||||
| /// XZ program path | ||||
| pub const PROGRAM_XZ: &str = "/usr/bin/xz"; | ||||
|  | ||||
| /// Bash program | ||||
| pub const PROGRAM_BASH: &str = "/usr/bin/bash"; | ||||
|  | ||||
|   | ||||
| @@ -28,8 +28,10 @@ pub enum DiskFileFormat { | ||||
|         #[serde(default)] | ||||
|         virtual_size: FileSize, | ||||
|     }, | ||||
|     CompressedRaw, | ||||
|     CompressedQCow2, | ||||
|     GzCompressedRaw, | ||||
|     GzCompressedQCow2, | ||||
|     XzCompressedRaw, | ||||
|     XzCompressedQCow2, | ||||
| } | ||||
|  | ||||
| impl DiskFileFormat { | ||||
| @@ -37,8 +39,10 @@ impl DiskFileFormat { | ||||
|         match self { | ||||
|             DiskFileFormat::Raw { .. } => &["raw", ""], | ||||
|             DiskFileFormat::QCow2 { .. } => &["qcow2"], | ||||
|             DiskFileFormat::CompressedRaw => &["raw.gz"], | ||||
|             DiskFileFormat::CompressedQCow2 => &["qcow2.gz"], | ||||
|             DiskFileFormat::GzCompressedRaw => &["raw.gz"], | ||||
|             DiskFileFormat::GzCompressedQCow2 => &["qcow2.gz"], | ||||
|             DiskFileFormat::XzCompressedRaw => &["raw.xz"], | ||||
|             DiskFileFormat::XzCompressedQCow2 => &["qcow2.xz"], | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -81,9 +85,14 @@ impl DiskFileInfo { | ||||
|             }, | ||||
|             "gz" if name.ends_with(".qcow2") => { | ||||
|                 name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string(); | ||||
|                 DiskFileFormat::CompressedQCow2 | ||||
|                 DiskFileFormat::GzCompressedQCow2 | ||||
|             } | ||||
|             "gz" => DiskFileFormat::CompressedRaw, | ||||
|             "gz" => DiskFileFormat::GzCompressedRaw, | ||||
|             "xz" if name.ends_with(".qcow2") => { | ||||
|                 name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string(); | ||||
|                 DiskFileFormat::XzCompressedQCow2 | ||||
|             } | ||||
|             "xz" => DiskFileFormat::XzCompressedRaw, | ||||
|             _ => anyhow::bail!("Unsupported disk extension: {ext}!"), | ||||
|         }; | ||||
|  | ||||
| @@ -159,8 +168,8 @@ impl DiskFileInfo { | ||||
|  | ||||
|         // Prepare the conversion | ||||
|         let mut cmd = match (self.format, dest_format) { | ||||
|             // Decompress QCow2 | ||||
|             (DiskFileFormat::CompressedQCow2, DiskFileFormat::QCow2 { .. }) => { | ||||
|             // Decompress QCow2 (GZIP) | ||||
|             (DiskFileFormat::GzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_GZIP); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--decompress") | ||||
| @@ -170,8 +179,19 @@ impl DiskFileInfo { | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Compress QCow2 | ||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::CompressedQCow2) => { | ||||
|             // Decompress QCow2 (XZ) | ||||
|             (DiskFileFormat::XzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_XZ); | ||||
|                 cmd.arg("--stdout") | ||||
|                     .arg("--keep") | ||||
|                     .arg("--decompress") | ||||
|                     .arg(&self.file_path) | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Compress QCow2 (Gzip) | ||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::GzCompressedQCow2) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_GZIP); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--to-stdout") | ||||
| @@ -180,6 +200,16 @@ impl DiskFileInfo { | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Compress QCow2 (Xz) | ||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::XzCompressedQCow2) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_XZ); | ||||
|                 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::PROGRAM_QEMU_IMAGE); | ||||
| @@ -244,8 +274,8 @@ impl DiskFileInfo { | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Compress Raw | ||||
|             (DiskFileFormat::Raw { .. }, DiskFileFormat::CompressedRaw) => { | ||||
|             // Compress Raw (Gz) | ||||
|             (DiskFileFormat::Raw { .. }, DiskFileFormat::GzCompressedRaw) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_GZIP); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--to-stdout") | ||||
| @@ -254,8 +284,18 @@ impl DiskFileInfo { | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Decompress Raw to not sparse file | ||||
|             (DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => { | ||||
|             // Compress Raw (Xz) | ||||
|             (DiskFileFormat::Raw { .. }, DiskFileFormat::XzCompressedRaw) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_XZ); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--to-stdout") | ||||
|                     .arg(&self.file_path) | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Decompress Raw (Gz) to not sparse file | ||||
|             (DiskFileFormat::GzCompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_GZIP); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--decompress") | ||||
| @@ -264,13 +304,23 @@ impl DiskFileInfo { | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|             // Decompress Raw (Xz) to not sparse file | ||||
|             (DiskFileFormat::XzCompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_XZ); | ||||
|                 cmd.arg("--keep") | ||||
|                     .arg("--decompress") | ||||
|                     .arg("--to-stdout") | ||||
|                     .arg(&self.file_path) | ||||
|                     .stdout(File::create(&temp_file)?); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Decompress Raw to sparse file | ||||
|             // Decompress Raw (Gz) to sparse file | ||||
|             // https://benou.fr/www/ben/decompressing-sparse-files.html | ||||
|             (DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => { | ||||
|             (DiskFileFormat::GzCompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_BASH); | ||||
|                 cmd.arg("-c").arg(format!( | ||||
|                     "{} -d -c {} | {} conv=sparse of={}", | ||||
|                     "{} --decompress --to-stdout {} | {} conv=sparse of={}", | ||||
|                     constants::PROGRAM_GZIP, | ||||
|                     self.file_path.display(), | ||||
|                     constants::PROGRAM_DD, | ||||
| @@ -279,6 +329,20 @@ impl DiskFileInfo { | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Decompress Raw (XZ) to sparse file | ||||
|             // https://benou.fr/www/ben/decompressing-sparse-files.html | ||||
|             (DiskFileFormat::XzCompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_BASH); | ||||
|                 cmd.arg("-c").arg(format!( | ||||
|                     "{} --decompress --to-stdout {} | {} conv=sparse of={}", | ||||
|                     constants::PROGRAM_XZ, | ||||
|                     self.file_path.display(), | ||||
|                     constants::PROGRAM_DD, | ||||
|                     temp_file.display() | ||||
|                 )); | ||||
|                 cmd | ||||
|             } | ||||
|  | ||||
|             // Dumb copy of file | ||||
|             (a, b) if a == b => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_COPY); | ||||
| @@ -330,6 +394,44 @@ impl DiskFileInfo { | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Get disk virtual size, if available | ||||
|     pub fn virtual_size(&self) -> Option<FileSize> { | ||||
|         match self.format { | ||||
|             DiskFileFormat::Raw { .. } => Some(self.file_size), | ||||
|             DiskFileFormat::QCow2 { virtual_size } => Some(virtual_size), | ||||
|             _ => None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Resize disk | ||||
|     pub fn resize(&self, new_size: FileSize) -> anyhow::Result<()> { | ||||
|         if new_size <= self.virtual_size().unwrap_or(new_size) { | ||||
|             anyhow::bail!("Shrinking disk image file is not supported!"); | ||||
|         } | ||||
|  | ||||
|         let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); | ||||
|         cmd.arg("resize") | ||||
|             .arg("-f") | ||||
|             .arg(match self.format { | ||||
|                 DiskFileFormat::QCow2 { .. } => "qcow2", | ||||
|                 DiskFileFormat::Raw { .. } => "raw", | ||||
|                 f => anyhow::bail!("Unsupported disk format for resize: {f:?}"), | ||||
|             }) | ||||
|             .arg(&self.file_path) | ||||
|             .arg(new_size.as_bytes().to_string()); | ||||
|  | ||||
|         let output = cmd.output()?; | ||||
|         if !output.status.success() { | ||||
|             anyhow::bail!( | ||||
|                 "{} info failed, status: {}, stderr: {}", | ||||
|                 constants::PROGRAM_QEMU_IMAGE, | ||||
|                 output.status, | ||||
|                 String::from_utf8_lossy(&output.stderr) | ||||
|             ); | ||||
|         } | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
|   | ||||
| @@ -44,6 +44,9 @@ pub struct VMFileDisk { | ||||
|     /// 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 resize disk image | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub resize: Option<bool>, | ||||
|     /// Set this variable to true to delete the disk | ||||
|     pub delete: bool, | ||||
| } | ||||
| @@ -78,6 +81,7 @@ impl VMFileDisk { | ||||
|  | ||||
|             delete: false, | ||||
|             from_image: None, | ||||
|             resize: None, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
| @@ -144,28 +148,40 @@ impl VMFileDisk { | ||||
|  | ||||
|         if file.exists() { | ||||
|             log::debug!("File {file:?} does not exists, so it was not touched"); | ||||
|             return Ok(()); | ||||
|         } | ||||
|         // Create disk if required | ||||
|         else { | ||||
|             // Determine file format | ||||
|             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)?; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         let format = match self.format { | ||||
|             VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse }, | ||||
|             VMDiskFormat::QCow2 => DiskFileFormat::QCow2 { | ||||
|                 virtual_size: self.size, | ||||
|             }, | ||||
|         }; | ||||
|         // Resize disk file if requested | ||||
|         if self.resize == Some(true) { | ||||
|             let disk = DiskFileInfo::load_file(&file)?; | ||||
|  | ||||
|         // 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)?; | ||||
|             // Can only increase disk size | ||||
|             if let Err(e) = disk.resize(self.size) { | ||||
|                 log::error!("Failed to resize disk file {}: {e:?}", self.name); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -4,8 +4,10 @@ import { VMFileDisk, VMInfo } from "./VMApi"; | ||||
| export type DiskImageFormat = | ||||
|   | { format: "Raw"; is_sparse: boolean } | ||||
|   | { format: "QCow2"; virtual_size?: number } | ||||
|   | { format: "CompressedQCow2" } | ||||
|   | { format: "CompressedRaw" }; | ||||
|   | { format: "GzCompressedQCow2" } | ||||
|   | { format: "GzCompressedRaw" } | ||||
|   | { format: "XzCompressedQCow2" } | ||||
|   | { format: "XzCompressedRaw" }; | ||||
|  | ||||
| export type DiskImage = { | ||||
|   file_size: number; | ||||
|   | ||||
| @@ -31,8 +31,12 @@ export interface BaseFileVMDisk { | ||||
|   // For new disk only | ||||
|   from_image?: string; | ||||
|  | ||||
|   // Resize disk image after clone | ||||
|   resize?: boolean; | ||||
|  | ||||
|   // application attributes | ||||
|   new?: boolean; | ||||
|   originalSize?: number; | ||||
|   deleteType?: "keepfile" | "deletefile"; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -42,13 +42,15 @@ export function ConvertDiskImageDialog( | ||||
|     setFormat({ format: value ?? ("QCow2" as any) }); | ||||
|  | ||||
|     if (value === "QCow2") setFilename(`${origFilename}.qcow2`); | ||||
|     if (value === "CompressedQCow2") setFilename(`${origFilename}.qcow2.gz`); | ||||
|     if (value === "GzCompressedQCow2") setFilename(`${origFilename}.qcow2.gz`); | ||||
|     if (value === "XzCompressedQCow2") setFilename(`${origFilename}.qcow2.xz`); | ||||
|     if (value === "Raw") { | ||||
|       setFilename(`${origFilename}.raw`); | ||||
|       // Check sparse checkbox by default | ||||
|       setFormat({ format: "Raw", is_sparse: true }); | ||||
|     } | ||||
|     if (value === "CompressedRaw") setFilename(`${origFilename}.raw.gz`); | ||||
|     if (value === "GzCompressedRaw") setFilename(`${origFilename}.raw.gz`); | ||||
|     if (value === "XzCompressedRaw") setFilename(`${origFilename}.raw.xz`); | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = async () => { | ||||
| @@ -104,8 +106,10 @@ export function ConvertDiskImageDialog( | ||||
|           options={[ | ||||
|             { value: "QCow2" }, | ||||
|             { value: "Raw" }, | ||||
|             { value: "CompressedRaw" }, | ||||
|             { value: "CompressedQCow2" }, | ||||
|             { value: "GzCompressedRaw" }, | ||||
|             { value: "XzCompressedRaw" }, | ||||
|             { value: "GzCompressedQCow2" }, | ||||
|             { value: "XzCompressedQCow2" }, | ||||
|           ]} | ||||
|         /> | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import VisibilityIcon from '@mui/icons-material/Visibility'; | ||||
| import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; | ||||
| import VisibilityIcon from "@mui/icons-material/Visibility"; | ||||
| import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; | ||||
| import { | ||||
|   Alert, | ||||
|   CircularProgress, | ||||
| @@ -36,7 +36,9 @@ export function LoginRoute(): React.ReactElement { | ||||
|   const canSubmit = username.length > 0 && password.length > 0; | ||||
|  | ||||
|   const [showPassword, setShowPassword] = React.useState(false); | ||||
|   const handleClickShowPassword = () => { setShowPassword((show) => !show); }; | ||||
|   const handleClickShowPassword = () => { | ||||
|     setShowPassword((show) => !show); | ||||
|   }; | ||||
|  | ||||
|   const handleMouseDownPassword = ( | ||||
|     event: React.MouseEvent<HTMLButtonElement> | ||||
| @@ -105,12 +107,14 @@ export function LoginRoute(): React.ReactElement { | ||||
|               label="Username" | ||||
|               name="username" | ||||
|               value={username} | ||||
|               onChange={(e) => { setUsername(e.target.value); }} | ||||
|               onChange={(e) => { | ||||
|                 setUsername(e.target.value); | ||||
|               }} | ||||
|               autoComplete="username" | ||||
|               autoFocus | ||||
|             /> | ||||
|  | ||||
|             <FormControl fullWidth variant="outlined"> | ||||
|             <FormControl required fullWidth variant="outlined"> | ||||
|               <InputLabel htmlFor="password">Password</InputLabel> | ||||
|               <OutlinedInput | ||||
|                 required | ||||
| @@ -120,7 +124,9 @@ export function LoginRoute(): React.ReactElement { | ||||
|                 type={showPassword ? "text" : "password"} | ||||
|                 id="password" | ||||
|                 value={password} | ||||
|                 onChange={(e) => { setPassword(e.target.value); }} | ||||
|                 onChange={(e) => { | ||||
|                   setPassword(e.target.value); | ||||
|                 }} | ||||
|                 autoComplete="current-password" | ||||
|                 endAdornment={ | ||||
|                   <InputAdornment position="end"> | ||||
| @@ -131,7 +137,11 @@ export function LoginRoute(): React.ReactElement { | ||||
|                         onMouseDown={handleMouseDownPassword} | ||||
|                         edge="end" | ||||
|                       > | ||||
|                         {showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />} | ||||
|                         {showPassword ? ( | ||||
|                           <VisibilityOffIcon /> | ||||
|                         ) : ( | ||||
|                           <VisibilityIcon /> | ||||
|                         )} | ||||
|                       </IconButton> | ||||
|                     </Tooltip> | ||||
|                   </InputAdornment> | ||||
|   | ||||
							
								
								
									
										25
									
								
								virtweb_frontend/src/widgets/forms/DiskSizeInput.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								virtweb_frontend/src/widgets/forms/DiskSizeInput.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| import { ServerApi } from "../../api/ServerApi"; | ||||
| import { TextInput } from "./TextInput"; | ||||
|  | ||||
| export function DiskSizeInput(p: { | ||||
|   editable: boolean; | ||||
|   label?: string; | ||||
|   value: number; | ||||
|   onChange?: (size: number) => void; | ||||
| }): React.ReactElement { | ||||
|   return ( | ||||
|     <TextInput | ||||
|       editable={p.editable} | ||||
|       label={p.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.value / (1000 * 1000 * 1000)).toString()} | ||||
|       onValueChange={(v) => { | ||||
|         p.onChange?.(Number(v ?? "0") * 1000 * 1000 * 1000); | ||||
|       }} | ||||
|       type="number" | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| @@ -25,6 +25,8 @@ export function OEMStringFormWidget(p: { | ||||
|     p.onChange?.(); | ||||
|   }; | ||||
|  | ||||
|   if (!p.editable && p.vm.oem_strings.length === 0) return <></>; | ||||
|  | ||||
|   return ( | ||||
|     <EditSection | ||||
|       title="SMBIOS OEM Strings" | ||||
|   | ||||
| @@ -2,7 +2,8 @@ import { mdiHarddiskPlus } from "@mdi/js"; | ||||
| import Icon from "@mdi/react"; | ||||
| import CheckCircleIcon from "@mui/icons-material/CheckCircle"; | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import { Button, IconButton, Paper, Tooltip } from "@mui/material"; | ||||
| import ExpandIcon from "@mui/icons-material/Expand"; | ||||
| import { Button, IconButton, Paper, Tooltip, Typography } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { DiskImage } from "../../api/DiskImageApi"; | ||||
| import { ServerApi } from "../../api/ServerApi"; | ||||
| @@ -13,6 +14,7 @@ import { VMDiskFileWidget } from "../vms/VMDiskFileWidget"; | ||||
| import { CheckboxInput } from "./CheckboxInput"; | ||||
| import { DiskBusSelect } from "./DiskBusSelect"; | ||||
| import { DiskImageSelect } from "./DiskImageSelect"; | ||||
| import { DiskSizeInput } from "./DiskSizeInput"; | ||||
| import { SelectInput } from "./SelectInput"; | ||||
| import { TextInput } from "./TextInput"; | ||||
|  | ||||
| @@ -67,6 +69,12 @@ export function VMDisksList(p: { | ||||
|         /> | ||||
|       ))} | ||||
|  | ||||
|       {p.vm.file_disks.length === 0 && ( | ||||
|         <Typography style={{ textAlign: "center", paddingTop: "25px" }}> | ||||
|           No disk file yet! | ||||
|         </Typography> | ||||
|       )} | ||||
|  | ||||
|       {p.editable && <Button onClick={addNewDisk}>Add new disk</Button>} | ||||
|  | ||||
|       {/* Disk backup */} | ||||
| @@ -93,6 +101,19 @@ function DiskInfo(p: { | ||||
|   diskImagesList: DiskImage[]; | ||||
| }): React.ReactElement { | ||||
|   const confirm = useConfirm(); | ||||
|  | ||||
|   const expandDisk = () => { | ||||
|     if (p.disk.resize === true) { | ||||
|       p.disk.resize = false; | ||||
|       p.disk.size = p.disk.originalSize!; | ||||
|     } else { | ||||
|       p.disk.resize = true; | ||||
|       p.disk.originalSize = p.disk.size!; | ||||
|     } | ||||
|  | ||||
|     p.onChange?.(); | ||||
|   }; | ||||
|  | ||||
|   const deleteDisk = async () => { | ||||
|     if (p.disk.deleteType) { | ||||
|       p.disk.deleteType = undefined; | ||||
| @@ -115,42 +136,75 @@ function DiskInfo(p: { | ||||
|  | ||||
|   if (!p.editable || !p.disk.new) | ||||
|     return ( | ||||
|       <VMDiskFileWidget | ||||
|         {...p} | ||||
|         secondaryAction={ | ||||
|           <> | ||||
|             {p.editable && ( | ||||
|               <IconButton | ||||
|                 edge="end" | ||||
|                 aria-label="delete disk" | ||||
|                 onClick={deleteDisk} | ||||
|               > | ||||
|                 {p.disk.deleteType ? ( | ||||
|                   <Tooltip title="Cancel disk removal"> | ||||
|                     <CheckCircleIcon /> | ||||
|                   </Tooltip> | ||||
|                 ) : ( | ||||
|                   <Tooltip title="Remove disk"> | ||||
|                     <DeleteIcon /> | ||||
|                   </Tooltip> | ||||
|                 )} | ||||
|               </IconButton> | ||||
|             )} | ||||
|  | ||||
|             {p.canBackup && ( | ||||
|               <Tooltip title="Backup this disk"> | ||||
|       <> | ||||
|         <VMDiskFileWidget | ||||
|           {...p} | ||||
|           secondaryAction={ | ||||
|             <> | ||||
|               {p.editable && !p.disk.deleteType && ( | ||||
|                 <IconButton | ||||
|                   onClick={() => { | ||||
|                     p.onRequestBackup(p.disk); | ||||
|                   }} | ||||
|                   edge="end" | ||||
|                   aria-label="expand disk" | ||||
|                   onClick={expandDisk} | ||||
|                 > | ||||
|                   <Icon path={mdiHarddiskPlus} size={1} /> | ||||
|                   {p.disk.resize === true ? ( | ||||
|                     <Tooltip title="Cancel disk expansion"> | ||||
|                       <ExpandIcon color="error" /> | ||||
|                     </Tooltip> | ||||
|                   ) : ( | ||||
|                     <Tooltip title="Increase disk size"> | ||||
|                       <ExpandIcon /> | ||||
|                     </Tooltip> | ||||
|                   )} | ||||
|                 </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> | ||||
|               )} | ||||
|  | ||||
|               {p.canBackup && ( | ||||
|                 <Tooltip title="Backup this disk"> | ||||
|                   <IconButton | ||||
|                     onClick={() => { | ||||
|                       p.onRequestBackup(p.disk); | ||||
|                     }} | ||||
|                   > | ||||
|                     <Icon path={mdiHarddiskPlus} size={1} /> | ||||
|                   </IconButton> | ||||
|                 </Tooltip> | ||||
|               )} | ||||
|             </> | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         {/* New disk size*/} | ||||
|         {p.disk.resize && !p.disk.deleteType && ( | ||||
|           <DiskSizeInput | ||||
|             editable | ||||
|             label="New disk size (GB)" | ||||
|             value={p.disk.size} | ||||
|             onChange={(v) => { | ||||
|               p.disk.size = v; | ||||
|               p.onChange?.(); | ||||
|             }} | ||||
|           /> | ||||
|         )} | ||||
|       </> | ||||
|     ); | ||||
|  | ||||
|   return ( | ||||
| @@ -212,24 +266,32 @@ function DiskInfo(p: { | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       <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} | ||||
|       /> | ||||
|       {/* Resize disk image */} | ||||
|       {!!p.disk.from_image && ( | ||||
|         <CheckboxInput | ||||
|           editable | ||||
|           checked={p.disk.resize} | ||||
|           label="Resize disk file" | ||||
|           onValueChange={(v) => { | ||||
|             p.disk.resize = v; | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       {/* Disk size */} | ||||
|       {(!p.disk.from_image || p.disk.resize === true) && ( | ||||
|         <DiskSizeInput | ||||
|           editable | ||||
|           value={p.disk.size} | ||||
|           onChange={(v) => { | ||||
|             p.disk.size = v; | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       {/* Disk image selection */} | ||||
|       <DiskImageSelect | ||||
|         label="Use disk image as template" | ||||
|         list={p.diskImagesList} | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import { | ||||
|   ListItemAvatar, | ||||
|   ListItemText, | ||||
|   Tooltip, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| import Grid from "@mui/material/Grid"; | ||||
| import { NWFilter } from "../../api/NWFilterApi"; | ||||
| @@ -49,6 +50,12 @@ export function VMNetworksList(p: { | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {p.vm.networks.length === 0 && ( | ||||
|         <Typography style={{ textAlign: "center", paddingTop: "25px" }}> | ||||
|           No network interface defined yet! | ||||
|         </Typography> | ||||
|       )} | ||||
|  | ||||
|       <Grid container spacing={2}> | ||||
|         {/* networks list */} | ||||
|         {p.vm.networks.map((n, num) => ( | ||||
|   | ||||
		Reference in New Issue
	
	Block a user