Compare commits
19 Commits
5cdbb58c03
...
9334b984ae
Author | SHA1 | Date | |
---|---|---|---|
9334b984ae | |||
d5fbc24c96 | |||
d765f9c2c3 | |||
21fd5de139 | |||
42f22c110c | |||
9822c5a72a | |||
452a395525 | |||
80d81c34bb | |||
b9353326f5 | |||
3ffc64f129 | |||
e869517bb1 | |||
90f4bf35e9 | |||
80d6fe0298 | |||
e017fe96d5 | |||
e7ac0198ab | |||
927a51cda7 | |||
615dc1ed83 | |||
20de618568 | |||
7451f1b7b4 |
4
virtweb_backend/Cargo.lock
generated
4
virtweb_backend/Cargo.lock
generated
@ -3413,9 +3413,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.45.0"
|
version = "1.45.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165"
|
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
@ -36,7 +36,7 @@ lazy-regex = "3.4.1"
|
|||||||
thiserror = "2.0.12"
|
thiserror = "2.0.12"
|
||||||
image = "0.25.6"
|
image = "0.25.6"
|
||||||
rand = "0.9.1"
|
rand = "0.9.1"
|
||||||
tokio = { version = "1.45.0", features = ["rt", "time", "macros"] }
|
tokio = { version = "1.45.1", features = ["rt", "time", "macros"] }
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
ipnetwork = { version = "0.21.1", features = ["serde"] }
|
ipnetwork = { version = "0.21.1", features = ["serde"] }
|
||||||
num = "0.4.3"
|
num = "0.4.3"
|
||||||
|
@ -245,7 +245,7 @@ impl AppConfig {
|
|||||||
storage_path.canonicalize().unwrap()
|
storage_path.canonicalize().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get iso storage directory
|
/// Get iso files storage directory
|
||||||
pub fn iso_storage_path(&self) -> PathBuf {
|
pub fn iso_storage_path(&self) -> PathBuf {
|
||||||
self.storage_path().join("iso")
|
self.storage_path().join("iso")
|
||||||
}
|
}
|
||||||
@ -265,15 +265,17 @@ impl AppConfig {
|
|||||||
self.vnc_sockets_path().join(format!("vnc-{}", name))
|
self.vnc_sockets_path().join(format!("vnc-{}", name))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get VM vnc sockets directory
|
/// Get VM root disks storage directory
|
||||||
pub fn disks_storage_path(&self) -> PathBuf {
|
pub fn root_vm_disks_storage_path(&self) -> PathBuf {
|
||||||
self.storage_path().join("disks")
|
self.storage_path().join("disks")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get specific VM disk storage directory
|
||||||
pub fn vm_storage_path(&self, id: XMLUuid) -> PathBuf {
|
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 {
|
pub fn definitions_path(&self) -> PathBuf {
|
||||||
self.storage_path().join("definitions")
|
self.storage_path().join("definitions")
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
use crate::utils::file_size_utils::FileSize;
|
||||||
|
|
||||||
/// Name of the cookie that contains session information
|
/// Name of the cookie that contains session information
|
||||||
pub const SESSION_COOKIE_NAME: &str = "X-auth-token";
|
pub const SESSION_COOKIE_NAME: &str = "X-auth-token";
|
||||||
|
|
||||||
@ -47,10 +49,10 @@ pub const DISK_NAME_MIN_LEN: usize = 2;
|
|||||||
pub const DISK_NAME_MAX_LEN: usize = 10;
|
pub const DISK_NAME_MAX_LEN: usize = 10;
|
||||||
|
|
||||||
/// Disk size min (B)
|
/// Disk size min (B)
|
||||||
pub const DISK_SIZE_MIN: usize = 100 * 1000 * 1000;
|
pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50);
|
||||||
|
|
||||||
/// Disk size max (B)
|
/// Disk size max (B)
|
||||||
pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 1000 * 1000 * 2;
|
pub const DISK_SIZE_MAX: FileSize = FileSize::from_gb(20000);
|
||||||
|
|
||||||
/// Net nat entry comment max size
|
/// Net nat entry comment max size
|
||||||
pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250;
|
pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250;
|
||||||
@ -121,3 +123,15 @@ pub const QEMU_IMAGE_PROGRAM: &str = "/usr/bin/qemu-img";
|
|||||||
|
|
||||||
/// IP program path
|
/// IP program path
|
||||||
pub const IP_PROGRAM: &str = "/usr/sbin/ip";
|
pub const IP_PROGRAM: &str = "/usr/sbin/ip";
|
||||||
|
|
||||||
|
/// Copy program path
|
||||||
|
pub const COPY_PROGRAM: &str = "/bin/cp";
|
||||||
|
|
||||||
|
/// Gzip program path
|
||||||
|
pub const GZIP_PROGRAM: &str = "/usr/bin/gzip";
|
||||||
|
|
||||||
|
/// Bash program
|
||||||
|
pub const BASH_PROGRAM: &str = "/usr/bin/bash";
|
||||||
|
|
||||||
|
/// DD program
|
||||||
|
pub const DD_PROGRAM: &str = "/usr/bin/dd";
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
use crate::app_config::AppConfig;
|
use crate::app_config::AppConfig;
|
||||||
use crate::constants;
|
use crate::constants;
|
||||||
use crate::controllers::HttpResult;
|
use crate::controllers::HttpResult;
|
||||||
use crate::utils::file_disks_utils::DiskFileInfo;
|
use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo};
|
||||||
use crate::utils::files_utils;
|
use crate::utils::files_utils;
|
||||||
|
use actix_files::NamedFile;
|
||||||
use actix_multipart::form::MultipartForm;
|
use actix_multipart::form::MultipartForm;
|
||||||
use actix_multipart::form::tempfile::TempFile;
|
use actix_multipart::form::tempfile::TempFile;
|
||||||
use actix_web::HttpResponse;
|
use actix_web::{HttpRequest, HttpResponse, web};
|
||||||
|
|
||||||
#[derive(Debug, MultipartForm)]
|
#[derive(Debug, MultipartForm)]
|
||||||
pub struct UploadDiskImageForm {
|
pub struct UploadDiskImageForm {
|
||||||
@ -67,3 +68,97 @@ pub async fn get_list() -> HttpResult {
|
|||||||
|
|
||||||
Ok(HttpResponse::Ok().json(list))
|
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_storage_path()
|
||||||
|
.join(&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!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 src_file_path = AppConfig::get()
|
||||||
|
.disk_images_storage_path()
|
||||||
|
.join(&p.filename);
|
||||||
|
|
||||||
|
let src = DiskFileInfo::load_file(&src_file_path)?;
|
||||||
|
|
||||||
|
let dst_file_path = AppConfig::get()
|
||||||
|
.disk_images_storage_path()
|
||||||
|
.join(&req.dest_file_name);
|
||||||
|
|
||||||
|
if dst_file_path.exists() {
|
||||||
|
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::Ok().json(src))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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_storage_path()
|
||||||
|
.join(&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())
|
||||||
|
}
|
||||||
|
@ -132,12 +132,12 @@ pub async fn get_list() -> HttpResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct DownloadFilePath {
|
pub struct IsoFilePath {
|
||||||
filename: String,
|
filename: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download ISO file
|
/// 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) {
|
if !files_utils::check_file_name(&p.filename) {
|
||||||
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
|
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
|
/// 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) {
|
if !files_utils::check_file_name(&p.filename) {
|
||||||
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
|
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
|
||||||
}
|
}
|
||||||
|
@ -46,6 +46,7 @@ struct ServerConstraints {
|
|||||||
memory_size: LenConstraints,
|
memory_size: LenConstraints,
|
||||||
disk_name_size: LenConstraints,
|
disk_name_size: LenConstraints,
|
||||||
disk_size: LenConstraints,
|
disk_size: LenConstraints,
|
||||||
|
disk_image_name_size: LenConstraints,
|
||||||
net_name_size: LenConstraints,
|
net_name_size: LenConstraints,
|
||||||
net_title_size: LenConstraints,
|
net_title_size: LenConstraints,
|
||||||
net_nat_comment_size: LenConstraints,
|
net_nat_comment_size: LenConstraints,
|
||||||
@ -87,10 +88,12 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
|
|||||||
max: DISK_NAME_MAX_LEN,
|
max: DISK_NAME_MAX_LEN,
|
||||||
},
|
},
|
||||||
disk_size: LenConstraints {
|
disk_size: LenConstraints {
|
||||||
min: DISK_SIZE_MIN,
|
min: DISK_SIZE_MIN.as_bytes(),
|
||||||
max: DISK_SIZE_MAX,
|
max: DISK_SIZE_MAX.as_bytes(),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
disk_image_name_size: LenConstraints { min: 5, max: 220 },
|
||||||
|
|
||||||
net_name_size: LenConstraints { min: 2, max: 50 },
|
net_name_size: LenConstraints { min: 2, max: 50 },
|
||||||
net_title_size: LenConstraints { min: 0, max: 50 },
|
net_title_size: LenConstraints { min: 0, max: 50 },
|
||||||
net_nat_comment_size: LenConstraints {
|
net_nat_comment_size: LenConstraints {
|
||||||
|
@ -4,9 +4,9 @@ use crate::libvirt_lib_structures::XMLUuid;
|
|||||||
use crate::libvirt_lib_structures::domain::*;
|
use crate::libvirt_lib_structures::domain::*;
|
||||||
use crate::libvirt_rest_structures::LibVirtStructError;
|
use crate::libvirt_rest_structures::LibVirtStructError;
|
||||||
use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction;
|
use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction;
|
||||||
use crate::utils::file_disks_utils::{VMDiskFormat, VMFileDisk};
|
|
||||||
use crate::utils::files_utils;
|
use crate::utils::files_utils;
|
||||||
use crate::utils::files_utils::convert_size_unit_to_mb;
|
use crate::utils::files_utils::convert_size_unit_to_mb;
|
||||||
|
use crate::utils::vm_file_disks_utils::{VMDiskFormat, VMFileDisk};
|
||||||
use lazy_regex::regex;
|
use lazy_regex::regex;
|
||||||
use num::Integer;
|
use num::Integer;
|
||||||
|
|
||||||
|
@ -60,7 +60,8 @@ async fn main() -> std::io::Result<()> {
|
|||||||
files_utils::create_directory_if_missing(AppConfig::get().disk_images_storage_path()).unwrap();
|
files_utils::create_directory_if_missing(AppConfig::get().disk_images_storage_path()).unwrap();
|
||||||
files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_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::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().nat_path()).unwrap();
|
||||||
files_utils::create_directory_if_missing(AppConfig::get().definitions_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();
|
files_utils::create_directory_if_missing(AppConfig::get().api_tokens_path()).unwrap();
|
||||||
@ -344,6 +345,18 @@ async fn main() -> std::io::Result<()> {
|
|||||||
"/api/disk_images/list",
|
"/api/disk_images/list",
|
||||||
web::get().to(disk_images_controller::get_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}",
|
||||||
|
web::delete().to(disk_images_controller::delete),
|
||||||
|
)
|
||||||
// API tokens controller
|
// API tokens controller
|
||||||
.route(
|
.route(
|
||||||
"/api/token/create",
|
"/api/token/create",
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
use crate::app_config::AppConfig;
|
use crate::app_config::AppConfig;
|
||||||
use crate::constants;
|
use crate::constants;
|
||||||
use crate::libvirt_lib_structures::XMLUuid;
|
use crate::utils::file_size_utils::FileSize;
|
||||||
use crate::utils::files_utils;
|
use std::fs::File;
|
||||||
use lazy_regex::regex;
|
|
||||||
use std::os::linux::fs::MetadataExt;
|
use std::os::linux::fs::MetadataExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@ -12,193 +11,48 @@ use std::time::UNIX_EPOCH;
|
|||||||
enum DisksError {
|
enum DisksError {
|
||||||
#[error("DiskParseError: {0}")]
|
#[error("DiskParseError: {0}")]
|
||||||
Parse(&'static str),
|
Parse(&'static str),
|
||||||
#[error("DiskConfigError: {0}")]
|
|
||||||
Config(&'static str),
|
|
||||||
#[error("DiskCreateError")]
|
#[error("DiskCreateError")]
|
||||||
Create,
|
Create,
|
||||||
|
#[error("DiskConvertError: {0}")]
|
||||||
|
Convert(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Type of disk allocation
|
#[derive(Debug, serde::Serialize, serde::Deserialize, Copy, Clone, PartialEq, Eq)]
|
||||||
#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
||||||
pub enum VMDiskAllocType {
|
|
||||||
Fixed,
|
|
||||||
Sparse,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disk allocation type
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
|
||||||
#[serde(tag = "format")]
|
|
||||||
pub enum VMDiskFormat {
|
|
||||||
Raw {
|
|
||||||
/// Type of disk allocation
|
|
||||||
alloc_type: VMDiskAllocType,
|
|
||||||
},
|
|
||||||
QCow2,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
|
||||||
pub struct VMFileDisk {
|
|
||||||
/// Disk name
|
|
||||||
pub name: String,
|
|
||||||
/// Disk size, in bytes
|
|
||||||
pub size: usize,
|
|
||||||
/// Disk format
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub format: VMDiskFormat,
|
|
||||||
/// Set this variable to true to delete the disk
|
|
||||||
pub delete: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VMFileDisk {
|
|
||||||
pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
|
|
||||||
let file = Path::new(path);
|
|
||||||
|
|
||||||
let info = DiskFileInfo::load_file(file)?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
name: info.name,
|
|
||||||
|
|
||||||
// Get only the virtual size of the file
|
|
||||||
size: match info.format {
|
|
||||||
DiskFileFormat::Raw { .. } => info.file_size,
|
|
||||||
DiskFileFormat::QCow2 { virtual_size } => virtual_size,
|
|
||||||
_ => anyhow::bail!("Unsupported image format: {:?}", info.format),
|
|
||||||
},
|
|
||||||
|
|
||||||
format: match info.format {
|
|
||||||
DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw {
|
|
||||||
alloc_type: match is_sparse {
|
|
||||||
true => VMDiskAllocType::Sparse,
|
|
||||||
false => VMDiskAllocType::Fixed,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2,
|
|
||||||
_ => anyhow::bail!("Unsupported image format: {:?}", info.format),
|
|
||||||
},
|
|
||||||
delete: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_config(&self) -> anyhow::Result<()> {
|
|
||||||
if constants::DISK_NAME_MIN_LEN > self.name.len()
|
|
||||||
|| constants::DISK_NAME_MAX_LEN < self.name.len()
|
|
||||||
{
|
|
||||||
return Err(DisksError::Config("Disk name length is invalid").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
if !regex!("^[a-zA-Z0-9]+$").is_match(&self.name) {
|
|
||||||
return Err(DisksError::Config("Disk name contains invalid characters!").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check disk size
|
|
||||||
if !(constants::DISK_SIZE_MIN..=constants::DISK_SIZE_MAX).contains(&self.size) {
|
|
||||||
return Err(DisksError::Config("Disk size is invalid!").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get disk path
|
|
||||||
pub fn disk_path(&self, id: XMLUuid) -> PathBuf {
|
|
||||||
let domain_dir = AppConfig::get().vm_storage_path(id);
|
|
||||||
let file_name = match self.format {
|
|
||||||
VMDiskFormat::Raw { .. } => self.name.to_string(),
|
|
||||||
VMDiskFormat::QCow2 => format!("{}.qcow2", self.name),
|
|
||||||
};
|
|
||||||
domain_dir.join(&file_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply disk configuration
|
|
||||||
pub fn apply_config(&self, id: XMLUuid) -> anyhow::Result<()> {
|
|
||||||
self.check_config()?;
|
|
||||||
|
|
||||||
let file = self.disk_path(id);
|
|
||||||
files_utils::create_directory_if_missing(file.parent().unwrap())?;
|
|
||||||
|
|
||||||
// Delete file if requested
|
|
||||||
if self.delete {
|
|
||||||
if !file.exists() {
|
|
||||||
log::debug!("File {file:?} does not exists, so it was not deleted");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Deleting {file:?}");
|
|
||||||
std::fs::remove_file(file)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if file.exists() {
|
|
||||||
log::debug!("File {file:?} does not exists, so it was not touched");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare command to create file
|
|
||||||
let res = match self.format {
|
|
||||||
VMDiskFormat::Raw { alloc_type } => {
|
|
||||||
let mut cmd = Command::new("/usr/bin/dd");
|
|
||||||
cmd.arg("if=/dev/zero")
|
|
||||||
.arg(format!("of={}", file.to_string_lossy()))
|
|
||||||
.arg("bs=1M");
|
|
||||||
|
|
||||||
match alloc_type {
|
|
||||||
VMDiskAllocType::Fixed => cmd.arg(format!("count={}", self.size_mb())),
|
|
||||||
VMDiskAllocType::Sparse => {
|
|
||||||
cmd.arg(format!("seek={}", self.size_mb())).arg("count=0")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
cmd.output()?
|
|
||||||
}
|
|
||||||
|
|
||||||
VMDiskFormat::QCow2 => {
|
|
||||||
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
|
|
||||||
cmd.arg("create")
|
|
||||||
.arg("-f")
|
|
||||||
.arg("qcow2")
|
|
||||||
.arg(file)
|
|
||||||
.arg(format!("{}M", self.size_mb()));
|
|
||||||
|
|
||||||
cmd.output()?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Execute Linux command
|
|
||||||
if !res.status.success() {
|
|
||||||
log::error!(
|
|
||||||
"Failed to create disk! stderr={} stdout={}",
|
|
||||||
String::from_utf8_lossy(&res.stderr),
|
|
||||||
String::from_utf8_lossy(&res.stdout)
|
|
||||||
);
|
|
||||||
return Err(DisksError::Create.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the size of file disk in megabytes
|
|
||||||
pub fn size_mb(&self) -> usize {
|
|
||||||
self.size / (1000 * 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
#[serde(tag = "format")]
|
#[serde(tag = "format")]
|
||||||
pub enum DiskFileFormat {
|
pub enum DiskFileFormat {
|
||||||
Raw { is_sparse: bool },
|
Raw {
|
||||||
QCow2 { virtual_size: usize },
|
#[serde(default)]
|
||||||
|
is_sparse: bool,
|
||||||
|
},
|
||||||
|
QCow2 {
|
||||||
|
#[serde(default)]
|
||||||
|
virtual_size: FileSize,
|
||||||
|
},
|
||||||
CompressedRaw,
|
CompressedRaw,
|
||||||
CompressedQCow2,
|
CompressedQCow2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DiskFileFormat {
|
||||||
|
pub fn ext(&self) -> &'static [&'static str] {
|
||||||
|
match self {
|
||||||
|
DiskFileFormat::Raw { .. } => &["raw", ""],
|
||||||
|
DiskFileFormat::QCow2 { .. } => &["qcow2"],
|
||||||
|
DiskFileFormat::CompressedRaw => &["raw.gz"],
|
||||||
|
DiskFileFormat::CompressedQCow2 => &["qcow2.gz"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Disk file information
|
/// Disk file information
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub struct DiskFileInfo {
|
pub struct DiskFileInfo {
|
||||||
file_size: usize,
|
pub file_path: PathBuf,
|
||||||
|
pub file_size: FileSize,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
format: DiskFileFormat,
|
pub format: DiskFileFormat,
|
||||||
file_name: String,
|
pub file_name: String,
|
||||||
name: String,
|
pub name: String,
|
||||||
created: u64,
|
pub created: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DiskFileInfo {
|
impl DiskFileInfo {
|
||||||
@ -234,8 +88,9 @@ impl DiskFileInfo {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
file_path: file.to_path_buf(),
|
||||||
name,
|
name,
|
||||||
file_size: metadata.len() as usize,
|
file_size: FileSize::from_bytes(metadata.len() as usize),
|
||||||
format,
|
format,
|
||||||
file_name: file
|
file_name: file
|
||||||
.file_name()
|
.file_name()
|
||||||
@ -249,6 +104,218 @@ impl DiskFileInfo {
|
|||||||
.as_secs(),
|
.as_secs(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new empty disk
|
||||||
|
pub fn create(file: &Path, format: DiskFileFormat, size: FileSize) -> anyhow::Result<()> {
|
||||||
|
// Prepare command to create file
|
||||||
|
let res = match format {
|
||||||
|
DiskFileFormat::Raw { is_sparse } => {
|
||||||
|
let mut cmd = Command::new("/usr/bin/dd");
|
||||||
|
cmd.arg("if=/dev/zero")
|
||||||
|
.arg(format!("of={}", file.to_string_lossy()))
|
||||||
|
.arg("bs=1M");
|
||||||
|
|
||||||
|
match is_sparse {
|
||||||
|
false => cmd.arg(format!("count={}", size.as_mb())),
|
||||||
|
true => cmd.arg(format!("seek={}", size.as_mb())).arg("count=0"),
|
||||||
|
};
|
||||||
|
|
||||||
|
cmd.output()?
|
||||||
|
}
|
||||||
|
|
||||||
|
DiskFileFormat::QCow2 { virtual_size } => {
|
||||||
|
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
|
||||||
|
cmd.arg("create")
|
||||||
|
.arg("-f")
|
||||||
|
.arg("qcow2")
|
||||||
|
.arg(file)
|
||||||
|
.arg(format!("{}M", virtual_size.as_mb()));
|
||||||
|
|
||||||
|
cmd.output()?
|
||||||
|
}
|
||||||
|
_ => anyhow::bail!("Cannot create disk file image of this format: {format:?}!"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute Linux command
|
||||||
|
if !res.status.success() {
|
||||||
|
log::error!(
|
||||||
|
"Failed to create disk! stderr={} stdout={}",
|
||||||
|
String::from_utf8_lossy(&res.stderr),
|
||||||
|
String::from_utf8_lossy(&res.stdout)
|
||||||
|
);
|
||||||
|
return Err(DisksError::Create.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy / convert file disk image into a new destination with optionally a new file format
|
||||||
|
pub fn convert(&self, dest_file: &Path, dest_format: DiskFileFormat) -> anyhow::Result<()> {
|
||||||
|
// Create a temporary directory to perform the operation
|
||||||
|
let temp_dir = tempfile::tempdir_in(&AppConfig::get().temp_dir)?;
|
||||||
|
let temp_file = temp_dir
|
||||||
|
.path()
|
||||||
|
.join(format!("temp_file.{}", dest_format.ext()[0]));
|
||||||
|
|
||||||
|
// Prepare the conversion
|
||||||
|
let mut cmd = match (self.format, dest_format) {
|
||||||
|
// Decompress QCow2
|
||||||
|
(DiskFileFormat::CompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
|
||||||
|
let mut cmd = Command::new(constants::GZIP_PROGRAM);
|
||||||
|
cmd.arg("--keep")
|
||||||
|
.arg("--decompress")
|
||||||
|
.arg("--to-stdout")
|
||||||
|
.arg(&self.file_path)
|
||||||
|
.stdout(File::create(&temp_file)?);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress QCow2
|
||||||
|
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::CompressedQCow2) => {
|
||||||
|
let mut cmd = Command::new(constants::GZIP_PROGRAM);
|
||||||
|
cmd.arg("--keep")
|
||||||
|
.arg("--to-stdout")
|
||||||
|
.arg(&self.file_path)
|
||||||
|
.stdout(File::create(&temp_file)?);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert QCow2 to Raw file
|
||||||
|
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => {
|
||||||
|
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
|
||||||
|
cmd.arg("convert").arg(&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)]
|
#[derive(serde::Deserialize)]
|
||||||
@ -258,7 +325,7 @@ struct QCowInfoOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get QCow2 virtual size
|
/// Get QCow2 virtual size
|
||||||
fn qcow_virt_size(path: &Path) -> anyhow::Result<usize> {
|
fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> {
|
||||||
// Run qemu-img
|
// Run qemu-img
|
||||||
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
|
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
|
||||||
cmd.args([
|
cmd.args([
|
||||||
@ -281,5 +348,5 @@ fn qcow_virt_size(path: &Path) -> anyhow::Result<usize> {
|
|||||||
|
|
||||||
// Decode JSON
|
// Decode JSON
|
||||||
let decoded: QCowInfoOutput = serde_json::from_str(&res_json)?;
|
let decoded: QCowInfoOutput = serde_json::from_str(&res_json)?;
|
||||||
Ok(decoded.virtual_size)
|
Ok(FileSize::from_bytes(decoded.virtual_size))
|
||||||
}
|
}
|
||||||
|
37
virtweb_backend/src/utils/file_size_utils.rs
Normal file
37
virtweb_backend/src/utils/file_size_utils.rs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
#[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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,9 @@
|
|||||||
pub mod exec_utils;
|
pub mod exec_utils;
|
||||||
pub mod file_disks_utils;
|
pub mod file_disks_utils;
|
||||||
|
pub mod file_size_utils;
|
||||||
pub mod files_utils;
|
pub mod files_utils;
|
||||||
pub mod net_utils;
|
pub mod net_utils;
|
||||||
pub mod rand_utils;
|
pub mod rand_utils;
|
||||||
pub mod time_utils;
|
pub mod time_utils;
|
||||||
pub mod url_utils;
|
pub mod url_utils;
|
||||||
|
pub mod vm_file_disks_utils;
|
||||||
|
146
virtweb_backend/src/utils/vm_file_disks_utils.rs
Normal file
146
virtweb_backend/src/utils/vm_file_disks_utils.rs
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
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),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type of disk allocation
|
||||||
|
#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq)]
|
||||||
|
pub enum VMDiskAllocType {
|
||||||
|
Fixed,
|
||||||
|
Sparse,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disk allocation type
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(tag = "format")]
|
||||||
|
pub enum VMDiskFormat {
|
||||||
|
Raw {
|
||||||
|
/// Type of disk allocation
|
||||||
|
alloc_type: VMDiskAllocType,
|
||||||
|
},
|
||||||
|
QCow2,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct VMFileDisk {
|
||||||
|
/// Disk name
|
||||||
|
pub name: String,
|
||||||
|
/// Disk size, in bytes
|
||||||
|
pub size: FileSize,
|
||||||
|
/// Disk format
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub format: VMDiskFormat,
|
||||||
|
/// Set this variable to true to delete the disk
|
||||||
|
pub delete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VMFileDisk {
|
||||||
|
pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
|
||||||
|
let file = Path::new(path);
|
||||||
|
|
||||||
|
let info = DiskFileInfo::load_file(file)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
name: info.name,
|
||||||
|
|
||||||
|
// Get only the virtual size of the file
|
||||||
|
size: match info.format {
|
||||||
|
DiskFileFormat::Raw { .. } => info.file_size,
|
||||||
|
DiskFileFormat::QCow2 { virtual_size } => virtual_size,
|
||||||
|
_ => anyhow::bail!("Unsupported image format: {:?}", info.format),
|
||||||
|
},
|
||||||
|
|
||||||
|
format: match info.format {
|
||||||
|
DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw {
|
||||||
|
alloc_type: match is_sparse {
|
||||||
|
true => VMDiskAllocType::Sparse,
|
||||||
|
false => VMDiskAllocType::Fixed,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2,
|
||||||
|
_ => anyhow::bail!("Unsupported image format: {:?}", info.format),
|
||||||
|
},
|
||||||
|
delete: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_config(&self) -> anyhow::Result<()> {
|
||||||
|
if constants::DISK_NAME_MIN_LEN > self.name.len()
|
||||||
|
|| constants::DISK_NAME_MAX_LEN < self.name.len()
|
||||||
|
{
|
||||||
|
return Err(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());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get disk path
|
||||||
|
pub fn disk_path(&self, id: XMLUuid) -> PathBuf {
|
||||||
|
let domain_dir = AppConfig::get().vm_storage_path(id);
|
||||||
|
let file_name = match self.format {
|
||||||
|
VMDiskFormat::Raw { .. } => self.name.to_string(),
|
||||||
|
VMDiskFormat::QCow2 => format!("{}.qcow2", self.name),
|
||||||
|
};
|
||||||
|
domain_dir.join(&file_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply disk configuration
|
||||||
|
pub fn apply_config(&self, id: XMLUuid) -> anyhow::Result<()> {
|
||||||
|
self.check_config()?;
|
||||||
|
|
||||||
|
let file = self.disk_path(id);
|
||||||
|
files_utils::create_directory_if_missing(file.parent().unwrap())?;
|
||||||
|
|
||||||
|
// Delete file if requested
|
||||||
|
if self.delete {
|
||||||
|
if !file.exists() {
|
||||||
|
log::debug!("File {file:?} does not exists, so it was not deleted");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Deleting {file:?}");
|
||||||
|
std::fs::remove_file(file)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.exists() {
|
||||||
|
log::debug!("File {file:?} does not exists, so it was not touched");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create disk file
|
||||||
|
DiskFileInfo::create(
|
||||||
|
&file,
|
||||||
|
match self.format {
|
||||||
|
VMDiskFormat::Raw { alloc_type } => DiskFileFormat::Raw {
|
||||||
|
is_sparse: alloc_type == VMDiskAllocType::Sparse,
|
||||||
|
},
|
||||||
|
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
|
||||||
|
virtual_size: self.size,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
self.size,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
@ -34,7 +34,7 @@ docker compose up
|
|||||||
sudo mkdir /var/virtweb
|
sudo mkdir /var/virtweb
|
||||||
sudo chown $USER:$USER /var/virtweb
|
sudo chown $USER:$USER /var/virtweb
|
||||||
cd virtweb_backend
|
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
|
7. Run the frontend
|
||||||
|
@ -1,16 +1,17 @@
|
|||||||
import { APIClient } from "./ApiClient";
|
import { APIClient } from "./ApiClient";
|
||||||
|
|
||||||
|
export type DiskImageFormat =
|
||||||
|
| { format: "Raw"; is_sparse: boolean }
|
||||||
|
| { format: "QCow2"; virtual_size?: number }
|
||||||
|
| { format: "CompressedQCow2" }
|
||||||
|
| { format: "CompressedRaw" };
|
||||||
|
|
||||||
export type DiskImage = {
|
export type DiskImage = {
|
||||||
file_size: number;
|
file_size: number;
|
||||||
file_name: string;
|
file_name: string;
|
||||||
name: string;
|
name: string;
|
||||||
created: number;
|
created: number;
|
||||||
} & (
|
} & DiskImageFormat;
|
||||||
| { format: "Raw"; is_sparse: boolean }
|
|
||||||
| { format: "QCow2"; virtual_size: number }
|
|
||||||
| { format: "CompressedQCow2" }
|
|
||||||
| { format: "CompressedRaw" }
|
|
||||||
);
|
|
||||||
|
|
||||||
export class DiskImageApi {
|
export class DiskImageApi {
|
||||||
/**
|
/**
|
||||||
@ -42,4 +43,47 @@ export class DiskImageApi {
|
|||||||
})
|
})
|
||||||
).data;
|
).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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete disk image file
|
||||||
|
*/
|
||||||
|
static async Delete(file: DiskImage): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "DELETE",
|
||||||
|
uri: `/disk_images/${file.file_name}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,7 @@ export interface ServerConstraints {
|
|||||||
memory_size: LenConstraint;
|
memory_size: LenConstraint;
|
||||||
disk_name_size: LenConstraint;
|
disk_name_size: LenConstraint;
|
||||||
disk_size: LenConstraint;
|
disk_size: LenConstraint;
|
||||||
|
disk_image_name_size: LenConstraint;
|
||||||
net_name_size: LenConstraint;
|
net_name_size: LenConstraint;
|
||||||
net_title_size: LenConstraint;
|
net_title_size: LenConstraint;
|
||||||
net_nat_comment_size: LenConstraint;
|
net_nat_comment_size: LenConstraint;
|
||||||
|
123
virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
Normal file
123
virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
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 { useAlert } from "../hooks/providers/AlertDialogProvider";
|
||||||
|
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
|
||||||
|
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
|
||||||
|
import { FileDiskImageWidget } from "../widgets/FileDiskImageWidget";
|
||||||
|
import { CheckboxInput } from "../widgets/forms/CheckboxInput";
|
||||||
|
import { SelectInput } from "../widgets/forms/SelectInput";
|
||||||
|
import { TextInput } from "../widgets/forms/TextInput";
|
||||||
|
|
||||||
|
export function ConvertDiskImageDialog(p: {
|
||||||
|
image: DiskImage;
|
||||||
|
onCancel: () => void;
|
||||||
|
onFinished: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const alert = useAlert();
|
||||||
|
const snackbar = useSnackbar();
|
||||||
|
const loadingMessage = useLoadingMessage();
|
||||||
|
|
||||||
|
const [format, setFormat] = React.useState<DiskImageFormat>({
|
||||||
|
format: "QCow2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [filename, setFilename] = React.useState(p.image.file_name + ".qcow2");
|
||||||
|
|
||||||
|
const handleFormatChange = (value?: string) => {
|
||||||
|
setFormat({ format: value ?? ("QCow2" as any) });
|
||||||
|
|
||||||
|
if (value === "QCow2") setFilename(`${p.image.file_name}.qcow2`);
|
||||||
|
if (value === "CompressedQCow2")
|
||||||
|
setFilename(`${p.image.file_name}.qcow2.gz`);
|
||||||
|
if (value === "Raw") {
|
||||||
|
setFilename(`${p.image.file_name}.raw`);
|
||||||
|
// Check sparse checkbox by default
|
||||||
|
setFormat({ format: "Raw", is_sparse: true });
|
||||||
|
}
|
||||||
|
if (value === "CompressedRaw") setFilename(`${p.image.file_name}.raw.gz`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
loadingMessage.show("Converting image...");
|
||||||
|
|
||||||
|
// Perform the conversion
|
||||||
|
await DiskImageApi.Convert(p.image, filename, format);
|
||||||
|
|
||||||
|
p.onFinished();
|
||||||
|
|
||||||
|
snackbar("Conversion successful!");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to convert image!", e);
|
||||||
|
alert(`Failed to convert image! ${e}`);
|
||||||
|
} finally {
|
||||||
|
loadingMessage.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={p.onCancel}>
|
||||||
|
<DialogTitle>Convert disk image</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Select the destination format for this image:
|
||||||
|
</DialogContentText>
|
||||||
|
<FileDiskImageWidget image={p.image} />
|
||||||
|
|
||||||
|
{/* 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"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={p.onCancel}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} autoFocus>
|
||||||
|
Convert image
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
@ -1,18 +1,29 @@
|
|||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import LoopIcon from "@mui/icons-material/Loop";
|
||||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
|
CircularProgress,
|
||||||
IconButton,
|
IconButton,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||||
import { filesize } from "filesize";
|
import { filesize } from "filesize";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { DiskImage, DiskImageApi } from "../api/DiskImageApi";
|
import { DiskImage, DiskImageApi } from "../api/DiskImageApi";
|
||||||
import { ServerApi } from "../api/ServerApi";
|
import { ServerApi } from "../api/ServerApi";
|
||||||
|
import { ConvertDiskImageDialog } from "../dialogs/ConvertDiskImageDialog";
|
||||||
import { useAlert } from "../hooks/providers/AlertDialogProvider";
|
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 { useSnackbar } from "../hooks/providers/SnackbarProvider";
|
||||||
|
import { downloadBlob } from "../utils/FilesUtils";
|
||||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||||
|
import { DateWidget } from "../widgets/DateWidget";
|
||||||
import { FileInput } from "../widgets/forms/FileInput";
|
import { FileInput } from "../widgets/forms/FileInput";
|
||||||
import { VirtWebPaper } from "../widgets/VirtWebPaper";
|
import { VirtWebPaper } from "../widgets/VirtWebPaper";
|
||||||
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
|
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
|
||||||
@ -32,28 +43,28 @@ export function DiskImagesRoute(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtWebRouteContainer label="Disk images">
|
<VirtWebRouteContainer
|
||||||
|
label="Disk images management"
|
||||||
|
actions={
|
||||||
|
<span>
|
||||||
|
<Tooltip title="Refresh Disk images list">
|
||||||
|
<IconButton onClick={reload}>
|
||||||
|
<RefreshIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
<AsyncWidget
|
<AsyncWidget
|
||||||
loadKey={loadKey.current}
|
loadKey={loadKey.current}
|
||||||
errMsg="Failed to load disk images list!"
|
errMsg="Failed to load disk images list!"
|
||||||
load={load}
|
load={load}
|
||||||
ready={list !== undefined}
|
ready={list !== undefined}
|
||||||
build={() => (
|
build={() => (
|
||||||
<VirtWebRouteContainer
|
<>
|
||||||
label="Disk images management"
|
|
||||||
actions={
|
|
||||||
<span>
|
|
||||||
<Tooltip title="Refresh Disk images list">
|
|
||||||
<IconButton onClick={reload}>
|
|
||||||
<RefreshIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<UploadDiskImageCard onFileUploaded={reload} />
|
<UploadDiskImageCard onFileUploaded={reload} />
|
||||||
<DiskImageList list={list!} onReload={reload} />
|
<DiskImageList list={list!} onReload={reload} />
|
||||||
</VirtWebRouteContainer>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</VirtWebRouteContainer>
|
</VirtWebRouteContainer>
|
||||||
@ -148,5 +159,175 @@ function DiskImageList(p: {
|
|||||||
list: DiskImage[];
|
list: DiskImage[];
|
||||||
onReload: () => void;
|
onReload: () => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return <>todo</>;
|
const alert = useAlert();
|
||||||
|
const snackbar = useSnackbar();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const loadingMessage = useLoadingMessage();
|
||||||
|
|
||||||
|
const [currConversion, setCurrConversion] = React.useState<
|
||||||
|
DiskImage | undefined
|
||||||
|
>();
|
||||||
|
const [dlProgress, setDlProgress] = React.useState<undefined | number>();
|
||||||
|
|
||||||
|
// Convert disk image file
|
||||||
|
const convertDiskImage = (entry: DiskImage) => {
|
||||||
|
setCurrConversion(entry);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 },
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
headerName: "",
|
||||||
|
width: 140,
|
||||||
|
renderCell(params) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip title="Convert disk image">
|
||||||
|
<IconButton onClick={() => { convertDiskImage(params.row); }}>
|
||||||
|
<LoopIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Download disk image">
|
||||||
|
<IconButton onClick={() => downloadDiskImage(params.row)}>
|
||||||
|
<DownloadIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete disk image">
|
||||||
|
<IconButton onClick={() => deleteDiskImage(params.row)}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 getRowId={(c) => c.file_name} rows={p.list} columns={columns} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
13
virtweb_frontend/src/widgets/DateWidget.tsx
Normal file
13
virtweb_frontend/src/widgets/DateWidget.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export function DateWidget(p: { time: number }): React.ReactElement {
|
||||||
|
const date = new Date(p.time * 1000);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pad(date.getDate())}/{pad(date.getMonth() + 1)}/{date.getFullYear()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(num: number): string {
|
||||||
|
return num.toString().padStart(2, "0");
|
||||||
|
}
|
23
virtweb_frontend/src/widgets/FileDiskImageWidget.tsx
Normal file
23
virtweb_frontend/src/widgets/FileDiskImageWidget.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material";
|
||||||
|
import { DiskImage } from "../api/DiskImageApi";
|
||||||
|
import { mdiHarddisk } from "@mdi/js";
|
||||||
|
import { filesize } from "filesize";
|
||||||
|
import Icon from "@mdi/react";
|
||||||
|
|
||||||
|
export function FileDiskImageWidget(p: {
|
||||||
|
image: DiskImage;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar>
|
||||||
|
<Icon path={mdiHarddisk} />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
primary={p.image.file_name}
|
||||||
|
secondary={`${p.image.format} - ${filesize(p.image.file_size)}`}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user