Compare commits
5 Commits
6b5d53e849
...
b58eaf8e35
Author | SHA1 | Date | |
---|---|---|---|
b58eaf8e35 | |||
5814b0ab6a | |||
5a5450070a | |||
19ec9992be | |||
b55880b43c |
@ -250,6 +250,11 @@ impl AppConfig {
|
||||
self.storage_path().join("iso")
|
||||
}
|
||||
|
||||
/// Get disk images storage directory
|
||||
pub fn disk_images_storage_path(&self) -> PathBuf {
|
||||
self.storage_path().join("disk_images")
|
||||
}
|
||||
|
||||
/// Get VM vnc sockets directory
|
||||
pub fn vnc_sockets_path(&self) -> PathBuf {
|
||||
self.storage_path().join("vnc")
|
||||
|
@ -27,6 +27,13 @@ pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [
|
||||
/// ISO max size
|
||||
pub const ISO_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000;
|
||||
|
||||
/// Allowed uploaded disk images formats
|
||||
pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 2] =
|
||||
["application/x-qemu-disk", "application/gzip"];
|
||||
|
||||
/// Disk image max size
|
||||
pub const DISK_IMAGE_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000 * 1000;
|
||||
|
||||
/// Min VM memory size (MB)
|
||||
pub const MIN_VM_MEMORY: usize = 100;
|
||||
|
||||
@ -39,11 +46,11 @@ pub const DISK_NAME_MIN_LEN: usize = 2;
|
||||
/// Disk name max length
|
||||
pub const DISK_NAME_MAX_LEN: usize = 10;
|
||||
|
||||
/// Disk size min (MB)
|
||||
pub const DISK_SIZE_MIN: usize = 100;
|
||||
/// Disk size min (B)
|
||||
pub const DISK_SIZE_MIN: usize = 100 * 1000 * 1000;
|
||||
|
||||
/// Disk size max (MB)
|
||||
pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 2;
|
||||
/// Disk size max (B)
|
||||
pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 1000 * 1000 * 2;
|
||||
|
||||
/// Net nat entry comment max size
|
||||
pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250;
|
||||
|
69
virtweb_backend/src/controllers/disk_images_controller.rs
Normal file
69
virtweb_backend/src/controllers/disk_images_controller.rs
Normal file
@ -0,0 +1,69 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::constants;
|
||||
use crate::controllers::HttpResult;
|
||||
use crate::utils::file_disks_utils::DiskFileInfo;
|
||||
use crate::utils::files_utils;
|
||||
use actix_multipart::form::MultipartForm;
|
||||
use actix_multipart::form::tempfile::TempFile;
|
||||
use actix_web::HttpResponse;
|
||||
|
||||
#[derive(Debug, MultipartForm)]
|
||||
pub struct UploadDiskImageForm {
|
||||
#[multipart(rename = "file")]
|
||||
files: Vec<TempFile>,
|
||||
}
|
||||
|
||||
/// Upload disk image file
|
||||
pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>) -> HttpResult {
|
||||
if form.files.is_empty() {
|
||||
log::error!("Missing uploaded disk file!");
|
||||
return Ok(HttpResponse::BadRequest().json("Missing file!"));
|
||||
}
|
||||
|
||||
let file = form.files.remove(0);
|
||||
|
||||
// Check uploaded file size
|
||||
if file.size > constants::DISK_IMAGE_MAX_SIZE {
|
||||
return Ok(HttpResponse::BadRequest().json("Disk image max size exceeded!"));
|
||||
}
|
||||
|
||||
// Check file mime type
|
||||
if let Some(mime_type) = file.content_type {
|
||||
if !constants::ALLOWED_DISK_IMAGES_MIME_TYPES.contains(&mime_type.as_ref()) {
|
||||
return Ok(HttpResponse::BadRequest().json(format!(
|
||||
"Unsupported file type for disk upload: {}",
|
||||
mime_type
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Extract and check file name
|
||||
let Some(file_name) = file.file_name else {
|
||||
return Ok(HttpResponse::BadRequest().json("Missing file name of uploaded file!"));
|
||||
};
|
||||
if !files_utils::check_file_name(&file_name) {
|
||||
return Ok(HttpResponse::BadRequest().json("Invalid uploaded file name!"));
|
||||
}
|
||||
|
||||
// Check if a file with the same name already exists
|
||||
let dest_path = AppConfig::get().disk_images_storage_path().join(file_name);
|
||||
if dest_path.is_file() {
|
||||
return Ok(HttpResponse::Conflict().json("A file with the same name already exists!"));
|
||||
}
|
||||
|
||||
// Copy the file to the destination
|
||||
file.file.persist(dest_path)?;
|
||||
|
||||
Ok(HttpResponse::Ok().json("Successfully uploaded disk image!"))
|
||||
}
|
||||
|
||||
/// Get disk images list
|
||||
pub async fn get_list() -> HttpResult {
|
||||
let mut list = vec![];
|
||||
for entry in AppConfig::get().disk_images_storage_path().read_dir()? {
|
||||
let entry = entry?;
|
||||
list.push(DiskFileInfo::load_file(&entry.path())?);
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().json(list))
|
||||
}
|
@ -7,6 +7,7 @@ use std::fmt::{Display, Formatter};
|
||||
|
||||
pub mod api_tokens_controller;
|
||||
pub mod auth_controller;
|
||||
pub mod disk_images_controller;
|
||||
pub mod groups_controller;
|
||||
pub mod iso_controller;
|
||||
pub mod network_controller;
|
||||
|
@ -16,6 +16,7 @@ struct StaticConfig {
|
||||
local_auth_enabled: bool,
|
||||
oidc_auth_enabled: bool,
|
||||
iso_mimetypes: &'static [&'static str],
|
||||
disk_images_mimetypes: &'static [&'static str],
|
||||
net_mac_prefix: &'static str,
|
||||
builtin_nwfilter_rules: &'static [&'static str],
|
||||
nwfilter_chains: &'static [&'static str],
|
||||
@ -37,6 +38,7 @@ struct SLenConstraints {
|
||||
#[derive(serde::Serialize)]
|
||||
struct ServerConstraints {
|
||||
iso_max_size: usize,
|
||||
disk_image_max_size: usize,
|
||||
vnc_token_duration: u64,
|
||||
vm_name_size: LenConstraints,
|
||||
vm_title_size: LenConstraints,
|
||||
@ -63,11 +65,13 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
|
||||
local_auth_enabled: *local_auth,
|
||||
oidc_auth_enabled: !AppConfig::get().disable_oidc,
|
||||
iso_mimetypes: &constants::ALLOWED_ISO_MIME_TYPES,
|
||||
disk_images_mimetypes: &constants::ALLOWED_DISK_IMAGES_MIME_TYPES,
|
||||
net_mac_prefix: constants::NET_MAC_ADDR_PREFIX,
|
||||
builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES,
|
||||
nwfilter_chains: &constants::NETWORK_CHAINS,
|
||||
constraints: ServerConstraints {
|
||||
iso_max_size: constants::ISO_MAX_SIZE,
|
||||
disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE,
|
||||
|
||||
vnc_token_duration: VNC_TOKEN_LIFETIME,
|
||||
|
||||
|
@ -4,7 +4,7 @@ use crate::libvirt_lib_structures::XMLUuid;
|
||||
use crate::libvirt_lib_structures::domain::*;
|
||||
use crate::libvirt_rest_structures::LibVirtStructError;
|
||||
use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction;
|
||||
use crate::utils::file_disks_utils::{DiskFormat, FileDisk};
|
||||
use crate::utils::file_disks_utils::{VMDiskFormat, VMFileDisk};
|
||||
use crate::utils::files_utils;
|
||||
use crate::utils::files_utils::convert_size_unit_to_mb;
|
||||
use lazy_regex::regex;
|
||||
@ -79,7 +79,7 @@ pub struct VMInfo {
|
||||
/// Attach ISO file(s)
|
||||
pub iso_files: Vec<String>,
|
||||
/// File Storage - https://access.redhat.com/documentation/fr-fr/red_hat_enterprise_linux/6/html/virtualization_administration_guide/sect-virtualization-virtualized_block_devices-adding_storage_devices_to_guests#sect-Virtualization-Adding_storage_devices_to_guests-Adding_file_based_storage_to_a_guest
|
||||
pub file_disks: Vec<FileDisk>,
|
||||
pub file_disks: Vec<VMFileDisk>,
|
||||
/// Network cards
|
||||
pub networks: Vec<Network>,
|
||||
/// Add a TPM v2.0 module
|
||||
@ -289,8 +289,8 @@ impl VMInfo {
|
||||
driver: DiskDriverXML {
|
||||
name: "qemu".to_string(),
|
||||
r#type: match disk.format {
|
||||
DiskFormat::Raw { .. } => "raw".to_string(),
|
||||
DiskFormat::QCow2 => "qcow2".to_string(),
|
||||
VMDiskFormat::Raw { .. } => "raw".to_string(),
|
||||
VMDiskFormat::QCow2 => "qcow2".to_string(),
|
||||
},
|
||||
cache: "none".to_string(),
|
||||
},
|
||||
@ -467,7 +467,7 @@ impl VMInfo {
|
||||
.disks
|
||||
.iter()
|
||||
.filter(|d| d.device == "disk")
|
||||
.map(|d| FileDisk::load_from_file(&d.source.file).unwrap())
|
||||
.map(|d| VMFileDisk::load_from_file(&d.source.file).unwrap())
|
||||
.collect(),
|
||||
|
||||
networks: domain
|
||||
|
@ -13,6 +13,7 @@ use actix_web::middleware::Logger;
|
||||
use actix_web::web::Data;
|
||||
use actix_web::{App, HttpServer, web};
|
||||
use light_openid::basic_state_manager::BasicStateManager;
|
||||
use std::cmp::max;
|
||||
use std::time::Duration;
|
||||
use virtweb_backend::actors::libvirt_actor::LibVirtActor;
|
||||
use virtweb_backend::actors::vnc_tokens_actor::VNCTokensManager;
|
||||
@ -22,8 +23,9 @@ use virtweb_backend::constants::{
|
||||
MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME,
|
||||
};
|
||||
use virtweb_backend::controllers::{
|
||||
api_tokens_controller, auth_controller, groups_controller, iso_controller, network_controller,
|
||||
nwfilter_controller, server_controller, static_controller, vm_controller,
|
||||
api_tokens_controller, auth_controller, disk_images_controller, groups_controller,
|
||||
iso_controller, network_controller, nwfilter_controller, server_controller, static_controller,
|
||||
vm_controller,
|
||||
};
|
||||
use virtweb_backend::libvirt_client::LibVirtClient;
|
||||
use virtweb_backend::middlewares::auth_middleware::AuthChecker;
|
||||
@ -55,6 +57,7 @@ async fn main() -> std::io::Result<()> {
|
||||
|
||||
log::debug!("Create required directory, if missing");
|
||||
files_utils::create_directory_if_missing(AppConfig::get().iso_storage_path()).unwrap();
|
||||
files_utils::create_directory_if_missing(AppConfig::get().disk_images_storage_path()).unwrap();
|
||||
files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap();
|
||||
files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap();
|
||||
files_utils::create_directory_if_missing(AppConfig::get().disks_storage_path()).unwrap();
|
||||
@ -118,7 +121,10 @@ async fn main() -> std::io::Result<()> {
|
||||
}))
|
||||
.app_data(conn.clone())
|
||||
// Uploaded files
|
||||
.app_data(MultipartFormConfig::default().total_limit(constants::ISO_MAX_SIZE))
|
||||
.app_data(
|
||||
MultipartFormConfig::default()
|
||||
.total_limit(max(constants::DISK_IMAGE_MAX_SIZE, constants::ISO_MAX_SIZE)),
|
||||
)
|
||||
.app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir))
|
||||
// Server controller
|
||||
.route(
|
||||
@ -329,6 +335,15 @@ async fn main() -> std::io::Result<()> {
|
||||
"/api/nwfilter/{uid}",
|
||||
web::delete().to(nwfilter_controller::delete),
|
||||
)
|
||||
// Disk images library
|
||||
.route(
|
||||
"/api/disk_images/upload",
|
||||
web::post().to(disk_images_controller::upload),
|
||||
)
|
||||
.route(
|
||||
"/api/disk_images/list",
|
||||
web::get().to(disk_images_controller::get_list),
|
||||
)
|
||||
// API tokens controller
|
||||
.route(
|
||||
"/api/token/create",
|
||||
|
@ -6,6 +6,7 @@ use lazy_regex::regex;
|
||||
use std::os::linux::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
enum DisksError {
|
||||
@ -19,7 +20,7 @@ enum DisksError {
|
||||
|
||||
/// Type of disk allocation
|
||||
#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum DiskAllocType {
|
||||
pub enum VMDiskAllocType {
|
||||
Fixed,
|
||||
Sparse,
|
||||
}
|
||||
@ -27,60 +28,53 @@ pub enum DiskAllocType {
|
||||
/// Disk allocation type
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "format")]
|
||||
pub enum DiskFormat {
|
||||
pub enum VMDiskFormat {
|
||||
Raw {
|
||||
/// Type of disk allocation
|
||||
alloc_type: DiskAllocType,
|
||||
alloc_type: VMDiskAllocType,
|
||||
},
|
||||
QCow2,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct FileDisk {
|
||||
pub struct VMFileDisk {
|
||||
/// Disk name
|
||||
pub name: String,
|
||||
/// Disk size, in megabytes
|
||||
/// Disk size, in bytes
|
||||
pub size: usize,
|
||||
/// Disk format
|
||||
#[serde(flatten)]
|
||||
pub format: DiskFormat,
|
||||
pub format: VMDiskFormat,
|
||||
/// Set this variable to true to delete the disk
|
||||
pub delete: bool,
|
||||
}
|
||||
|
||||
impl FileDisk {
|
||||
impl VMFileDisk {
|
||||
pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
|
||||
let file = Path::new(path);
|
||||
|
||||
if !file.is_file() {
|
||||
return Err(DisksError::Parse("Path is not a file!").into());
|
||||
}
|
||||
|
||||
let metadata = file.metadata()?;
|
||||
let name = file.file_stem().and_then(|s| s.to_str()).unwrap_or("disk");
|
||||
let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("raw");
|
||||
|
||||
// Approximate raw file estimation
|
||||
let is_raw_sparse = metadata.len() / 512 >= metadata.st_blocks();
|
||||
|
||||
let format = match ext {
|
||||
"qcow2" => DiskFormat::QCow2,
|
||||
"raw" => DiskFormat::Raw {
|
||||
alloc_type: match is_raw_sparse {
|
||||
true => DiskAllocType::Sparse,
|
||||
false => DiskAllocType::Fixed,
|
||||
},
|
||||
},
|
||||
_ => anyhow::bail!("Unsupported disk extension: {ext}!"),
|
||||
};
|
||||
let info = DiskFileInfo::load_file(file)?;
|
||||
|
||||
Ok(Self {
|
||||
name: name.to_string(),
|
||||
size: match format {
|
||||
DiskFormat::Raw { .. } => metadata.len() as usize / (1000 * 1000),
|
||||
DiskFormat::QCow2 => qcow_virt_size(path)? / (1000 * 1000),
|
||||
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),
|
||||
},
|
||||
format,
|
||||
delete: false,
|
||||
})
|
||||
}
|
||||
@ -108,8 +102,8 @@ impl FileDisk {
|
||||
pub fn disk_path(&self, id: XMLUuid) -> PathBuf {
|
||||
let domain_dir = AppConfig::get().vm_storage_path(id);
|
||||
let file_name = match self.format {
|
||||
DiskFormat::Raw { .. } => self.name.to_string(),
|
||||
DiskFormat::QCow2 => format!("{}.qcow2", self.name),
|
||||
VMDiskFormat::Raw { .. } => self.name.to_string(),
|
||||
VMDiskFormat::QCow2 => format!("{}.qcow2", self.name),
|
||||
};
|
||||
domain_dir.join(&file_name)
|
||||
}
|
||||
@ -140,27 +134,29 @@ impl FileDisk {
|
||||
|
||||
// Prepare command to create file
|
||||
let res = match self.format {
|
||||
DiskFormat::Raw { alloc_type } => {
|
||||
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 {
|
||||
DiskAllocType::Fixed => cmd.arg(format!("count={}", self.size)),
|
||||
DiskAllocType::Sparse => cmd.arg(format!("seek={}", self.size)).arg("count=0"),
|
||||
VMDiskAllocType::Fixed => cmd.arg(format!("count={}", self.size_mb())),
|
||||
VMDiskAllocType::Sparse => {
|
||||
cmd.arg(format!("seek={}", self.size_mb())).arg("count=0")
|
||||
}
|
||||
};
|
||||
|
||||
cmd.output()?
|
||||
}
|
||||
|
||||
DiskFormat::QCow2 => {
|
||||
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));
|
||||
.arg(format!("{}M", self.size_mb()));
|
||||
|
||||
cmd.output()?
|
||||
}
|
||||
@ -178,6 +174,81 @@ impl FileDisk {
|
||||
|
||||
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")]
|
||||
pub enum DiskFileFormat {
|
||||
Raw { is_sparse: bool },
|
||||
QCow2 { virtual_size: usize },
|
||||
CompressedRaw,
|
||||
CompressedQCow2,
|
||||
}
|
||||
|
||||
/// Disk file information
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct DiskFileInfo {
|
||||
file_size: usize,
|
||||
#[serde(flatten)]
|
||||
format: DiskFileFormat,
|
||||
file_name: String,
|
||||
name: String,
|
||||
created: u64,
|
||||
}
|
||||
|
||||
impl DiskFileInfo {
|
||||
/// Get disk image file information
|
||||
pub fn load_file(file: &Path) -> anyhow::Result<Self> {
|
||||
if !file.is_file() {
|
||||
return Err(DisksError::Parse("Path is not a file!").into());
|
||||
}
|
||||
|
||||
// Get file metadata
|
||||
let metadata = file.metadata()?;
|
||||
let mut name = file
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("disk")
|
||||
.to_string();
|
||||
let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("raw");
|
||||
|
||||
// Determine file format
|
||||
let format = match ext {
|
||||
"qcow2" => DiskFileFormat::QCow2 {
|
||||
virtual_size: qcow_virt_size(file)?,
|
||||
},
|
||||
"raw" => DiskFileFormat::Raw {
|
||||
is_sparse: metadata.len() / 512 >= metadata.st_blocks(),
|
||||
},
|
||||
"gz" if name.ends_with(".qcow2") => {
|
||||
name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string();
|
||||
DiskFileFormat::CompressedQCow2
|
||||
}
|
||||
"gz" => DiskFileFormat::CompressedRaw,
|
||||
_ => anyhow::bail!("Unsupported disk extension: {ext}!"),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
name,
|
||||
file_size: metadata.len() as usize,
|
||||
format,
|
||||
file_name: file
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
created: metadata
|
||||
.created()?
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@ -187,10 +258,16 @@ struct QCowInfoOutput {
|
||||
}
|
||||
|
||||
/// Get QCow2 virtual size
|
||||
fn qcow_virt_size(path: &str) -> anyhow::Result<usize> {
|
||||
fn qcow_virt_size(path: &Path) -> anyhow::Result<usize> {
|
||||
// Run qemu-img
|
||||
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
|
||||
cmd.args(["info", path, "--output", "json", "--force-share"]);
|
||||
cmd.args([
|
||||
"info",
|
||||
path.to_str().unwrap_or(""),
|
||||
"--output",
|
||||
"json",
|
||||
"--force-share",
|
||||
]);
|
||||
let output = cmd.output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
|
@ -38,6 +38,7 @@ import { LoginRoute } from "./routes/auth/LoginRoute";
|
||||
import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
|
||||
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
||||
import { BaseLoginPage } from "./widgets/BaseLoginPage";
|
||||
import { DiskImagesRoute } from "./routes/DiskImagesRoute";
|
||||
|
||||
interface AuthContext {
|
||||
signedIn: boolean;
|
||||
@ -63,6 +64,8 @@ export function App() {
|
||||
<Route path="*" element={<BaseAuthenticatedPage />}>
|
||||
<Route path="" element={<HomeRoute />} />
|
||||
|
||||
<Route path="disk_images" element={<DiskImagesRoute />} />
|
||||
|
||||
<Route path="iso" element={<IsoFilesRoute />} />
|
||||
|
||||
<Route path="vms" element={<VMListRoute />} />
|
||||
|
45
virtweb_frontend/src/api/DiskImageApi.ts
Normal file
45
virtweb_frontend/src/api/DiskImageApi.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { APIClient } from "./ApiClient";
|
||||
|
||||
export type DiskImage = {
|
||||
file_size: number;
|
||||
file_name: string;
|
||||
name: string;
|
||||
created: number;
|
||||
} & (
|
||||
| { format: "Raw"; is_sparse: boolean }
|
||||
| { format: "QCow2"; virtual_size: number }
|
||||
| { format: "CompressedQCow2" }
|
||||
| { format: "CompressedRaw" }
|
||||
);
|
||||
|
||||
export class DiskImageApi {
|
||||
/**
|
||||
* Upload a new disk image file to the server
|
||||
*/
|
||||
static async Upload(
|
||||
file: File,
|
||||
progress: (progress: number) => void
|
||||
): Promise<void> {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
|
||||
await APIClient.exec({
|
||||
method: "POST",
|
||||
uri: "/disk_images/upload",
|
||||
formData: fd,
|
||||
upProgress: progress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of disk images
|
||||
*/
|
||||
static async GetList(): Promise<DiskImage[]> {
|
||||
return (
|
||||
await APIClient.exec({
|
||||
method: "GET",
|
||||
uri: "/disk_images/list",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ export interface ServerConfig {
|
||||
local_auth_enabled: boolean;
|
||||
oidc_auth_enabled: boolean;
|
||||
iso_mimetypes: string[];
|
||||
disk_images_mimetypes: string[];
|
||||
net_mac_prefix: string;
|
||||
builtin_nwfilter_rules: string[];
|
||||
nwfilter_chains: string[];
|
||||
@ -13,6 +14,7 @@ export interface ServerConfig {
|
||||
|
||||
export interface ServerConstraints {
|
||||
iso_max_size: number;
|
||||
disk_image_max_size: number;
|
||||
vnc_token_duration: number;
|
||||
vm_name_size: LenConstraint;
|
||||
vm_title_size: LenConstraint;
|
||||
|
152
virtweb_frontend/src/routes/DiskImagesRoute.tsx
Normal file
152
virtweb_frontend/src/routes/DiskImagesRoute.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { filesize } from "filesize";
|
||||
import React from "react";
|
||||
import { DiskImage, DiskImageApi } from "../api/DiskImageApi";
|
||||
import { ServerApi } from "../api/ServerApi";
|
||||
import { useAlert } from "../hooks/providers/AlertDialogProvider";
|
||||
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
|
||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||
import { FileInput } from "../widgets/forms/FileInput";
|
||||
import { VirtWebPaper } from "../widgets/VirtWebPaper";
|
||||
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
|
||||
|
||||
export function DiskImagesRoute(): React.ReactElement {
|
||||
const [list, setList] = React.useState<DiskImage[] | undefined>();
|
||||
|
||||
const loadKey = React.useRef(1);
|
||||
|
||||
const load = async () => {
|
||||
setList(await DiskImageApi.GetList());
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
loadKey.current += 1;
|
||||
setList(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<VirtWebRouteContainer label="Disk images">
|
||||
<AsyncWidget
|
||||
loadKey={loadKey.current}
|
||||
errMsg="Failed to load disk images list!"
|
||||
load={load}
|
||||
ready={list !== undefined}
|
||||
build={() => (
|
||||
<VirtWebRouteContainer
|
||||
label="Disk images management"
|
||||
actions={
|
||||
<span>
|
||||
<Tooltip title="Refresh Disk images list">
|
||||
<IconButton onClick={reload}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<UploadDiskImageCard onFileUploaded={reload} />
|
||||
<DiskImageList list={list!} onReload={reload} />
|
||||
</VirtWebRouteContainer>
|
||||
)}
|
||||
/>
|
||||
</VirtWebRouteContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadDiskImageCard(p: {
|
||||
onFileUploaded: () => void;
|
||||
}): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [value, setValue] = React.useState<File | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = React.useState<number | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const handleChange = (newValue: File | null) => {
|
||||
if (
|
||||
newValue &&
|
||||
newValue.size > ServerApi.Config.constraints.disk_image_max_size
|
||||
) {
|
||||
alert(
|
||||
`The file is too big (max size allowed: ${filesize(
|
||||
ServerApi.Config.constraints.disk_image_max_size
|
||||
)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
newValue &&
|
||||
newValue.type.length > 0 &&
|
||||
!ServerApi.Config.disk_images_mimetypes.includes(newValue.type)
|
||||
) {
|
||||
alert(`Selected file mimetype is not allowed! (${newValue.type})`);
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
const upload = async () => {
|
||||
try {
|
||||
setUploadProgress(0);
|
||||
await DiskImageApi.Upload(value!, setUploadProgress);
|
||||
|
||||
setValue(null);
|
||||
snackbar("The file was successfully uploaded!");
|
||||
|
||||
p.onFileUploaded();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
await alert(`Failed to perform file upload! ${e}`);
|
||||
}
|
||||
|
||||
setUploadProgress(null);
|
||||
};
|
||||
|
||||
if (uploadProgress !== null) {
|
||||
return (
|
||||
<VirtWebPaper label="File upload" noHorizontalMargin>
|
||||
<Typography variant="body1">
|
||||
Upload in progress ({Math.floor(uploadProgress * 100)}%)...
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={uploadProgress * 100} />
|
||||
</VirtWebPaper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtWebPaper label="Disk image upload" noHorizontalMargin>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<FileInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
style={{ flex: 1 }}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
accept: ServerApi.Config.disk_images_mimetypes.join(","),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{value && <Button onClick={upload}>Upload</Button>}
|
||||
</div>
|
||||
</VirtWebPaper>
|
||||
);
|
||||
}
|
||||
|
||||
function DiskImageList(p: {
|
||||
list: DiskImage[];
|
||||
onReload: () => void;
|
||||
}): React.ReactElement {
|
||||
return <>todo</>;
|
||||
}
|
@ -3,7 +3,15 @@ import { RouterLink } from "../widgets/RouterLink";
|
||||
|
||||
export function NotFoundRoute(): React.ReactElement {
|
||||
return (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<h1>404 Not found</h1>
|
||||
<p>The page you requested was not found!</p>
|
||||
<RouterLink to="/">
|
||||
|
@ -2,6 +2,7 @@ import {
|
||||
mdiApi,
|
||||
mdiBoxShadow,
|
||||
mdiDisc,
|
||||
mdiHarddisk,
|
||||
mdiHome,
|
||||
mdiInformation,
|
||||
mdiLan,
|
||||
@ -66,6 +67,11 @@ export function BaseAuthenticatedPage(): React.ReactElement {
|
||||
uri="/nwfilter"
|
||||
icon={<Icon path={mdiSecurityNetwork} size={1} />}
|
||||
/>
|
||||
<NavLink
|
||||
label="Disk images"
|
||||
uri="/disk_images"
|
||||
icon={<Icon path={mdiHarddisk} size={1} />}
|
||||
/>
|
||||
<NavLink
|
||||
label="ISO files"
|
||||
uri="/iso"
|
||||
|
@ -35,7 +35,7 @@ export function FileInput(
|
||||
<InputAdornment position="start">
|
||||
<AttachFileIcon />
|
||||
|
||||
{p.value ? p.value.name : "Insert a file"}
|
||||
{p.value ? p.value.name : "Select a file"}
|
||||
</InputAdornment>
|
||||
</>
|
||||
),
|
||||
|
@ -27,7 +27,7 @@ export function VMDisksList(p: {
|
||||
const addNewDisk = () => {
|
||||
p.vm.file_disks.push({
|
||||
format: "QCow2",
|
||||
size: 10000,
|
||||
size: 10000 * 1000 * 1000,
|
||||
delete: false,
|
||||
name: `disk${p.vm.file_disks.length}`,
|
||||
new: true,
|
||||
@ -125,9 +125,9 @@ function DiskInfo(p: {
|
||||
)}
|
||||
</>
|
||||
}
|
||||
secondary={`${filesize(p.disk.size * 1000 * 1000)} - ${
|
||||
p.disk.format
|
||||
}${p.disk.format == "Raw" ? " - " + p.disk.alloc_type : ""}`}
|
||||
secondary={`${filesize(p.disk.size)} - ${p.disk.format}${
|
||||
p.disk.format == "Raw" ? " - " + p.disk.alloc_type : ""
|
||||
}`}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
@ -153,11 +153,16 @@ function DiskInfo(p: {
|
||||
|
||||
<TextInput
|
||||
editable={true}
|
||||
label="Disk size (MB)"
|
||||
size={ServerApi.Config.constraints.disk_size}
|
||||
value={p.disk.size.toString()}
|
||||
label="Disk size (GB)"
|
||||
size={{
|
||||
min:
|
||||
ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000),
|
||||
max:
|
||||
ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000),
|
||||
}}
|
||||
value={(p.disk.size / (1000 * 1000 * 1000)).toString()}
|
||||
onValueChange={(v) => {
|
||||
p.disk.size = Number(v ?? "0");
|
||||
p.disk.size = Number(v ?? "0") * 1000 * 1000 * 1000;
|
||||
p.onChange?.();
|
||||
}}
|
||||
type="number"
|
||||
|
Loading…
x
Reference in New Issue
Block a user