47 Commits

Author SHA1 Message Date
f850ca5cb7 Can rename disk image files
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-05-31 10:45:15 +02:00
4ee01cad4b Add legacy boot mode
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-31 10:05:13 +02:00
5518b45219 Fix ESLint issues
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-31 09:45:23 +02:00
0279907ca9 Can change disk bus after disk creation
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-31 09:41:12 +02:00
5fe481ffed Refactorize disks list
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-31 09:05:10 +02:00
c7cc15d8d0 Can select disk bus type when adding new disk to VM
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-31 08:52:07 +02:00
22416badcf Can change network interface type
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 20:30:30 +02:00
ef0d77f1d6 Minor fixes
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 15:25:46 +02:00
1d4af8c74e Can restore disk image when adding disks to virtual machine
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 14:41:48 +02:00
ec9492c933 Show a message on tokens list route when no token was created yet
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 11:35:00 +02:00
fa03ae885f Add new REST API routes to token rights editor
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 11:30:27 +02:00
ea98aaf856 Fix ESLint issue
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 11:20:19 +02:00
794d16bdaa Simplify raw disks definition
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 11:15:21 +02:00
a3ac56f849 Replace success snackbar with alert message in convert disk image dialog 2025-05-30 11:05:14 +02:00
6130f37336 Attempt to increase requests timeout 2025-05-30 11:04:38 +02:00
6b6fef5ccc Can backup vm disks as images 2025-05-30 10:55:40 +02:00
83df7e1b20 Prepare UI for disks backups 2025-05-30 10:28:54 +02:00
a18310e04a Simplify RAM management
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 09:20:49 +02:00
dd7f9176fa Clarify some constants 2025-05-30 09:03:00 +02:00
d5fbc24c96 Fix ESLint issues
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-29 15:52:23 +02:00
d765f9c2c3 Improve raw sparse conversion
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-29 15:44:21 +02:00
21fd5de139 Can decompress raw files to sparse file 2025-05-29 15:41:08 +02:00
42f22c110c Can compress raw files 2025-05-29 15:33:32 +02:00
9822c5a72a Can convert QCow2 to QCow2 2025-05-29 15:13:31 +02:00
452a395525 Can convert QCow2 to raw 2025-05-29 15:00:14 +02:00
80d81c34bb Add support of sparse files for QCow2 to sparse conversion 2025-05-29 14:51:57 +02:00
b9353326f5 Can convert QCow2 to raw file 2025-05-29 14:47:29 +02:00
3ffc64f129 Can compress QCow2 2025-05-29 14:22:09 +02:00
e869517bb1 Can decompress QCow2 2025-05-29 14:18:27 +02:00
90f4bf35e9 Add checkbox for sparse file conversion
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-29 12:18:26 +02:00
80d6fe0298 Can perform basic file copy 2025-05-29 12:15:52 +02:00
e017fe96d5 Add the logic which will call disk image conversion from disk image route
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-29 11:37:11 +02:00
e7ac0198ab Can download disk images
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-29 10:23:26 +02:00
927a51cda7 Can delete uploaded disk images 2025-05-29 08:45:38 +02:00
615dc1ed83 Display the list of uploaded disk images 2025-05-29 08:33:19 +02:00
20de618568 Generalize disk file creation logic
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-29 08:03:42 +02:00
7451f1b7b4 Split file_disks_utils into two files
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-29 07:40:42 +02:00
5814b0ab6a Clarify disk structures names
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-27 21:42:46 +02:00
5a5450070a Process size of disks in bytes instead of megabytes
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-27 21:29:16 +02:00
19ec9992be Can get the list of uploaded disk images
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-27 21:17:16 +02:00
b55880b43c Can upload disk images on the server
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-27 20:40:48 +02:00
6a7af7e6c4 Add support to bridge option on Web UI
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-26 21:02:02 +02:00
a8171375a8 Added REST route to get the list of bridges 2025-05-26 20:43:19 +02:00
de33c7d521 Check if qemu-img is present before startup
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-26 19:21:40 +02:00
ff372800bd Add backend bridge support 2025-05-26 19:12:18 +02:00
aac05b7faf Update dependency @types/react to ^19.1.5
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-05-25 00:10:08 +00:00
61e5a42ef7 Update dependency @types/humanize-duration to ^3.27.4
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-05-24 00:10:02 +00:00
44 changed files with 2425 additions and 430 deletions

View File

@ -245,11 +245,21 @@ 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")
}
/// Get disk images storage directory
pub fn disk_images_storage_path(&self) -> PathBuf {
self.storage_path().join("disk_images")
}
/// Get the path of a disk image file
pub fn disk_images_file_path(&self, name: &str) -> PathBuf {
self.disk_images_storage_path().join(name)
}
/// Get VM vnc sockets directory
pub fn vnc_sockets_path(&self) -> PathBuf {
self.storage_path().join("vnc")
@ -260,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")
}

View File

@ -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,13 +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);
/// Min VM memory size (MB)
pub const MIN_VM_MEMORY: usize = 100;
/// Allowed uploaded disk images formats
pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 3] = [
"application/x-qemu-disk",
"application/gzip",
"application/octet-stream",
];
/// Max VM memory size (MB)
pub const MAX_VM_MEMORY: usize = 64000;
/// Disk image max size
pub const DISK_IMAGE_MAX_SIZE: FileSize = FileSize::from_gb(10 * 1000);
/// Min VM memory size
pub const MIN_VM_MEMORY: FileSize = FileSize::from_mb(100);
/// Max VM memory size
pub const MAX_VM_MEMORY: FileSize = FileSize::from_gb(64);
/// Disk name min length
pub const DISK_NAME_MIN_LEN: usize = 2;
@ -39,11 +51,11 @@ pub const DISK_NAME_MIN_LEN: usize = 2;
/// Disk name max length
pub const DISK_NAME_MAX_LEN: usize = 10;
/// Disk size min (MB)
pub const DISK_SIZE_MIN: usize = 100;
/// Disk size min (B)
pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50);
/// Disk size max (MB)
pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 2;
/// Disk size max (B)
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;
@ -108,3 +120,21 @@ pub const API_TOKEN_DESCRIPTION_MAX_LENGTH: usize = 30;
/// API token right path max length
pub const API_TOKEN_RIGHT_PATH_MAX_LENGTH: usize = 255;
/// Qemu image program path
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";

View File

@ -0,0 +1,247 @@
use crate::app_config::AppConfig;
use crate::constants;
use crate::controllers::{HttpResult, LibVirtReq};
use crate::libvirt_lib_structures::XMLUuid;
use crate::libvirt_rest_structures::vm::VMInfo;
use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo};
use crate::utils::files_utils;
use actix_files::NamedFile;
use actix_multipart::form::MultipartForm;
use actix_multipart::form::tempfile::TempFile;
use actix_web::{HttpRequest, HttpResponse, web};
#[derive(Debug, MultipartForm)]
pub struct UploadDiskImageForm {
#[multipart(rename = "file")]
files: Vec<TempFile>,
}
/// Upload disk image file
pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>) -> HttpResult {
if form.files.is_empty() {
log::error!("Missing uploaded disk file!");
return Ok(HttpResponse::BadRequest().json("Missing file!"));
}
let file = form.files.remove(0);
// Check uploaded file size
if file.size > constants::DISK_IMAGE_MAX_SIZE.as_bytes() {
return Ok(HttpResponse::BadRequest().json("Disk image max size exceeded!"));
}
// Check file mime type
if let Some(mime_type) = file.content_type {
if !constants::ALLOWED_DISK_IMAGES_MIME_TYPES.contains(&mime_type.as_ref()) {
return Ok(HttpResponse::BadRequest().json(format!(
"Unsupported file type for disk upload: {}",
mime_type
)));
}
}
// Extract and check file name
let Some(file_name) = file.file_name else {
return Ok(HttpResponse::BadRequest().json("Missing file name of uploaded file!"));
};
if !files_utils::check_file_name(&file_name) {
return Ok(HttpResponse::BadRequest().json("Invalid uploaded file name!"));
}
// Check if a file with the same name already exists
let dest_path = AppConfig::get().disk_images_file_path(&file_name);
if dest_path.is_file() {
return Ok(HttpResponse::Conflict().json("A file with the same name already exists!"));
}
// Copy the file to the destination
file.file.persist(dest_path)?;
Ok(HttpResponse::Ok().json("Successfully uploaded disk image!"))
}
/// Get disk images list
pub async fn get_list() -> HttpResult {
let mut list = vec![];
for entry in AppConfig::get().disk_images_storage_path().read_dir()? {
let entry = entry?;
list.push(DiskFileInfo::load_file(&entry.path())?);
}
Ok(HttpResponse::Ok().json(list))
}
#[derive(serde::Deserialize)]
pub struct DiskFilePath {
filename: String,
}
/// Download disk image
pub async fn download(p: web::Path<DiskFilePath>, req: HttpRequest) -> HttpResult {
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
}
let file_path = AppConfig::get().disk_images_file_path(&p.filename);
if !file_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
}
Ok(NamedFile::open(file_path)?.into_response(&req))
}
#[derive(serde::Deserialize)]
pub struct ConvertDiskImageRequest {
dest_file_name: String,
#[serde(flatten)]
format: DiskFileFormat,
}
/// Convert disk image into a new format
pub async fn convert(
p: web::Path<DiskFilePath>,
req: web::Json<ConvertDiskImageRequest>,
) -> HttpResult {
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid src file name!"));
}
let src_file_path = AppConfig::get().disk_images_file_path(&p.filename);
let src = DiskFileInfo::load_file(&src_file_path)?;
handle_convert_request(src, &req).await
}
#[derive(serde::Deserialize)]
pub struct BackupVMDiskPath {
uid: XMLUuid,
diskid: String,
}
/// Perform disk backup
pub async fn backup_disk(
client: LibVirtReq,
path: web::Path<BackupVMDiskPath>,
req: web::Json<ConvertDiskImageRequest>,
) -> HttpResult {
// Get the VM information
let info = match client.get_single_domain(path.uid).await {
Ok(i) => i,
Err(e) => {
log::error!("Failed to get domain info! {e}");
return Ok(HttpResponse::InternalServerError().json(e.to_string()));
}
};
let vm = VMInfo::from_domain(info)?;
// Load disk information
let Some(disk) = vm
.file_disks
.into_iter()
.find(|disk| disk.name == path.diskid)
else {
return Ok(HttpResponse::NotFound()
.json(format!("Disk {} not found for vm {}", path.diskid, vm.name)));
};
let src_path = disk.disk_path(vm.uuid.expect("Missing VM uuid!"));
let src_disk = DiskFileInfo::load_file(&src_path)?;
// Perform conversion
handle_convert_request(src_disk, &req).await
}
/// Generic controller code that performs image conversion to create a disk image file
pub async fn handle_convert_request(
src: DiskFileInfo,
req: &ConvertDiskImageRequest,
) -> HttpResult {
// Check destination file
if !files_utils::check_file_name(&req.dest_file_name) {
return Ok(HttpResponse::BadRequest().json("Invalid destination file name!"));
}
if !req
.format
.ext()
.iter()
.any(|e| req.dest_file_name.ends_with(e))
{
return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!"));
}
let dst_file_path = AppConfig::get().disk_images_file_path(&req.dest_file_name);
if dst_file_path.exists() {
return Ok(HttpResponse::Conflict().json("Specified destination file already exists!"));
}
// Perform conversion
if let Err(e) = src.convert(&dst_file_path, req.format) {
log::error!("Disk file conversion error: {e}");
return Ok(
HttpResponse::InternalServerError().json(format!("Disk file conversion error: {e}"))
);
}
Ok(HttpResponse::Accepted().json("Successfully converted disk file"))
}
#[derive(serde::Deserialize)]
pub struct RenameDiskImageRequest {
name: String,
}
/// Rename disk image
pub async fn rename(
p: web::Path<DiskFilePath>,
req: web::Json<RenameDiskImageRequest>,
) -> HttpResult {
// Check source
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid src file name!"));
}
let src_path = AppConfig::get().disk_images_file_path(&p.filename);
if !src_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
}
// Check destination
if !files_utils::check_file_name(&req.name) {
return Ok(HttpResponse::BadRequest().json("Invalid dst file name!"));
}
let dst_path = AppConfig::get().disk_images_file_path(&req.name);
if dst_path.exists() {
return Ok(HttpResponse::Conflict().json("Destination name already exists!"));
}
// Check extension
let disk = DiskFileInfo::load_file(&src_path)?;
if !disk.format.ext().iter().any(|e| req.name.ends_with(e)) {
return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!"));
}
// Perform rename
std::fs::rename(&src_path, &dst_path)?;
Ok(HttpResponse::Accepted().finish())
}
/// Delete a disk image
pub async fn delete(p: web::Path<DiskFilePath>) -> HttpResult {
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
}
let file_path = AppConfig::get().disk_images_file_path(&p.filename);
if !file_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
}
std::fs::remove_file(file_path)?;
Ok(HttpResponse::Accepted().finish())
}

View File

@ -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!"));
}

View File

@ -7,6 +7,7 @@ use std::fmt::{Display, Formatter};
pub mod api_tokens_controller;
pub mod auth_controller;
pub mod disk_images_controller;
pub mod groups_controller;
pub mod iso_controller;
pub mod network_controller;

View File

@ -16,6 +16,7 @@ struct StaticConfig {
local_auth_enabled: bool,
oidc_auth_enabled: bool,
iso_mimetypes: &'static [&'static str],
disk_images_mimetypes: &'static [&'static str],
net_mac_prefix: &'static str,
builtin_nwfilter_rules: &'static [&'static str],
nwfilter_chains: &'static [&'static str],
@ -37,6 +38,7 @@ struct SLenConstraints {
#[derive(serde::Serialize)]
struct ServerConstraints {
iso_max_size: usize,
disk_image_max_size: usize,
vnc_token_duration: u64,
vm_name_size: LenConstraints,
vm_title_size: LenConstraints,
@ -44,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,
@ -63,11 +66,13 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
local_auth_enabled: *local_auth,
oidc_auth_enabled: !AppConfig::get().disable_oidc,
iso_mimetypes: &constants::ALLOWED_ISO_MIME_TYPES,
disk_images_mimetypes: &constants::ALLOWED_DISK_IMAGES_MIME_TYPES,
net_mac_prefix: constants::NET_MAC_ADDR_PREFIX,
builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES,
nwfilter_chains: &constants::NETWORK_CHAINS,
constraints: ServerConstraints {
iso_max_size: constants::ISO_MAX_SIZE,
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,
@ -75,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 {
@ -188,3 +195,7 @@ pub async fn number_vcpus() -> HttpResult {
pub async fn networks_list() -> HttpResult {
Ok(HttpResponse::Ok().json(net_utils::net_list()))
}
pub async fn bridges_list() -> HttpResult {
Ok(HttpResponse::Ok().json(net_utils::bridges_list()?))
}

View File

@ -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")]
@ -80,7 +93,9 @@ pub struct NetMacAddress {
#[serde(rename = "source")]
pub struct NetIntSourceXML {
#[serde(rename = "@network")]
pub network: String,
pub network: Option<String>,
#[serde(rename = "@bridge")]
pub bridge: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]

View File

@ -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::{DiskFormat, FileDisk};
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>,
}
@ -53,7 +61,8 @@ pub struct Network {
#[serde(tag = "type")]
pub enum NetworkType {
UserspaceSLIRPStack,
DefinedNetwork { network: String }, // TODO : complete network types
DefinedNetwork { network: String },
Bridge { bridge: String },
}
#[derive(serde::Serialize, serde::Deserialize)]
@ -69,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
@ -78,7 +87,7 @@ pub struct VMInfo {
/// Attach ISO file(s)
pub iso_files: Vec<String>,
/// File Storage - https://access.redhat.com/documentation/fr-fr/red_hat_enterprise_linux/6/html/virtualization_administration_guide/sect-virtualization-virtualized_block_devices-adding_storage_devices_to_guests#sect-Virtualization-Adding_storage_devices_to_guests-Adding_file_based_storage_to_a_guest
pub file_disks: Vec<FileDisk>,
pub file_disks: Vec<VMFileDisk>,
/// Network cards
pub networks: Vec<Network>,
/// Add a TPM v2.0 module
@ -195,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 {
@ -240,7 +253,18 @@ impl VMInfo {
mac,
r#type: "network".to_string(),
source: Some(NetIntSourceXML {
network: network.to_string(),
network: Some(network.to_string()),
bridge: None,
}),
model,
filterref,
},
NetworkType::Bridge { bridge } => DomainNetInterfaceXML {
r#type: "bridge".to_string(),
mac,
source: Some(NetIntSourceXML {
network: None,
bridge: Some(bridge.to_string()),
}),
model,
filterref,
@ -277,8 +301,8 @@ impl VMInfo {
driver: DiskDriverXML {
name: "qemu".to_string(),
r#type: match disk.format {
DiskFormat::Raw { .. } => "raw".to_string(),
DiskFormat::QCow2 => "qcow2".to_string(),
VMDiskFormat::Raw { .. } => "raw".to_string(),
VMDiskFormat::QCow2 => "qcow2".to_string(),
},
cache: "none".to_string(),
},
@ -290,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 {
@ -324,13 +352,26 @@ impl VMInfo {
machine: "q35".to_string(),
body: "hvm".to_string(),
},
firmware: "efi".to_string(),
loader: Some(OSLoaderXML {
secure: match self.boot_type {
BootType::UEFI => "no".to_string(),
BootType::UEFISecureBoot => "yes".to_string(),
},
}),
firmware: match self.boot_type {
BootType::Legacy => None,
_ => Some("efi".to_string()),
},
loader: match self.boot_type {
BootType::Legacy => None,
_ => Some(OSLoaderXML {
secure: match self.boot_type {
BootType::UEFISecureBoot => "yes".to_string(),
_ => "no".to_string(),
},
}),
},
bootmenu: match self.boot_type {
BootType::Legacy => Some(OSBootMenuXML {
enable: "yes".to_string(),
timeout: 3000,
}),
_ => None,
},
smbios: Some(OSSMBiosXML {
mode: "sysinfo".to_string(),
}),
@ -368,7 +409,7 @@ impl VMInfo {
memory: DomainMemoryXML {
unit: "MB".to_string(),
memory: self.memory,
memory: self.memory.as_mb(),
},
vcpu: DomainVCPUXML {
@ -422,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,
},
@ -440,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
@ -455,7 +497,10 @@ impl VMInfo {
.disks
.iter()
.filter(|d| d.device == "disk")
.map(|d| FileDisk::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
@ -468,7 +513,34 @@ impl VMInfo {
r#type: match d.r#type.as_str() {
"user" => NetworkType::UserspaceSLIRPStack,
"network" => NetworkType::DefinedNetwork {
network: d.source.as_ref().unwrap().network.to_string(),
network: d
.source
.as_ref()
.unwrap()
.network
.as_deref()
.ok_or_else(|| {
LibVirtStructError::DomainExtraction(
"Missing source network for defined network!"
.to_string(),
)
})?
.to_string(),
},
"bridge" => NetworkType::Bridge {
bridge: d
.source
.as_ref()
.unwrap()
.bridge
.as_deref()
.ok_or_else(|| {
LibVirtStructError::DomainExtraction(
"Missing bridge name for bridge connection!"
.to_string(),
)
})?
.to_string(),
},
a => {
return Err(LibVirtStructError::DomainExtraction(format!(
@ -476,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

View File

@ -13,6 +13,7 @@ use actix_web::middleware::Logger;
use actix_web::web::Data;
use actix_web::{App, HttpServer, web};
use light_openid::basic_state_manager::BasicStateManager;
use std::cmp::max;
use std::time::Duration;
use virtweb_backend::actors::libvirt_actor::LibVirtActor;
use virtweb_backend::actors::vnc_tokens_actor::VNCTokensManager;
@ -22,13 +23,14 @@ use virtweb_backend::constants::{
MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME,
};
use virtweb_backend::controllers::{
api_tokens_controller, auth_controller, groups_controller, iso_controller, network_controller,
nwfilter_controller, server_controller, static_controller, vm_controller,
api_tokens_controller, auth_controller, disk_images_controller, groups_controller,
iso_controller, network_controller, nwfilter_controller, server_controller, static_controller,
vm_controller,
};
use virtweb_backend::libvirt_client::LibVirtClient;
use virtweb_backend::middlewares::auth_middleware::AuthChecker;
use virtweb_backend::nat::nat_conf_mode;
use virtweb_backend::utils::files_utils;
use virtweb_backend::utils::{exec_utils, files_utils};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
@ -43,11 +45,23 @@ async fn main() -> std::io::Result<()> {
// Load additional config from file, if requested
AppConfig::parse_env_file().unwrap();
log::debug!("Checking for required programs");
exec_utils::check_program(
constants::QEMU_IMAGE_PROGRAM,
"QEMU disk image utility is required to manipulate QCow2 files!",
);
exec_utils::check_program(
constants::IP_PROGRAM,
"ip is required to access bridges information!",
);
log::debug!("Create required directory, if missing");
files_utils::create_directory_if_missing(AppConfig::get().iso_storage_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().disk_images_storage_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap();
files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().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();
@ -108,7 +122,9 @@ async fn main() -> std::io::Result<()> {
}))
.app_data(conn.clone())
// Uploaded files
.app_data(MultipartFormConfig::default().total_limit(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(
@ -131,6 +147,10 @@ async fn main() -> std::io::Result<()> {
"/api/server/networks",
web::get().to(server_controller::networks_list),
)
.route(
"/api/server/bridges",
web::get().to(server_controller::bridges_list),
)
// Auth controller
.route(
"/api/auth/local",
@ -315,6 +335,35 @@ async fn main() -> std::io::Result<()> {
"/api/nwfilter/{uid}",
web::delete().to(nwfilter_controller::delete),
)
// Disk images library
.route(
"/api/disk_images/upload",
web::post().to(disk_images_controller::upload),
)
.route(
"/api/disk_images/list",
web::get().to(disk_images_controller::get_list),
)
.route(
"/api/disk_images/{filename}",
web::get().to(disk_images_controller::download),
)
.route(
"/api/disk_images/{filename}/convert",
web::post().to(disk_images_controller::convert),
)
.route(
"/api/disk_images/{filename}/rename",
web::post().to(disk_images_controller::rename),
)
.route(
"/api/disk_images/{filename}",
web::delete().to(disk_images_controller::delete),
)
.route(
"/api/vm/{uid}/disk/{diskid}/backup",
web::post().to(disk_images_controller::backup_disk),
)
// API tokens controller
.route(
"/api/token/create",

View File

@ -0,0 +1,10 @@
use std::path::Path;
/// Check the existence of a required program
pub fn check_program(name: &str, description: &str) {
let path = Path::new(name);
if !path.exists() {
panic!("{name} does not exist! {description}");
}
}

View File

@ -1,169 +1,139 @@
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;
use std::time::UNIX_EPOCH;
#[derive(thiserror::Error, Debug)]
enum DisksError {
#[error("DiskParseError: {0}")]
Parse(&'static str),
#[error("DiskConfigError: {0}")]
Config(&'static str),
#[error("DiskCreateError")]
Create,
#[error("DiskConvertError: {0}")]
Convert(String),
}
/// Type of disk allocation
#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum DiskAllocType {
Fixed,
Sparse,
}
/// Disk allocation type
#[derive(serde::Serialize, serde::Deserialize)]
#[derive(Debug, serde::Serialize, serde::Deserialize, Copy, Clone, PartialEq, Eq)]
#[serde(tag = "format")]
pub enum DiskFormat {
pub enum DiskFileFormat {
Raw {
/// Type of disk allocation
alloc_type: DiskAllocType,
#[serde(default)]
is_sparse: bool,
},
QCow2,
QCow2 {
#[serde(default)]
virtual_size: FileSize,
},
CompressedRaw,
CompressedQCow2,
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct FileDisk {
/// Disk name
pub name: String,
/// Disk size, in megabytes
pub size: usize,
/// Disk format
impl DiskFileFormat {
pub fn ext(&self) -> &'static [&'static str] {
match self {
DiskFileFormat::Raw { .. } => &["raw", ""],
DiskFileFormat::QCow2 { .. } => &["qcow2"],
DiskFileFormat::CompressedRaw => &["raw.gz"],
DiskFileFormat::CompressedQCow2 => &["qcow2.gz"],
}
}
}
/// Disk file information
#[derive(serde::Serialize)]
pub struct DiskFileInfo {
pub file_path: PathBuf,
pub file_size: FileSize,
#[serde(flatten)]
pub format: DiskFormat,
/// Set this variable to true to delete the disk
pub delete: bool,
pub format: DiskFileFormat,
pub file_name: String,
pub name: String,
pub created: u64,
}
impl FileDisk {
pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
let file = Path::new(path);
impl DiskFileInfo {
/// Get disk image file information
pub fn load_file(file: &Path) -> anyhow::Result<Self> {
if !file.is_file() {
return Err(DisksError::Parse("Path is not a file!").into());
}
// Get file metadata
let metadata = file.metadata()?;
let name = file.file_stem().and_then(|s| s.to_str()).unwrap_or("disk");
let mut name = file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("disk")
.to_string();
let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("raw");
// Approximate raw file estimation
let is_raw_sparse = metadata.len() / 512 >= metadata.st_blocks();
// Determine file format
let format = match ext {
"qcow2" => DiskFormat::QCow2,
"raw" => DiskFormat::Raw {
alloc_type: match is_raw_sparse {
true => DiskAllocType::Sparse,
false => DiskAllocType::Fixed,
},
"qcow2" => DiskFileFormat::QCow2 {
virtual_size: qcow_virt_size(file)?,
},
"raw" => DiskFileFormat::Raw {
is_sparse: metadata.len() / 512 >= metadata.st_blocks(),
},
"gz" if name.ends_with(".qcow2") => {
name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string();
DiskFileFormat::CompressedQCow2
}
"gz" => DiskFileFormat::CompressedRaw,
_ => anyhow::bail!("Unsupported disk extension: {ext}!"),
};
Ok(Self {
name: name.to_string(),
size: match format {
DiskFormat::Raw { .. } => metadata.len() as usize / (1000 * 1000),
DiskFormat::QCow2 => qcow_virt_size(path)? / (1000 * 1000),
},
file_path: file.to_path_buf(),
name,
file_size: FileSize::from_bytes(metadata.len() as usize),
format,
delete: false,
file_name: file
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string(),
created: metadata
.created()?
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
})
}
pub fn check_config(&self) -> anyhow::Result<()> {
if constants::DISK_NAME_MIN_LEN > self.name.len()
|| constants::DISK_NAME_MAX_LEN < self.name.len()
{
return Err(DisksError::Config("Disk name length is invalid").into());
}
if !regex!("^[a-zA-Z0-9]+$").is_match(&self.name) {
return Err(DisksError::Config("Disk name contains invalid characters!").into());
}
// Check disk size
if !(constants::DISK_SIZE_MIN..=constants::DISK_SIZE_MAX).contains(&self.size) {
return Err(DisksError::Config("Disk size is invalid!").into());
}
Ok(())
}
/// Get disk path
pub fn disk_path(&self, id: XMLUuid) -> PathBuf {
let domain_dir = AppConfig::get().vm_storage_path(id);
let file_name = match self.format {
DiskFormat::Raw { .. } => self.name.to_string(),
DiskFormat::QCow2 => format!("{}.qcow2", self.name),
};
domain_dir.join(&file_name)
}
/// Apply disk configuration
pub fn apply_config(&self, id: XMLUuid) -> anyhow::Result<()> {
self.check_config()?;
let file = self.disk_path(id);
files_utils::create_directory_if_missing(file.parent().unwrap())?;
// Delete file if requested
if self.delete {
if !file.exists() {
log::debug!("File {file:?} does not exists, so it was not deleted");
return Ok(());
}
log::info!("Deleting {file:?}");
std::fs::remove_file(file)?;
return Ok(());
}
if file.exists() {
log::debug!("File {file:?} does not exists, so it was not touched");
return Ok(());
}
/// Create a new empty disk
pub fn create(file: &Path, format: DiskFileFormat, size: FileSize) -> anyhow::Result<()> {
// Prepare command to create file
let res = match self.format {
DiskFormat::Raw { alloc_type } => {
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 alloc_type {
DiskAllocType::Fixed => cmd.arg(format!("count={}", self.size)),
DiskAllocType::Sparse => cmd.arg(format!("seek={}", self.size)).arg("count=0"),
match is_sparse {
false => cmd.arg(format!("count={}", size.as_mb())),
true => cmd.arg(format!("seek={}", size.as_mb())).arg("count=0"),
};
cmd.output()?
}
DiskFormat::QCow2 => {
let mut cmd = Command::new("/usr/bin/qemu-img");
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", self.size));
.arg(format!("{}M", virtual_size.as_mb()));
cmd.output()?
}
_ => anyhow::bail!("Cannot create disk file image of this format: {format:?}!"),
};
// Execute Linux command
@ -178,6 +148,174 @@ impl FileDisk {
Ok(())
}
/// Copy / convert file disk image into a new destination with optionally a new file format
pub fn convert(&self, dest_file: &Path, dest_format: DiskFileFormat) -> anyhow::Result<()> {
// Create a temporary directory to perform the operation
let temp_dir = tempfile::tempdir_in(&AppConfig::get().temp_dir)?;
let temp_file = temp_dir
.path()
.join(format!("temp_file.{}", dest_format.ext()[0]));
// Prepare the conversion
let mut cmd = match (self.format, dest_format) {
// Decompress QCow2
(DiskFileFormat::CompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
let mut cmd = Command::new(constants::GZIP_PROGRAM);
cmd.arg("--keep")
.arg("--decompress")
.arg("--to-stdout")
.arg(&self.file_path)
.stdout(File::create(&temp_file)?);
cmd
}
// Compress QCow2
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::CompressedQCow2) => {
let mut cmd = Command::new(constants::GZIP_PROGRAM);
cmd.arg("--keep")
.arg("--to-stdout")
.arg(&self.file_path)
.stdout(File::create(&temp_file)?);
cmd
}
// Convert QCow2 to Raw file
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => {
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.arg("convert").arg(&self.file_path).arg(&temp_file);
if !is_sparse {
cmd.args(["-S", "0"]);
}
cmd
}
// Clone a QCow file, using qemu-image instead of cp might improve "sparsification" of
// file
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::QCow2 { .. }) => {
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.arg("convert")
.arg("-O")
.arg("qcow2")
.arg(&self.file_path)
.arg(&temp_file);
cmd
}
// Convert Raw to QCow2 file
(DiskFileFormat::Raw { .. }, DiskFileFormat::QCow2 { .. }) => {
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.arg("convert").arg(&self.file_path).arg(&temp_file);
cmd
}
// Render raw file non sparse
(DiskFileFormat::Raw { is_sparse: true }, DiskFileFormat::Raw { is_sparse: false }) => {
let mut cmd = Command::new(constants::COPY_PROGRAM);
cmd.arg("--sparse=never")
.arg(&self.file_path)
.arg(&temp_file);
cmd
}
// Render raw file sparse
(DiskFileFormat::Raw { is_sparse: false }, DiskFileFormat::Raw { is_sparse: true }) => {
let mut cmd = Command::new(constants::DD_PROGRAM);
cmd.arg("conv=sparse")
.arg(format!("if={}", self.file_path.display()))
.arg(format!("of={}", temp_file.display()));
cmd
}
// Compress Raw
(DiskFileFormat::Raw { .. }, DiskFileFormat::CompressedRaw) => {
let mut cmd = Command::new(constants::GZIP_PROGRAM);
cmd.arg("--keep")
.arg("--to-stdout")
.arg(&self.file_path)
.stdout(File::create(&temp_file)?);
cmd
}
// Decompress Raw to not sparse file
(DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => {
let mut cmd = Command::new(constants::GZIP_PROGRAM);
cmd.arg("--keep")
.arg("--decompress")
.arg("--to-stdout")
.arg(&self.file_path)
.stdout(File::create(&temp_file)?);
cmd
}
// Decompress Raw to sparse file
// https://benou.fr/www/ben/decompressing-sparse-files.html
(DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => {
let mut cmd = Command::new(constants::BASH_PROGRAM);
cmd.arg("-c").arg(format!(
"{} -d -c {} | {} conv=sparse of={}",
constants::GZIP_PROGRAM,
self.file_path.display(),
constants::DD_PROGRAM,
temp_file.display()
));
cmd
}
// Dumb copy of file
(a, b) if a == b => {
let mut cmd = Command::new(constants::COPY_PROGRAM);
cmd.arg("--sparse=auto")
.arg(&self.file_path)
.arg(&temp_file);
cmd
}
// By default, conversion is unsupported
(src, dest) => {
return Err(DisksError::Convert(format!(
"Conversion from {src:?} to {dest:?} is not supported!"
))
.into());
}
};
// Execute the conversion
let command_s = format!(
"{} {}",
cmd.get_program().display(),
cmd.get_args()
.map(|a| format!("'{}'", a.display()))
.collect::<Vec<String>>()
.join(" ")
);
let cmd_output = cmd.output()?;
if !cmd_output.status.success() {
return Err(DisksError::Convert(format!(
"Command failed:\n{command_s}\nStatus: {}\nstdout: {}\nstderr: {}",
cmd_output.status,
String::from_utf8_lossy(&cmd_output.stdout),
String::from_utf8_lossy(&cmd_output.stderr)
))
.into());
}
// Check the file was created
if !temp_file.is_file() {
return Err(DisksError::Convert(
"Temporary was not created after execution of command!".to_string(),
)
.into());
}
// Move the file to its final location
std::fs::rename(temp_file, dest_file)?;
Ok(())
}
}
#[derive(serde::Deserialize)]
@ -187,14 +325,21 @@ struct QCowInfoOutput {
}
/// Get QCow2 virtual size
fn qcow_virt_size(path: &str) -> anyhow::Result<usize> {
fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> {
// Run qemu-img
let mut cmd = Command::new("qemu-img");
cmd.args(["info", path, "--output", "json", "--force-share"]);
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.args([
"info",
path.to_str().unwrap_or(""),
"--output",
"json",
"--force-share",
]);
let output = cmd.output()?;
if !output.status.success() {
anyhow::bail!(
"qemu-img info failed, status: {}, stderr: {}",
"{} info failed, status: {}, stderr: {}",
constants::QEMU_IMAGE_PROGRAM,
output.status,
String::from_utf8_lossy(&output.stderr)
);
@ -203,5 +348,5 @@ fn qcow_virt_size(path: &str) -> 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))
}

View 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
);
}
}

View File

@ -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);
}
}

View File

@ -1,6 +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;

View File

@ -1,6 +1,8 @@
use crate::constants;
use nix::sys::socket::{AddressFamily, SockaddrLike};
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::process::Command;
use std::str::FromStr;
use sysinfo::Networks;
@ -68,7 +70,7 @@ pub fn net_list() -> Vec<String> {
/// Get the list of available network interfaces associated with their IP address
pub fn net_list_and_ips() -> anyhow::Result<HashMap<String, Vec<IpAddr>>> {
let addrs = nix::ifaddrs::getifaddrs().unwrap();
let addrs = nix::ifaddrs::getifaddrs()?;
let mut res = HashMap::new();
@ -136,6 +138,31 @@ pub fn net_list_and_ips() -> anyhow::Result<HashMap<String, Vec<IpAddr>>> {
Ok(res)
}
#[derive(serde::Deserialize)]
struct IPBridgeInfo {
ifname: String,
}
/// Get the list of bridge interfaces
pub fn bridges_list() -> anyhow::Result<Vec<String>> {
let mut cmd = Command::new(constants::IP_PROGRAM);
cmd.args(["-json", "link", "show", "type", "bridge"]);
let output = cmd.output()?;
if !output.status.success() {
anyhow::bail!(
"{} failed, status: {}, stderr: {}",
constants::IP_PROGRAM,
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
// Parse JSON result
let res: Vec<IPBridgeInfo> = serde_json::from_str(&String::from_utf8_lossy(&output.stdout))?;
Ok(res.iter().map(|ip| ip.ifname.clone()).collect())
}
#[cfg(test)]
mod tests {
use crate::utils::net_utils::{

View 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(())
}
}

47
virtweb_docs/BRIDGE.md Normal file
View File

@ -0,0 +1,47 @@
# Bridges
Bridges can be used to connect virtual machines to networks.
## Setup Bridge on Ubuntu
1. Install dependencies:
```bash
sudo apt install bridge-utils
```
2. Adapt your netplan configuration to set the following:
```yaml
network:
version: 2
renderer: networkd
ethernets:
enp2s0:
dhcp4: no
bridges:
br0: # Bridge name
dhcp4: yes
interfaces:
- enp2s0 # Set to your interface
```
3. Apply netplan configuration:
```bash
sudo netplan apply
```
4. Get the state and the list of bridges in the system:
```bash
sudo brctl show
# Or
ip link show type bridge
```
## Reference
[How to Configure Network Bridge in Ubuntu](https://www.tecmint.com/create-network-bridge-in-ubuntu/)

View File

@ -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

View File

@ -30,7 +30,7 @@
},
"devDependencies": {
"@eslint/js": "^9.27.0",
"@types/humanize-duration": "^3.27.1",
"@types/humanize-duration": "^3.27.4",
"@types/jest": "^29.5.14",
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",

View File

@ -32,7 +32,7 @@
},
"devDependencies": {
"@eslint/js": "^9.27.0",
"@types/humanize-duration": "^3.27.1",
"@types/humanize-duration": "^3.27.4",
"@types/jest": "^29.5.14",
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",

View File

@ -38,6 +38,7 @@ import { LoginRoute } from "./routes/auth/LoginRoute";
import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
import { BaseLoginPage } from "./widgets/BaseLoginPage";
import { DiskImagesRoute } from "./routes/DiskImagesRoute";
interface AuthContext {
signedIn: boolean;
@ -63,6 +64,8 @@ export function App() {
<Route path="*" element={<BaseAuthenticatedPage />}>
<Route path="" element={<HomeRoute />} />
<Route path="disk_images" element={<DiskImagesRoute />} />
<Route path="iso" element={<IsoFilesRoute />} />
<Route path="vms" element={<VMListRoute />} />

View File

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

View File

@ -0,0 +1,117 @@
import { APIClient } from "./ApiClient";
import { VMFileDisk, VMInfo } from "./VMApi";
export type DiskImageFormat =
| { format: "Raw"; is_sparse: boolean }
| { format: "QCow2"; virtual_size?: number }
| { format: "CompressedQCow2" }
| { format: "CompressedRaw" };
export type DiskImage = {
file_size: number;
file_name: string;
name: string;
created: number;
} & DiskImageFormat;
export class DiskImageApi {
/**
* Upload a new disk image file to the server
*/
static async Upload(
file: File,
progress: (progress: number) => void
): Promise<void> {
const fd = new FormData();
fd.append("file", file);
await APIClient.exec({
method: "POST",
uri: "/disk_images/upload",
formData: fd,
upProgress: progress,
});
}
/**
* Get the list of disk images
*/
static async GetList(): Promise<DiskImage[]> {
return (
await APIClient.exec({
method: "GET",
uri: "/disk_images/list",
})
).data;
}
/**
* Download disk image file
*/
static async Download(
file: DiskImage,
progress: (p: number) => void
): Promise<Blob> {
return (
await APIClient.exec({
method: "GET",
uri: `/disk_images/${file.file_name}`,
downProgress(e) {
progress(Math.floor(100 * (e.progress / e.total)));
},
})
).data;
}
/**
* Convert disk image file
*/
static async Convert(
file: DiskImage,
dest_file_name: string,
dest_format: DiskImageFormat
): Promise<void> {
await APIClient.exec({
method: "POST",
uri: `/disk_images/${file.file_name}/convert`,
jsonData: { ...dest_format, dest_file_name },
});
}
/**
* Backup VM disk into image disks library
*/
static async BackupVMDisk(
vm: VMInfo,
disk: VMFileDisk,
dest_file_name: string,
format: DiskImageFormat
): Promise<void> {
await APIClient.exec({
uri: `/vm/${vm.uuid}/disk/${disk.name}/backup`,
method: "POST",
jsonData: { ...format, dest_file_name },
});
}
/**
* Rename disk image file
*/
static async Rename(file: DiskImage, name: string): Promise<void> {
await APIClient.exec({
method: "POST",
uri: `/disk_images/${file.file_name}/rename`,
jsonData: { name },
});
}
/**
* Delete disk image file
*/
static async Delete(file: DiskImage): Promise<void> {
await APIClient.exec({
method: "DELETE",
uri: `/disk_images/${file.file_name}`,
});
}
}

View File

@ -5,6 +5,7 @@ export interface ServerConfig {
local_auth_enabled: boolean;
oidc_auth_enabled: boolean;
iso_mimetypes: string[];
disk_images_mimetypes: string[];
net_mac_prefix: string;
builtin_nwfilter_rules: string[];
nwfilter_chains: string[];
@ -13,6 +14,7 @@ export interface ServerConfig {
export interface ServerConstraints {
iso_max_size: number;
disk_image_max_size: number;
vnc_token_duration: number;
vm_name_size: LenConstraint;
vm_title_size: LenConstraint;
@ -20,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;
@ -217,4 +220,16 @@ export class ServerApi {
})
).data;
}
/**
* Get host networks bridges list
*/
static async GetNetworksBridgesList(): Promise<string[]> {
return (
await APIClient.exec({
method: "GET",
uri: "/server/bridges",
})
).data;
}
}

View File

@ -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 {
@ -50,11 +55,16 @@ export interface VMNetInterfaceFilter {
parameters: VMNetInterfaceFilterParams[];
}
export type VMNetInterface = (VMNetUserspaceSLIRPStack | VMNetDefinedNetwork) &
export type VMNetInterface = (
| VMNetUserspaceSLIRPStack
| VMNetDefinedNetwork
| VMNetBridge
) &
VMNetInterfaceBase;
export interface VMNetInterfaceBase {
mac: string;
model: "Virtio" | "E1000";
nwfilterref?: VMNetInterfaceFilter;
}
@ -67,6 +77,13 @@ export interface VMNetDefinedNetwork {
network: string;
}
export interface VMNetBridge {
type: "Bridge";
bridge: string;
}
export type VMBootType = "UEFI" | "UEFISecureBoot" | "Legacy";
interface VMInfoInterface {
name: string;
uuid?: string;
@ -74,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;
@ -93,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;
@ -128,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: [],

View 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>
);
}

View File

@ -0,0 +1,417 @@
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import LoopIcon from "@mui/icons-material/Loop";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import RefreshIcon from "@mui/icons-material/Refresh";
import {
Alert,
Button,
CircularProgress,
IconButton,
LinearProgress,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Tooltip,
Typography,
} from "@mui/material";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { filesize } from "filesize";
import React from "react";
import { DiskImage, DiskImageApi } from "../api/DiskImageApi";
import { ServerApi } from "../api/ServerApi";
import { ConvertDiskImageDialog } from "../dialogs/ConvertDiskImageDialog";
import { useAlert } from "../hooks/providers/AlertDialogProvider";
import { useConfirm } from "../hooks/providers/ConfirmDialogProvider";
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
import { downloadBlob } from "../utils/FilesUtils";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { DateWidget } from "../widgets/DateWidget";
import { FileInput } from "../widgets/forms/FileInput";
import { VirtWebPaper } from "../widgets/VirtWebPaper";
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
export function DiskImagesRoute(): React.ReactElement {
const [list, setList] = React.useState<DiskImage[] | undefined>();
const loadKey = React.useRef(1);
const load = async () => {
setList(await DiskImageApi.GetList());
};
const reload = () => {
loadKey.current += 1;
setList(undefined);
};
return (
<VirtWebRouteContainer
label="Disk images management"
actions={
<span>
<Tooltip title="Refresh Disk images list">
<IconButton onClick={reload}>
<RefreshIcon />
</IconButton>
</Tooltip>
</span>
}
>
<AsyncWidget
loadKey={loadKey.current}
errMsg="Failed to load disk images list!"
load={load}
ready={list !== undefined}
build={() => (
<>
<UploadDiskImageCard onFileUploaded={reload} />
<DiskImageList list={list!} onReload={reload} />
</>
)}
/>
</VirtWebRouteContainer>
);
}
function UploadDiskImageCard(p: {
onFileUploaded: () => void;
}): React.ReactElement {
const alert = useAlert();
const snackbar = useSnackbar();
const [value, setValue] = React.useState<File | null>(null);
const [uploadProgress, setUploadProgress] = React.useState<number | null>(
null
);
const handleChange = (newValue: File | null) => {
if (
newValue &&
newValue.size > ServerApi.Config.constraints.disk_image_max_size
) {
alert(
`The file is too big (max size allowed: ${filesize(
ServerApi.Config.constraints.disk_image_max_size
)}`
);
return;
}
if (
newValue &&
newValue.type.length > 0 &&
!ServerApi.Config.disk_images_mimetypes.includes(newValue.type)
) {
alert(`Selected file mimetype is not allowed! (${newValue.type})`);
return;
}
setValue(newValue);
};
const upload = async () => {
try {
setUploadProgress(0);
await DiskImageApi.Upload(value!, setUploadProgress);
setValue(null);
snackbar("The file was successfully uploaded!");
p.onFileUploaded();
} catch (e) {
console.error(e);
await alert(`Failed to perform file upload! ${e}`);
}
setUploadProgress(null);
};
if (uploadProgress !== null) {
return (
<VirtWebPaper label="File upload" noHorizontalMargin>
<Typography variant="body1">
Upload in progress ({Math.floor(uploadProgress * 100)}%)...
</Typography>
<LinearProgress variant="determinate" value={uploadProgress * 100} />
</VirtWebPaper>
);
}
return (
<VirtWebPaper label="Disk image upload" noHorizontalMargin>
<div style={{ display: "flex", alignItems: "center" }}>
<FileInput
value={value}
onChange={handleChange}
style={{ flex: 1 }}
slotProps={{
htmlInput: {
accept: ServerApi.Config.disk_images_mimetypes.join(","),
},
}}
/>
{value && <Button onClick={upload}>Upload</Button>}
</div>
</VirtWebPaper>
);
}
function DiskImageList(p: {
list: DiskImage[];
onReload: () => void;
}): React.ReactElement {
const alert = useAlert();
const snackbar = useSnackbar();
const confirm = useConfirm();
const loadingMessage = useLoadingMessage();
const [dlProgress, setDlProgress] = React.useState<undefined | number>();
const [currConversion, setCurrConversion] = React.useState<
DiskImage | undefined
>();
// Download disk image file
const downloadDiskImage = async (entry: DiskImage) => {
setDlProgress(0);
try {
const blob = await DiskImageApi.Download(entry, setDlProgress);
downloadBlob(blob, entry.file_name);
} catch (e) {
console.error(e);
alert(`Failed to download disk image file! ${e}`);
}
setDlProgress(undefined);
};
// Convert disk image file
const convertDiskImage = (entry: DiskImage) => {
setCurrConversion(entry);
};
// Delete disk image
const deleteDiskImage = async (entry: DiskImage) => {
if (
!(await confirm(
`Do you really want to delete this disk image (${entry.file_name}) ?`
))
)
return;
loadingMessage.show("Deleting disk image file...");
try {
await DiskImageApi.Delete(entry);
snackbar("The disk image has been successfully deleted!");
p.onReload();
} catch (e) {
console.error(e);
alert(`Failed to delete disk image!\n${e}`);
}
loadingMessage.hide();
};
if (p.list.length === 0)
return (
<Typography variant="body1" style={{ textAlign: "center" }}>
No disk image uploaded for now.
</Typography>
);
const columns: GridColDef<(typeof p.list)[number]>[] = [
{ field: "file_name", headerName: "File name", flex: 3, editable: true },
{
field: "format",
headerName: "Format",
flex: 1,
renderCell(params) {
let content = params.row.format;
if (params.row.format === "Raw") {
content += params.row.is_sparse ? " (Sparse)" : " (Fixed)";
}
return content;
},
},
{
field: "file_size",
headerName: "File size",
flex: 1,
renderCell(params) {
let res = filesize(params.row.file_size);
if (params.row.format === "QCow2") {
res += ` (${filesize(params.row.virtual_size!)})`;
}
return res;
},
},
{
field: "created",
headerName: "Created",
flex: 1,
renderCell(params) {
return <DateWidget time={params.row.created} />;
},
},
{
field: "actions",
type: "actions",
headerName: "",
width: 55,
cellClassName: "actions",
editable: false,
getActions: (params) => {
return [
<DiskImageActionMenu
key="menu"
diskImage={params.row}
onDownload={downloadDiskImage}
onConvert={convertDiskImage}
onDelete={deleteDiskImage}
/>,
];
},
},
];
return (
<>
{/* Download notification */}
{dlProgress !== undefined && (
<Alert severity="info">
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
overflow: "hidden",
}}
>
<Typography variant="body1">
Downloading... {dlProgress}%
</Typography>
<CircularProgress
variant="determinate"
size={"1.5rem"}
style={{ marginLeft: "10px" }}
value={dlProgress}
/>
</div>
</Alert>
)}
{/* Disk image conversion dialog */}
{currConversion && (
<ConvertDiskImageDialog
image={currConversion}
onCancel={() => {
setCurrConversion(undefined);
}}
onFinished={() => {
setCurrConversion(undefined);
p.onReload();
}}
/>
)}
{/* The table itself */}
<DataGrid<DiskImage>
getRowId={(c) => c.file_name}
rows={p.list}
columns={columns}
processRowUpdate={async (n, o) => {
try {
await DiskImageApi.Rename(o, n.file_name);
return n;
} catch (e) {
console.error("Failed to rename disk image!", e);
alert(`Failed to rename disk image! ${e}`);
throw e;
} finally {
p.onReload();
}
}}
/>
</>
);
}
function DiskImageActionMenu(p: {
diskImage: DiskImage;
onDownload: (d: DiskImage) => void;
onConvert: (d: DiskImage) => void;
onDelete: (d: DiskImage) => void;
}): React.ReactElement {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<>
<IconButton
aria-label="Actions"
aria-haspopup="true"
onClick={handleClick}
>
<MoreVertIcon />
</IconButton>
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
{/* Download disk image */}
<MenuItem
onClick={() => {
handleClose();
p.onDownload(p.diskImage);
}}
>
<ListItemIcon>
<DownloadIcon />
</ListItemIcon>
<ListItemText secondary={"Download disk image"}>
Download
</ListItemText>
</MenuItem>
{/* Convert disk image */}
<MenuItem
onClick={() => {
handleClose();
p.onConvert(p.diskImage);
}}
>
<ListItemIcon>
<LoopIcon />
</ListItemIcon>
<ListItemText secondary={"Convert disk image"}>Convert</ListItemText>
</MenuItem>
{/* Delete disk image */}
<MenuItem
onClick={() => {
handleClose();
p.onDelete(p.diskImage);
}}
>
<ListItemIcon>
<DeleteIcon color="error" />
</ListItemIcon>
<ListItemText secondary={"Delete disk image"}>Delete</ListItemText>
</MenuItem>
</Menu>
</>
);
}

View File

@ -3,7 +3,15 @@ import { RouterLink } from "../widgets/RouterLink";
export function NotFoundRoute(): React.ReactElement {
return (
<div style={{ textAlign: "center" }}>
<div
style={{
textAlign: "center",
flex: 1,
justifyContent: "center",
display: "flex",
flexDirection: "column",
}}
>
<h1>404 Not found</h1>
<p>The page you requested was not found!</p>
<RouterLink to="/">

View File

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

View File

@ -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);
}

View File

@ -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>

View File

@ -2,6 +2,7 @@ import {
mdiApi,
mdiBoxShadow,
mdiDisc,
mdiHarddisk,
mdiHome,
mdiInformation,
mdiLan,
@ -66,6 +67,11 @@ export function BaseAuthenticatedPage(): React.ReactElement {
uri="/nwfilter"
icon={<Icon path={mdiSecurityNetwork} size={1} />}
/>
<NavLink
label="Disk images"
uri="/disk_images"
icon={<Icon path={mdiHarddisk} size={1} />}
/>
<NavLink
label="ISO files"
uri="/iso"

View 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");
}

View 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>
);
}

View 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); }}
/>
);
}

View 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>
);
}

View File

@ -35,7 +35,7 @@ export function FileInput(
<InputAdornment position="start">
<AttachFileIcon />
&nbsp;&nbsp;
{p.value ? p.value.name : "Insert a file"}
{p.value ? p.value.name : "Select a file"}
</InputAdornment>
</>
),

View File

@ -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

View File

@ -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) =>

View File

@ -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,
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,50 +115,42 @@ function DiskInfo(p: {
if (!p.editable || !p.disk.new)
return (
<ListItem
<VMDiskFileWidget
{...p}
secondaryAction={
p.editable && (
<IconButton
edge="end"
aria-label="delete disk"
onClick={deleteDisk}
>
{p.disk.deleteType ? (
<Tooltip title="Cancel disk removal">
<CheckCircleIcon />
</Tooltip>
) : (
<Tooltip title="Remove disk">
<DeleteIcon />
</Tooltip>
)}
</IconButton>
)
<>
{p.editable && (
<IconButton
edge="end"
aria-label="delete disk"
onClick={deleteDisk}
>
{p.disk.deleteType ? (
<Tooltip title="Cancel disk removal">
<CheckCircleIcon />
</Tooltip>
) : (
<Tooltip title="Remove disk">
<DeleteIcon />
</Tooltip>
)}
</IconButton>
)}
{p.canBackup && (
<Tooltip title="Backup this disk">
<IconButton
onClick={() => {
p.onRequestBackup(p.disk);
}}
>
<Icon path={mdiHarddiskPlus} size={1} />
</IconButton>
</Tooltip>
)}
</>
}
>
<ListItemAvatar>
<Avatar>
<Icon path={mdiHarddisk} />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<>
{p.disk.name}{" "}
{p.disk.deleteType && (
<span style={{ color: "red" }}>
{p.disk.deleteType === "deletefile"
? "Remove, DELETING block file"
: "Remove, keeping block file"}
</span>
)}
</>
}
secondary={`${filesize(p.disk.size * 1000 * 1000)} - ${
p.disk.format
}${p.disk.format == "Raw" ? " - " + p.disk.alloc_type : ""}`}
/>
</ListItem>
/>
);
return (
@ -151,18 +172,6 @@ function DiskInfo(p: {
</IconButton>
</div>
<TextInput
editable={true}
label="Disk size (MB)"
size={ServerApi.Config.constraints.disk_size}
value={p.disk.size.toString()}
onValueChange={(v) => {
p.disk.size = Number(v ?? "0");
p.onChange?.();
}}
type="number"
/>
<SelectInput
editable={true}
label="Disk format"
@ -173,25 +182,63 @@ function DiskInfo(p: {
value={p.disk.format}
onValueChange={(v) => {
p.disk.format = v as any;
if (p.disk.format === "Raw") p.disk.is_sparse = true;
p.onChange?.();
}}
/>
{/* Bus selection */}
<DiskBusSelect
editable
value={p.disk.bus}
onValueChange={(v) => {
p.disk.bus = v;
p.onChange?.();
}}
/>
{/* Raw disk: choose sparse mode */}
{p.disk.format === "Raw" && (
<SelectInput
editable={true}
label="File allocation type"
options={[
{ label: "Sparse allocation", value: "Sparse" },
{ label: "Fixed allocation", value: "Fixed" },
]}
value={p.disk.alloc_type}
<CheckboxInput
editable
label="Sparse file"
checked={p.disk.is_sparse}
onValueChange={(v) => {
if (p.disk.format === "Raw") p.disk.alloc_type = v as any;
if (p.disk.format === "Raw") p.disk.is_sparse = v;
p.onChange?.();
}}
/>
)}
<TextInput
editable={true}
label="Disk size (GB)"
size={{
min:
ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000),
max:
ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000),
}}
value={(p.disk.size / (1000 * 1000 * 1000)).toString()}
onValueChange={(v) => {
p.disk.size = Number(v ?? "0") * 1000 * 1000 * 1000;
p.onChange?.();
}}
type="number"
disabled={!!p.disk.from_image}
/>
<DiskImageSelect
label="Use disk image as template"
list={p.diskImagesList}
value={p.disk.from_image}
onValueChange={(v) => {
p.disk.from_image = v;
p.onChange?.();
}}
/>
</Paper>
);
}

View File

@ -29,11 +29,13 @@ export function VMNetworksList(p: {
onChange?: () => void;
editable: boolean;
networksList: NetworkInfo[];
bridgesList: string[];
networkFiltersList: NWFilter[];
}): React.ReactElement {
const addNew = () => {
p.vm.networks.push({
type: "UserspaceSLIRPStack",
model: "Virtio",
mac: randomMacAddress(ServerApi.Config.net_mac_prefix),
});
p.onChange?.();
@ -72,6 +74,7 @@ function NetworkInfoWidget(p: {
onChange?: () => void;
removeFromList: () => void;
networksList: NetworkInfo[];
bridgesList: string[];
networkFiltersList: NWFilter[];
}): React.ReactElement {
const confirm = useConfirm();
@ -130,6 +133,11 @@ function NetworkInfoWidget(p: {
value: "DefinedNetwork",
description: "Attach to a defined network",
},
{
label: "Host bridge",
value: "Bridge",
description: "Attach to an host's bridge",
},
]}
/>
) : (
@ -139,6 +147,7 @@ function NetworkInfoWidget(p: {
/>
</ListItem>
<div style={{ marginLeft: "70px" }}>
{/* MAC address input */}
<MACInput
editable={p.editable}
label="MAC Address"
@ -149,31 +158,73 @@ 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
editable={p.editable}
label="Defined network"
options={p.networksList.map((n) => {
const chars = [n.forward_mode.toString()];
if (n.ip_v4) chars.push("IPv4");
if (n.ip_v6) chars.push("IPv6");
if (n.description) chars.push(n.description);
return {
label: n.name,
value: n.name,
description: chars.join(" - "),
};
})}
value={p.network.network}
onValueChange={(v) => {
if (p.network.type === "DefinedNetwork")
p.network.network = v as any;
p.onChange?.();
}}
/>
)}
{/* Bridge selection */}
{p.network.type === "Bridge" && (
<SelectInput
editable={p.editable}
label="Host bridge"
options={p.bridgesList.map((n) => {
return {
label: n,
value: n,
};
})}
value={p.network.bridge}
onValueChange={(v) => {
if (p.network.type === "Bridge") p.network.bridge = v as any;
p.onChange?.();
}}
/>
)}
{p.network.type !== "UserspaceSLIRPStack" && (
<>
<SelectInput
editable={p.editable}
label="Defined network"
options={p.networksList.map((n) => {
const chars = [n.forward_mode.toString()];
if (n.ip_v4) chars.push("IPv4");
if (n.ip_v6) chars.push("IPv6");
if (n.description) chars.push(n.description);
return {
label: n.name,
value: n.name,
description: chars.join(" - "),
};
})}
value={p.network.network}
onValueChange={(v) => {
if (p.network.type === "DefinedNetwork")
p.network.network = v as any;
p.onChange?.();
}}
/>
{/* Network Filter */}
<NWFilterSelectInput
editable={p.editable}

View File

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

View File

@ -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,17 +27,23 @@ 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<
number[] | undefined
>();
@ -50,7 +56,9 @@ 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());
setNetworksList(await NetworkApi.GetList());
setNetworkFiltersList(await NWFilterApi.GetList());
@ -64,7 +72,9 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
build={() => (
<VMDetailsInner
groupsList={groupsList!}
diskImagesList={diskImagesList!}
isoList={isoList!}
bridgesList={bridgesList!}
vcpuCombinations={vcpuCombinations!}
networksList={networksList!}
networkFiltersList={networkFiltersList!}
@ -86,7 +96,9 @@ enum VMTab {
type DetailsInnerProps = DetailsProps & {
groupsList: string[];
diskImagesList: DiskImage[];
isoList: IsoFile[];
bridgesList: string[];
vcpuCombinations: number[];
networksList: NetworkInfo[];
networkFiltersList: NWFilter[];
@ -268,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" },
]}
/>
@ -275,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)
}
/>

View 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>
);
}