Compare commits

...

14 Commits

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

View File

@ -3413,9 +3413,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.45.0"
version = "1.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165"
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
dependencies = [
"backtrace",
"bytes",

View File

@ -36,7 +36,7 @@ lazy-regex = "3.4.1"
thiserror = "2.0.12"
image = "0.25.6"
rand = "0.9.1"
tokio = { version = "1.45.0", features = ["rt", "time", "macros"] }
tokio = { version = "1.45.1", features = ["rt", "time", "macros"] }
futures = "0.3.31"
ipnetwork = { version = "0.21.1", features = ["serde"] }
num = "0.4.3"

View File

@ -255,6 +255,11 @@ impl AppConfig {
self.storage_path().join("disk_images")
}
/// Get the path of a disk image file
pub fn disk_images_file_path(&self, name: &str) -> PathBuf {
self.disk_images_storage_path().join(name)
}
/// Get VM vnc sockets directory
pub fn vnc_sockets_path(&self) -> PathBuf {
self.storage_path().join("vnc")

View File

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

View File

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

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

View File

@ -71,8 +71,8 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES,
nwfilter_chains: &constants::NETWORK_CHAINS,
constraints: ServerConstraints {
iso_max_size: constants::ISO_MAX_SIZE,
disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE,
iso_max_size: constants::ISO_MAX_SIZE.as_bytes(),
disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE.as_bytes(),
vnc_token_duration: VNC_TOKEN_LIFETIME,
@ -80,8 +80,8 @@ 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,

View File

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

View File

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

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

@ -13,20 +13,13 @@ enum VMDisksError {
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,
/// Is raw file a sparse file?
is_sparse: bool,
},
QCow2,
}
@ -40,6 +33,9 @@ pub struct VMFileDisk {
/// 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,
}
@ -61,16 +57,12 @@ impl VMFileDisk {
},
format: match info.format {
DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw {
alloc_type: match is_sparse {
true => VMDiskAllocType::Sparse,
false => VMDiskAllocType::Fixed,
},
},
DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw { is_sparse },
DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2,
_ => anyhow::bail!("Unsupported image format: {:?}", info.format),
},
delete: false,
from_image: None,
})
}
@ -90,10 +82,23 @@ impl VMFileDisk {
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
/// 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 {
@ -127,19 +132,27 @@ impl VMFileDisk {
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,
},
let format = match self.format {
VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
virtual_size: self.size,
},
self.size,
)?;
};
// Create / Restore disk file
match &self.from_image {
// Create disk file
None => {
DiskFileInfo::create(&file, format, self.size)?;
}
// Restore disk image template
Some(disk_img) => {
let src_file =
DiskFileInfo::load_file(&AppConfig::get().disk_images_file_path(disk_img))?;
src_file.convert(&file, format)?;
}
}
Ok(())
}

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

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

View File

@ -24,16 +24,17 @@ export interface BaseFileVMDisk {
name: string;
delete: boolean;
// application attribute
// For new disk only
from_image?: string;
// application attributes
new?: boolean;
deleteType?: "keepfile" | "deletefile";
}
export type DiskAllocType = "Sparse" | "Fixed";
interface RawVMDisk {
format: "Raw";
alloc_type: DiskAllocType;
is_sparse: boolean;
}
interface QCow2Disk {
@ -59,6 +60,7 @@ export type VMNetInterface = (
export interface VMNetInterfaceBase {
mac: string;
model: "Virtio" | "E1000";
nwfilterref?: VMNetInterfaceFilter;
}
@ -137,7 +139,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

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

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

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

View File

@ -17,6 +17,7 @@ export function TextInput(p: {
type?: React.HTMLInputTypeAttribute;
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,4 +1,4 @@
import { mdiHarddisk } from "@mdi/js";
import { mdiHarddisk, mdiHarddiskPlus } from "@mdi/js";
import Icon from "@mdi/react";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import DeleteIcon from "@mui/icons-material/Delete";
@ -13,17 +13,28 @@ import {
Tooltip,
} from "@mui/material";
import { filesize } from "filesize";
import React from "react";
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 { CheckboxInput } from "./CheckboxInput";
import { SelectInput } from "./SelectInput";
import { TextInput } from "./TextInput";
import { DiskImageSelect } from "./DiskImageSelect";
import { DiskImage } from "../../api/DiskImageApi";
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",
@ -35,6 +46,14 @@ export function VMDisksList(p: {
p.onChange?.();
};
const handleBackupRequest = (disk: VMFileDisk) => {
setCurrBackupRequest(disk);
};
const handleFinishBackup = () => {
setCurrBackupRequest(undefined);
};
return (
<>
{/* disks list */}
@ -43,25 +62,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 () => {
@ -88,23 +124,37 @@ function DiskInfo(p: {
return (
<ListItem
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>
@ -126,7 +176,9 @@ function DiskInfo(p: {
</>
}
secondary={`${filesize(p.disk.size)} - ${p.disk.format}${
p.disk.format == "Raw" ? " - " + p.disk.alloc_type : ""
p.disk.format == "Raw"
? " - " + (p.disk.is_sparse ? "Sparse" : "Fixed")
: ""
}`}
/>
</ListItem>
@ -151,6 +203,35 @@ function DiskInfo(p: {
</IconButton>
</div>
<SelectInput
editable={true}
label="Disk format"
options={[
{ label: "Raw file", value: "Raw" },
{ label: "QCow2", value: "QCow2" },
]}
value={p.disk.format}
onValueChange={(v) => {
p.disk.format = v as any;
if (p.disk.format === "Raw") p.disk.is_sparse = true;
p.onChange?.();
}}
/>
{p.disk.format === "Raw" && (
<CheckboxInput
editable
label="Sparse file"
checked={p.disk.is_sparse}
onValueChange={(v) => {
if (p.disk.format === "Raw") p.disk.is_sparse = v;
p.onChange?.();
}}
/>
)}
<TextInput
editable={true}
label="Disk size (GB)"
@ -166,37 +247,18 @@ function DiskInfo(p: {
p.onChange?.();
}}
type="number"
disabled={!!p.disk.from_image}
/>
<SelectInput
editable={true}
label="Disk format"
options={[
{ label: "Raw file", value: "Raw" },
{ label: "QCow2", value: "QCow2" },
]}
value={p.disk.format}
<DiskImageSelect
label="Use disk image as template"
list={p.diskImagesList}
value={p.disk.from_image}
onValueChange={(v) => {
p.disk.format = v as any;
p.disk.from_image = v;
p.onChange?.();
}}
/>
{p.disk.format === "Raw" && (
<SelectInput
editable={true}
label="File allocation type"
options={[
{ label: "Sparse allocation", value: "Sparse" },
{ label: "Fixed allocation", value: "Fixed" },
]}
value={p.disk.alloc_type}
onValueChange={(v) => {
if (p.disk.format === "Raw") p.disk.alloc_type = v as any;
p.onChange?.();
}}
/>
)}
</Paper>
);
}

View File

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

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,68 @@ 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: "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">

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,16 +27,21 @@ import { VMDisksList } from "../forms/VMDisksList";
import { VMNetworksList } from "../forms/VMNetworksList";
import { VMSelectIsoInput } from "../forms/VMSelectIsoInput";
import { VMScreenshot } from "./VMScreenshot";
import { DiskImage, DiskImageApi } from "../../api/DiskImageApi";
interface DetailsProps {
vm: VMInfo;
editable: boolean;
onChange?: () => void;
screenshot?: boolean;
state?: VMState | undefined;
}
export function VMDetails(p: DetailsProps): React.ReactElement {
const [groupsList, setGroupsList] = React.useState<string[] | undefined>();
const [diskImagesList, setDiskImagesList] = React.useState<
DiskImage[] | undefined
>();
const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>();
const [bridgesList, setBridgesList] = React.useState<string[] | undefined>();
const [vcpuCombinations, setVCPUCombinations] = React.useState<
@ -51,6 +56,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
const load = async () => {
setGroupsList(await GroupApi.GetList());
setDiskImagesList(await DiskImageApi.GetList());
setIsoList(await IsoFilesApi.GetList());
setBridgesList(await ServerApi.GetNetworksBridgesList());
setVCPUCombinations(await ServerApi.NumberVCPUs());
@ -66,6 +72,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
build={() => (
<VMDetailsInner
groupsList={groupsList!}
diskImagesList={diskImagesList!}
isoList={isoList!}
bridgesList={bridgesList!}
vcpuCombinations={vcpuCombinations!}
@ -89,6 +96,7 @@ enum VMTab {
type DetailsInnerProps = DetailsProps & {
groupsList: string[];
diskImagesList: DiskImage[];
isoList: IsoFile[];
bridgesList: string[];
vcpuCombinations: number[];
@ -279,14 +287,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,21 @@
import { mdiHarddisk } from "@mdi/js";
import { Icon } from "@mdi/react";
import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material";
import { filesize } from "filesize";
import { VMFileDisk } from "../../api/VMApi";
export function VMDiskFileWidget(p: { disk: VMFileDisk }): React.ReactElement {
return (
<ListItem>
<ListItemAvatar>
<Avatar>
<Icon path={mdiHarddisk} />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={p.disk.name}
secondary={`${p.disk.format} - ${filesize(p.disk.size)}`}
/>
</ListItem>
);
}