11 Commits

Author SHA1 Message Date
115728d9c9 Update Rust crate reqwest to 0.12.19
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-06-10 00:26:02 +00:00
0de15af10e Normalize disk size input
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-09 18:11:49 +02:00
d4bc92f562 Do not offer to expand disk if disk deletion is request by user
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-09 18:09:55 +02:00
a1439689dd Can resize existing disks
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-09 18:00:23 +02:00
63126c75fa Prevent shrinking attempts
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-09 17:48:17 +02:00
940302a825 Can resize disk image when adding a new disk image to a VM
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-09 17:42:36 +02:00
9c374f849b Shwo a message when some lists are empty
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-09 17:17:54 +02:00
2fa4d0e11b Can compress XZ files
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-09 17:05:47 +02:00
d7796e1459 Can decompress XZ files 2025-06-09 17:04:35 +02:00
759361d9f6 Add qcow2.xz file format support 2025-06-09 16:58:21 +02:00
b2529c250a Minor improvements
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-09 16:43:27 +02:00
11 changed files with 337 additions and 100 deletions

View File

@ -137,6 +137,9 @@ pub const PROGRAM_COPY: &str = "/bin/cp";
/// Gzip program path /// Gzip program path
pub const PROGRAM_GZIP: &str = "/usr/bin/gzip"; pub const PROGRAM_GZIP: &str = "/usr/bin/gzip";
/// XZ program path
pub const PROGRAM_XZ: &str = "/usr/bin/xz";
/// Bash program /// Bash program
pub const PROGRAM_BASH: &str = "/usr/bin/bash"; pub const PROGRAM_BASH: &str = "/usr/bin/bash";

View File

@ -28,8 +28,10 @@ pub enum DiskFileFormat {
#[serde(default)] #[serde(default)]
virtual_size: FileSize, virtual_size: FileSize,
}, },
CompressedRaw, GzCompressedRaw,
CompressedQCow2, GzCompressedQCow2,
XzCompressedRaw,
XzCompressedQCow2,
} }
impl DiskFileFormat { impl DiskFileFormat {
@ -37,8 +39,10 @@ impl DiskFileFormat {
match self { match self {
DiskFileFormat::Raw { .. } => &["raw", ""], DiskFileFormat::Raw { .. } => &["raw", ""],
DiskFileFormat::QCow2 { .. } => &["qcow2"], DiskFileFormat::QCow2 { .. } => &["qcow2"],
DiskFileFormat::CompressedRaw => &["raw.gz"], DiskFileFormat::GzCompressedRaw => &["raw.gz"],
DiskFileFormat::CompressedQCow2 => &["qcow2.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") => { "gz" if name.ends_with(".qcow2") => {
name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string(); 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}!"), _ => anyhow::bail!("Unsupported disk extension: {ext}!"),
}; };
@ -159,8 +168,8 @@ impl DiskFileInfo {
// Prepare the conversion // Prepare the conversion
let mut cmd = match (self.format, dest_format) { let mut cmd = match (self.format, dest_format) {
// Decompress QCow2 // Decompress QCow2 (GZIP)
(DiskFileFormat::CompressedQCow2, DiskFileFormat::QCow2 { .. }) => { (DiskFileFormat::GzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
let mut cmd = Command::new(constants::PROGRAM_GZIP); let mut cmd = Command::new(constants::PROGRAM_GZIP);
cmd.arg("--keep") cmd.arg("--keep")
.arg("--decompress") .arg("--decompress")
@ -170,8 +179,19 @@ impl DiskFileInfo {
cmd cmd
} }
// Compress QCow2 // Decompress QCow2 (XZ)
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::CompressedQCow2) => { (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); let mut cmd = Command::new(constants::PROGRAM_GZIP);
cmd.arg("--keep") cmd.arg("--keep")
.arg("--to-stdout") .arg("--to-stdout")
@ -180,6 +200,16 @@ impl DiskFileInfo {
cmd 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 // Convert QCow2 to Raw file
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => { (DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => {
let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
@ -244,8 +274,8 @@ impl DiskFileInfo {
cmd cmd
} }
// Compress Raw // Compress Raw (Gz)
(DiskFileFormat::Raw { .. }, DiskFileFormat::CompressedRaw) => { (DiskFileFormat::Raw { .. }, DiskFileFormat::GzCompressedRaw) => {
let mut cmd = Command::new(constants::PROGRAM_GZIP); let mut cmd = Command::new(constants::PROGRAM_GZIP);
cmd.arg("--keep") cmd.arg("--keep")
.arg("--to-stdout") .arg("--to-stdout")
@ -254,8 +284,18 @@ impl DiskFileInfo {
cmd cmd
} }
// Decompress Raw to not sparse file // Compress Raw (Xz)
(DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => { (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); let mut cmd = Command::new(constants::PROGRAM_GZIP);
cmd.arg("--keep") cmd.arg("--keep")
.arg("--decompress") .arg("--decompress")
@ -264,13 +304,23 @@ impl DiskFileInfo {
.stdout(File::create(&temp_file)?); .stdout(File::create(&temp_file)?);
cmd 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 // 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); let mut cmd = Command::new(constants::PROGRAM_BASH);
cmd.arg("-c").arg(format!( cmd.arg("-c").arg(format!(
"{} -d -c {} | {} conv=sparse of={}", "{} --decompress --to-stdout {} | {} conv=sparse of={}",
constants::PROGRAM_GZIP, constants::PROGRAM_GZIP,
self.file_path.display(), self.file_path.display(),
constants::PROGRAM_DD, constants::PROGRAM_DD,
@ -279,6 +329,20 @@ impl DiskFileInfo {
cmd 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 // Dumb copy of file
(a, b) if a == b => { (a, b) if a == b => {
let mut cmd = Command::new(constants::PROGRAM_COPY); let mut cmd = Command::new(constants::PROGRAM_COPY);
@ -330,6 +394,44 @@ impl DiskFileInfo {
Ok(()) 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)] #[derive(serde::Deserialize)]

View File

@ -44,6 +44,9 @@ pub struct VMFileDisk {
/// When creating a new disk, specify the disk image template to use /// When creating a new disk, specify the disk image template to use
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub from_image: Option<String>, 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 /// Set this variable to true to delete the disk
pub delete: bool, pub delete: bool,
} }
@ -78,6 +81,7 @@ impl VMFileDisk {
delete: false, delete: false,
from_image: None, from_image: None,
resize: None,
}) })
} }
@ -144,9 +148,10 @@ impl VMFileDisk {
if file.exists() { if file.exists() {
log::debug!("File {file:?} does not exists, so it was not touched"); 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 { let format = 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 {
@ -168,6 +173,17 @@ impl VMFileDisk {
src_file.convert(&file, format)?; src_file.convert(&file, format)?;
} }
} }
}
// Resize disk file if requested
if self.resize == Some(true) {
let disk = DiskFileInfo::load_file(&file)?;
// Can only increase disk size
if let Err(e) = disk.resize(self.size) {
log::error!("Failed to resize disk file {}: {e:?}", self.name);
}
}
Ok(()) Ok(())
} }

View File

@ -4,8 +4,10 @@ import { VMFileDisk, VMInfo } from "./VMApi";
export type DiskImageFormat = export type DiskImageFormat =
| { format: "Raw"; is_sparse: boolean } | { format: "Raw"; is_sparse: boolean }
| { format: "QCow2"; virtual_size?: number } | { format: "QCow2"; virtual_size?: number }
| { format: "CompressedQCow2" } | { format: "GzCompressedQCow2" }
| { format: "CompressedRaw" }; | { format: "GzCompressedRaw" }
| { format: "XzCompressedQCow2" }
| { format: "XzCompressedRaw" };
export type DiskImage = { export type DiskImage = {
file_size: number; file_size: number;

View File

@ -31,8 +31,12 @@ export interface BaseFileVMDisk {
// For new disk only // For new disk only
from_image?: string; from_image?: string;
// Resize disk image after clone
resize?: boolean;
// application attributes // application attributes
new?: boolean; new?: boolean;
originalSize?: number;
deleteType?: "keepfile" | "deletefile"; deleteType?: "keepfile" | "deletefile";
} }

View File

@ -42,13 +42,15 @@ export function ConvertDiskImageDialog(
setFormat({ format: value ?? ("QCow2" as any) }); setFormat({ format: value ?? ("QCow2" as any) });
if (value === "QCow2") setFilename(`${origFilename}.qcow2`); 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") { if (value === "Raw") {
setFilename(`${origFilename}.raw`); setFilename(`${origFilename}.raw`);
// Check sparse checkbox by default // Check sparse checkbox by default
setFormat({ format: "Raw", is_sparse: true }); 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 () => { const handleSubmit = async () => {
@ -104,8 +106,10 @@ export function ConvertDiskImageDialog(
options={[ options={[
{ value: "QCow2" }, { value: "QCow2" },
{ value: "Raw" }, { value: "Raw" },
{ value: "CompressedRaw" }, { value: "GzCompressedRaw" },
{ value: "CompressedQCow2" }, { value: "XzCompressedRaw" },
{ value: "GzCompressedQCow2" },
{ value: "XzCompressedQCow2" },
]} ]}
/> />

View File

@ -1,5 +1,5 @@
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import { import {
Alert, Alert,
CircularProgress, CircularProgress,
@ -36,7 +36,9 @@ export function LoginRoute(): React.ReactElement {
const canSubmit = username.length > 0 && password.length > 0; const canSubmit = username.length > 0 && password.length > 0;
const [showPassword, setShowPassword] = React.useState(false); const [showPassword, setShowPassword] = React.useState(false);
const handleClickShowPassword = () => { setShowPassword((show) => !show); }; const handleClickShowPassword = () => {
setShowPassword((show) => !show);
};
const handleMouseDownPassword = ( const handleMouseDownPassword = (
event: React.MouseEvent<HTMLButtonElement> event: React.MouseEvent<HTMLButtonElement>
@ -105,12 +107,14 @@ export function LoginRoute(): React.ReactElement {
label="Username" label="Username"
name="username" name="username"
value={username} value={username}
onChange={(e) => { setUsername(e.target.value); }} onChange={(e) => {
setUsername(e.target.value);
}}
autoComplete="username" autoComplete="username"
autoFocus autoFocus
/> />
<FormControl fullWidth variant="outlined"> <FormControl required fullWidth variant="outlined">
<InputLabel htmlFor="password">Password</InputLabel> <InputLabel htmlFor="password">Password</InputLabel>
<OutlinedInput <OutlinedInput
required required
@ -120,7 +124,9 @@ export function LoginRoute(): React.ReactElement {
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
id="password" id="password"
value={password} value={password}
onChange={(e) => { setPassword(e.target.value); }} onChange={(e) => {
setPassword(e.target.value);
}}
autoComplete="current-password" autoComplete="current-password"
endAdornment={ endAdornment={
<InputAdornment position="end"> <InputAdornment position="end">
@ -131,7 +137,11 @@ export function LoginRoute(): React.ReactElement {
onMouseDown={handleMouseDownPassword} onMouseDown={handleMouseDownPassword}
edge="end" edge="end"
> >
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />} {showPassword ? (
<VisibilityOffIcon />
) : (
<VisibilityIcon />
)}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</InputAdornment> </InputAdornment>

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

View File

@ -25,6 +25,8 @@ export function OEMStringFormWidget(p: {
p.onChange?.(); p.onChange?.();
}; };
if (!p.editable && p.vm.oem_strings.length === 0) return <></>;
return ( return (
<EditSection <EditSection
title="SMBIOS OEM Strings" title="SMBIOS OEM Strings"

View File

@ -2,7 +2,8 @@ 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 { 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 React from "react";
import { DiskImage } from "../../api/DiskImageApi"; import { DiskImage } from "../../api/DiskImageApi";
import { ServerApi } from "../../api/ServerApi"; import { ServerApi } from "../../api/ServerApi";
@ -13,6 +14,7 @@ import { VMDiskFileWidget } from "../vms/VMDiskFileWidget";
import { CheckboxInput } from "./CheckboxInput"; import { CheckboxInput } from "./CheckboxInput";
import { DiskBusSelect } from "./DiskBusSelect"; import { DiskBusSelect } from "./DiskBusSelect";
import { DiskImageSelect } from "./DiskImageSelect"; import { DiskImageSelect } from "./DiskImageSelect";
import { DiskSizeInput } from "./DiskSizeInput";
import { SelectInput } from "./SelectInput"; import { SelectInput } from "./SelectInput";
import { TextInput } from "./TextInput"; 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>} {p.editable && <Button onClick={addNewDisk}>Add new disk</Button>}
{/* Disk backup */} {/* Disk backup */}
@ -93,6 +101,19 @@ function DiskInfo(p: {
diskImagesList: DiskImage[]; diskImagesList: DiskImage[];
}): React.ReactElement { }): React.ReactElement {
const confirm = useConfirm(); 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 () => { const deleteDisk = async () => {
if (p.disk.deleteType) { if (p.disk.deleteType) {
p.disk.deleteType = undefined; p.disk.deleteType = undefined;
@ -115,10 +136,29 @@ function DiskInfo(p: {
if (!p.editable || !p.disk.new) if (!p.editable || !p.disk.new)
return ( return (
<>
<VMDiskFileWidget <VMDiskFileWidget
{...p} {...p}
secondaryAction={ secondaryAction={
<> <>
{p.editable && !p.disk.deleteType && (
<IconButton
edge="end"
aria-label="expand disk"
onClick={expandDisk}
>
{p.disk.resize === true ? (
<Tooltip title="Cancel disk expansion">
<ExpandIcon color="error" />
</Tooltip>
) : (
<Tooltip title="Increase disk size">
<ExpandIcon />
</Tooltip>
)}
</IconButton>
)}
{p.editable && ( {p.editable && (
<IconButton <IconButton
edge="end" edge="end"
@ -151,6 +191,20 @@ function DiskInfo(p: {
</> </>
} }
/> />
{/* 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 ( return (
@ -212,24 +266,32 @@ function DiskInfo(p: {
/> />
)} )}
<TextInput {/* Resize disk image */}
editable={true} {!!p.disk.from_image && (
label="Disk size (GB)" <CheckboxInput
size={{ editable
min: checked={p.disk.resize}
ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000), label="Resize disk file"
max:
ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000),
}}
value={(p.disk.size / (1000 * 1000 * 1000)).toString()}
onValueChange={(v) => { onValueChange={(v) => {
p.disk.size = Number(v ?? "0") * 1000 * 1000 * 1000; p.disk.resize = v;
p.onChange?.(); p.onChange?.();
}} }}
type="number"
disabled={!!p.disk.from_image}
/> />
)}
{/* 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 <DiskImageSelect
label="Use disk image as template" label="Use disk image as template"
list={p.diskImagesList} list={p.diskImagesList}

View File

@ -10,6 +10,7 @@ import {
ListItemAvatar, ListItemAvatar,
ListItemText, ListItemText,
Tooltip, Tooltip,
Typography,
} from "@mui/material"; } from "@mui/material";
import Grid from "@mui/material/Grid"; import Grid from "@mui/material/Grid";
import { NWFilter } from "../../api/NWFilterApi"; import { NWFilter } from "../../api/NWFilterApi";
@ -49,6 +50,12 @@ export function VMNetworksList(p: {
</div> </div>
)} )}
{p.vm.networks.length === 0 && (
<Typography style={{ textAlign: "center", paddingTop: "25px" }}>
No network interface defined yet!
</Typography>
)}
<Grid container spacing={2}> <Grid container spacing={2}>
{/* networks list */} {/* networks list */}
{p.vm.networks.map((n, num) => ( {p.vm.networks.map((n, num) => (