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
|
/// 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";
|
||||||
|
|
||||||
|
@ -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)]
|
||||||
|
@ -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,28 +148,40 @@ 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 {
|
||||||
|
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 {
|
// Resize disk file if requested
|
||||||
VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
|
if self.resize == Some(true) {
|
||||||
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
|
let disk = DiskFileInfo::load_file(&file)?;
|
||||||
virtual_size: self.size,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create / Restore disk file
|
// Can only increase disk size
|
||||||
match &self.from_image {
|
if let Err(e) = disk.resize(self.size) {
|
||||||
// Create disk file
|
log::error!("Failed to resize disk file {}: {e:?}", self.name);
|
||||||
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)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
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?.();
|
p.onChange?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!p.editable && p.vm.oem_strings.length === 0) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditSection
|
<EditSection
|
||||||
title="SMBIOS OEM Strings"
|
title="SMBIOS OEM Strings"
|
||||||
|
@ -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,42 +136,75 @@ function DiskInfo(p: {
|
|||||||
|
|
||||||
if (!p.editable || !p.disk.new)
|
if (!p.editable || !p.disk.new)
|
||||||
return (
|
return (
|
||||||
<VMDiskFileWidget
|
<>
|
||||||
{...p}
|
<VMDiskFileWidget
|
||||||
secondaryAction={
|
{...p}
|
||||||
<>
|
secondaryAction={
|
||||||
{p.editable && (
|
<>
|
||||||
<IconButton
|
{p.editable && !p.disk.deleteType && (
|
||||||
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
|
<IconButton
|
||||||
onClick={() => {
|
edge="end"
|
||||||
p.onRequestBackup(p.disk);
|
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>
|
</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 (
|
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:
|
onValueChange={(v) => {
|
||||||
ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000),
|
p.disk.resize = v;
|
||||||
}}
|
p.onChange?.();
|
||||||
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}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
{/* 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}
|
||||||
|
@ -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) => (
|
||||||
|
Reference in New Issue
Block a user