12 Commits

Author SHA1 Message Date
f850ca5cb7 Can rename disk image files
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-05-31 10:45:15 +02:00
4ee01cad4b Add legacy boot mode
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-31 10:05:13 +02:00
5518b45219 Fix ESLint issues
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-31 09:45:23 +02:00
0279907ca9 Can change disk bus after disk creation
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-31 09:41:12 +02:00
5fe481ffed Refactorize disks list
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-31 09:05:10 +02:00
c7cc15d8d0 Can select disk bus type when adding new disk to VM
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-31 08:52:07 +02:00
22416badcf Can change network interface type
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 20:30:30 +02:00
ef0d77f1d6 Minor fixes
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 15:25:46 +02:00
1d4af8c74e Can restore disk image when adding disks to virtual machine
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 14:41:48 +02:00
ec9492c933 Show a message on tokens list route when no token was created yet
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 11:35:00 +02:00
fa03ae885f Add new REST API routes to token rights editor
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 11:30:27 +02:00
ea98aaf856 Fix ESLint issue
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 11:20:19 +02:00
20 changed files with 696 additions and 229 deletions

View File

@ -255,6 +255,11 @@ impl AppConfig {
self.storage_path().join("disk_images") 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 /// Get VM vnc sockets directory
pub fn vnc_sockets_path(&self) -> PathBuf { pub fn vnc_sockets_path(&self) -> PathBuf {
self.storage_path().join("vnc") self.storage_path().join("vnc")

View File

@ -30,8 +30,11 @@ pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [
pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10); pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10);
/// Allowed uploaded disk images formats /// Allowed uploaded disk images formats
pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 2] = pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 3] = [
["application/x-qemu-disk", "application/gzip"]; "application/x-qemu-disk",
"application/gzip",
"application/octet-stream",
];
/// Disk image max size /// Disk image max size
pub const DISK_IMAGE_MAX_SIZE: FileSize = FileSize::from_gb(10 * 1000); pub const DISK_IMAGE_MAX_SIZE: FileSize = FileSize::from_gb(10 * 1000);

View File

@ -49,7 +49,7 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>)
} }
// Check if a file with the same name already exists // 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() { if dest_path.is_file() {
return Ok(HttpResponse::Conflict().json("A file with the same name already exists!")); return Ok(HttpResponse::Conflict().json("A file with the same name already exists!"));
} }
@ -82,9 +82,7 @@ pub async fn download(p: web::Path<DiskFilePath>, req: HttpRequest) -> HttpResul
return Ok(HttpResponse::BadRequest().json("Invalid file name!")); return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
} }
let file_path = AppConfig::get() let file_path = AppConfig::get().disk_images_file_path(&p.filename);
.disk_images_storage_path()
.join(&p.filename);
if !file_path.exists() { if !file_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!")); return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
@ -109,9 +107,7 @@ pub async fn convert(
return Ok(HttpResponse::BadRequest().json("Invalid src file name!")); return Ok(HttpResponse::BadRequest().json("Invalid src file name!"));
} }
let src_file_path = AppConfig::get() let src_file_path = AppConfig::get().disk_images_file_path(&p.filename);
.disk_images_storage_path()
.join(&p.filename);
let src = DiskFileInfo::load_file(&src_file_path)?; let src = DiskFileInfo::load_file(&src_file_path)?;
@ -176,9 +172,7 @@ pub async fn handle_convert_request(
return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!")); return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!"));
} }
let dst_file_path = AppConfig::get() let dst_file_path = AppConfig::get().disk_images_file_path(&req.dest_file_name);
.disk_images_storage_path()
.join(&req.dest_file_name);
if dst_file_path.exists() { if dst_file_path.exists() {
return Ok(HttpResponse::Conflict().json("Specified destination file already exists!")); return Ok(HttpResponse::Conflict().json("Specified destination file already exists!"));
@ -195,15 +189,53 @@ pub async fn handle_convert_request(
Ok(HttpResponse::Accepted().json("Successfully converted disk file")) Ok(HttpResponse::Accepted().json("Successfully converted disk file"))
} }
#[derive(serde::Deserialize)]
pub struct RenameDiskImageRequest {
name: String,
}
/// Rename disk image
pub async fn rename(
p: web::Path<DiskFilePath>,
req: web::Json<RenameDiskImageRequest>,
) -> HttpResult {
// Check source
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid src file name!"));
}
let src_path = AppConfig::get().disk_images_file_path(&p.filename);
if !src_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
}
// Check destination
if !files_utils::check_file_name(&req.name) {
return Ok(HttpResponse::BadRequest().json("Invalid dst file name!"));
}
let dst_path = AppConfig::get().disk_images_file_path(&req.name);
if dst_path.exists() {
return Ok(HttpResponse::Conflict().json("Destination name already exists!"));
}
// Check extension
let disk = DiskFileInfo::load_file(&src_path)?;
if !disk.format.ext().iter().any(|e| req.name.ends_with(e)) {
return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!"));
}
// Perform rename
std::fs::rename(&src_path, &dst_path)?;
Ok(HttpResponse::Accepted().finish())
}
/// Delete a disk image /// Delete a disk image
pub async fn delete(p: web::Path<DiskFilePath>) -> HttpResult { pub async fn delete(p: web::Path<DiskFilePath>) -> 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!"));
} }
let file_path = AppConfig::get() let file_path = AppConfig::get().disk_images_file_path(&p.filename);
.disk_images_storage_path()
.join(&p.filename);
if !file_path.exists() { if !file_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!")); return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));

View File

@ -22,10 +22,13 @@ pub struct DomainMetadataXML {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename = "os")] #[serde(rename = "os")]
pub struct OSXML { pub struct OSXML {
#[serde(rename = "@firmware", default)] #[serde(rename = "@firmware", default, skip_serializing_if = "Option::is_none")]
pub firmware: String, pub firmware: Option<String>,
pub r#type: OSTypeXML, pub r#type: OSTypeXML,
#[serde(skip_serializing_if = "Option::is_none")]
pub loader: Option<OSLoaderXML>, pub loader: Option<OSLoaderXML>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bootmenu: Option<OSBootMenuXML>,
pub smbios: Option<OSSMBiosXML>, pub smbios: Option<OSSMBiosXML>,
} }
@ -49,6 +52,16 @@ pub struct OSLoaderXML {
pub secure: String, pub secure: String,
} }
/// Legacy boot menu information
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename = "bootmenu")]
pub struct OSBootMenuXML {
#[serde(rename = "@enable")]
pub enable: String,
#[serde(rename = "@timeout")]
pub timeout: usize,
}
/// SMBIOS System information /// SMBIOS System information
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename = "smbios")] #[serde(rename = "smbios")]

View File

@ -6,7 +6,7 @@ use crate::libvirt_rest_structures::LibVirtStructError;
use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction; use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction;
use crate::utils::file_size_utils::FileSize; use crate::utils::file_size_utils::FileSize;
use crate::utils::files_utils; use crate::utils::files_utils;
use crate::utils::vm_file_disks_utils::{VMDiskFormat, VMFileDisk}; use crate::utils::vm_file_disks_utils::{VMDiskBus, VMDiskFormat, VMFileDisk};
use lazy_regex::regex; use lazy_regex::regex;
use num::Integer; use num::Integer;
@ -17,6 +17,7 @@ pub struct VMGroupId(pub String);
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
pub enum BootType { pub enum BootType {
Legacy,
UEFI, UEFI,
UEFISecureBoot, UEFISecureBoot,
} }
@ -29,6 +30,12 @@ pub enum VMArchitecture {
X86_64, X86_64,
} }
#[derive(serde::Serialize, serde::Deserialize)]
pub enum NetworkInterfaceModelType {
Virtio,
E1000,
}
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
pub struct NWFilterParam { pub struct NWFilterParam {
name: String, name: String,
@ -46,6 +53,7 @@ pub struct Network {
#[serde(flatten)] #[serde(flatten)]
r#type: NetworkType, r#type: NetworkType,
mac: String, mac: String,
model: NetworkInterfaceModelType,
nwfilterref: Option<NWFilterRef>, nwfilterref: Option<NWFilterRef>,
} }
@ -196,7 +204,11 @@ impl VMInfo {
}; };
let model = Some(NetIntModelXML { 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 { let filterref = if let Some(n) = &n.nwfilterref {
@ -302,7 +314,11 @@ impl VMInfo {
"vd{}", "vd{}",
["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"][disks.len()] ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"][disks.len()]
), ),
bus: "virtio".to_string(), bus: match disk.bus {
VMDiskBus::Virtio => "virtio",
VMDiskBus::SATA => "sata",
}
.to_string(),
}, },
readonly: None, readonly: None,
boot: DiskBootXML { boot: DiskBootXML {
@ -336,13 +352,26 @@ impl VMInfo {
machine: "q35".to_string(), machine: "q35".to_string(),
body: "hvm".to_string(), body: "hvm".to_string(),
}, },
firmware: "efi".to_string(), firmware: match self.boot_type {
loader: Some(OSLoaderXML { BootType::Legacy => None,
_ => Some("efi".to_string()),
},
loader: match self.boot_type {
BootType::Legacy => None,
_ => Some(OSLoaderXML {
secure: match self.boot_type { secure: match self.boot_type {
BootType::UEFI => "no".to_string(),
BootType::UEFISecureBoot => "yes".to_string(), BootType::UEFISecureBoot => "yes".to_string(),
_ => "no".to_string(),
}, },
}), }),
},
bootmenu: match self.boot_type {
BootType::Legacy => Some(OSBootMenuXML {
enable: "yes".to_string(),
timeout: 3000,
}),
_ => None,
},
smbios: Some(OSSMBiosXML { smbios: Some(OSSMBiosXML {
mode: "sysinfo".to_string(), mode: "sysinfo".to_string(),
}), }),
@ -434,9 +463,10 @@ impl VMInfo {
.virtweb .virtweb
.group .group
.map(VMGroupId), .map(VMGroupId),
boot_type: match domain.os.loader { boot_type: match (domain.os.loader, domain.os.bootmenu) {
None => BootType::UEFI, (_, Some(_)) => BootType::Legacy,
Some(l) => match l.secure.as_str() { (None, _) => BootType::UEFI,
(Some(l), _) => match l.secure.as_str() {
"yes" => BootType::UEFISecureBoot, "yes" => BootType::UEFISecureBoot,
_ => BootType::UEFI, _ => BootType::UEFI,
}, },
@ -467,7 +497,10 @@ impl VMInfo {
.disks .disks
.iter() .iter()
.filter(|d| d.device == "disk") .filter(|d| d.device == "disk")
.map(|d| VMFileDisk::load_from_file(&d.source.file).unwrap()) .map(|d| {
VMFileDisk::load_from_file(&d.source.file, &d.target.bus)
.expect("Failed to load file disk information!")
})
.collect(), .collect(),
networks: domain networks: domain
@ -515,6 +548,18 @@ impl VMInfo {
))); )));
} }
}, },
model: match d.model.as_ref() {
None => NetworkInterfaceModelType::Virtio,
Some(model) => match model.r#type.as_str() {
"virtio" => NetworkInterfaceModelType::Virtio,
"e1000" => NetworkInterfaceModelType::E1000,
model => {
return Err(LibVirtStructError::DomainExtraction(format!(
"Unknown network interface model type: {model}! "
)));
}
},
},
nwfilterref: d.filterref.as_ref().map(|f| NWFilterRef { nwfilterref: d.filterref.as_ref().map(|f| NWFilterRef {
name: f.filter.to_string(), name: f.filter.to_string(),
parameters: f parameters: f

View File

@ -352,6 +352,10 @@ async fn main() -> std::io::Result<()> {
"/api/disk_images/{filename}/convert", "/api/disk_images/{filename}/convert",
web::post().to(disk_images_controller::convert), web::post().to(disk_images_controller::convert),
) )
.route(
"/api/disk_images/{filename}/rename",
web::post().to(disk_images_controller::rename),
)
.route( .route(
"/api/disk_images/{filename}", "/api/disk_images/{filename}",
web::delete().to(disk_images_controller::delete), web::delete().to(disk_images_controller::delete),

View File

@ -13,6 +13,12 @@ enum VMDisksError {
Config(&'static str), Config(&'static str),
} }
#[derive(serde::Serialize, serde::Deserialize)]
pub enum VMDiskBus {
Virtio,
SATA,
}
/// Disk allocation type /// Disk allocation type
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
#[serde(tag = "format")] #[serde(tag = "format")]
@ -30,15 +36,20 @@ pub struct VMFileDisk {
pub name: String, pub name: String,
/// Disk size, in bytes /// Disk size, in bytes
pub size: FileSize, pub size: FileSize,
/// Disk bus
pub bus: VMDiskBus,
/// Disk format /// Disk format
#[serde(flatten)] #[serde(flatten)]
pub format: VMDiskFormat, 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 /// Set this variable to true to delete the disk
pub delete: bool, pub delete: bool,
} }
impl VMFileDisk { impl VMFileDisk {
pub fn load_from_file(path: &str) -> anyhow::Result<Self> { pub fn load_from_file(path: &str, bus: &str) -> anyhow::Result<Self> {
let file = Path::new(path); let file = Path::new(path);
let info = DiskFileInfo::load_file(file)?; let info = DiskFileInfo::load_file(file)?;
@ -58,7 +69,15 @@ impl VMFileDisk {
DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2, DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2,
_ => anyhow::bail!("Unsupported image format: {:?}", info.format), _ => anyhow::bail!("Unsupported image format: {:?}", info.format),
}, },
bus: match bus {
"virtio" => VMDiskBus::Virtio,
"sata" => VMDiskBus::SATA,
_ => anyhow::bail!("Unsupported disk bus type: {bus}"),
},
delete: false, delete: false,
from_image: None,
}) })
} }
@ -78,6 +97,19 @@ impl VMFileDisk {
return Err(VMDisksError::Config("Disk size is invalid!").into()); 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(()) Ok(())
} }
@ -115,17 +147,27 @@ impl VMFileDisk {
return Ok(()); return Ok(());
} }
// Create disk file let format = match self.format {
DiskFileInfo::create(
&file,
match self.format {
VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse }, VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 { VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
virtual_size: self.size, 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(()) Ok(())
} }

View File

@ -94,6 +94,17 @@ export class DiskImageApi {
}); });
} }
/**
* Rename disk image file
*/
static async Rename(file: DiskImage, name: string): Promise<void> {
await APIClient.exec({
method: "POST",
uri: `/disk_images/${file.file_name}/rename`,
jsonData: { name },
});
}
/** /**
* Delete disk image file * Delete disk image file
*/ */

View File

@ -19,11 +19,18 @@ export type VMState =
export type VMFileDisk = BaseFileVMDisk & (RawVMDisk | QCow2Disk); export type VMFileDisk = BaseFileVMDisk & (RawVMDisk | QCow2Disk);
export type DiskBusType = "Virtio" | "SATA";
export interface BaseFileVMDisk { export interface BaseFileVMDisk {
size: number; size: number;
name: string; name: string;
bus: DiskBusType;
delete: boolean; delete: boolean;
// For new disk only
from_image?: string;
// application attributes // application attributes
new?: boolean; new?: boolean;
deleteType?: "keepfile" | "deletefile"; deleteType?: "keepfile" | "deletefile";
@ -57,6 +64,7 @@ export type VMNetInterface = (
export interface VMNetInterfaceBase { export interface VMNetInterfaceBase {
mac: string; mac: string;
model: "Virtio" | "E1000";
nwfilterref?: VMNetInterfaceFilter; nwfilterref?: VMNetInterfaceFilter;
} }
@ -74,6 +82,8 @@ export interface VMNetBridge {
bridge: string; bridge: string;
} }
export type VMBootType = "UEFI" | "UEFISecureBoot" | "Legacy";
interface VMInfoInterface { interface VMInfoInterface {
name: string; name: string;
uuid?: string; uuid?: string;
@ -81,7 +91,7 @@ interface VMInfoInterface {
title?: string; title?: string;
description?: string; description?: string;
group?: string; group?: string;
boot_type: "UEFI" | "UEFISecureBoot"; boot_type: VMBootType;
architecture: "i686" | "x86_64"; architecture: "i686" | "x86_64";
memory: number; memory: number;
number_vcpu: number; number_vcpu: number;
@ -100,7 +110,7 @@ export class VMInfo implements VMInfoInterface {
title?: string; title?: string;
description?: string; description?: string;
group?: string; group?: string;
boot_type: "UEFI" | "UEFISecureBoot"; boot_type: VMBootType;
architecture: "i686" | "x86_64"; architecture: "i686" | "x86_64";
number_vcpu: number; number_vcpu: number;
memory: number; memory: number;
@ -135,7 +145,7 @@ export class VMInfo implements VMInfoInterface {
name: "", name: "",
boot_type: "UEFI", boot_type: "UEFI",
architecture: "x86_64", architecture: "x86_64",
memory: 1024, memory: 1000 * 1000 * 1000,
number_vcpu: 1, number_vcpu: 1,
vnc_access: true, vnc_access: true,
iso_files: [], iso_files: [],

View File

@ -1,6 +1,7 @@
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import LoopIcon from "@mui/icons-material/Loop"; import LoopIcon from "@mui/icons-material/Loop";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import RefreshIcon from "@mui/icons-material/Refresh"; import RefreshIcon from "@mui/icons-material/Refresh";
import { import {
Alert, Alert,
@ -8,6 +9,10 @@ import {
CircularProgress, CircularProgress,
IconButton, IconButton,
LinearProgress, LinearProgress,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Tooltip, Tooltip,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
@ -164,15 +169,11 @@ function DiskImageList(p: {
const confirm = useConfirm(); const confirm = useConfirm();
const loadingMessage = useLoadingMessage(); const loadingMessage = useLoadingMessage();
const [dlProgress, setDlProgress] = React.useState<undefined | number>();
const [currConversion, setCurrConversion] = React.useState< const [currConversion, setCurrConversion] = React.useState<
DiskImage | undefined DiskImage | undefined
>(); >();
const [dlProgress, setDlProgress] = React.useState<undefined | number>();
// Convert disk image file
const convertDiskImage = (entry: DiskImage) => {
setCurrConversion(entry);
};
// Download disk image file // Download disk image file
const downloadDiskImage = async (entry: DiskImage) => { const downloadDiskImage = async (entry: DiskImage) => {
@ -190,6 +191,11 @@ function DiskImageList(p: {
setDlProgress(undefined); setDlProgress(undefined);
}; };
// Convert disk image file
const convertDiskImage = (entry: DiskImage) => {
setCurrConversion(entry);
};
// Delete disk image // Delete disk image
const deleteDiskImage = async (entry: DiskImage) => { const deleteDiskImage = async (entry: DiskImage) => {
if ( if (
@ -221,7 +227,7 @@ function DiskImageList(p: {
); );
const columns: GridColDef<(typeof p.list)[number]>[] = [ const columns: GridColDef<(typeof p.list)[number]>[] = [
{ field: "file_name", headerName: "File name", flex: 3 }, { field: "file_name", headerName: "File name", flex: 3, editable: true },
{ {
field: "format", field: "format",
headerName: "Format", headerName: "Format",
@ -260,28 +266,21 @@ function DiskImageList(p: {
}, },
{ {
field: "actions", field: "actions",
type: "actions",
headerName: "", headerName: "",
width: 140, width: 55,
renderCell(params) { cellClassName: "actions",
return ( editable: false,
<> getActions: (params) => {
<Tooltip title="Convert disk image"> return [
<IconButton onClick={() => { convertDiskImage(params.row); }}> <DiskImageActionMenu
<LoopIcon /> key="menu"
</IconButton> diskImage={params.row}
</Tooltip> onDownload={downloadDiskImage}
<Tooltip title="Download disk image"> onConvert={convertDiskImage}
<IconButton onClick={() => downloadDiskImage(params.row)}> onDelete={deleteDiskImage}
<DownloadIcon /> />,
</IconButton> ];
</Tooltip>
<Tooltip title="Delete disk image">
<IconButton onClick={() => deleteDiskImage(params.row)}>
<DeleteIcon />
</IconButton>
</Tooltip>
</>
);
}, },
}, },
]; ];
@ -327,7 +326,92 @@ function DiskImageList(p: {
)} )}
{/* The table itself */} {/* The table itself */}
<DataGrid getRowId={(c) => c.file_name} rows={p.list} columns={columns} /> <DataGrid<DiskImage>
getRowId={(c) => c.file_name}
rows={p.list}
columns={columns}
processRowUpdate={async (n, o) => {
try {
await DiskImageApi.Rename(o, n.file_name);
return n;
} catch (e) {
console.error("Failed to rename disk image!", e);
alert(`Failed to rename disk image! ${e}`);
throw e;
} finally {
p.onReload();
}
}}
/>
</>
);
}
function DiskImageActionMenu(p: {
diskImage: DiskImage;
onDownload: (d: DiskImage) => void;
onConvert: (d: DiskImage) => void;
onDelete: (d: DiskImage) => void;
}): React.ReactElement {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<>
<IconButton
aria-label="Actions"
aria-haspopup="true"
onClick={handleClick}
>
<MoreVertIcon />
</IconButton>
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
{/* Download disk image */}
<MenuItem
onClick={() => {
handleClose();
p.onDownload(p.diskImage);
}}
>
<ListItemIcon>
<DownloadIcon />
</ListItemIcon>
<ListItemText secondary={"Download disk image"}>
Download
</ListItemText>
</MenuItem>
{/* Convert disk image */}
<MenuItem
onClick={() => {
handleClose();
p.onConvert(p.diskImage);
}}
>
<ListItemIcon>
<LoopIcon />
</ListItemIcon>
<ListItemText secondary={"Convert disk image"}>Convert</ListItemText>
</MenuItem>
{/* Delete disk image */}
<MenuItem
onClick={() => {
handleClose();
p.onDelete(p.diskImage);
}}
>
<ListItemIcon>
<DeleteIcon color="error" />
</ListItemIcon>
<ListItemText secondary={"Delete disk image"}>Delete</ListItemText>
</MenuItem>
</Menu>
</> </>
); );
} }

View File

@ -10,6 +10,7 @@ import {
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
Typography,
} from "@mui/material"; } from "@mui/material";
import React from "react"; import React from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@ -58,6 +59,7 @@ export function TokensListRouteInner(p: {
</RouterLink> </RouterLink>
} }
> >
{p.list.length > 0 && (
<TableContainer component={Paper}> <TableContainer component={Paper}>
<Table> <Table>
<TableHead> <TableHead>
@ -122,6 +124,13 @@ export function TokensListRouteInner(p: {
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
)}
{p.list.length === 0 && (
<Typography style={{ textAlign: "center" }}>
No API token created yet.
</Typography>
)}
</VirtWebRouteContainer> </VirtWebRouteContainer>
); );
} }

View File

@ -0,0 +1,24 @@
import { DiskBusType } from "../../api/VMApi";
import { SelectInput } from "./SelectInput";
export function DiskBusSelect(p: {
editable: boolean;
value: DiskBusType;
label?: string;
onValueChange: (value: DiskBusType) => void;
size?: "medium" | "small";
disableUnderline?: boolean;
disableBottomMargin?: boolean;
}): React.ReactElement {
return (
<SelectInput
{...p}
label={p.label ?? "Disk bus type"}
options={[
{ label: "virtio", value: "Virtio" },
{ label: "sata", value: "SATA" },
]}
onValueChange={(v) => { p.onValueChange(v as any); }}
/>
);
}

View File

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

View File

@ -17,8 +17,11 @@ export function SelectInput(p: {
value?: string; value?: string;
editable: boolean; editable: boolean;
label?: string; label?: string;
size?: "medium" | "small";
options: SelectOption[]; options: SelectOption[];
onValueChange: (o?: string) => void; onValueChange: (o?: string) => void;
disableUnderline?: boolean;
disableBottomMargin?: boolean;
}): React.ReactElement { }): React.ReactElement {
if (!p.editable && !p.value) return <></>; if (!p.editable && !p.value) return <></>;
@ -28,12 +31,18 @@ export function SelectInput(p: {
} }
return ( return (
<FormControl fullWidth variant="standard" style={{ marginBottom: "15px" }}> <FormControl
fullWidth
variant="standard"
style={{ marginBottom: p.disableBottomMargin ? "0px" : "15px" }}
>
{p.label && <InputLabel>{p.label}</InputLabel>} {p.label && <InputLabel>{p.label}</InputLabel>}
<Select <Select
{...p}
value={p.value ?? ""} value={p.value ?? ""}
label={p.label} onChange={(e) => {
onChange={(e) => { p.onValueChange(e.target.value); }} p.onValueChange(e.target.value);
}}
> >
{p.options.map((e) => ( {p.options.map((e) => (
<MenuItem <MenuItem

View File

@ -17,6 +17,7 @@ export function TextInput(p: {
type?: React.HTMLInputTypeAttribute; type?: React.HTMLInputTypeAttribute;
style?: React.CSSProperties; style?: React.CSSProperties;
helperText?: string; helperText?: string;
disabled?: boolean;
}): React.ReactElement { }): React.ReactElement {
if (!p.editable && (p.value ?? "") === "") return <></>; if (!p.editable && (p.value ?? "") === "") return <></>;
@ -35,6 +36,7 @@ export function TextInput(p: {
return ( return (
<TextField <TextField
disabled={p.disabled}
label={p.label} label={p.label}
value={p.value ?? ""} value={p.value ?? ""}
onChange={(e) => onChange={(e) =>

View File

@ -1,24 +1,18 @@
import { mdiHarddisk, mdiHarddiskPlus } from "@mdi/js"; import { mdiHarddiskPlus } from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import { import { Button, IconButton, Paper, Tooltip } from "@mui/material";
Avatar,
Button,
IconButton,
ListItem,
ListItemAvatar,
ListItemText,
Paper,
Tooltip,
} from "@mui/material";
import { filesize } from "filesize";
import React from "react"; import React from "react";
import { DiskImage } from "../../api/DiskImageApi";
import { ServerApi } from "../../api/ServerApi"; import { ServerApi } from "../../api/ServerApi";
import { VMFileDisk, VMInfo, VMState } from "../../api/VMApi"; import { VMFileDisk, VMInfo, VMState } from "../../api/VMApi";
import { ConvertDiskImageDialog } from "../../dialogs/ConvertDiskImageDialog"; import { ConvertDiskImageDialog } from "../../dialogs/ConvertDiskImageDialog";
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider"; import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
import { VMDiskFileWidget } from "../vms/VMDiskFileWidget";
import { CheckboxInput } from "./CheckboxInput"; import { CheckboxInput } from "./CheckboxInput";
import { DiskBusSelect } from "./DiskBusSelect";
import { DiskImageSelect } from "./DiskImageSelect";
import { SelectInput } from "./SelectInput"; import { SelectInput } from "./SelectInput";
import { TextInput } from "./TextInput"; import { TextInput } from "./TextInput";
@ -27,6 +21,7 @@ export function VMDisksList(p: {
state?: VMState; state?: VMState;
onChange?: () => void; onChange?: () => void;
editable: boolean; editable: boolean;
diskImagesList: DiskImage[];
}): React.ReactElement { }): React.ReactElement {
const [currBackupRequest, setCurrBackupRequest] = React.useState< const [currBackupRequest, setCurrBackupRequest] = React.useState<
VMFileDisk | undefined VMFileDisk | undefined
@ -36,6 +31,7 @@ export function VMDisksList(p: {
p.vm.file_disks.push({ p.vm.file_disks.push({
format: "QCow2", format: "QCow2",
size: 10000 * 1000 * 1000, size: 10000 * 1000 * 1000,
bus: "Virtio",
delete: false, delete: false,
name: `disk${p.vm.file_disks.length}`, name: `disk${p.vm.file_disks.length}`,
new: true, new: true,
@ -67,6 +63,7 @@ export function VMDisksList(p: {
p.onChange?.(); p.onChange?.();
}} }}
onRequestBackup={handleBackupRequest} onRequestBackup={handleBackupRequest}
diskImagesList={p.diskImagesList}
/> />
))} ))}
@ -93,6 +90,7 @@ function DiskInfo(p: {
onChange?: () => void; onChange?: () => void;
removeFromList: () => void; removeFromList: () => void;
onRequestBackup: (disk: VMFileDisk) => void; onRequestBackup: (disk: VMFileDisk) => void;
diskImagesList: DiskImage[];
}): React.ReactElement { }): React.ReactElement {
const confirm = useConfirm(); const confirm = useConfirm();
const deleteDisk = async () => { const deleteDisk = async () => {
@ -117,7 +115,8 @@ function DiskInfo(p: {
if (!p.editable || !p.disk.new) if (!p.editable || !p.disk.new)
return ( return (
<ListItem <VMDiskFileWidget
{...p}
secondaryAction={ secondaryAction={
<> <>
{p.editable && ( {p.editable && (
@ -140,39 +139,18 @@ function DiskInfo(p: {
{p.canBackup && ( {p.canBackup && (
<Tooltip title="Backup this disk"> <Tooltip title="Backup this disk">
<IconButton onClick={() => p.onRequestBackup(p.disk)}> <IconButton
onClick={() => {
p.onRequestBackup(p.disk);
}}
>
<Icon path={mdiHarddiskPlus} size={1} /> <Icon path={mdiHarddiskPlus} size={1} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)} )}
</> </>
} }
>
<ListItemAvatar>
<Avatar>
<Icon path={mdiHarddisk} />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<>
{p.disk.name}{" "}
{p.disk.deleteType && (
<span style={{ color: "red" }}>
{p.disk.deleteType === "deletefile"
? "Remove, DELETING block file"
: "Remove, keeping block file"}
</span>
)}
</>
}
secondary={`${filesize(p.disk.size)} - ${p.disk.format}${
p.disk.format == "Raw"
? " - " + (p.disk.is_sparse ? "Sparse" : "Fixed")
: ""
}`}
/> />
</ListItem>
); );
return ( return (
@ -194,23 +172,6 @@ function DiskInfo(p: {
</IconButton> </IconButton>
</div> </div>
<TextInput
editable={true}
label="Disk size (GB)"
size={{
min:
ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000),
max:
ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000),
}}
value={(p.disk.size / (1000 * 1000 * 1000)).toString()}
onValueChange={(v) => {
p.disk.size = Number(v ?? "0") * 1000 * 1000 * 1000;
p.onChange?.();
}}
type="number"
/>
<SelectInput <SelectInput
editable={true} editable={true}
label="Disk format" label="Disk format"
@ -228,6 +189,17 @@ function DiskInfo(p: {
}} }}
/> />
{/* Bus selection */}
<DiskBusSelect
editable
value={p.disk.bus}
onValueChange={(v) => {
p.disk.bus = v;
p.onChange?.();
}}
/>
{/* Raw disk: choose sparse mode */}
{p.disk.format === "Raw" && ( {p.disk.format === "Raw" && (
<CheckboxInput <CheckboxInput
editable editable
@ -239,6 +211,34 @@ function DiskInfo(p: {
}} }}
/> />
)} )}
<TextInput
editable={true}
label="Disk size (GB)"
size={{
min:
ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000),
max:
ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000),
}}
value={(p.disk.size / (1000 * 1000 * 1000)).toString()}
onValueChange={(v) => {
p.disk.size = Number(v ?? "0") * 1000 * 1000 * 1000;
p.onChange?.();
}}
type="number"
disabled={!!p.disk.from_image}
/>
<DiskImageSelect
label="Use disk image as template"
list={p.diskImagesList}
value={p.disk.from_image}
onValueChange={(v) => {
p.disk.from_image = v;
p.onChange?.();
}}
/>
</Paper> </Paper>
); );
} }

View File

@ -35,6 +35,7 @@ export function VMNetworksList(p: {
const addNew = () => { const addNew = () => {
p.vm.networks.push({ p.vm.networks.push({
type: "UserspaceSLIRPStack", type: "UserspaceSLIRPStack",
model: "Virtio",
mac: randomMacAddress(ServerApi.Config.net_mac_prefix), mac: randomMacAddress(ServerApi.Config.net_mac_prefix),
}); });
p.onChange?.(); p.onChange?.();
@ -146,6 +147,7 @@ function NetworkInfoWidget(p: {
/> />
</ListItem> </ListItem>
<div style={{ marginLeft: "70px" }}> <div style={{ marginLeft: "70px" }}>
{/* MAC address input */}
<MACInput <MACInput
editable={p.editable} editable={p.editable}
label="MAC Address" 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 */} {/* Defined network selection */}
{p.network.type === "DefinedNetwork" && ( {p.network.type === "DefinedNetwork" && (
<SelectInput <SelectInput

View File

@ -1,6 +1,7 @@
import { import {
Checkbox, Checkbox,
FormControlLabel, FormControlLabel,
Grid,
Paper, Paper,
Table, Table,
TableBody, TableBody,
@ -59,6 +60,7 @@ export function TokenRightsEditor(p: {
<TableCell align="center">Get XML definition</TableCell> <TableCell align="center">Get XML definition</TableCell>
<TableCell align="center">Get autostart</TableCell> <TableCell align="center">Get autostart</TableCell>
<TableCell align="center">Set autostart</TableCell> <TableCell align="center">Set autostart</TableCell>
<TableCell align="center">Backup disk</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@ -82,6 +84,10 @@ export function TokenRightsEditor(p: {
{...p} {...p}
right={{ verb: "PUT", path: "/api/vm/*/autostart" }} right={{ verb: "PUT", path: "/api/vm/*/autostart" }}
/> />
<CellRight
{...p}
right={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }}
/>
</TableRow> </TableRow>
{/* Per VM operations */} {/* Per VM operations */}
@ -117,6 +123,14 @@ export function TokenRightsEditor(p: {
{...p} {...p}
right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }} right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }}
parent={{ verb: "PUT", path: "/api/vm/*/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> </TableRow>
))} ))}
@ -669,6 +683,43 @@ export function TokenRightsEditor(p: {
</Table> </Table>
</RightsSection> </RightsSection>
<Grid container>
<Grid size={{ md: 6 }}>
{/* Disk images */}
<RightsSection label="Disk images">
<RouteRight
{...p}
right={{ verb: "POST", path: "/api/disk_images/upload" }}
label="Upload a new disk image"
/>
<RouteRight
{...p}
right={{ verb: "GET", path: "/api/disk_images/list" }}
label="Get the list of disk images"
/>
<RouteRight
{...p}
right={{ verb: "GET", path: "/api/disk_images/*" }}
label="Download disk images"
/>
<RouteRight
{...p}
right={{ verb: "POST", path: "/api/disk_images/*/convert" }}
label="Convert disk images"
/>
<RouteRight
{...p}
right={{ verb: "POST", path: "/api/disk_images/*/rename" }}
label="Rename disk images"
/>
<RouteRight
{...p}
right={{ verb: "DELETE", path: "/api/disk_images/*" }}
label="Delete disk images"
/>
</RightsSection>
</Grid>
<Grid size={{ md: 6 }}>
{/* ISO files */} {/* ISO files */}
<RightsSection label="ISO files"> <RightsSection label="ISO files">
<RouteRight <RouteRight
@ -697,6 +748,8 @@ export function TokenRightsEditor(p: {
label="Delete ISO files" label="Delete ISO files"
/> />
</RightsSection> </RightsSection>
</Grid>
</Grid>
{/* Server general information */} {/* Server general information */}
<RightsSection label="Server"> <RightsSection label="Server">

View File

@ -27,6 +27,7 @@ import { VMDisksList } from "../forms/VMDisksList";
import { VMNetworksList } from "../forms/VMNetworksList"; import { VMNetworksList } from "../forms/VMNetworksList";
import { VMSelectIsoInput } from "../forms/VMSelectIsoInput"; import { VMSelectIsoInput } from "../forms/VMSelectIsoInput";
import { VMScreenshot } from "./VMScreenshot"; import { VMScreenshot } from "./VMScreenshot";
import { DiskImage, DiskImageApi } from "../../api/DiskImageApi";
interface DetailsProps { interface DetailsProps {
vm: VMInfo; vm: VMInfo;
@ -38,6 +39,9 @@ interface DetailsProps {
export function VMDetails(p: DetailsProps): React.ReactElement { export function VMDetails(p: DetailsProps): React.ReactElement {
const [groupsList, setGroupsList] = React.useState<string[] | undefined>(); const [groupsList, setGroupsList] = React.useState<string[] | undefined>();
const [diskImagesList, setDiskImagesList] = React.useState<
DiskImage[] | undefined
>();
const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>(); const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>();
const [bridgesList, setBridgesList] = React.useState<string[] | undefined>(); const [bridgesList, setBridgesList] = React.useState<string[] | undefined>();
const [vcpuCombinations, setVCPUCombinations] = React.useState< const [vcpuCombinations, setVCPUCombinations] = React.useState<
@ -52,6 +56,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
const load = async () => { const load = async () => {
setGroupsList(await GroupApi.GetList()); setGroupsList(await GroupApi.GetList());
setDiskImagesList(await DiskImageApi.GetList());
setIsoList(await IsoFilesApi.GetList()); setIsoList(await IsoFilesApi.GetList());
setBridgesList(await ServerApi.GetNetworksBridgesList()); setBridgesList(await ServerApi.GetNetworksBridgesList());
setVCPUCombinations(await ServerApi.NumberVCPUs()); setVCPUCombinations(await ServerApi.NumberVCPUs());
@ -67,6 +72,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
build={() => ( build={() => (
<VMDetailsInner <VMDetailsInner
groupsList={groupsList!} groupsList={groupsList!}
diskImagesList={diskImagesList!}
isoList={isoList!} isoList={isoList!}
bridgesList={bridgesList!} bridgesList={bridgesList!}
vcpuCombinations={vcpuCombinations!} vcpuCombinations={vcpuCombinations!}
@ -90,6 +96,7 @@ enum VMTab {
type DetailsInnerProps = DetailsProps & { type DetailsInnerProps = DetailsProps & {
groupsList: string[]; groupsList: string[];
diskImagesList: DiskImage[];
isoList: IsoFile[]; isoList: IsoFile[];
bridgesList: string[]; bridgesList: string[];
vcpuCombinations: number[]; vcpuCombinations: number[];
@ -273,6 +280,7 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement {
options={[ options={[
{ label: "UEFI with Secure Boot", value: "UEFISecureBoot" }, { label: "UEFI with Secure Boot", value: "UEFISecureBoot" },
{ label: "UEFI", value: "UEFI" }, { label: "UEFI", value: "UEFI" },
{ label: "Legacy", value: "Legacy" },
]} ]}
/> />

View File

@ -3,18 +3,69 @@ import { Icon } from "@mdi/react";
import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material"; import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material";
import { filesize } from "filesize"; import { filesize } from "filesize";
import { VMFileDisk } from "../../api/VMApi"; import { VMFileDisk } from "../../api/VMApi";
import { DiskBusSelect } from "../forms/DiskBusSelect";
export function VMDiskFileWidget(p: {
editable?: boolean;
disk: VMFileDisk;
secondaryAction?: React.ReactElement;
onChange?: () => void;
}): React.ReactElement {
const info = [filesize(p.disk.size), p.disk.format];
if (p.disk.format === "Raw") info.push(p.disk.is_sparse ? "Sparse" : "Fixed");
if (!p.editable) info.push(p.disk.bus);
export function VMDiskFileWidget(p: { disk: VMFileDisk }): React.ReactElement {
return ( return (
<ListItem> <ListItem secondaryAction={p.secondaryAction}>
<ListItemAvatar> <ListItemAvatar>
<Avatar> <Avatar>
<Icon path={mdiHarddisk} /> <Icon path={mdiHarddisk} />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary={p.disk.name} primary={
secondary={`${p.disk.format} - ${filesize(p.disk.size)}`} <>
{p.disk.name}{" "}
{p.disk.deleteType && (
<span style={{ color: "red" }}>
{p.disk.deleteType === "deletefile"
? "Remove, DELETING block file"
: "Remove, keeping block file"}
</span>
)}
</>
}
secondary={
<div style={{ display: "flex", alignItems: "center" }}>
{p.editable ? (
<div
style={{
maxWidth: "80px",
display: "inline-block",
marginRight: "10px",
}}
>
<DiskBusSelect
onValueChange={(v) => {
p.disk.bus = v;
p.onChange?.();
}}
label=""
editable
value={p.disk.bus}
size="small"
disableUnderline
disableBottomMargin
/>
</div>
) : (
""
)}
<div style={{ height: "100%" }}>{info.join(" - ")}</div>
</div>
}
/> />
</ListItem> </ListItem>
); );