Compare commits
38 Commits
5e3ca1356a
...
20250531v2
Author | SHA1 | Date | |
---|---|---|---|
d1ca9aee39 | |||
f850ca5cb7 | |||
4ee01cad4b | |||
5518b45219 | |||
0279907ca9 | |||
5fe481ffed | |||
c7cc15d8d0 | |||
22416badcf | |||
ef0d77f1d6 | |||
1d4af8c74e | |||
ec9492c933 | |||
fa03ae885f | |||
ea98aaf856 | |||
794d16bdaa | |||
a3ac56f849 | |||
6130f37336 | |||
6b6fef5ccc | |||
83df7e1b20 | |||
a18310e04a | |||
dd7f9176fa | |||
d5fbc24c96 | |||
d765f9c2c3 | |||
21fd5de139 | |||
42f22c110c | |||
9822c5a72a | |||
452a395525 | |||
80d81c34bb | |||
b9353326f5 | |||
3ffc64f129 | |||
e869517bb1 | |||
90f4bf35e9 | |||
80d6fe0298 | |||
e017fe96d5 | |||
e7ac0198ab | |||
927a51cda7 | |||
615dc1ed83 | |||
20de618568 | |||
7451f1b7b4 |
@ -245,7 +245,7 @@ impl AppConfig {
|
||||
storage_path.canonicalize().unwrap()
|
||||
}
|
||||
|
||||
/// Get iso storage directory
|
||||
/// Get iso files storage directory
|
||||
pub fn iso_storage_path(&self) -> PathBuf {
|
||||
self.storage_path().join("iso")
|
||||
}
|
||||
@ -255,6 +255,11 @@ impl AppConfig {
|
||||
self.storage_path().join("disk_images")
|
||||
}
|
||||
|
||||
/// Get the path of a disk image file
|
||||
pub fn disk_images_file_path(&self, name: &str) -> PathBuf {
|
||||
self.disk_images_storage_path().join(name)
|
||||
}
|
||||
|
||||
/// Get VM vnc sockets directory
|
||||
pub fn vnc_sockets_path(&self) -> PathBuf {
|
||||
self.storage_path().join("vnc")
|
||||
@ -265,15 +270,17 @@ impl AppConfig {
|
||||
self.vnc_sockets_path().join(format!("vnc-{}", name))
|
||||
}
|
||||
|
||||
/// Get VM vnc sockets directory
|
||||
pub fn disks_storage_path(&self) -> PathBuf {
|
||||
/// Get VM root disks storage directory
|
||||
pub fn root_vm_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.disks_storage_path().join(id.as_string())
|
||||
self.root_vm_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,3 +1,5 @@
|
||||
use crate::utils::file_size_utils::FileSize;
|
||||
|
||||
/// Name of the cookie that contains session information
|
||||
pub const SESSION_COOKIE_NAME: &str = "X-auth-token";
|
||||
|
||||
@ -25,20 +27,23 @@ pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [
|
||||
];
|
||||
|
||||
/// ISO max size
|
||||
pub const ISO_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000;
|
||||
pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10);
|
||||
|
||||
/// Allowed uploaded disk images formats
|
||||
pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 2] =
|
||||
["application/x-qemu-disk", "application/gzip"];
|
||||
pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 3] = [
|
||||
"application/x-qemu-disk",
|
||||
"application/gzip",
|
||||
"application/octet-stream",
|
||||
];
|
||||
|
||||
/// Disk image max size
|
||||
pub const DISK_IMAGE_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000 * 1000;
|
||||
pub const DISK_IMAGE_MAX_SIZE: FileSize = FileSize::from_gb(10 * 1000);
|
||||
|
||||
/// Min VM memory size (MB)
|
||||
pub const MIN_VM_MEMORY: usize = 100;
|
||||
/// Min VM memory size
|
||||
pub const MIN_VM_MEMORY: FileSize = FileSize::from_mb(100);
|
||||
|
||||
/// Max VM memory size (MB)
|
||||
pub const MAX_VM_MEMORY: usize = 64000;
|
||||
/// Max VM memory size
|
||||
pub const MAX_VM_MEMORY: FileSize = FileSize::from_gb(64);
|
||||
|
||||
/// Disk name min length
|
||||
pub const DISK_NAME_MIN_LEN: usize = 2;
|
||||
@ -47,10 +52,10 @@ pub const DISK_NAME_MIN_LEN: usize = 2;
|
||||
pub const DISK_NAME_MAX_LEN: usize = 10;
|
||||
|
||||
/// Disk size min (B)
|
||||
pub const DISK_SIZE_MIN: usize = 100 * 1000 * 1000;
|
||||
pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50);
|
||||
|
||||
/// Disk size max (B)
|
||||
pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 1000 * 1000 * 2;
|
||||
pub const DISK_SIZE_MAX: FileSize = FileSize::from_gb(20000);
|
||||
|
||||
/// Net nat entry comment max size
|
||||
pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250;
|
||||
@ -121,3 +126,15 @@ 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,11 +1,14 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::constants;
|
||||
use crate::controllers::HttpResult;
|
||||
use crate::utils::file_disks_utils::DiskFileInfo;
|
||||
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::HttpResponse;
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
|
||||
#[derive(Debug, MultipartForm)]
|
||||
pub struct UploadDiskImageForm {
|
||||
@ -23,7 +26,7 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>)
|
||||
let file = form.files.remove(0);
|
||||
|
||||
// Check uploaded file size
|
||||
if file.size > constants::DISK_IMAGE_MAX_SIZE {
|
||||
if file.size > constants::DISK_IMAGE_MAX_SIZE.as_bytes() {
|
||||
return Ok(HttpResponse::BadRequest().json("Disk image max size exceeded!"));
|
||||
}
|
||||
|
||||
@ -46,7 +49,7 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>)
|
||||
}
|
||||
|
||||
// Check if a file with the same name already exists
|
||||
let dest_path = AppConfig::get().disk_images_storage_path().join(file_name);
|
||||
let dest_path = AppConfig::get().disk_images_file_path(&file_name);
|
||||
if dest_path.is_file() {
|
||||
return Ok(HttpResponse::Conflict().json("A file with the same name already exists!"));
|
||||
}
|
||||
@ -67,3 +70,178 @@ pub async fn get_list() -> HttpResult {
|
||||
|
||||
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 {
|
||||
if file.size > constants::ISO_MAX_SIZE.as_bytes() {
|
||||
log::error!("Uploaded ISO file is too large!");
|
||||
return Ok(HttpResponse::BadRequest().json("File is too large!"));
|
||||
}
|
||||
@ -88,7 +88,7 @@ pub async fn upload_from_url(req: web::Json<DownloadFromURLReq>) -> HttpResult {
|
||||
let response = reqwest::get(&req.url).await?;
|
||||
|
||||
if let Some(len) = response.content_length() {
|
||||
if len > constants::ISO_MAX_SIZE as u64 {
|
||||
if len > constants::ISO_MAX_SIZE.as_bytes() as u64 {
|
||||
return Ok(HttpResponse::BadRequest().json("File is too large!"));
|
||||
}
|
||||
}
|
||||
@ -132,12 +132,12 @@ pub async fn get_list() -> HttpResult {
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct DownloadFilePath {
|
||||
pub struct IsoFilePath {
|
||||
filename: String,
|
||||
}
|
||||
|
||||
/// Download ISO file
|
||||
pub async fn download_file(p: web::Path<DownloadFilePath>, req: HttpRequest) -> HttpResult {
|
||||
pub async fn download_file(p: web::Path<IsoFilePath>, 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<DownloadFilePath>, req: HttpRequest) ->
|
||||
}
|
||||
|
||||
/// Delete ISO file
|
||||
pub async fn delete_file(p: web::Path<DownloadFilePath>) -> HttpResult {
|
||||
pub async fn delete_file(p: web::Path<IsoFilePath>) -> HttpResult {
|
||||
if !files_utils::check_file_name(&p.filename) {
|
||||
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
|
||||
}
|
||||
|
@ -46,6 +46,7 @@ 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,
|
||||
@ -70,8 +71,8 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
|
||||
builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES,
|
||||
nwfilter_chains: &constants::NETWORK_CHAINS,
|
||||
constraints: ServerConstraints {
|
||||
iso_max_size: constants::ISO_MAX_SIZE,
|
||||
disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE,
|
||||
iso_max_size: constants::ISO_MAX_SIZE.as_bytes(),
|
||||
disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE.as_bytes(),
|
||||
|
||||
vnc_token_duration: VNC_TOKEN_LIFETIME,
|
||||
|
||||
@ -79,18 +80,20 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
|
||||
vm_title_size: LenConstraints { min: 0, max: 50 },
|
||||
group_id_size: LenConstraints { min: 3, max: 50 },
|
||||
memory_size: LenConstraints {
|
||||
min: constants::MIN_VM_MEMORY,
|
||||
max: constants::MAX_VM_MEMORY,
|
||||
min: constants::MIN_VM_MEMORY.as_bytes(),
|
||||
max: constants::MAX_VM_MEMORY.as_bytes(),
|
||||
},
|
||||
disk_name_size: LenConstraints {
|
||||
min: DISK_NAME_MIN_LEN,
|
||||
max: DISK_NAME_MAX_LEN,
|
||||
},
|
||||
disk_size: LenConstraints {
|
||||
min: DISK_SIZE_MIN,
|
||||
max: DISK_SIZE_MAX,
|
||||
min: DISK_SIZE_MIN.as_bytes(),
|
||||
max: DISK_SIZE_MAX.as_bytes(),
|
||||
},
|
||||
|
||||
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,10 +22,13 @@ pub struct DomainMetadataXML {
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename = "os")]
|
||||
pub struct OSXML {
|
||||
#[serde(rename = "@firmware", default)]
|
||||
pub firmware: String,
|
||||
#[serde(rename = "@firmware", default, skip_serializing_if = "Option::is_none")]
|
||||
pub firmware: Option<String>,
|
||||
pub r#type: OSTypeXML,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub loader: Option<OSLoaderXML>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bootmenu: Option<OSBootMenuXML>,
|
||||
pub smbios: Option<OSSMBiosXML>,
|
||||
}
|
||||
|
||||
@ -49,6 +52,16 @@ pub struct OSLoaderXML {
|
||||
pub secure: String,
|
||||
}
|
||||
|
||||
/// Legacy boot menu information
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename = "bootmenu")]
|
||||
pub struct OSBootMenuXML {
|
||||
#[serde(rename = "@enable")]
|
||||
pub enable: String,
|
||||
#[serde(rename = "@timeout")]
|
||||
pub timeout: usize,
|
||||
}
|
||||
|
||||
/// SMBIOS System information
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename = "smbios")]
|
||||
|
@ -4,9 +4,9 @@ use crate::libvirt_lib_structures::XMLUuid;
|
||||
use crate::libvirt_lib_structures::domain::*;
|
||||
use crate::libvirt_rest_structures::LibVirtStructError;
|
||||
use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction;
|
||||
use crate::utils::file_disks_utils::{VMDiskFormat, VMFileDisk};
|
||||
use crate::utils::file_size_utils::FileSize;
|
||||
use crate::utils::files_utils;
|
||||
use crate::utils::files_utils::convert_size_unit_to_mb;
|
||||
use crate::utils::vm_file_disks_utils::{VMDiskBus, VMDiskFormat, VMFileDisk};
|
||||
use lazy_regex::regex;
|
||||
use num::Integer;
|
||||
|
||||
@ -17,6 +17,7 @@ pub struct VMGroupId(pub String);
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub enum BootType {
|
||||
Legacy,
|
||||
UEFI,
|
||||
UEFISecureBoot,
|
||||
}
|
||||
@ -29,6 +30,12 @@ pub enum VMArchitecture {
|
||||
X86_64,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub enum NetworkInterfaceModelType {
|
||||
Virtio,
|
||||
E1000,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct NWFilterParam {
|
||||
name: String,
|
||||
@ -46,6 +53,7 @@ pub struct Network {
|
||||
#[serde(flatten)]
|
||||
r#type: NetworkType,
|
||||
mac: String,
|
||||
model: NetworkInterfaceModelType,
|
||||
nwfilterref: Option<NWFilterRef>,
|
||||
}
|
||||
|
||||
@ -70,8 +78,8 @@ pub struct VMInfo {
|
||||
pub group: Option<VMGroupId>,
|
||||
pub boot_type: BootType,
|
||||
pub architecture: VMArchitecture,
|
||||
/// VM allocated memory, in megabytes
|
||||
pub memory: usize,
|
||||
/// VM allocated RAM memory
|
||||
pub memory: FileSize,
|
||||
/// Number of vCPU for the VM
|
||||
pub number_vcpu: usize,
|
||||
/// Enable VNC access through admin console
|
||||
@ -196,7 +204,11 @@ impl VMInfo {
|
||||
};
|
||||
|
||||
let model = Some(NetIntModelXML {
|
||||
r#type: "virtio".to_string(),
|
||||
r#type: match n.model {
|
||||
NetworkInterfaceModelType::Virtio => "virtio",
|
||||
NetworkInterfaceModelType::E1000 => "e1000",
|
||||
}
|
||||
.to_string(),
|
||||
});
|
||||
|
||||
let filterref = if let Some(n) = &n.nwfilterref {
|
||||
@ -302,7 +314,11 @@ impl VMInfo {
|
||||
"vd{}",
|
||||
["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"][disks.len()]
|
||||
),
|
||||
bus: "virtio".to_string(),
|
||||
bus: match disk.bus {
|
||||
VMDiskBus::Virtio => "virtio",
|
||||
VMDiskBus::SATA => "sata",
|
||||
}
|
||||
.to_string(),
|
||||
},
|
||||
readonly: None,
|
||||
boot: DiskBootXML {
|
||||
@ -336,13 +352,26 @@ impl VMInfo {
|
||||
machine: "q35".to_string(),
|
||||
body: "hvm".to_string(),
|
||||
},
|
||||
firmware: "efi".to_string(),
|
||||
loader: Some(OSLoaderXML {
|
||||
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::UEFI => "no".to_string(),
|
||||
BootType::UEFISecureBoot => "yes".to_string(),
|
||||
_ => "no".to_string(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
bootmenu: match self.boot_type {
|
||||
BootType::Legacy => Some(OSBootMenuXML {
|
||||
enable: "yes".to_string(),
|
||||
timeout: 3000,
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
smbios: Some(OSSMBiosXML {
|
||||
mode: "sysinfo".to_string(),
|
||||
}),
|
||||
@ -380,7 +409,7 @@ impl VMInfo {
|
||||
|
||||
memory: DomainMemoryXML {
|
||||
unit: "MB".to_string(),
|
||||
memory: self.memory,
|
||||
memory: self.memory.as_mb(),
|
||||
},
|
||||
|
||||
vcpu: DomainVCPUXML {
|
||||
@ -434,9 +463,10 @@ impl VMInfo {
|
||||
.virtweb
|
||||
.group
|
||||
.map(VMGroupId),
|
||||
boot_type: match domain.os.loader {
|
||||
None => BootType::UEFI,
|
||||
Some(l) => match l.secure.as_str() {
|
||||
boot_type: match (domain.os.loader, domain.os.bootmenu) {
|
||||
(_, Some(_)) => BootType::Legacy,
|
||||
(None, _) => BootType::UEFI,
|
||||
(Some(l), _) => match l.secure.as_str() {
|
||||
"yes" => BootType::UEFISecureBoot,
|
||||
_ => BootType::UEFI,
|
||||
},
|
||||
@ -452,7 +482,7 @@ impl VMInfo {
|
||||
}
|
||||
},
|
||||
number_vcpu: domain.vcpu.body,
|
||||
memory: convert_size_unit_to_mb(&domain.memory.unit, domain.memory.memory)?,
|
||||
memory: FileSize::from_size_unit(&domain.memory.unit, domain.memory.memory)?,
|
||||
vnc_access: domain.devices.graphics.is_some(),
|
||||
iso_files: domain
|
||||
.devices
|
||||
@ -467,7 +497,10 @@ impl VMInfo {
|
||||
.disks
|
||||
.iter()
|
||||
.filter(|d| d.device == "disk")
|
||||
.map(|d| VMFileDisk::load_from_file(&d.source.file).unwrap())
|
||||
.map(|d| {
|
||||
VMFileDisk::load_from_file(&d.source.file, &d.target.bus)
|
||||
.expect("Failed to load file disk information!")
|
||||
})
|
||||
.collect(),
|
||||
|
||||
networks: domain
|
||||
@ -515,6 +548,18 @@ impl VMInfo {
|
||||
)));
|
||||
}
|
||||
},
|
||||
model: match d.model.as_ref() {
|
||||
None => NetworkInterfaceModelType::Virtio,
|
||||
Some(model) => match model.r#type.as_str() {
|
||||
"virtio" => NetworkInterfaceModelType::Virtio,
|
||||
"e1000" => NetworkInterfaceModelType::E1000,
|
||||
model => {
|
||||
return Err(LibVirtStructError::DomainExtraction(format!(
|
||||
"Unknown network interface model type: {model}! "
|
||||
)));
|
||||
}
|
||||
},
|
||||
},
|
||||
nwfilterref: d.filterref.as_ref().map(|f| NWFilterRef {
|
||||
name: f.filter.to_string(),
|
||||
parameters: f
|
||||
|
@ -60,7 +60,8 @@ async fn main() -> std::io::Result<()> {
|
||||
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().disks_storage_path()).unwrap();
|
||||
files_utils::create_directory_if_missing(AppConfig::get().root_vm_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();
|
||||
@ -121,10 +122,9 @@ async fn main() -> std::io::Result<()> {
|
||||
}))
|
||||
.app_data(conn.clone())
|
||||
// Uploaded files
|
||||
.app_data(
|
||||
MultipartFormConfig::default()
|
||||
.total_limit(max(constants::DISK_IMAGE_MAX_SIZE, constants::ISO_MAX_SIZE)),
|
||||
)
|
||||
.app_data(MultipartFormConfig::default().total_limit(
|
||||
max(constants::DISK_IMAGE_MAX_SIZE, constants::ISO_MAX_SIZE).as_bytes(),
|
||||
))
|
||||
.app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir))
|
||||
// Server controller
|
||||
.route(
|
||||
@ -344,6 +344,26 @@ async fn main() -> std::io::Result<()> {
|
||||
"/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,8 +1,7 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::constants;
|
||||
use crate::libvirt_lib_structures::XMLUuid;
|
||||
use crate::utils::files_utils;
|
||||
use lazy_regex::regex;
|
||||
use crate::utils::file_size_utils::FileSize;
|
||||
use std::fs::File;
|
||||
use std::os::linux::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
@ -12,193 +11,48 @@ use std::time::UNIX_EPOCH;
|
||||
enum DisksError {
|
||||
#[error("DiskParseError: {0}")]
|
||||
Parse(&'static str),
|
||||
#[error("DiskConfigError: {0}")]
|
||||
Config(&'static str),
|
||||
#[error("DiskCreateError")]
|
||||
Create,
|
||||
#[error("DiskConvertError: {0}")]
|
||||
Convert(String),
|
||||
}
|
||||
|
||||
/// Type of disk allocation
|
||||
#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum VMDiskAllocType {
|
||||
Fixed,
|
||||
Sparse,
|
||||
}
|
||||
|
||||
/// Disk allocation type
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "format")]
|
||||
pub enum VMDiskFormat {
|
||||
Raw {
|
||||
/// Type of disk allocation
|
||||
alloc_type: VMDiskAllocType,
|
||||
},
|
||||
QCow2,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct VMFileDisk {
|
||||
/// Disk name
|
||||
pub name: String,
|
||||
/// Disk size, in bytes
|
||||
pub size: usize,
|
||||
/// Disk format
|
||||
#[serde(flatten)]
|
||||
pub format: VMDiskFormat,
|
||||
/// Set this variable to true to delete the disk
|
||||
pub delete: bool,
|
||||
}
|
||||
|
||||
impl VMFileDisk {
|
||||
pub fn load_from_file(path: &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 {
|
||||
alloc_type: match is_sparse {
|
||||
true => VMDiskAllocType::Sparse,
|
||||
false => VMDiskAllocType::Fixed,
|
||||
},
|
||||
},
|
||||
DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2,
|
||||
_ => anyhow::bail!("Unsupported image format: {:?}", info.format),
|
||||
},
|
||||
delete: false,
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
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(());
|
||||
}
|
||||
|
||||
// Prepare command to create file
|
||||
let res = match self.format {
|
||||
VMDiskFormat::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 alloc_type {
|
||||
VMDiskAllocType::Fixed => cmd.arg(format!("count={}", self.size_mb())),
|
||||
VMDiskAllocType::Sparse => {
|
||||
cmd.arg(format!("seek={}", self.size_mb())).arg("count=0")
|
||||
}
|
||||
};
|
||||
|
||||
cmd.output()?
|
||||
}
|
||||
|
||||
VMDiskFormat::QCow2 => {
|
||||
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
|
||||
cmd.arg("create")
|
||||
.arg("-f")
|
||||
.arg("qcow2")
|
||||
.arg(file)
|
||||
.arg(format!("{}M", self.size_mb()));
|
||||
|
||||
cmd.output()?
|
||||
}
|
||||
};
|
||||
|
||||
// Execute Linux command
|
||||
if !res.status.success() {
|
||||
log::error!(
|
||||
"Failed to create disk! stderr={} stdout={}",
|
||||
String::from_utf8_lossy(&res.stderr),
|
||||
String::from_utf8_lossy(&res.stdout)
|
||||
);
|
||||
return Err(DisksError::Create.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the size of file disk in megabytes
|
||||
pub fn size_mb(&self) -> usize {
|
||||
self.size / (1000 * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Copy, Clone, PartialEq, Eq)]
|
||||
#[serde(tag = "format")]
|
||||
pub enum DiskFileFormat {
|
||||
Raw { is_sparse: bool },
|
||||
QCow2 { virtual_size: usize },
|
||||
Raw {
|
||||
#[serde(default)]
|
||||
is_sparse: bool,
|
||||
},
|
||||
QCow2 {
|
||||
#[serde(default)]
|
||||
virtual_size: FileSize,
|
||||
},
|
||||
CompressedRaw,
|
||||
CompressedQCow2,
|
||||
}
|
||||
|
||||
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 {
|
||||
file_size: usize,
|
||||
pub file_path: PathBuf,
|
||||
pub file_size: FileSize,
|
||||
#[serde(flatten)]
|
||||
format: DiskFileFormat,
|
||||
file_name: String,
|
||||
name: String,
|
||||
created: u64,
|
||||
pub format: DiskFileFormat,
|
||||
pub file_name: String,
|
||||
pub name: String,
|
||||
pub created: u64,
|
||||
}
|
||||
|
||||
impl DiskFileInfo {
|
||||
@ -234,8 +88,9 @@ impl DiskFileInfo {
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
file_path: file.to_path_buf(),
|
||||
name,
|
||||
file_size: metadata.len() as usize,
|
||||
file_size: FileSize::from_bytes(metadata.len() as usize),
|
||||
format,
|
||||
file_name: file
|
||||
.file_name()
|
||||
@ -249,6 +104,232 @@ impl DiskFileInfo {
|
||||
.as_secs(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new empty disk
|
||||
pub fn create(file: &Path, format: DiskFileFormat, size: FileSize) -> anyhow::Result<()> {
|
||||
// Prepare command to create file
|
||||
let res = match format {
|
||||
DiskFileFormat::Raw { is_sparse } => {
|
||||
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"),
|
||||
};
|
||||
|
||||
cmd.output()?
|
||||
}
|
||||
|
||||
DiskFileFormat::QCow2 { virtual_size } => {
|
||||
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()));
|
||||
|
||||
cmd.output()?
|
||||
}
|
||||
_ => anyhow::bail!("Cannot create disk file image of this format: {format:?}!"),
|
||||
};
|
||||
|
||||
// Execute Linux command
|
||||
if !res.status.success() {
|
||||
log::error!(
|
||||
"Failed to create disk! stderr={} stdout={}",
|
||||
String::from_utf8_lossy(&res.stderr),
|
||||
String::from_utf8_lossy(&res.stdout)
|
||||
);
|
||||
return Err(DisksError::Create.into());
|
||||
}
|
||||
|
||||
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("-f")
|
||||
.arg("qcow2")
|
||||
.arg("-O")
|
||||
.arg("raw")
|
||||
.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("-f")
|
||||
.arg("qcow2")
|
||||
.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("-f")
|
||||
.arg("raw")
|
||||
.arg("-O")
|
||||
.arg("qcow2")
|
||||
.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)]
|
||||
@ -258,7 +339,7 @@ struct QCowInfoOutput {
|
||||
}
|
||||
|
||||
/// Get QCow2 virtual size
|
||||
fn qcow_virt_size(path: &Path) -> anyhow::Result<usize> {
|
||||
fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> {
|
||||
// Run qemu-img
|
||||
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
|
||||
cmd.args([
|
||||
@ -281,5 +362,5 @@ fn qcow_virt_size(path: &Path) -> anyhow::Result<usize> {
|
||||
|
||||
// Decode JSON
|
||||
let decoded: QCowInfoOutput = serde_json::from_str(&res_json)?;
|
||||
Ok(decoded.virtual_size)
|
||||
Ok(FileSize::from_bytes(decoded.virtual_size))
|
||||
}
|
||||
|
91
virtweb_backend/src/utils/file_size_utils.rs
Normal file
91
virtweb_backend/src/utils/file_size_utils.rs
Normal file
@ -0,0 +1,91 @@
|
||||
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,13 +1,6 @@
|
||||
use std::ops::{Div, Mul};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
enum FilesUtilsError {
|
||||
#[error("UnitConvertError: {0}")]
|
||||
UnitConvert(String),
|
||||
}
|
||||
|
||||
const INVALID_CHARS: [&str; 19] = [
|
||||
"@", "\\", "/", ":", ",", "<", ">", "%", "'", "\"", "?", "{", "}", "$", "*", "|", ";", "=",
|
||||
"\t",
|
||||
@ -35,31 +28,9 @@ pub fn set_file_permission<P: AsRef<Path>>(path: P, mode: u32) -> anyhow::Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert size unit to MB
|
||||
pub fn convert_size_unit_to_mb(unit: &str, value: usize) -> anyhow::Result<usize> {
|
||||
let fact = match unit {
|
||||
"bytes" | "b" => 1f64,
|
||||
"KB" => 1000f64,
|
||||
"MB" => 1000f64 * 1000f64,
|
||||
"GB" => 1000f64 * 1000f64 * 1000f64,
|
||||
"TB" => 1000f64 * 1000f64 * 1000f64 * 1000f64,
|
||||
|
||||
"k" | "KiB" => 1024f64,
|
||||
"M" | "MiB" => 1024f64 * 1024f64,
|
||||
"G" | "GiB" => 1024f64 * 1024f64 * 1024f64,
|
||||
"T" | "TiB" => 1024f64 * 1024f64 * 1024f64 * 1024f64,
|
||||
|
||||
_ => {
|
||||
return Err(FilesUtilsError::UnitConvert(format!("Unknown size unit: {unit}")).into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok((value as f64).mul(fact.div((1000 * 1000) as f64)).ceil() as usize)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::utils::files_utils::{check_file_name, convert_size_unit_to_mb};
|
||||
use crate::utils::files_utils::check_file_name;
|
||||
|
||||
#[test]
|
||||
fn empty_file_name() {
|
||||
@ -85,14 +56,4 @@ mod test {
|
||||
fn valid_file_name() {
|
||||
assert!(check_file_name("test.iso"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_units_mb() {
|
||||
assert_eq!(convert_size_unit_to_mb("MB", 1).unwrap(), 1);
|
||||
assert_eq!(convert_size_unit_to_mb("MB", 1000).unwrap(), 1000);
|
||||
assert_eq!(convert_size_unit_to_mb("GB", 1000).unwrap(), 1000 * 1000);
|
||||
assert_eq!(convert_size_unit_to_mb("GB", 1).unwrap(), 1000);
|
||||
assert_eq!(convert_size_unit_to_mb("GiB", 3).unwrap(), 3222);
|
||||
assert_eq!(convert_size_unit_to_mb("KiB", 488281).unwrap(), 500);
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
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;
|
||||
|
174
virtweb_backend/src/utils/vm_file_disks_utils.rs
Normal file
174
virtweb_backend/src/utils/vm_file_disks_utils.rs
Normal file
@ -0,0 +1,174 @@
|
||||
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"
|
||||
cargo fmt && cargo clippy && cargo run -- -s /var/virtweb --hypervisor-uri "qemu:///system" --website-origin "http://localhost:5173"
|
||||
```
|
||||
|
||||
7. Run the frontend
|
||||
|
@ -103,6 +103,7 @@ export class APIClient {
|
||||
body: body,
|
||||
headers: headers,
|
||||
credentials: "include",
|
||||
signal: AbortSignal.timeout(50 * 1000 * 1000),
|
||||
});
|
||||
|
||||
// Process response
|
||||
|
@ -1,16 +1,18 @@
|
||||
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;
|
||||
} & (
|
||||
| { format: "Raw"; is_sparse: boolean }
|
||||
| { format: "QCow2"; virtual_size: number }
|
||||
| { format: "CompressedQCow2" }
|
||||
| { format: "CompressedRaw" }
|
||||
);
|
||||
} & DiskImageFormat;
|
||||
|
||||
export class DiskImageApi {
|
||||
/**
|
||||
@ -42,4 +44,74 @@ export class DiskImageApi {
|
||||
})
|
||||
).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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ 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,21 +19,26 @@ export type VMState =
|
||||
|
||||
export type VMFileDisk = BaseFileVMDisk & (RawVMDisk | QCow2Disk);
|
||||
|
||||
export type DiskBusType = "Virtio" | "SATA";
|
||||
|
||||
export interface BaseFileVMDisk {
|
||||
size: number;
|
||||
name: string;
|
||||
bus: DiskBusType;
|
||||
|
||||
delete: boolean;
|
||||
|
||||
// application attribute
|
||||
// For new disk only
|
||||
from_image?: string;
|
||||
|
||||
// application attributes
|
||||
new?: boolean;
|
||||
deleteType?: "keepfile" | "deletefile";
|
||||
}
|
||||
|
||||
export type DiskAllocType = "Sparse" | "Fixed";
|
||||
|
||||
interface RawVMDisk {
|
||||
format: "Raw";
|
||||
alloc_type: DiskAllocType;
|
||||
is_sparse: boolean;
|
||||
}
|
||||
|
||||
interface QCow2Disk {
|
||||
@ -59,6 +64,7 @@ export type VMNetInterface = (
|
||||
|
||||
export interface VMNetInterfaceBase {
|
||||
mac: string;
|
||||
model: "Virtio" | "E1000";
|
||||
nwfilterref?: VMNetInterfaceFilter;
|
||||
}
|
||||
|
||||
@ -76,6 +82,8 @@ export interface VMNetBridge {
|
||||
bridge: string;
|
||||
}
|
||||
|
||||
export type VMBootType = "UEFI" | "UEFISecureBoot" | "Legacy";
|
||||
|
||||
interface VMInfoInterface {
|
||||
name: string;
|
||||
uuid?: string;
|
||||
@ -83,7 +91,7 @@ interface VMInfoInterface {
|
||||
title?: string;
|
||||
description?: string;
|
||||
group?: string;
|
||||
boot_type: "UEFI" | "UEFISecureBoot";
|
||||
boot_type: VMBootType;
|
||||
architecture: "i686" | "x86_64";
|
||||
memory: number;
|
||||
number_vcpu: number;
|
||||
@ -102,7 +110,7 @@ export class VMInfo implements VMInfoInterface {
|
||||
title?: string;
|
||||
description?: string;
|
||||
group?: string;
|
||||
boot_type: "UEFI" | "UEFISecureBoot";
|
||||
boot_type: VMBootType;
|
||||
architecture: "i686" | "x86_64";
|
||||
number_vcpu: number;
|
||||
memory: number;
|
||||
@ -137,7 +145,7 @@ export class VMInfo implements VMInfoInterface {
|
||||
name: "",
|
||||
boot_type: "UEFI",
|
||||
architecture: "x86_64",
|
||||
memory: 1024,
|
||||
memory: 1000 * 1000 * 1000,
|
||||
number_vcpu: 1,
|
||||
vnc_access: true,
|
||||
iso_files: [],
|
||||
|
144
virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
Normal file
144
virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
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,18 +1,34 @@
|
||||
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";
|
||||
@ -32,13 +48,6 @@ export function DiskImagesRoute(): React.ReactElement {
|
||||
};
|
||||
|
||||
return (
|
||||
<VirtWebRouteContainer label="Disk images">
|
||||
<AsyncWidget
|
||||
loadKey={loadKey.current}
|
||||
errMsg="Failed to load disk images list!"
|
||||
load={load}
|
||||
ready={list !== undefined}
|
||||
build={() => (
|
||||
<VirtWebRouteContainer
|
||||
label="Disk images management"
|
||||
actions={
|
||||
@ -51,9 +60,16 @@ export function DiskImagesRoute(): React.ReactElement {
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</VirtWebRouteContainer>
|
||||
@ -148,5 +164,254 @@ function DiskImageList(p: {
|
||||
list: DiskImage[];
|
||||
onReload: () => void;
|
||||
}): React.ReactElement {
|
||||
return <>todo</>;
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@ -58,6 +59,7 @@ export function TokensListRouteInner(p: {
|
||||
</RouterLink>
|
||||
}
|
||||
>
|
||||
{p.list.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
@ -122,6 +124,13 @@ export function TokensListRouteInner(p: {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{p.list.length === 0 && (
|
||||
<Typography style={{ textAlign: "center" }}>
|
||||
No API token created yet.
|
||||
</Typography>
|
||||
)}
|
||||
</VirtWebRouteContainer>
|
||||
);
|
||||
}
|
||||
|
@ -154,7 +154,7 @@ function VMListWidget(p: {
|
||||
{row.name}
|
||||
</TableCell>
|
||||
<TableCell>{row.description ?? ""}</TableCell>
|
||||
<TableCell>{vmMemoryToHuman(row.memory)}</TableCell>
|
||||
<TableCell>{filesize(row.memory)}</TableCell>
|
||||
<TableCell>{row.number_vcpu}</TableCell>
|
||||
<TableCell>
|
||||
<VMStatusWidget
|
||||
@ -183,13 +183,13 @@ function VMListWidget(p: {
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell>
|
||||
{vmMemoryToHuman(
|
||||
{filesize(
|
||||
p.list
|
||||
.filter((v) => runningVMs.has(v.name))
|
||||
.reduce((s, v) => s + v.memory, 0)
|
||||
)}
|
||||
{" / "}
|
||||
{vmMemoryToHuman(p.list.reduce((s, v) => s + v.memory, 0))}
|
||||
{filesize(p.list.reduce((s, v) => s + v.memory, 0))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p.list
|
||||
@ -206,7 +206,3 @@ function VMListWidget(p: {
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function vmMemoryToHuman(size: number): string {
|
||||
return filesize(size * 1000 * 1000);
|
||||
}
|
||||
|
@ -59,6 +59,7 @@ function VMRouteBody(p: { vm: VMInfo }): React.ReactElement {
|
||||
<VMDetails
|
||||
vm={p.vm}
|
||||
editable={false}
|
||||
state={state}
|
||||
screenshot={p.vm.vnc_access && state === "Running"}
|
||||
/>
|
||||
</VirtWebRouteContainer>
|
||||
|
13
virtweb_frontend/src/widgets/DateWidget.tsx
Normal file
13
virtweb_frontend/src/widgets/DateWidget.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
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");
|
||||
}
|
23
virtweb_frontend/src/widgets/FileDiskImageWidget.tsx
Normal file
23
virtweb_frontend/src/widgets/FileDiskImageWidget.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
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>
|
||||
);
|
||||
}
|
24
virtweb_frontend/src/widgets/forms/DiskBusSelect.tsx
Normal file
24
virtweb_frontend/src/widgets/forms/DiskBusSelect.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { DiskBusType } from "../../api/VMApi";
|
||||
import { SelectInput } from "./SelectInput";
|
||||
|
||||
export function DiskBusSelect(p: {
|
||||
editable: boolean;
|
||||
value: DiskBusType;
|
||||
label?: string;
|
||||
onValueChange: (value: DiskBusType) => void;
|
||||
size?: "medium" | "small";
|
||||
disableUnderline?: boolean;
|
||||
disableBottomMargin?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<SelectInput
|
||||
{...p}
|
||||
label={p.label ?? "Disk bus type"}
|
||||
options={[
|
||||
{ label: "virtio", value: "Virtio" },
|
||||
{ label: "sata", value: "SATA" },
|
||||
]}
|
||||
onValueChange={(v) => { p.onValueChange(v as any); }}
|
||||
/>
|
||||
);
|
||||
}
|
40
virtweb_frontend/src/widgets/forms/DiskImageSelect.tsx
Normal file
40
virtweb_frontend/src/widgets/forms/DiskImageSelect.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import {
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
SelectChangeEvent,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { DiskImage } from "../../api/DiskImageApi";
|
||||
import { FileDiskImageWidget } from "../FileDiskImageWidget";
|
||||
|
||||
/**
|
||||
* Select a disk image
|
||||
*/
|
||||
export function DiskImageSelect(p: {
|
||||
label: string;
|
||||
value?: string;
|
||||
onValueChange: (image: string | undefined) => void;
|
||||
list: DiskImage[];
|
||||
}): React.ReactElement {
|
||||
const handleChange = (event: SelectChangeEvent) => {
|
||||
p.onValueChange(event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl fullWidth variant="standard">
|
||||
<InputLabel>{p.label}</InputLabel>
|
||||
<Select value={p.value} label={p.label} onChange={handleChange}>
|
||||
<MenuItem value={undefined}>
|
||||
<i>None</i>
|
||||
</MenuItem>
|
||||
{p.list.map((d) => (
|
||||
<MenuItem key={d.file_name} value={d.file_name}>
|
||||
<FileDiskImageWidget image={d} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
@ -17,8 +17,11 @@ export function SelectInput(p: {
|
||||
value?: string;
|
||||
editable: boolean;
|
||||
label?: string;
|
||||
size?: "medium" | "small";
|
||||
options: SelectOption[];
|
||||
onValueChange: (o?: string) => void;
|
||||
disableUnderline?: boolean;
|
||||
disableBottomMargin?: boolean;
|
||||
}): React.ReactElement {
|
||||
if (!p.editable && !p.value) return <></>;
|
||||
|
||||
@ -28,12 +31,18 @@ export function SelectInput(p: {
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl fullWidth variant="standard" style={{ marginBottom: "15px" }}>
|
||||
<FormControl
|
||||
fullWidth
|
||||
variant="standard"
|
||||
style={{ marginBottom: p.disableBottomMargin ? "0px" : "15px" }}
|
||||
>
|
||||
{p.label && <InputLabel>{p.label}</InputLabel>}
|
||||
<Select
|
||||
{...p}
|
||||
value={p.value ?? ""}
|
||||
label={p.label}
|
||||
onChange={(e) => { p.onValueChange(e.target.value); }}
|
||||
onChange={(e) => {
|
||||
p.onValueChange(e.target.value);
|
||||
}}
|
||||
>
|
||||
{p.options.map((e) => (
|
||||
<MenuItem
|
||||
|
@ -17,6 +17,7 @@ export function TextInput(p: {
|
||||
type?: React.HTMLInputTypeAttribute;
|
||||
style?: React.CSSProperties;
|
||||
helperText?: string;
|
||||
disabled?: boolean;
|
||||
}): React.ReactElement {
|
||||
if (!p.editable && (p.value ?? "") === "") return <></>;
|
||||
|
||||
@ -35,6 +36,7 @@ export function TextInput(p: {
|
||||
|
||||
return (
|
||||
<TextField
|
||||
disabled={p.disabled}
|
||||
label={p.label}
|
||||
value={p.value ?? ""}
|
||||
onChange={(e) =>
|
||||
|
@ -1,33 +1,37 @@
|
||||
import { mdiHarddisk } from "@mdi/js";
|
||||
import { mdiHarddiskPlus } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
IconButton,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import { filesize } from "filesize";
|
||||
import { Button, IconButton, Paper, Tooltip } from "@mui/material";
|
||||
import React from "react";
|
||||
import { DiskImage } from "../../api/DiskImageApi";
|
||||
import { ServerApi } from "../../api/ServerApi";
|
||||
import { VMFileDisk, VMInfo } from "../../api/VMApi";
|
||||
import { VMFileDisk, VMInfo, VMState } from "../../api/VMApi";
|
||||
import { ConvertDiskImageDialog } from "../../dialogs/ConvertDiskImageDialog";
|
||||
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
|
||||
import { VMDiskFileWidget } from "../vms/VMDiskFileWidget";
|
||||
import { CheckboxInput } from "./CheckboxInput";
|
||||
import { DiskBusSelect } from "./DiskBusSelect";
|
||||
import { DiskImageSelect } from "./DiskImageSelect";
|
||||
import { SelectInput } from "./SelectInput";
|
||||
import { TextInput } from "./TextInput";
|
||||
|
||||
export function VMDisksList(p: {
|
||||
vm: VMInfo;
|
||||
state?: VMState;
|
||||
onChange?: () => void;
|
||||
editable: boolean;
|
||||
diskImagesList: DiskImage[];
|
||||
}): React.ReactElement {
|
||||
const [currBackupRequest, setCurrBackupRequest] = React.useState<
|
||||
VMFileDisk | undefined
|
||||
>();
|
||||
|
||||
const addNewDisk = () => {
|
||||
p.vm.file_disks.push({
|
||||
format: "QCow2",
|
||||
size: 10000 * 1000 * 1000,
|
||||
bus: "Virtio",
|
||||
delete: false,
|
||||
name: `disk${p.vm.file_disks.length}`,
|
||||
new: true,
|
||||
@ -35,6 +39,14 @@ export function VMDisksList(p: {
|
||||
p.onChange?.();
|
||||
};
|
||||
|
||||
const handleBackupRequest = (disk: VMFileDisk) => {
|
||||
setCurrBackupRequest(disk);
|
||||
};
|
||||
|
||||
const handleFinishBackup = () => {
|
||||
setCurrBackupRequest(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* disks list */}
|
||||
@ -43,25 +55,42 @@ export function VMDisksList(p: {
|
||||
// eslint-disable-next-line react-x/no-array-index-key
|
||||
key={num}
|
||||
editable={p.editable}
|
||||
canBackup={!p.editable && !d.new && p.state !== "Running"}
|
||||
disk={d}
|
||||
onChange={p.onChange}
|
||||
removeFromList={() => {
|
||||
p.vm.file_disks.splice(num, 1);
|
||||
p.onChange?.();
|
||||
}}
|
||||
onRequestBackup={handleBackupRequest}
|
||||
diskImagesList={p.diskImagesList}
|
||||
/>
|
||||
))}
|
||||
|
||||
{p.editable && <Button onClick={addNewDisk}>Add new disk</Button>}
|
||||
|
||||
{/* Disk backup */}
|
||||
{currBackupRequest && (
|
||||
<ConvertDiskImageDialog
|
||||
backup
|
||||
onCancel={handleFinishBackup}
|
||||
onFinished={handleFinishBackup}
|
||||
vm={p.vm}
|
||||
disk={currBackupRequest}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DiskInfo(p: {
|
||||
editable: boolean;
|
||||
canBackup: boolean;
|
||||
disk: VMFileDisk;
|
||||
onChange?: () => void;
|
||||
removeFromList: () => void;
|
||||
onRequestBackup: (disk: VMFileDisk) => void;
|
||||
diskImagesList: DiskImage[];
|
||||
}): React.ReactElement {
|
||||
const confirm = useConfirm();
|
||||
const deleteDisk = async () => {
|
||||
@ -86,9 +115,11 @@ function DiskInfo(p: {
|
||||
|
||||
if (!p.editable || !p.disk.new)
|
||||
return (
|
||||
<ListItem
|
||||
<VMDiskFileWidget
|
||||
{...p}
|
||||
secondaryAction={
|
||||
p.editable && (
|
||||
<>
|
||||
{p.editable && (
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="delete disk"
|
||||
@ -104,32 +135,22 @@ function DiskInfo(p: {
|
||||
</Tooltip>
|
||||
)}
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
)}
|
||||
|
||||
{p.canBackup && (
|
||||
<Tooltip title="Backup this disk">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
p.onRequestBackup(p.disk);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<Icon path={mdiHarddiskPlus} size={1} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
secondary={`${filesize(p.disk.size)} - ${p.disk.format}${
|
||||
p.disk.format == "Raw" ? " - " + p.disk.alloc_type : ""
|
||||
}`}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -151,6 +172,46 @@ function DiskInfo(p: {
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<SelectInput
|
||||
editable={true}
|
||||
label="Disk format"
|
||||
options={[
|
||||
{ label: "Raw file", value: "Raw" },
|
||||
{ label: "QCow2", value: "QCow2" },
|
||||
]}
|
||||
value={p.disk.format}
|
||||
onValueChange={(v) => {
|
||||
p.disk.format = v as any;
|
||||
|
||||
if (p.disk.format === "Raw") p.disk.is_sparse = true;
|
||||
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bus selection */}
|
||||
<DiskBusSelect
|
||||
editable
|
||||
value={p.disk.bus}
|
||||
onValueChange={(v) => {
|
||||
p.disk.bus = v;
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Raw disk: choose sparse mode */}
|
||||
{p.disk.format === "Raw" && (
|
||||
<CheckboxInput
|
||||
editable
|
||||
label="Sparse file"
|
||||
checked={p.disk.is_sparse}
|
||||
onValueChange={(v) => {
|
||||
if (p.disk.format === "Raw") p.disk.is_sparse = v;
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
editable={true}
|
||||
label="Disk size (GB)"
|
||||
@ -166,37 +227,18 @@ function DiskInfo(p: {
|
||||
p.onChange?.();
|
||||
}}
|
||||
type="number"
|
||||
disabled={!!p.disk.from_image}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
editable={true}
|
||||
label="Disk format"
|
||||
options={[
|
||||
{ label: "Raw file", value: "Raw" },
|
||||
{ label: "QCow2", value: "QCow2" },
|
||||
]}
|
||||
value={p.disk.format}
|
||||
<DiskImageSelect
|
||||
label="Use disk image as template"
|
||||
list={p.diskImagesList}
|
||||
value={p.disk.from_image}
|
||||
onValueChange={(v) => {
|
||||
p.disk.format = v as any;
|
||||
p.disk.from_image = v;
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
|
||||
{p.disk.format === "Raw" && (
|
||||
<SelectInput
|
||||
editable={true}
|
||||
label="File allocation type"
|
||||
options={[
|
||||
{ label: "Sparse allocation", value: "Sparse" },
|
||||
{ label: "Fixed allocation", value: "Fixed" },
|
||||
]}
|
||||
value={p.disk.alloc_type}
|
||||
onValueChange={(v) => {
|
||||
if (p.disk.format === "Raw") p.disk.alloc_type = v as any;
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
@ -35,6 +35,7 @@ export function VMNetworksList(p: {
|
||||
const addNew = () => {
|
||||
p.vm.networks.push({
|
||||
type: "UserspaceSLIRPStack",
|
||||
model: "Virtio",
|
||||
mac: randomMacAddress(ServerApi.Config.net_mac_prefix),
|
||||
});
|
||||
p.onChange?.();
|
||||
@ -146,6 +147,7 @@ function NetworkInfoWidget(p: {
|
||||
/>
|
||||
</ListItem>
|
||||
<div style={{ marginLeft: "70px" }}>
|
||||
{/* MAC address input */}
|
||||
<MACInput
|
||||
editable={p.editable}
|
||||
label="MAC Address"
|
||||
@ -156,6 +158,26 @@ function NetworkInfoWidget(p: {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* NIC model */}
|
||||
<SelectInput
|
||||
editable={p.editable}
|
||||
label="NIC Model"
|
||||
value={p.network.model}
|
||||
onValueChange={(v) => {
|
||||
p.network.model = v as any;
|
||||
p.onChange?.();
|
||||
}}
|
||||
options={[
|
||||
{ label: "e1000", value: "E1000" },
|
||||
{
|
||||
label: "virtio",
|
||||
value: "Virtio",
|
||||
description:
|
||||
"Recommended model, but will require specific drivers on OS that do not support it.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Defined network selection */}
|
||||
{p.network.type === "DefinedNetwork" && (
|
||||
<SelectInput
|
||||
|
@ -1,6 +1,7 @@
|
||||
import {
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
@ -59,6 +60,7 @@ export function TokenRightsEditor(p: {
|
||||
<TableCell align="center">Get XML definition</TableCell>
|
||||
<TableCell align="center">Get autostart</TableCell>
|
||||
<TableCell align="center">Set autostart</TableCell>
|
||||
<TableCell align="center">Backup disk</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@ -82,6 +84,10 @@ export function TokenRightsEditor(p: {
|
||||
{...p}
|
||||
right={{ verb: "PUT", path: "/api/vm/*/autostart" }}
|
||||
/>
|
||||
<CellRight
|
||||
{...p}
|
||||
right={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }}
|
||||
/>
|
||||
</TableRow>
|
||||
|
||||
{/* Per VM operations */}
|
||||
@ -117,6 +123,14 @@ export function TokenRightsEditor(p: {
|
||||
{...p}
|
||||
right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }}
|
||||
parent={{ verb: "PUT", path: "/api/vm/*/autostart" }}
|
||||
/>{" "}
|
||||
<CellRight
|
||||
{...p}
|
||||
right={{
|
||||
verb: "POST",
|
||||
path: `/api/vm/${v.uuid}/disk/*/backup`,
|
||||
}}
|
||||
parent={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }}
|
||||
/>
|
||||
</TableRow>
|
||||
))}
|
||||
@ -669,6 +683,43 @@ 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
|
||||
@ -697,6 +748,8 @@ export function TokenRightsEditor(p: {
|
||||
label="Delete ISO files"
|
||||
/>
|
||||
</RightsSection>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Server general information */}
|
||||
<RightsSection label="Server">
|
||||
|
@ -10,7 +10,7 @@ import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi";
|
||||
import { NWFilter, NWFilterApi } from "../../api/NWFilterApi";
|
||||
import { NetworkApi, NetworkInfo } from "../../api/NetworksApi";
|
||||
import { ServerApi } from "../../api/ServerApi";
|
||||
import { VMApi, VMInfo } from "../../api/VMApi";
|
||||
import { VMApi, VMInfo, VMState } from "../../api/VMApi";
|
||||
import { useAlert } from "../../hooks/providers/AlertDialogProvider";
|
||||
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
|
||||
import { useSnackbar } from "../../hooks/providers/SnackbarProvider";
|
||||
@ -27,16 +27,21 @@ import { VMDisksList } from "../forms/VMDisksList";
|
||||
import { VMNetworksList } from "../forms/VMNetworksList";
|
||||
import { VMSelectIsoInput } from "../forms/VMSelectIsoInput";
|
||||
import { VMScreenshot } from "./VMScreenshot";
|
||||
import { DiskImage, DiskImageApi } from "../../api/DiskImageApi";
|
||||
|
||||
interface DetailsProps {
|
||||
vm: VMInfo;
|
||||
editable: boolean;
|
||||
onChange?: () => void;
|
||||
screenshot?: boolean;
|
||||
state?: VMState | undefined;
|
||||
}
|
||||
|
||||
export function VMDetails(p: DetailsProps): React.ReactElement {
|
||||
const [groupsList, setGroupsList] = React.useState<string[] | undefined>();
|
||||
const [diskImagesList, setDiskImagesList] = React.useState<
|
||||
DiskImage[] | undefined
|
||||
>();
|
||||
const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>();
|
||||
const [bridgesList, setBridgesList] = React.useState<string[] | undefined>();
|
||||
const [vcpuCombinations, setVCPUCombinations] = React.useState<
|
||||
@ -51,6 +56,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
|
||||
|
||||
const load = async () => {
|
||||
setGroupsList(await GroupApi.GetList());
|
||||
setDiskImagesList(await DiskImageApi.GetList());
|
||||
setIsoList(await IsoFilesApi.GetList());
|
||||
setBridgesList(await ServerApi.GetNetworksBridgesList());
|
||||
setVCPUCombinations(await ServerApi.NumberVCPUs());
|
||||
@ -66,6 +72,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
|
||||
build={() => (
|
||||
<VMDetailsInner
|
||||
groupsList={groupsList!}
|
||||
diskImagesList={diskImagesList!}
|
||||
isoList={isoList!}
|
||||
bridgesList={bridgesList!}
|
||||
vcpuCombinations={vcpuCombinations!}
|
||||
@ -89,6 +96,7 @@ enum VMTab {
|
||||
|
||||
type DetailsInnerProps = DetailsProps & {
|
||||
groupsList: string[];
|
||||
diskImagesList: DiskImage[];
|
||||
isoList: IsoFile[];
|
||||
bridgesList: string[];
|
||||
vcpuCombinations: number[];
|
||||
@ -272,6 +280,7 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement {
|
||||
options={[
|
||||
{ label: "UEFI with Secure Boot", value: "UEFISecureBoot" },
|
||||
{ label: "UEFI", value: "UEFI" },
|
||||
{ label: "Legacy", value: "Legacy" },
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -279,14 +288,16 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement {
|
||||
label="Memory (MB)"
|
||||
editable={p.editable}
|
||||
type="number"
|
||||
value={p.vm.memory.toString()}
|
||||
value={Math.floor(p.vm.memory / (1000 * 1000)).toString()}
|
||||
onValueChange={(v) => {
|
||||
p.vm.memory = Number(v ?? "0");
|
||||
p.vm.memory = Number(v ?? "0") * 1000 * 1000;
|
||||
p.onChange?.();
|
||||
}}
|
||||
checkValue={(v) =>
|
||||
Number(v) > ServerApi.Config.constraints.memory_size.min &&
|
||||
Number(v) < ServerApi.Config.constraints.memory_size.max
|
||||
Number(v) >
|
||||
ServerApi.Config.constraints.memory_size.min / (1000 * 1000) &&
|
||||
Number(v) <
|
||||
ServerApi.Config.constraints.memory_size.max / (1000 * 1000)
|
||||
}
|
||||
/>
|
||||
|
||||
|
72
virtweb_frontend/src/widgets/vms/VMDiskFileWidget.tsx
Normal file
72
virtweb_frontend/src/widgets/vms/VMDiskFileWidget.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { mdiHarddisk } from "@mdi/js";
|
||||
import { Icon } from "@mdi/react";
|
||||
import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material";
|
||||
import { filesize } from "filesize";
|
||||
import { VMFileDisk } from "../../api/VMApi";
|
||||
import { DiskBusSelect } from "../forms/DiskBusSelect";
|
||||
|
||||
export function VMDiskFileWidget(p: {
|
||||
editable?: boolean;
|
||||
disk: VMFileDisk;
|
||||
secondaryAction?: React.ReactElement;
|
||||
onChange?: () => void;
|
||||
}): React.ReactElement {
|
||||
const info = [filesize(p.disk.size), p.disk.format];
|
||||
|
||||
if (p.disk.format === "Raw") info.push(p.disk.is_sparse ? "Sparse" : "Fixed");
|
||||
|
||||
if (!p.editable) info.push(p.disk.bus);
|
||||
|
||||
return (
|
||||
<ListItem secondaryAction={p.secondaryAction}>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<Icon path={mdiHarddisk} />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
<>
|
||||
{p.disk.name}{" "}
|
||||
{p.disk.deleteType && (
|
||||
<span style={{ color: "red" }}>
|
||||
{p.disk.deleteType === "deletefile"
|
||||
? "Remove, DELETING block file"
|
||||
: "Remove, keeping block file"}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
secondary={
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{p.editable ? (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "80px",
|
||||
display: "inline-block",
|
||||
marginRight: "10px",
|
||||
}}
|
||||
>
|
||||
<DiskBusSelect
|
||||
onValueChange={(v) => {
|
||||
p.disk.bus = v;
|
||||
p.onChange?.();
|
||||
}}
|
||||
label=""
|
||||
editable
|
||||
value={p.disk.bus}
|
||||
size="small"
|
||||
disableUnderline
|
||||
disableBottomMargin
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<div style={{ height: "100%" }}>{info.join(" - ")}</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user