Compare commits
11 Commits
3680ceed61
...
115728d9c9
Author | SHA1 | Date | |
---|---|---|---|
115728d9c9 | |||
0de15af10e | |||
d4bc92f562 | |||
a1439689dd | |||
63126c75fa | |||
940302a825 | |||
9c374f849b | |||
2fa4d0e11b | |||
d7796e1459 | |||
759361d9f6 | |||
b2529c250a |
@ -137,6 +137,9 @@ pub const PROGRAM_COPY: &str = "/bin/cp";
|
||||
/// Gzip program path
|
||||
pub const PROGRAM_GZIP: &str = "/usr/bin/gzip";
|
||||
|
||||
/// XZ program path
|
||||
pub const PROGRAM_XZ: &str = "/usr/bin/xz";
|
||||
|
||||
/// Bash program
|
||||
pub const PROGRAM_BASH: &str = "/usr/bin/bash";
|
||||
|
||||
|
@ -28,8 +28,10 @@ pub enum DiskFileFormat {
|
||||
#[serde(default)]
|
||||
virtual_size: FileSize,
|
||||
},
|
||||
CompressedRaw,
|
||||
CompressedQCow2,
|
||||
GzCompressedRaw,
|
||||
GzCompressedQCow2,
|
||||
XzCompressedRaw,
|
||||
XzCompressedQCow2,
|
||||
}
|
||||
|
||||
impl DiskFileFormat {
|
||||
@ -37,8 +39,10 @@ impl DiskFileFormat {
|
||||
match self {
|
||||
DiskFileFormat::Raw { .. } => &["raw", ""],
|
||||
DiskFileFormat::QCow2 { .. } => &["qcow2"],
|
||||
DiskFileFormat::CompressedRaw => &["raw.gz"],
|
||||
DiskFileFormat::CompressedQCow2 => &["qcow2.gz"],
|
||||
DiskFileFormat::GzCompressedRaw => &["raw.gz"],
|
||||
DiskFileFormat::GzCompressedQCow2 => &["qcow2.gz"],
|
||||
DiskFileFormat::XzCompressedRaw => &["raw.xz"],
|
||||
DiskFileFormat::XzCompressedQCow2 => &["qcow2.xz"],
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -81,9 +85,14 @@ impl DiskFileInfo {
|
||||
},
|
||||
"gz" if name.ends_with(".qcow2") => {
|
||||
name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string();
|
||||
DiskFileFormat::CompressedQCow2
|
||||
DiskFileFormat::GzCompressedQCow2
|
||||
}
|
||||
"gz" => DiskFileFormat::CompressedRaw,
|
||||
"gz" => DiskFileFormat::GzCompressedRaw,
|
||||
"xz" if name.ends_with(".qcow2") => {
|
||||
name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string();
|
||||
DiskFileFormat::XzCompressedQCow2
|
||||
}
|
||||
"xz" => DiskFileFormat::XzCompressedRaw,
|
||||
_ => anyhow::bail!("Unsupported disk extension: {ext}!"),
|
||||
};
|
||||
|
||||
@ -159,8 +168,8 @@ impl DiskFileInfo {
|
||||
|
||||
// Prepare the conversion
|
||||
let mut cmd = match (self.format, dest_format) {
|
||||
// Decompress QCow2
|
||||
(DiskFileFormat::CompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
|
||||
// Decompress QCow2 (GZIP)
|
||||
(DiskFileFormat::GzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_GZIP);
|
||||
cmd.arg("--keep")
|
||||
.arg("--decompress")
|
||||
@ -170,8 +179,19 @@ impl DiskFileInfo {
|
||||
cmd
|
||||
}
|
||||
|
||||
// Compress QCow2
|
||||
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::CompressedQCow2) => {
|
||||
// Decompress QCow2 (XZ)
|
||||
(DiskFileFormat::XzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_XZ);
|
||||
cmd.arg("--stdout")
|
||||
.arg("--keep")
|
||||
.arg("--decompress")
|
||||
.arg(&self.file_path)
|
||||
.stdout(File::create(&temp_file)?);
|
||||
cmd
|
||||
}
|
||||
|
||||
// Compress QCow2 (Gzip)
|
||||
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::GzCompressedQCow2) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_GZIP);
|
||||
cmd.arg("--keep")
|
||||
.arg("--to-stdout")
|
||||
@ -180,6 +200,16 @@ impl DiskFileInfo {
|
||||
cmd
|
||||
}
|
||||
|
||||
// Compress QCow2 (Xz)
|
||||
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::XzCompressedQCow2) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_XZ);
|
||||
cmd.arg("--keep")
|
||||
.arg("--to-stdout")
|
||||
.arg(&self.file_path)
|
||||
.stdout(File::create(&temp_file)?);
|
||||
cmd
|
||||
}
|
||||
|
||||
// Convert QCow2 to Raw file
|
||||
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
|
||||
@ -244,8 +274,8 @@ impl DiskFileInfo {
|
||||
cmd
|
||||
}
|
||||
|
||||
// Compress Raw
|
||||
(DiskFileFormat::Raw { .. }, DiskFileFormat::CompressedRaw) => {
|
||||
// Compress Raw (Gz)
|
||||
(DiskFileFormat::Raw { .. }, DiskFileFormat::GzCompressedRaw) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_GZIP);
|
||||
cmd.arg("--keep")
|
||||
.arg("--to-stdout")
|
||||
@ -254,8 +284,18 @@ impl DiskFileInfo {
|
||||
cmd
|
||||
}
|
||||
|
||||
// Decompress Raw to not sparse file
|
||||
(DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => {
|
||||
// Compress Raw (Xz)
|
||||
(DiskFileFormat::Raw { .. }, DiskFileFormat::XzCompressedRaw) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_XZ);
|
||||
cmd.arg("--keep")
|
||||
.arg("--to-stdout")
|
||||
.arg(&self.file_path)
|
||||
.stdout(File::create(&temp_file)?);
|
||||
cmd
|
||||
}
|
||||
|
||||
// Decompress Raw (Gz) to not sparse file
|
||||
(DiskFileFormat::GzCompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_GZIP);
|
||||
cmd.arg("--keep")
|
||||
.arg("--decompress")
|
||||
@ -264,13 +304,23 @@ impl DiskFileInfo {
|
||||
.stdout(File::create(&temp_file)?);
|
||||
cmd
|
||||
}
|
||||
// Decompress Raw (Xz) to not sparse file
|
||||
(DiskFileFormat::XzCompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_XZ);
|
||||
cmd.arg("--keep")
|
||||
.arg("--decompress")
|
||||
.arg("--to-stdout")
|
||||
.arg(&self.file_path)
|
||||
.stdout(File::create(&temp_file)?);
|
||||
cmd
|
||||
}
|
||||
|
||||
// Decompress Raw to sparse file
|
||||
// Decompress Raw (Gz) to sparse file
|
||||
// https://benou.fr/www/ben/decompressing-sparse-files.html
|
||||
(DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => {
|
||||
(DiskFileFormat::GzCompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_BASH);
|
||||
cmd.arg("-c").arg(format!(
|
||||
"{} -d -c {} | {} conv=sparse of={}",
|
||||
"{} --decompress --to-stdout {} | {} conv=sparse of={}",
|
||||
constants::PROGRAM_GZIP,
|
||||
self.file_path.display(),
|
||||
constants::PROGRAM_DD,
|
||||
@ -279,6 +329,20 @@ impl DiskFileInfo {
|
||||
cmd
|
||||
}
|
||||
|
||||
// Decompress Raw (XZ) to sparse file
|
||||
// https://benou.fr/www/ben/decompressing-sparse-files.html
|
||||
(DiskFileFormat::XzCompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_BASH);
|
||||
cmd.arg("-c").arg(format!(
|
||||
"{} --decompress --to-stdout {} | {} conv=sparse of={}",
|
||||
constants::PROGRAM_XZ,
|
||||
self.file_path.display(),
|
||||
constants::PROGRAM_DD,
|
||||
temp_file.display()
|
||||
));
|
||||
cmd
|
||||
}
|
||||
|
||||
// Dumb copy of file
|
||||
(a, b) if a == b => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_COPY);
|
||||
@ -330,6 +394,44 @@ impl DiskFileInfo {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get disk virtual size, if available
|
||||
pub fn virtual_size(&self) -> Option<FileSize> {
|
||||
match self.format {
|
||||
DiskFileFormat::Raw { .. } => Some(self.file_size),
|
||||
DiskFileFormat::QCow2 { virtual_size } => Some(virtual_size),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize disk
|
||||
pub fn resize(&self, new_size: FileSize) -> anyhow::Result<()> {
|
||||
if new_size <= self.virtual_size().unwrap_or(new_size) {
|
||||
anyhow::bail!("Shrinking disk image file is not supported!");
|
||||
}
|
||||
|
||||
let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
|
||||
cmd.arg("resize")
|
||||
.arg("-f")
|
||||
.arg(match self.format {
|
||||
DiskFileFormat::QCow2 { .. } => "qcow2",
|
||||
DiskFileFormat::Raw { .. } => "raw",
|
||||
f => anyhow::bail!("Unsupported disk format for resize: {f:?}"),
|
||||
})
|
||||
.arg(&self.file_path)
|
||||
.arg(new_size.as_bytes().to_string());
|
||||
|
||||
let output = cmd.output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"{} info failed, status: {}, stderr: {}",
|
||||
constants::PROGRAM_QEMU_IMAGE,
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
|
@ -44,6 +44,9 @@ pub struct VMFileDisk {
|
||||
/// 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 resize disk image
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resize: Option<bool>,
|
||||
/// Set this variable to true to delete the disk
|
||||
pub delete: bool,
|
||||
}
|
||||
@ -78,6 +81,7 @@ impl VMFileDisk {
|
||||
|
||||
delete: false,
|
||||
from_image: None,
|
||||
resize: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -144,28 +148,40 @@ impl VMFileDisk {
|
||||
|
||||
if file.exists() {
|
||||
log::debug!("File {file:?} does not exists, so it was not touched");
|
||||
return Ok(());
|
||||
}
|
||||
// Create disk if required
|
||||
else {
|
||||
// Determine file format
|
||||
let format = match self.format {
|
||||
VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
|
||||
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
|
||||
virtual_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)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let format = match self.format {
|
||||
VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
|
||||
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
|
||||
virtual_size: self.size,
|
||||
},
|
||||
};
|
||||
// Resize disk file if requested
|
||||
if self.resize == Some(true) {
|
||||
let disk = DiskFileInfo::load_file(&file)?;
|
||||
|
||||
// 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)?;
|
||||
// Can only increase disk size
|
||||
if let Err(e) = disk.resize(self.size) {
|
||||
log::error!("Failed to resize disk file {}: {e:?}", self.name);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4,8 +4,10 @@ import { VMFileDisk, VMInfo } from "./VMApi";
|
||||
export type DiskImageFormat =
|
||||
| { format: "Raw"; is_sparse: boolean }
|
||||
| { format: "QCow2"; virtual_size?: number }
|
||||
| { format: "CompressedQCow2" }
|
||||
| { format: "CompressedRaw" };
|
||||
| { format: "GzCompressedQCow2" }
|
||||
| { format: "GzCompressedRaw" }
|
||||
| { format: "XzCompressedQCow2" }
|
||||
| { format: "XzCompressedRaw" };
|
||||
|
||||
export type DiskImage = {
|
||||
file_size: number;
|
||||
|
@ -31,8 +31,12 @@ export interface BaseFileVMDisk {
|
||||
// For new disk only
|
||||
from_image?: string;
|
||||
|
||||
// Resize disk image after clone
|
||||
resize?: boolean;
|
||||
|
||||
// application attributes
|
||||
new?: boolean;
|
||||
originalSize?: number;
|
||||
deleteType?: "keepfile" | "deletefile";
|
||||
}
|
||||
|
||||
|
@ -42,13 +42,15 @@ export function ConvertDiskImageDialog(
|
||||
setFormat({ format: value ?? ("QCow2" as any) });
|
||||
|
||||
if (value === "QCow2") setFilename(`${origFilename}.qcow2`);
|
||||
if (value === "CompressedQCow2") setFilename(`${origFilename}.qcow2.gz`);
|
||||
if (value === "GzCompressedQCow2") setFilename(`${origFilename}.qcow2.gz`);
|
||||
if (value === "XzCompressedQCow2") setFilename(`${origFilename}.qcow2.xz`);
|
||||
if (value === "Raw") {
|
||||
setFilename(`${origFilename}.raw`);
|
||||
// Check sparse checkbox by default
|
||||
setFormat({ format: "Raw", is_sparse: true });
|
||||
}
|
||||
if (value === "CompressedRaw") setFilename(`${origFilename}.raw.gz`);
|
||||
if (value === "GzCompressedRaw") setFilename(`${origFilename}.raw.gz`);
|
||||
if (value === "XzCompressedRaw") setFilename(`${origFilename}.raw.xz`);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@ -104,8 +106,10 @@ export function ConvertDiskImageDialog(
|
||||
options={[
|
||||
{ value: "QCow2" },
|
||||
{ value: "Raw" },
|
||||
{ value: "CompressedRaw" },
|
||||
{ value: "CompressedQCow2" },
|
||||
{ value: "GzCompressedRaw" },
|
||||
{ value: "XzCompressedRaw" },
|
||||
{ value: "GzCompressedQCow2" },
|
||||
{ value: "XzCompressedQCow2" },
|
||||
]}
|
||||
/>
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
|
||||
import {
|
||||
Alert,
|
||||
CircularProgress,
|
||||
@ -36,7 +36,9 @@ export function LoginRoute(): React.ReactElement {
|
||||
const canSubmit = username.length > 0 && password.length > 0;
|
||||
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const handleClickShowPassword = () => { setShowPassword((show) => !show); };
|
||||
const handleClickShowPassword = () => {
|
||||
setShowPassword((show) => !show);
|
||||
};
|
||||
|
||||
const handleMouseDownPassword = (
|
||||
event: React.MouseEvent<HTMLButtonElement>
|
||||
@ -105,12 +107,14 @@ export function LoginRoute(): React.ReactElement {
|
||||
label="Username"
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={(e) => { setUsername(e.target.value); }}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
}}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<FormControl fullWidth variant="outlined">
|
||||
<FormControl required fullWidth variant="outlined">
|
||||
<InputLabel htmlFor="password">Password</InputLabel>
|
||||
<OutlinedInput
|
||||
required
|
||||
@ -120,7 +124,9 @@ export function LoginRoute(): React.ReactElement {
|
||||
type={showPassword ? "text" : "password"}
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); }}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
@ -131,7 +137,11 @@ export function LoginRoute(): React.ReactElement {
|
||||
onMouseDown={handleMouseDownPassword}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||
{showPassword ? (
|
||||
<VisibilityOffIcon />
|
||||
) : (
|
||||
<VisibilityIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
|
25
virtweb_frontend/src/widgets/forms/DiskSizeInput.tsx
Normal file
25
virtweb_frontend/src/widgets/forms/DiskSizeInput.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { ServerApi } from "../../api/ServerApi";
|
||||
import { TextInput } from "./TextInput";
|
||||
|
||||
export function DiskSizeInput(p: {
|
||||
editable: boolean;
|
||||
label?: string;
|
||||
value: number;
|
||||
onChange?: (size: number) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<TextInput
|
||||
editable={p.editable}
|
||||
label={p.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.value / (1000 * 1000 * 1000)).toString()}
|
||||
onValueChange={(v) => {
|
||||
p.onChange?.(Number(v ?? "0") * 1000 * 1000 * 1000);
|
||||
}}
|
||||
type="number"
|
||||
/>
|
||||
);
|
||||
}
|
@ -25,6 +25,8 @@ export function OEMStringFormWidget(p: {
|
||||
p.onChange?.();
|
||||
};
|
||||
|
||||
if (!p.editable && p.vm.oem_strings.length === 0) return <></>;
|
||||
|
||||
return (
|
||||
<EditSection
|
||||
title="SMBIOS OEM Strings"
|
||||
|
@ -2,7 +2,8 @@ import { mdiHarddiskPlus } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { Button, IconButton, Paper, Tooltip } from "@mui/material";
|
||||
import ExpandIcon from "@mui/icons-material/Expand";
|
||||
import { Button, IconButton, Paper, Tooltip, Typography } from "@mui/material";
|
||||
import React from "react";
|
||||
import { DiskImage } from "../../api/DiskImageApi";
|
||||
import { ServerApi } from "../../api/ServerApi";
|
||||
@ -13,6 +14,7 @@ import { VMDiskFileWidget } from "../vms/VMDiskFileWidget";
|
||||
import { CheckboxInput } from "./CheckboxInput";
|
||||
import { DiskBusSelect } from "./DiskBusSelect";
|
||||
import { DiskImageSelect } from "./DiskImageSelect";
|
||||
import { DiskSizeInput } from "./DiskSizeInput";
|
||||
import { SelectInput } from "./SelectInput";
|
||||
import { TextInput } from "./TextInput";
|
||||
|
||||
@ -67,6 +69,12 @@ export function VMDisksList(p: {
|
||||
/>
|
||||
))}
|
||||
|
||||
{p.vm.file_disks.length === 0 && (
|
||||
<Typography style={{ textAlign: "center", paddingTop: "25px" }}>
|
||||
No disk file yet!
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{p.editable && <Button onClick={addNewDisk}>Add new disk</Button>}
|
||||
|
||||
{/* Disk backup */}
|
||||
@ -93,6 +101,19 @@ function DiskInfo(p: {
|
||||
diskImagesList: DiskImage[];
|
||||
}): React.ReactElement {
|
||||
const confirm = useConfirm();
|
||||
|
||||
const expandDisk = () => {
|
||||
if (p.disk.resize === true) {
|
||||
p.disk.resize = false;
|
||||
p.disk.size = p.disk.originalSize!;
|
||||
} else {
|
||||
p.disk.resize = true;
|
||||
p.disk.originalSize = p.disk.size!;
|
||||
}
|
||||
|
||||
p.onChange?.();
|
||||
};
|
||||
|
||||
const deleteDisk = async () => {
|
||||
if (p.disk.deleteType) {
|
||||
p.disk.deleteType = undefined;
|
||||
@ -115,42 +136,75 @@ function DiskInfo(p: {
|
||||
|
||||
if (!p.editable || !p.disk.new)
|
||||
return (
|
||||
<VMDiskFileWidget
|
||||
{...p}
|
||||
secondaryAction={
|
||||
<>
|
||||
{p.editable && (
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="delete disk"
|
||||
onClick={deleteDisk}
|
||||
>
|
||||
{p.disk.deleteType ? (
|
||||
<Tooltip title="Cancel disk removal">
|
||||
<CheckCircleIcon />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Remove disk">
|
||||
<DeleteIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{p.canBackup && (
|
||||
<Tooltip title="Backup this disk">
|
||||
<>
|
||||
<VMDiskFileWidget
|
||||
{...p}
|
||||
secondaryAction={
|
||||
<>
|
||||
{p.editable && !p.disk.deleteType && (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
p.onRequestBackup(p.disk);
|
||||
}}
|
||||
edge="end"
|
||||
aria-label="expand disk"
|
||||
onClick={expandDisk}
|
||||
>
|
||||
<Icon path={mdiHarddiskPlus} size={1} />
|
||||
{p.disk.resize === true ? (
|
||||
<Tooltip title="Cancel disk expansion">
|
||||
<ExpandIcon color="error" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Increase disk size">
|
||||
<ExpandIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{p.editable && (
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="delete disk"
|
||||
onClick={deleteDisk}
|
||||
>
|
||||
{p.disk.deleteType ? (
|
||||
<Tooltip title="Cancel disk removal">
|
||||
<CheckCircleIcon />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Remove disk">
|
||||
<DeleteIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{p.canBackup && (
|
||||
<Tooltip title="Backup this disk">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
p.onRequestBackup(p.disk);
|
||||
}}
|
||||
>
|
||||
<Icon path={mdiHarddiskPlus} size={1} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* New disk size*/}
|
||||
{p.disk.resize && !p.disk.deleteType && (
|
||||
<DiskSizeInput
|
||||
editable
|
||||
label="New disk size (GB)"
|
||||
value={p.disk.size}
|
||||
onChange={(v) => {
|
||||
p.disk.size = v;
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -212,24 +266,32 @@ 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}
|
||||
/>
|
||||
{/* Resize disk image */}
|
||||
{!!p.disk.from_image && (
|
||||
<CheckboxInput
|
||||
editable
|
||||
checked={p.disk.resize}
|
||||
label="Resize disk file"
|
||||
onValueChange={(v) => {
|
||||
p.disk.resize = v;
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Disk size */}
|
||||
{(!p.disk.from_image || p.disk.resize === true) && (
|
||||
<DiskSizeInput
|
||||
editable
|
||||
value={p.disk.size}
|
||||
onChange={(v) => {
|
||||
p.disk.size = v;
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Disk image selection */}
|
||||
<DiskImageSelect
|
||||
label="Use disk image as template"
|
||||
list={p.diskImagesList}
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { NWFilter } from "../../api/NWFilterApi";
|
||||
@ -49,6 +50,12 @@ export function VMNetworksList(p: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{p.vm.networks.length === 0 && (
|
||||
<Typography style={{ textAlign: "center", paddingTop: "25px" }}>
|
||||
No network interface defined yet!
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{/* networks list */}
|
||||
{p.vm.networks.map((n, num) => (
|
||||
|
Reference in New Issue
Block a user