Compare commits

...

14 Commits

Author SHA1 Message Date
c43882061b Update Rust crate actix-http to 3.11.0
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-05-31 00:10:51 +00:00
22416badcf Can change network interface type
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 20:30:30 +02:00
ef0d77f1d6 Minor fixes
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 15:25:46 +02:00
1d4af8c74e Can restore disk image when adding disks to virtual machine
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 14:41:48 +02:00
ec9492c933 Show a message on tokens list route when no token was created yet
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 11:35:00 +02:00
fa03ae885f Add new REST API routes to token rights editor
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 11:30:27 +02:00
ea98aaf856 Fix ESLint issue
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 11:20:19 +02:00
794d16bdaa Simplify raw disks definition
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 11:15:21 +02:00
a3ac56f849 Replace success snackbar with alert message in convert disk image dialog 2025-05-30 11:05:14 +02:00
6130f37336 Attempt to increase requests timeout 2025-05-30 11:04:38 +02:00
6b6fef5ccc Can backup vm disks as images 2025-05-30 10:55:40 +02:00
83df7e1b20 Prepare UI for disks backups 2025-05-30 10:28:54 +02:00
a18310e04a Simplify RAM management
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 09:20:49 +02:00
dd7f9176fa Clarify some constants 2025-05-30 09:03:00 +02:00
25 changed files with 649 additions and 289 deletions

View File

@ -19,7 +19,7 @@ actix-identity = "0.8.0"
actix-cors = "0.7.1" actix-cors = "0.7.1"
actix-files = "0.6.6" actix-files = "0.6.6"
actix-ws = "0.3.0" actix-ws = "0.3.0"
actix-http = "3.10.0" actix-http = "3.11.0"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140" serde_json = "1.0.140"
quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] } quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] }

View File

@ -255,6 +255,11 @@ impl AppConfig {
self.storage_path().join("disk_images") 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 /// Get VM vnc sockets directory
pub fn vnc_sockets_path(&self) -> PathBuf { pub fn vnc_sockets_path(&self) -> PathBuf {
self.storage_path().join("vnc") self.storage_path().join("vnc")

View File

@ -27,20 +27,23 @@ pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [
]; ];
/// ISO max size /// ISO max size
pub const ISO_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000; pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10);
/// Allowed uploaded disk images formats /// Allowed uploaded disk images formats
pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 2] = pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 3] = [
["application/x-qemu-disk", "application/gzip"]; "application/x-qemu-disk",
"application/gzip",
"application/octet-stream",
];
/// Disk image max size /// Disk image max size
pub const DISK_IMAGE_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000 * 1000; pub const DISK_IMAGE_MAX_SIZE: FileSize = FileSize::from_gb(10 * 1000);
/// Min VM memory size (MB) /// Min VM memory size
pub const MIN_VM_MEMORY: usize = 100; pub const MIN_VM_MEMORY: FileSize = FileSize::from_mb(100);
/// Max VM memory size (MB) /// Max VM memory size
pub const MAX_VM_MEMORY: usize = 64000; pub const MAX_VM_MEMORY: FileSize = FileSize::from_gb(64);
/// Disk name min length /// Disk name min length
pub const DISK_NAME_MIN_LEN: usize = 2; pub const DISK_NAME_MIN_LEN: usize = 2;

View File

@ -1,6 +1,8 @@
use crate::app_config::AppConfig; use crate::app_config::AppConfig;
use crate::constants; use crate::constants;
use crate::controllers::HttpResult; use crate::controllers::{HttpResult, LibVirtReq};
use crate::libvirt_lib_structures::XMLUuid;
use crate::libvirt_rest_structures::vm::VMInfo;
use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo}; use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo};
use crate::utils::files_utils; use crate::utils::files_utils;
use actix_files::NamedFile; use actix_files::NamedFile;
@ -24,7 +26,7 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>)
let file = form.files.remove(0); let file = form.files.remove(0);
// Check uploaded file size // Check uploaded file size
if file.size > constants::DISK_IMAGE_MAX_SIZE { if file.size > constants::DISK_IMAGE_MAX_SIZE.as_bytes() {
return Ok(HttpResponse::BadRequest().json("Disk image max size exceeded!")); return Ok(HttpResponse::BadRequest().json("Disk image max size exceeded!"));
} }
@ -47,7 +49,7 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>)
} }
// Check if a file with the same name already exists // Check if a file with the same name already exists
let dest_path = AppConfig::get().disk_images_storage_path().join(file_name); let dest_path = AppConfig::get().disk_images_file_path(&file_name);
if dest_path.is_file() { if dest_path.is_file() {
return Ok(HttpResponse::Conflict().json("A file with the same name already exists!")); return Ok(HttpResponse::Conflict().json("A file with the same name already exists!"));
} }
@ -80,9 +82,7 @@ pub async fn download(p: web::Path<DiskFilePath>, req: HttpRequest) -> HttpResul
return Ok(HttpResponse::BadRequest().json("Invalid file name!")); return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
} }
let file_path = AppConfig::get() let file_path = AppConfig::get().disk_images_file_path(&p.filename);
.disk_images_storage_path()
.join(&p.filename);
if !file_path.exists() { if !file_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!")); return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
@ -107,6 +107,59 @@ pub async fn convert(
return Ok(HttpResponse::BadRequest().json("Invalid src file name!")); 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) { if !files_utils::check_file_name(&req.dest_file_name) {
return Ok(HttpResponse::BadRequest().json("Invalid destination file name!")); return Ok(HttpResponse::BadRequest().json("Invalid destination file name!"));
} }
@ -119,15 +172,7 @@ pub async fn convert(
return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!")); return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!"));
} }
let src_file_path = AppConfig::get() let dst_file_path = AppConfig::get().disk_images_file_path(&req.dest_file_name);
.disk_images_storage_path()
.join(&p.filename);
let src = DiskFileInfo::load_file(&src_file_path)?;
let dst_file_path = AppConfig::get()
.disk_images_storage_path()
.join(&req.dest_file_name);
if dst_file_path.exists() { if dst_file_path.exists() {
return Ok(HttpResponse::Conflict().json("Specified destination file already exists!")); return Ok(HttpResponse::Conflict().json("Specified destination file already exists!"));
@ -141,7 +186,7 @@ pub async fn convert(
); );
} }
Ok(HttpResponse::Ok().json(src)) Ok(HttpResponse::Accepted().json("Successfully converted disk file"))
} }
/// Delete a disk image /// Delete a disk image
@ -150,9 +195,7 @@ pub async fn delete(p: web::Path<DiskFilePath>) -> HttpResult {
return Ok(HttpResponse::BadRequest().json("Invalid file name!")); return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
} }
let file_path = AppConfig::get() let file_path = AppConfig::get().disk_images_file_path(&p.filename);
.disk_images_storage_path()
.join(&p.filename);
if !file_path.exists() { if !file_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!")); return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));

View File

@ -26,7 +26,7 @@ pub async fn upload_file(MultipartForm(mut form): MultipartForm<UploadIsoForm>)
let file = form.files.remove(0); let file = form.files.remove(0);
if file.size > constants::ISO_MAX_SIZE { if file.size > constants::ISO_MAX_SIZE.as_bytes() {
log::error!("Uploaded ISO file is too large!"); log::error!("Uploaded ISO file is too large!");
return Ok(HttpResponse::BadRequest().json("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?; let response = reqwest::get(&req.url).await?;
if let Some(len) = response.content_length() { if let Some(len) = response.content_length() {
if len > constants::ISO_MAX_SIZE as u64 { if len > constants::ISO_MAX_SIZE.as_bytes() as u64 {
return Ok(HttpResponse::BadRequest().json("File is too large!")); return Ok(HttpResponse::BadRequest().json("File is too large!"));
} }
} }

View File

@ -71,8 +71,8 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES, builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES,
nwfilter_chains: &constants::NETWORK_CHAINS, nwfilter_chains: &constants::NETWORK_CHAINS,
constraints: ServerConstraints { constraints: ServerConstraints {
iso_max_size: constants::ISO_MAX_SIZE, iso_max_size: constants::ISO_MAX_SIZE.as_bytes(),
disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE, disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE.as_bytes(),
vnc_token_duration: VNC_TOKEN_LIFETIME, vnc_token_duration: VNC_TOKEN_LIFETIME,
@ -80,8 +80,8 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
vm_title_size: LenConstraints { min: 0, max: 50 }, vm_title_size: LenConstraints { min: 0, max: 50 },
group_id_size: LenConstraints { min: 3, max: 50 }, group_id_size: LenConstraints { min: 3, max: 50 },
memory_size: LenConstraints { memory_size: LenConstraints {
min: constants::MIN_VM_MEMORY, min: constants::MIN_VM_MEMORY.as_bytes(),
max: constants::MAX_VM_MEMORY, max: constants::MAX_VM_MEMORY.as_bytes(),
}, },
disk_name_size: LenConstraints { disk_name_size: LenConstraints {
min: DISK_NAME_MIN_LEN, min: DISK_NAME_MIN_LEN,

View File

@ -4,8 +4,8 @@ use crate::libvirt_lib_structures::XMLUuid;
use crate::libvirt_lib_structures::domain::*; use crate::libvirt_lib_structures::domain::*;
use crate::libvirt_rest_structures::LibVirtStructError; use crate::libvirt_rest_structures::LibVirtStructError;
use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction; use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction;
use crate::utils::file_size_utils::FileSize;
use crate::utils::files_utils; use crate::utils::files_utils;
use crate::utils::files_utils::convert_size_unit_to_mb;
use crate::utils::vm_file_disks_utils::{VMDiskFormat, VMFileDisk}; use crate::utils::vm_file_disks_utils::{VMDiskFormat, VMFileDisk};
use lazy_regex::regex; use lazy_regex::regex;
use num::Integer; use num::Integer;
@ -29,6 +29,12 @@ pub enum VMArchitecture {
X86_64, X86_64,
} }
#[derive(serde::Serialize, serde::Deserialize)]
pub enum NetworkInterfaceModelType {
Virtio,
E1000,
}
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
pub struct NWFilterParam { pub struct NWFilterParam {
name: String, name: String,
@ -46,6 +52,7 @@ pub struct Network {
#[serde(flatten)] #[serde(flatten)]
r#type: NetworkType, r#type: NetworkType,
mac: String, mac: String,
model: NetworkInterfaceModelType,
nwfilterref: Option<NWFilterRef>, nwfilterref: Option<NWFilterRef>,
} }
@ -70,8 +77,8 @@ pub struct VMInfo {
pub group: Option<VMGroupId>, pub group: Option<VMGroupId>,
pub boot_type: BootType, pub boot_type: BootType,
pub architecture: VMArchitecture, pub architecture: VMArchitecture,
/// VM allocated memory, in megabytes /// VM allocated RAM memory
pub memory: usize, pub memory: FileSize,
/// Number of vCPU for the VM /// Number of vCPU for the VM
pub number_vcpu: usize, pub number_vcpu: usize,
/// Enable VNC access through admin console /// Enable VNC access through admin console
@ -196,7 +203,11 @@ impl VMInfo {
}; };
let model = Some(NetIntModelXML { let model = Some(NetIntModelXML {
r#type: "virtio".to_string(), r#type: match n.model {
NetworkInterfaceModelType::Virtio => "virtio",
NetworkInterfaceModelType::E1000 => "e1000",
}
.to_string(),
}); });
let filterref = if let Some(n) = &n.nwfilterref { let filterref = if let Some(n) = &n.nwfilterref {
@ -380,7 +391,7 @@ impl VMInfo {
memory: DomainMemoryXML { memory: DomainMemoryXML {
unit: "MB".to_string(), unit: "MB".to_string(),
memory: self.memory, memory: self.memory.as_mb(),
}, },
vcpu: DomainVCPUXML { vcpu: DomainVCPUXML {
@ -452,7 +463,7 @@ impl VMInfo {
} }
}, },
number_vcpu: domain.vcpu.body, number_vcpu: domain.vcpu.body,
memory: convert_size_unit_to_mb(&domain.memory.unit, domain.memory.memory)?, memory: FileSize::from_size_unit(&domain.memory.unit, domain.memory.memory)?,
vnc_access: domain.devices.graphics.is_some(), vnc_access: domain.devices.graphics.is_some(),
iso_files: domain iso_files: domain
.devices .devices
@ -467,7 +478,10 @@ impl VMInfo {
.disks .disks
.iter() .iter()
.filter(|d| d.device == "disk") .filter(|d| d.device == "disk")
.map(|d| VMFileDisk::load_from_file(&d.source.file).unwrap()) .map(|d| {
VMFileDisk::load_from_file(&d.source.file)
.expect("Failed to load file disk information!")
})
.collect(), .collect(),
networks: domain networks: domain
@ -515,6 +529,18 @@ impl VMInfo {
))); )));
} }
}, },
model: match d.model.as_ref() {
None => NetworkInterfaceModelType::Virtio,
Some(model) => match model.r#type.as_str() {
"virtio" => NetworkInterfaceModelType::Virtio,
"e1000" => NetworkInterfaceModelType::E1000,
model => {
return Err(LibVirtStructError::DomainExtraction(format!(
"Unknown network interface model type: {model}! "
)));
}
},
},
nwfilterref: d.filterref.as_ref().map(|f| NWFilterRef { nwfilterref: d.filterref.as_ref().map(|f| NWFilterRef {
name: f.filter.to_string(), name: f.filter.to_string(),
parameters: f parameters: f

View File

@ -122,10 +122,9 @@ async fn main() -> std::io::Result<()> {
})) }))
.app_data(conn.clone()) .app_data(conn.clone())
// Uploaded files // Uploaded files
.app_data( .app_data(MultipartFormConfig::default().total_limit(
MultipartFormConfig::default() max(constants::DISK_IMAGE_MAX_SIZE, constants::ISO_MAX_SIZE).as_bytes(),
.total_limit(max(constants::DISK_IMAGE_MAX_SIZE, constants::ISO_MAX_SIZE)), ))
)
.app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir)) .app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir))
// Server controller // Server controller
.route( .route(
@ -357,6 +356,10 @@ async fn main() -> std::io::Result<()> {
"/api/disk_images/{filename}", "/api/disk_images/{filename}",
web::delete().to(disk_images_controller::delete), 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 // API tokens controller
.route( .route(
"/api/token/create", "/api/token/create",

View File

@ -1,3 +1,12 @@
use std::ops::Mul;
#[derive(thiserror::Error, Debug)]
enum FilesSizeUtilsError {
#[error("UnitConvertError: {0}")]
UnitConvert(String),
}
/// Holds a data size, convertible in any form
#[derive( #[derive(
serde::Serialize, serde::Serialize,
serde::Deserialize, serde::Deserialize,
@ -25,6 +34,30 @@ impl FileSize {
Self(gb * 1000 * 1000 * 1000) 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 /// Get file size as bytes
pub fn as_bytes(&self) -> usize { pub fn as_bytes(&self) -> usize {
self.0 self.0
@ -35,3 +68,24 @@ impl FileSize {
self.0 / (1000 * 1000) 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
);
}
}

View File

@ -1,13 +1,6 @@
use std::ops::{Div, Mul};
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::path::Path; use std::path::Path;
#[derive(thiserror::Error, Debug)]
enum FilesUtilsError {
#[error("UnitConvertError: {0}")]
UnitConvert(String),
}
const INVALID_CHARS: [&str; 19] = [ const INVALID_CHARS: [&str; 19] = [
"@", "\\", "/", ":", ",", "<", ">", "%", "'", "\"", "?", "{", "}", "$", "*", "|", ";", "=", "@", "\\", "/", ":", ",", "<", ">", "%", "'", "\"", "?", "{", "}", "$", "*", "|", ";", "=",
"\t", "\t",
@ -35,31 +28,9 @@ pub fn set_file_permission<P: AsRef<Path>>(path: P, mode: u32) -> anyhow::Result
Ok(()) 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)] #[cfg(test)]
mod test { mod test {
use crate::utils::files_utils::{check_file_name, convert_size_unit_to_mb}; use crate::utils::files_utils::check_file_name;
#[test] #[test]
fn empty_file_name() { fn empty_file_name() {
@ -85,14 +56,4 @@ mod test {
fn valid_file_name() { fn valid_file_name() {
assert!(check_file_name("test.iso")); 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);
}
} }

View File

@ -13,20 +13,13 @@ enum VMDisksError {
Config(&'static str), Config(&'static str),
} }
/// Type of disk allocation
#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq)]
pub enum VMDiskAllocType {
Fixed,
Sparse,
}
/// Disk allocation type /// Disk allocation type
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
#[serde(tag = "format")] #[serde(tag = "format")]
pub enum VMDiskFormat { pub enum VMDiskFormat {
Raw { Raw {
/// Type of disk allocation /// Is raw file a sparse file?
alloc_type: VMDiskAllocType, is_sparse: bool,
}, },
QCow2, QCow2,
} }
@ -40,6 +33,9 @@ pub struct VMFileDisk {
/// Disk format /// Disk format
#[serde(flatten)] #[serde(flatten)]
pub format: VMDiskFormat, 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 /// Set this variable to true to delete the disk
pub delete: bool, pub delete: bool,
} }
@ -61,16 +57,12 @@ impl VMFileDisk {
}, },
format: match info.format { format: match info.format {
DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw { DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw { is_sparse },
alloc_type: match is_sparse {
true => VMDiskAllocType::Sparse,
false => VMDiskAllocType::Fixed,
},
},
DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2, DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2,
_ => anyhow::bail!("Unsupported image format: {:?}", info.format), _ => anyhow::bail!("Unsupported image format: {:?}", info.format),
}, },
delete: false, delete: false,
from_image: None,
}) })
} }
@ -90,10 +82,23 @@ impl VMFileDisk {
return Err(VMDisksError::Config("Disk size is invalid!").into()); 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(()) Ok(())
} }
/// Get disk path /// Get disk path on file system
pub fn disk_path(&self, id: XMLUuid) -> PathBuf { pub fn disk_path(&self, id: XMLUuid) -> PathBuf {
let domain_dir = AppConfig::get().vm_storage_path(id); let domain_dir = AppConfig::get().vm_storage_path(id);
let file_name = match self.format { let file_name = match self.format {
@ -127,19 +132,27 @@ impl VMFileDisk {
return Ok(()); return Ok(());
} }
// Create disk file let format = match self.format {
DiskFileInfo::create( VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
&file, VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
match self.format { virtual_size: self.size,
VMDiskFormat::Raw { alloc_type } => DiskFileFormat::Raw {
is_sparse: alloc_type == VMDiskAllocType::Sparse,
},
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
virtual_size: self.size,
},
}, },
self.size, };
)?;
// Create / Restore disk file
match &self.from_image {
// Create disk file
None => {
DiskFileInfo::create(&file, format, self.size)?;
}
// Restore disk image template
Some(disk_img) => {
let src_file =
DiskFileInfo::load_file(&AppConfig::get().disk_images_file_path(disk_img))?;
src_file.convert(&file, format)?;
}
}
Ok(()) Ok(())
} }

View File

@ -103,6 +103,7 @@ export class APIClient {
body: body, body: body,
headers: headers, headers: headers,
credentials: "include", credentials: "include",
signal: AbortSignal.timeout(50 * 1000 * 1000),
}); });
// Process response // Process response

View File

@ -1,4 +1,5 @@
import { APIClient } from "./ApiClient"; import { APIClient } from "./ApiClient";
import { VMFileDisk, VMInfo } from "./VMApi";
export type DiskImageFormat = export type DiskImageFormat =
| { format: "Raw"; is_sparse: boolean } | { format: "Raw"; is_sparse: boolean }
@ -77,6 +78,22 @@ export class DiskImageApi {
}); });
} }
/**
* Backup VM disk into image disks library
*/
static async BackupVMDisk(
vm: VMInfo,
disk: VMFileDisk,
dest_file_name: string,
format: DiskImageFormat
): Promise<void> {
await APIClient.exec({
uri: `/vm/${vm.uuid}/disk/${disk.name}/backup`,
method: "POST",
jsonData: { ...format, dest_file_name },
});
}
/** /**
* Delete disk image file * Delete disk image file
*/ */

View File

@ -24,16 +24,17 @@ export interface BaseFileVMDisk {
name: string; name: string;
delete: boolean; delete: boolean;
// application attribute // For new disk only
from_image?: string;
// application attributes
new?: boolean; new?: boolean;
deleteType?: "keepfile" | "deletefile"; deleteType?: "keepfile" | "deletefile";
} }
export type DiskAllocType = "Sparse" | "Fixed";
interface RawVMDisk { interface RawVMDisk {
format: "Raw"; format: "Raw";
alloc_type: DiskAllocType; is_sparse: boolean;
} }
interface QCow2Disk { interface QCow2Disk {
@ -59,6 +60,7 @@ export type VMNetInterface = (
export interface VMNetInterfaceBase { export interface VMNetInterfaceBase {
mac: string; mac: string;
model: "Virtio" | "E1000";
nwfilterref?: VMNetInterfaceFilter; nwfilterref?: VMNetInterfaceFilter;
} }
@ -137,7 +139,7 @@ export class VMInfo implements VMInfoInterface {
name: "", name: "",
boot_type: "UEFI", boot_type: "UEFI",
architecture: "x86_64", architecture: "x86_64",
memory: 1024, memory: 1000 * 1000 * 1000,
number_vcpu: 1, number_vcpu: 1,
vnc_access: true, vnc_access: true,
iso_files: [], iso_files: [],

View File

@ -9,56 +9,69 @@ import {
import React from "react"; import React from "react";
import { DiskImage, DiskImageApi, DiskImageFormat } from "../api/DiskImageApi"; import { DiskImage, DiskImageApi, DiskImageFormat } from "../api/DiskImageApi";
import { ServerApi } from "../api/ServerApi"; import { ServerApi } from "../api/ServerApi";
import { VMFileDisk, VMInfo } from "../api/VMApi";
import { useAlert } from "../hooks/providers/AlertDialogProvider"; import { useAlert } from "../hooks/providers/AlertDialogProvider";
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider"; import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
import { FileDiskImageWidget } from "../widgets/FileDiskImageWidget"; import { FileDiskImageWidget } from "../widgets/FileDiskImageWidget";
import { CheckboxInput } from "../widgets/forms/CheckboxInput"; import { CheckboxInput } from "../widgets/forms/CheckboxInput";
import { SelectInput } from "../widgets/forms/SelectInput"; import { SelectInput } from "../widgets/forms/SelectInput";
import { TextInput } from "../widgets/forms/TextInput"; import { TextInput } from "../widgets/forms/TextInput";
import { VMDiskFileWidget } from "../widgets/vms/VMDiskFileWidget";
export function ConvertDiskImageDialog(p: { export function ConvertDiskImageDialog(
image: DiskImage; p: {
onCancel: () => void; onCancel: () => void;
onFinished: () => void; onFinished: () => void;
}): React.ReactElement { } & (
| { backup?: false; image: DiskImage }
| { backup: true; disk: VMFileDisk; vm: VMInfo }
)
): React.ReactElement {
const alert = useAlert(); const alert = useAlert();
const snackbar = useSnackbar();
const loadingMessage = useLoadingMessage(); const loadingMessage = useLoadingMessage();
const [format, setFormat] = React.useState<DiskImageFormat>({ const [format, setFormat] = React.useState<DiskImageFormat>({
format: "QCow2", format: "QCow2",
}); });
const [filename, setFilename] = React.useState(p.image.file_name + ".qcow2"); const origFilename = p.backup ? p.disk.name : p.image.file_name;
const [filename, setFilename] = React.useState(origFilename + ".qcow2");
const handleFormatChange = (value?: string) => { const handleFormatChange = (value?: string) => {
setFormat({ format: value ?? ("QCow2" as any) }); setFormat({ format: value ?? ("QCow2" as any) });
if (value === "QCow2") setFilename(`${p.image.file_name}.qcow2`); if (value === "QCow2") setFilename(`${origFilename}.qcow2`);
if (value === "CompressedQCow2") if (value === "CompressedQCow2") setFilename(`${origFilename}.qcow2.gz`);
setFilename(`${p.image.file_name}.qcow2.gz`);
if (value === "Raw") { if (value === "Raw") {
setFilename(`${p.image.file_name}.raw`); setFilename(`${origFilename}.raw`);
// Check sparse checkbox by default // Check sparse checkbox by default
setFormat({ format: "Raw", is_sparse: true }); setFormat({ format: "Raw", is_sparse: true });
} }
if (value === "CompressedRaw") setFilename(`${p.image.file_name}.raw.gz`); if (value === "CompressedRaw") setFilename(`${origFilename}.raw.gz`);
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
loadingMessage.show("Converting image..."); loadingMessage.show(
p.backup ? "Performing backup..." : "Converting image..."
);
// Perform the conversion // Perform the conversion / backup operation
await DiskImageApi.Convert(p.image, filename, format); if (p.backup)
await DiskImageApi.BackupVMDisk(p.vm, p.disk, filename, format);
else await DiskImageApi.Convert(p.image, filename, format);
p.onFinished(); p.onFinished();
snackbar("Conversion successful!"); alert(p.backup ? "Backup successful!" : "Conversion successful!");
} catch (e) { } catch (e) {
console.error("Failed to convert image!", e); console.error("Failed to perform backup/conversion!", e);
alert(`Failed to convert image! ${e}`); alert(
p.backup
? `Failed to perform backup! ${e}`
: `Failed to convert image! ${e}`
);
} finally { } finally {
loadingMessage.hide(); loadingMessage.hide();
} }
@ -66,13 +79,21 @@ export function ConvertDiskImageDialog(p: {
return ( return (
<Dialog open onClose={p.onCancel}> <Dialog open onClose={p.onCancel}>
<DialogTitle>Convert disk image</DialogTitle> <DialogTitle>
{p.backup ? `Backup disk ${p.disk.name}` : "Convert disk image"}
</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
Select the destination format for this image: Select the destination format for this image:
</DialogContentText> </DialogContentText>
<FileDiskImageWidget image={p.image} />
{/* Show details of of the image */}
{p.backup ? (
<VMDiskFileWidget {...p} />
) : (
<FileDiskImageWidget {...p} />
)}
{/* New image format */} {/* New image format */}
<SelectInput <SelectInput
@ -109,13 +130,13 @@ export function ConvertDiskImageDialog(p: {
setFilename(s ?? ""); setFilename(s ?? "");
}} }}
size={ServerApi.Config.constraints.disk_image_name_size} size={ServerApi.Config.constraints.disk_image_name_size}
helperText="The image name shall contain the proper file extension" helperText="The image name shall contain the proper file extension for the selected target format"
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={p.onCancel}>Cancel</Button> <Button onClick={p.onCancel}>Cancel</Button>
<Button onClick={handleSubmit} autoFocus> <Button onClick={handleSubmit} autoFocus>
Convert image {p.backup ? "Perform backup" : "Convert image"}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@ -10,6 +10,7 @@ import {
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
Typography,
} from "@mui/material"; } from "@mui/material";
import React from "react"; import React from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@ -58,70 +59,78 @@ export function TokensListRouteInner(p: {
</RouterLink> </RouterLink>
} }
> >
<TableContainer component={Paper}> {p.list.length > 0 && (
<Table> <TableContainer component={Paper}>
<TableHead> <Table>
<TableRow> <TableHead>
<TableCell>Name</TableCell> <TableRow>
<TableCell>Description</TableCell> <TableCell>Name</TableCell>
<TableCell>Created</TableCell> <TableCell>Description</TableCell>
<TableCell>Updated</TableCell> <TableCell>Created</TableCell>
<TableCell>Last used</TableCell> <TableCell>Updated</TableCell>
<TableCell>IP restriction</TableCell> <TableCell>Last used</TableCell>
<TableCell>Max inactivity</TableCell> <TableCell>IP restriction</TableCell>
<TableCell>Rights</TableCell> <TableCell>Max inactivity</TableCell>
<TableCell>Actions</TableCell> <TableCell>Rights</TableCell>
</TableRow> <TableCell>Actions</TableCell>
</TableHead> </TableRow>
<TableBody> </TableHead>
{p.list.map((t) => { <TableBody>
return ( {p.list.map((t) => {
<TableRow return (
key={t.id} <TableRow
hover key={t.id}
onDoubleClick={() => navigate(APITokenURL(t))} hover
style={{ backgroundColor: ExpiredAPIToken(t) ? "red" : "" }} onDoubleClick={() => navigate(APITokenURL(t))}
> style={{ backgroundColor: ExpiredAPIToken(t) ? "red" : "" }}
<TableCell> >
{t.name} {ExpiredAPIToken(t) && <i>(Expired)</i>} <TableCell>
</TableCell> {t.name} {ExpiredAPIToken(t) && <i>(Expired)</i>}
<TableCell>{t.description}</TableCell> </TableCell>
<TableCell> <TableCell>{t.description}</TableCell>
<TimeWidget time={t.created} /> <TableCell>
</TableCell> <TimeWidget time={t.created} />
<TableCell> </TableCell>
<TimeWidget time={t.updated} /> <TableCell>
</TableCell> <TimeWidget time={t.updated} />
<TableCell> </TableCell>
<TimeWidget time={t.last_used} /> <TableCell>
</TableCell> <TimeWidget time={t.last_used} />
<TableCell>{t.ip_restriction}</TableCell> </TableCell>
<TableCell> <TableCell>{t.ip_restriction}</TableCell>
{t.max_inactivity && timeDiff(0, t.max_inactivity)} <TableCell>
</TableCell> {t.max_inactivity && timeDiff(0, t.max_inactivity)}
<TableCell> </TableCell>
{t.rights.map((r, n) => { <TableCell>
return ( {t.rights.map((r, n) => {
<div key={n}> return (
{r.verb} {r.path} <div key={n}>
</div> {r.verb} {r.path}
); </div>
})} );
</TableCell> })}
</TableCell>
<TableCell> <TableCell>
<RouterLink to={APITokenURL(t)}> <RouterLink to={APITokenURL(t)}>
<IconButton> <IconButton>
<VisibilityIcon /> <VisibilityIcon />
</IconButton> </IconButton>
</RouterLink> </RouterLink>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
})} })}
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
)}
{p.list.length === 0 && (
<Typography style={{ textAlign: "center" }}>
No API token created yet.
</Typography>
)}
</VirtWebRouteContainer> </VirtWebRouteContainer>
); );
} }

View File

@ -154,7 +154,7 @@ function VMListWidget(p: {
{row.name} {row.name}
</TableCell> </TableCell>
<TableCell>{row.description ?? ""}</TableCell> <TableCell>{row.description ?? ""}</TableCell>
<TableCell>{vmMemoryToHuman(row.memory)}</TableCell> <TableCell>{filesize(row.memory)}</TableCell>
<TableCell>{row.number_vcpu}</TableCell> <TableCell>{row.number_vcpu}</TableCell>
<TableCell> <TableCell>
<VMStatusWidget <VMStatusWidget
@ -183,13 +183,13 @@ function VMListWidget(p: {
<TableCell></TableCell> <TableCell></TableCell>
<TableCell></TableCell> <TableCell></TableCell>
<TableCell> <TableCell>
{vmMemoryToHuman( {filesize(
p.list p.list
.filter((v) => runningVMs.has(v.name)) .filter((v) => runningVMs.has(v.name))
.reduce((s, v) => s + v.memory, 0) .reduce((s, v) => s + v.memory, 0)
)} )}
{" / "} {" / "}
{vmMemoryToHuman(p.list.reduce((s, v) => s + v.memory, 0))} {filesize(p.list.reduce((s, v) => s + v.memory, 0))}
</TableCell> </TableCell>
<TableCell> <TableCell>
{p.list {p.list
@ -206,7 +206,3 @@ function VMListWidget(p: {
</TableContainer> </TableContainer>
); );
} }
function vmMemoryToHuman(size: number): string {
return filesize(size * 1000 * 1000);
}

View File

@ -59,6 +59,7 @@ function VMRouteBody(p: { vm: VMInfo }): React.ReactElement {
<VMDetails <VMDetails
vm={p.vm} vm={p.vm}
editable={false} editable={false}
state={state}
screenshot={p.vm.vnc_access && state === "Running"} screenshot={p.vm.vnc_access && state === "Running"}
/> />
</VirtWebRouteContainer> </VirtWebRouteContainer>

View File

@ -0,0 +1,40 @@
import React from "react";
import { DiskImage } from "../../api/DiskImageApi";
import {
FormControl,
InputLabel,
Select,
MenuItem,
SelectChangeEvent,
} from "@mui/material";
import { FileDiskImageWidget } from "../FileDiskImageWidget";
/**
* Select a disk image
*/
export function DiskImageSelect(p: {
label: string;
value?: string;
onValueChange: (image: string | undefined) => void;
list: DiskImage[];
}): React.ReactElement {
const handleChange = (event: SelectChangeEvent) => {
p.onValueChange(event.target.value);
};
return (
<FormControl fullWidth variant="standard">
<InputLabel>{p.label}</InputLabel>
<Select value={p.value} label={p.label} onChange={handleChange}>
<MenuItem value={undefined}>
<i>None</i>
</MenuItem>
{p.list.map((d) => (
<MenuItem value={d.file_name}>
<FileDiskImageWidget image={d} />
</MenuItem>
))}
</Select>
</FormControl>
);
}

View File

@ -17,6 +17,7 @@ export function TextInput(p: {
type?: React.HTMLInputTypeAttribute; type?: React.HTMLInputTypeAttribute;
style?: React.CSSProperties; style?: React.CSSProperties;
helperText?: string; helperText?: string;
disabled?: boolean;
}): React.ReactElement { }): React.ReactElement {
if (!p.editable && (p.value ?? "") === "") return <></>; if (!p.editable && (p.value ?? "") === "") return <></>;
@ -35,6 +36,7 @@ export function TextInput(p: {
return ( return (
<TextField <TextField
disabled={p.disabled}
label={p.label} label={p.label}
value={p.value ?? ""} value={p.value ?? ""}
onChange={(e) => onChange={(e) =>

View File

@ -1,4 +1,4 @@
import { mdiHarddisk } from "@mdi/js"; import { mdiHarddisk, mdiHarddiskPlus } from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
@ -13,17 +13,28 @@ import {
Tooltip, Tooltip,
} from "@mui/material"; } from "@mui/material";
import { filesize } from "filesize"; import { filesize } from "filesize";
import React from "react";
import { ServerApi } from "../../api/ServerApi"; import { ServerApi } from "../../api/ServerApi";
import { VMFileDisk, VMInfo } from "../../api/VMApi"; import { VMFileDisk, VMInfo, VMState } from "../../api/VMApi";
import { ConvertDiskImageDialog } from "../../dialogs/ConvertDiskImageDialog";
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider"; import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
import { CheckboxInput } from "./CheckboxInput";
import { SelectInput } from "./SelectInput"; import { SelectInput } from "./SelectInput";
import { TextInput } from "./TextInput"; import { TextInput } from "./TextInput";
import { DiskImageSelect } from "./DiskImageSelect";
import { DiskImage } from "../../api/DiskImageApi";
export function VMDisksList(p: { export function VMDisksList(p: {
vm: VMInfo; vm: VMInfo;
state?: VMState;
onChange?: () => void; onChange?: () => void;
editable: boolean; editable: boolean;
diskImagesList: DiskImage[];
}): React.ReactElement { }): React.ReactElement {
const [currBackupRequest, setCurrBackupRequest] = React.useState<
VMFileDisk | undefined
>();
const addNewDisk = () => { const addNewDisk = () => {
p.vm.file_disks.push({ p.vm.file_disks.push({
format: "QCow2", format: "QCow2",
@ -35,6 +46,14 @@ export function VMDisksList(p: {
p.onChange?.(); p.onChange?.();
}; };
const handleBackupRequest = (disk: VMFileDisk) => {
setCurrBackupRequest(disk);
};
const handleFinishBackup = () => {
setCurrBackupRequest(undefined);
};
return ( return (
<> <>
{/* disks list */} {/* disks list */}
@ -43,25 +62,42 @@ export function VMDisksList(p: {
// eslint-disable-next-line react-x/no-array-index-key // eslint-disable-next-line react-x/no-array-index-key
key={num} key={num}
editable={p.editable} editable={p.editable}
canBackup={!p.editable && !d.new && p.state !== "Running"}
disk={d} disk={d}
onChange={p.onChange} onChange={p.onChange}
removeFromList={() => { removeFromList={() => {
p.vm.file_disks.splice(num, 1); p.vm.file_disks.splice(num, 1);
p.onChange?.(); p.onChange?.();
}} }}
onRequestBackup={handleBackupRequest}
diskImagesList={p.diskImagesList}
/> />
))} ))}
{p.editable && <Button onClick={addNewDisk}>Add new disk</Button>} {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: { function DiskInfo(p: {
editable: boolean; editable: boolean;
canBackup: boolean;
disk: VMFileDisk; disk: VMFileDisk;
onChange?: () => void; onChange?: () => void;
removeFromList: () => void; removeFromList: () => void;
onRequestBackup: (disk: VMFileDisk) => void;
diskImagesList: DiskImage[];
}): React.ReactElement { }): React.ReactElement {
const confirm = useConfirm(); const confirm = useConfirm();
const deleteDisk = async () => { const deleteDisk = async () => {
@ -88,23 +124,37 @@ function DiskInfo(p: {
return ( return (
<ListItem <ListItem
secondaryAction={ secondaryAction={
p.editable && ( <>
<IconButton {p.editable && (
edge="end" <IconButton
aria-label="delete disk" edge="end"
onClick={deleteDisk} aria-label="delete disk"
> onClick={deleteDisk}
{p.disk.deleteType ? ( >
<Tooltip title="Cancel disk removal"> {p.disk.deleteType ? (
<CheckCircleIcon /> <Tooltip title="Cancel disk removal">
</Tooltip> <CheckCircleIcon />
) : ( </Tooltip>
<Tooltip title="Remove disk"> ) : (
<DeleteIcon /> <Tooltip title="Remove disk">
</Tooltip> <DeleteIcon />
)} </Tooltip>
</IconButton> )}
) </IconButton>
)}
{p.canBackup && (
<Tooltip title="Backup this disk">
<IconButton
onClick={() => {
p.onRequestBackup(p.disk);
}}
>
<Icon path={mdiHarddiskPlus} size={1} />
</IconButton>
</Tooltip>
)}
</>
} }
> >
<ListItemAvatar> <ListItemAvatar>
@ -126,7 +176,9 @@ function DiskInfo(p: {
</> </>
} }
secondary={`${filesize(p.disk.size)} - ${p.disk.format}${ secondary={`${filesize(p.disk.size)} - ${p.disk.format}${
p.disk.format == "Raw" ? " - " + p.disk.alloc_type : "" p.disk.format == "Raw"
? " - " + (p.disk.is_sparse ? "Sparse" : "Fixed")
: ""
}`} }`}
/> />
</ListItem> </ListItem>
@ -151,6 +203,35 @@ function DiskInfo(p: {
</IconButton> </IconButton>
</div> </div>
<SelectInput
editable={true}
label="Disk format"
options={[
{ label: "Raw file", value: "Raw" },
{ label: "QCow2", value: "QCow2" },
]}
value={p.disk.format}
onValueChange={(v) => {
p.disk.format = v as any;
if (p.disk.format === "Raw") p.disk.is_sparse = true;
p.onChange?.();
}}
/>
{p.disk.format === "Raw" && (
<CheckboxInput
editable
label="Sparse file"
checked={p.disk.is_sparse}
onValueChange={(v) => {
if (p.disk.format === "Raw") p.disk.is_sparse = v;
p.onChange?.();
}}
/>
)}
<TextInput <TextInput
editable={true} editable={true}
label="Disk size (GB)" label="Disk size (GB)"
@ -166,37 +247,18 @@ function DiskInfo(p: {
p.onChange?.(); p.onChange?.();
}} }}
type="number" type="number"
disabled={!!p.disk.from_image}
/> />
<SelectInput <DiskImageSelect
editable={true} label="Use disk image as template"
label="Disk format" list={p.diskImagesList}
options={[ value={p.disk.from_image}
{ label: "Raw file", value: "Raw" },
{ label: "QCow2", value: "QCow2" },
]}
value={p.disk.format}
onValueChange={(v) => { onValueChange={(v) => {
p.disk.format = v as any; p.disk.from_image = v;
p.onChange?.(); p.onChange?.();
}} }}
/> />
{p.disk.format === "Raw" && (
<SelectInput
editable={true}
label="File allocation type"
options={[
{ label: "Sparse allocation", value: "Sparse" },
{ label: "Fixed allocation", value: "Fixed" },
]}
value={p.disk.alloc_type}
onValueChange={(v) => {
if (p.disk.format === "Raw") p.disk.alloc_type = v as any;
p.onChange?.();
}}
/>
)}
</Paper> </Paper>
); );
} }

View File

@ -35,6 +35,7 @@ export function VMNetworksList(p: {
const addNew = () => { const addNew = () => {
p.vm.networks.push({ p.vm.networks.push({
type: "UserspaceSLIRPStack", type: "UserspaceSLIRPStack",
model: "Virtio",
mac: randomMacAddress(ServerApi.Config.net_mac_prefix), mac: randomMacAddress(ServerApi.Config.net_mac_prefix),
}); });
p.onChange?.(); p.onChange?.();
@ -146,6 +147,7 @@ function NetworkInfoWidget(p: {
/> />
</ListItem> </ListItem>
<div style={{ marginLeft: "70px" }}> <div style={{ marginLeft: "70px" }}>
{/* MAC address input */}
<MACInput <MACInput
editable={p.editable} editable={p.editable}
label="MAC Address" label="MAC Address"
@ -156,6 +158,26 @@ function NetworkInfoWidget(p: {
}} }}
/> />
{/* NIC model */}
<SelectInput
editable={p.editable}
label="NIC Model"
value={p.network.model}
onValueChange={(v) => {
p.network.model = v as any;
p.onChange?.();
}}
options={[
{ label: "e1000", value: "E1000" },
{
label: "virtio",
value: "Virtio",
description:
"Recommended model, but will require specific drivers on OS that do not support it.",
},
]}
/>
{/* Defined network selection */} {/* Defined network selection */}
{p.network.type === "DefinedNetwork" && ( {p.network.type === "DefinedNetwork" && (
<SelectInput <SelectInput

View File

@ -1,6 +1,7 @@
import { import {
Checkbox, Checkbox,
FormControlLabel, FormControlLabel,
Grid,
Paper, Paper,
Table, Table,
TableBody, TableBody,
@ -59,6 +60,7 @@ export function TokenRightsEditor(p: {
<TableCell align="center">Get XML definition</TableCell> <TableCell align="center">Get XML definition</TableCell>
<TableCell align="center">Get autostart</TableCell> <TableCell align="center">Get autostart</TableCell>
<TableCell align="center">Set autostart</TableCell> <TableCell align="center">Set autostart</TableCell>
<TableCell align="center">Backup disk</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@ -82,6 +84,10 @@ export function TokenRightsEditor(p: {
{...p} {...p}
right={{ verb: "PUT", path: "/api/vm/*/autostart" }} right={{ verb: "PUT", path: "/api/vm/*/autostart" }}
/> />
<CellRight
{...p}
right={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }}
/>
</TableRow> </TableRow>
{/* Per VM operations */} {/* Per VM operations */}
@ -117,6 +123,14 @@ export function TokenRightsEditor(p: {
{...p} {...p}
right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }} right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }}
parent={{ verb: "PUT", path: "/api/vm/*/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> </TableRow>
))} ))}
@ -669,34 +683,68 @@ export function TokenRightsEditor(p: {
</Table> </Table>
</RightsSection> </RightsSection>
{/* ISO files */} <Grid container>
<RightsSection label="ISO files"> <Grid size={{ md: 6 }}>
<RouteRight {/* Disk images */}
{...p} <RightsSection label="Disk images">
right={{ verb: "POST", path: "/api/iso/upload" }} <RouteRight
label="Upload a new ISO file" {...p}
/> right={{ verb: "POST", path: "/api/disk_images/upload" }}
<RouteRight label="Upload a new disk image"
{...p} />
right={{ verb: "POST", path: "/api/iso/upload_from_url" }} <RouteRight
label="Upload a new ISO file from a given URL" {...p}
/> right={{ verb: "GET", path: "/api/disk_images/list" }}
<RouteRight label="Get the list of disk images"
{...p} />
right={{ verb: "GET", path: "/api/iso/list" }} <RouteRight
label="Get the list of ISO files" {...p}
/> right={{ verb: "GET", path: "/api/disk_images/*" }}
<RouteRight label="Download disk images"
{...p} />
right={{ verb: "GET", path: "/api/iso/*" }} <RouteRight
label="Download ISO files" {...p}
/> right={{ verb: "POST", path: "/api/disk_images/*/convert" }}
<RouteRight label="Convert disk images"
{...p} />
right={{ verb: "DELETE", path: "/api/iso/*" }} <RouteRight
label="Delete ISO files" {...p}
/> right={{ verb: "DELETE", path: "/api/disk_images/*" }}
</RightsSection> label="Delete disk images"
/>
</RightsSection>
</Grid>
<Grid size={{ md: 6 }}>
{/* ISO files */}
<RightsSection label="ISO files">
<RouteRight
{...p}
right={{ verb: "POST", path: "/api/iso/upload" }}
label="Upload a new ISO file"
/>
<RouteRight
{...p}
right={{ verb: "POST", path: "/api/iso/upload_from_url" }}
label="Upload a new ISO file from a given URL"
/>
<RouteRight
{...p}
right={{ verb: "GET", path: "/api/iso/list" }}
label="Get the list of ISO files"
/>
<RouteRight
{...p}
right={{ verb: "GET", path: "/api/iso/*" }}
label="Download ISO files"
/>
<RouteRight
{...p}
right={{ verb: "DELETE", path: "/api/iso/*" }}
label="Delete ISO files"
/>
</RightsSection>
</Grid>
</Grid>
{/* Server general information */} {/* Server general information */}
<RightsSection label="Server"> <RightsSection label="Server">

View File

@ -10,7 +10,7 @@ import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi";
import { NWFilter, NWFilterApi } from "../../api/NWFilterApi"; import { NWFilter, NWFilterApi } from "../../api/NWFilterApi";
import { NetworkApi, NetworkInfo } from "../../api/NetworksApi"; import { NetworkApi, NetworkInfo } from "../../api/NetworksApi";
import { ServerApi } from "../../api/ServerApi"; import { ServerApi } from "../../api/ServerApi";
import { VMApi, VMInfo } from "../../api/VMApi"; import { VMApi, VMInfo, VMState } from "../../api/VMApi";
import { useAlert } from "../../hooks/providers/AlertDialogProvider"; import { useAlert } from "../../hooks/providers/AlertDialogProvider";
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider"; import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
import { useSnackbar } from "../../hooks/providers/SnackbarProvider"; import { useSnackbar } from "../../hooks/providers/SnackbarProvider";
@ -27,16 +27,21 @@ import { VMDisksList } from "../forms/VMDisksList";
import { VMNetworksList } from "../forms/VMNetworksList"; import { VMNetworksList } from "../forms/VMNetworksList";
import { VMSelectIsoInput } from "../forms/VMSelectIsoInput"; import { VMSelectIsoInput } from "../forms/VMSelectIsoInput";
import { VMScreenshot } from "./VMScreenshot"; import { VMScreenshot } from "./VMScreenshot";
import { DiskImage, DiskImageApi } from "../../api/DiskImageApi";
interface DetailsProps { interface DetailsProps {
vm: VMInfo; vm: VMInfo;
editable: boolean; editable: boolean;
onChange?: () => void; onChange?: () => void;
screenshot?: boolean; screenshot?: boolean;
state?: VMState | undefined;
} }
export function VMDetails(p: DetailsProps): React.ReactElement { export function VMDetails(p: DetailsProps): React.ReactElement {
const [groupsList, setGroupsList] = React.useState<string[] | undefined>(); const [groupsList, setGroupsList] = React.useState<string[] | undefined>();
const [diskImagesList, setDiskImagesList] = React.useState<
DiskImage[] | undefined
>();
const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>(); const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>();
const [bridgesList, setBridgesList] = React.useState<string[] | undefined>(); const [bridgesList, setBridgesList] = React.useState<string[] | undefined>();
const [vcpuCombinations, setVCPUCombinations] = React.useState< const [vcpuCombinations, setVCPUCombinations] = React.useState<
@ -51,6 +56,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
const load = async () => { const load = async () => {
setGroupsList(await GroupApi.GetList()); setGroupsList(await GroupApi.GetList());
setDiskImagesList(await DiskImageApi.GetList());
setIsoList(await IsoFilesApi.GetList()); setIsoList(await IsoFilesApi.GetList());
setBridgesList(await ServerApi.GetNetworksBridgesList()); setBridgesList(await ServerApi.GetNetworksBridgesList());
setVCPUCombinations(await ServerApi.NumberVCPUs()); setVCPUCombinations(await ServerApi.NumberVCPUs());
@ -66,6 +72,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
build={() => ( build={() => (
<VMDetailsInner <VMDetailsInner
groupsList={groupsList!} groupsList={groupsList!}
diskImagesList={diskImagesList!}
isoList={isoList!} isoList={isoList!}
bridgesList={bridgesList!} bridgesList={bridgesList!}
vcpuCombinations={vcpuCombinations!} vcpuCombinations={vcpuCombinations!}
@ -89,6 +96,7 @@ enum VMTab {
type DetailsInnerProps = DetailsProps & { type DetailsInnerProps = DetailsProps & {
groupsList: string[]; groupsList: string[];
diskImagesList: DiskImage[];
isoList: IsoFile[]; isoList: IsoFile[];
bridgesList: string[]; bridgesList: string[];
vcpuCombinations: number[]; vcpuCombinations: number[];
@ -279,14 +287,16 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement {
label="Memory (MB)" label="Memory (MB)"
editable={p.editable} editable={p.editable}
type="number" type="number"
value={p.vm.memory.toString()} value={Math.floor(p.vm.memory / (1000 * 1000)).toString()}
onValueChange={(v) => { onValueChange={(v) => {
p.vm.memory = Number(v ?? "0"); p.vm.memory = Number(v ?? "0") * 1000 * 1000;
p.onChange?.(); p.onChange?.();
}} }}
checkValue={(v) => checkValue={(v) =>
Number(v) > ServerApi.Config.constraints.memory_size.min && Number(v) >
Number(v) < ServerApi.Config.constraints.memory_size.max ServerApi.Config.constraints.memory_size.min / (1000 * 1000) &&
Number(v) <
ServerApi.Config.constraints.memory_size.max / (1000 * 1000)
} }
/> />

View File

@ -0,0 +1,21 @@
import { mdiHarddisk } from "@mdi/js";
import { Icon } from "@mdi/react";
import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material";
import { filesize } from "filesize";
import { VMFileDisk } from "../../api/VMApi";
export function VMDiskFileWidget(p: { disk: VMFileDisk }): React.ReactElement {
return (
<ListItem>
<ListItemAvatar>
<Avatar>
<Icon path={mdiHarddisk} />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={p.disk.name}
secondary={`${p.disk.format} - ${filesize(p.disk.size)}`}
/>
</ListItem>
);
}