Compare commits
8 Commits
bae22e9daf
...
5574037b73
Author | SHA1 | Date | |
---|---|---|---|
5574037b73 | |||
d1ca9aee39 | |||
f850ca5cb7 | |||
4ee01cad4b | |||
5518b45219 | |||
0279907ca9 | |||
5fe481ffed | |||
c7cc15d8d0 |
@ -189,6 +189,46 @@ 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) {
|
||||||
|
@ -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")]
|
||||||
|
@ -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,
|
||||||
}
|
}
|
||||||
@ -313,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 {
|
||||||
@ -347,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(),
|
||||||
}),
|
}),
|
||||||
@ -445,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,
|
||||||
},
|
},
|
||||||
@ -479,7 +498,7 @@ impl VMInfo {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|d| d.device == "disk")
|
.filter(|d| d.device == "disk")
|
||||||
.map(|d| {
|
.map(|d| {
|
||||||
VMFileDisk::load_from_file(&d.source.file)
|
VMFileDisk::load_from_file(&d.source.file, &d.target.bus)
|
||||||
.expect("Failed to load file disk information!")
|
.expect("Failed to load file disk information!")
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
@ -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),
|
||||||
|
@ -183,7 +183,13 @@ impl DiskFileInfo {
|
|||||||
// Convert QCow2 to Raw file
|
// Convert QCow2 to Raw file
|
||||||
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => {
|
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => {
|
||||||
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
|
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
|
||||||
cmd.arg("convert").arg(&self.file_path).arg(&temp_file);
|
cmd.arg("convert")
|
||||||
|
.arg("-f")
|
||||||
|
.arg("qcow2")
|
||||||
|
.arg("-O")
|
||||||
|
.arg("raw")
|
||||||
|
.arg(&self.file_path)
|
||||||
|
.arg(&temp_file);
|
||||||
|
|
||||||
if !is_sparse {
|
if !is_sparse {
|
||||||
cmd.args(["-S", "0"]);
|
cmd.args(["-S", "0"]);
|
||||||
@ -197,6 +203,8 @@ impl DiskFileInfo {
|
|||||||
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::QCow2 { .. }) => {
|
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::QCow2 { .. }) => {
|
||||||
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
|
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
|
||||||
cmd.arg("convert")
|
cmd.arg("convert")
|
||||||
|
.arg("-f")
|
||||||
|
.arg("qcow2")
|
||||||
.arg("-O")
|
.arg("-O")
|
||||||
.arg("qcow2")
|
.arg("qcow2")
|
||||||
.arg(&self.file_path)
|
.arg(&self.file_path)
|
||||||
@ -207,7 +215,13 @@ impl DiskFileInfo {
|
|||||||
// Convert Raw to QCow2 file
|
// Convert Raw to QCow2 file
|
||||||
(DiskFileFormat::Raw { .. }, DiskFileFormat::QCow2 { .. }) => {
|
(DiskFileFormat::Raw { .. }, DiskFileFormat::QCow2 { .. }) => {
|
||||||
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
|
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
|
||||||
cmd.arg("convert").arg(&self.file_path).arg(&temp_file);
|
cmd.arg("convert")
|
||||||
|
.arg("-f")
|
||||||
|
.arg("raw")
|
||||||
|
.arg("-O")
|
||||||
|
.arg("qcow2")
|
||||||
|
.arg(&self.file_path)
|
||||||
|
.arg(&temp_file);
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
}
|
}
|
||||||
|
@ -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,6 +36,8 @@ 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,
|
||||||
@ -41,7 +49,7 @@ pub struct VMFileDisk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)?;
|
||||||
@ -61,6 +69,13 @@ 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,
|
from_image: None,
|
||||||
})
|
})
|
||||||
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
@ -19,9 +19,13 @@ 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
|
// For new disk only
|
||||||
@ -78,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;
|
||||||
@ -85,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;
|
||||||
@ -104,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;
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
24
virtweb_frontend/src/widgets/forms/DiskBusSelect.tsx
Normal file
24
virtweb_frontend/src/widgets/forms/DiskBusSelect.tsx
Normal 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); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -1,12 +1,12 @@
|
|||||||
import React from "react";
|
|
||||||
import { DiskImage } from "../../api/DiskImageApi";
|
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
Select,
|
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
Select,
|
||||||
SelectChangeEvent,
|
SelectChangeEvent,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
|
import { DiskImage } from "../../api/DiskImageApi";
|
||||||
import { FileDiskImageWidget } from "../FileDiskImageWidget";
|
import { FileDiskImageWidget } from "../FileDiskImageWidget";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -30,7 +30,7 @@ export function DiskImageSelect(p: {
|
|||||||
<i>None</i>
|
<i>None</i>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{p.list.map((d) => (
|
{p.list.map((d) => (
|
||||||
<MenuItem value={d.file_name}>
|
<MenuItem key={d.file_name} value={d.file_name}>
|
||||||
<FileDiskImageWidget image={d} />
|
<FileDiskImageWidget image={d} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
|
@ -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
|
||||||
|
@ -1,28 +1,20 @@
|
|||||||
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";
|
||||||
import { DiskImageSelect } from "./DiskImageSelect";
|
|
||||||
import { DiskImage } from "../../api/DiskImageApi";
|
|
||||||
|
|
||||||
export function VMDisksList(p: {
|
export function VMDisksList(p: {
|
||||||
vm: VMInfo;
|
vm: VMInfo;
|
||||||
@ -39,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,
|
||||||
@ -122,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 && (
|
||||||
@ -156,32 +150,7 @@ function DiskInfo(p: {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
|
||||||
<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 (
|
||||||
@ -220,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
|
||||||
|
@ -707,6 +707,11 @@ export function TokenRightsEditor(p: {
|
|||||||
right={{ verb: "POST", path: "/api/disk_images/*/convert" }}
|
right={{ verb: "POST", path: "/api/disk_images/*/convert" }}
|
||||||
label="Convert disk images"
|
label="Convert disk images"
|
||||||
/>
|
/>
|
||||||
|
<RouteRight
|
||||||
|
{...p}
|
||||||
|
right={{ verb: "POST", path: "/api/disk_images/*/rename" }}
|
||||||
|
label="Rename disk images"
|
||||||
|
/>
|
||||||
<RouteRight
|
<RouteRight
|
||||||
{...p}
|
{...p}
|
||||||
right={{ verb: "DELETE", path: "/api/disk_images/*" }}
|
right={{ verb: "DELETE", path: "/api/disk_images/*" }}
|
||||||
|
@ -280,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" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
Reference in New Issue
Block a user