Compare commits
5 Commits
a18310e04a
...
794d16bdaa
Author | SHA1 | Date | |
---|---|---|---|
794d16bdaa | |||
a3ac56f849 | |||
6130f37336 | |||
6b6fef5ccc | |||
83df7e1b20 |
@ -1,6 +1,8 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::constants;
|
||||
use crate::controllers::HttpResult;
|
||||
use crate::controllers::{HttpResult, LibVirtReq};
|
||||
use crate::libvirt_lib_structures::XMLUuid;
|
||||
use crate::libvirt_rest_structures::vm::VMInfo;
|
||||
use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo};
|
||||
use crate::utils::files_utils;
|
||||
use actix_files::NamedFile;
|
||||
@ -107,6 +109,61 @@ pub async fn convert(
|
||||
return Ok(HttpResponse::BadRequest().json("Invalid src file name!"));
|
||||
}
|
||||
|
||||
let src_file_path = AppConfig::get()
|
||||
.disk_images_storage_path()
|
||||
.join(&p.filename);
|
||||
|
||||
let src = DiskFileInfo::load_file(&src_file_path)?;
|
||||
|
||||
handle_convert_request(src, &req).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BackupVMDiskPath {
|
||||
uid: XMLUuid,
|
||||
diskid: String,
|
||||
}
|
||||
|
||||
/// Perform disk backup
|
||||
pub async fn backup_disk(
|
||||
client: LibVirtReq,
|
||||
path: web::Path<BackupVMDiskPath>,
|
||||
req: web::Json<ConvertDiskImageRequest>,
|
||||
) -> HttpResult {
|
||||
// Get the VM information
|
||||
let info = match client.get_single_domain(path.uid).await {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
log::error!("Failed to get domain info! {e}");
|
||||
return Ok(HttpResponse::InternalServerError().json(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let vm = VMInfo::from_domain(info)?;
|
||||
|
||||
// Load disk information
|
||||
let Some(disk) = vm
|
||||
.file_disks
|
||||
.into_iter()
|
||||
.find(|disk| disk.name == path.diskid)
|
||||
else {
|
||||
return Ok(HttpResponse::NotFound()
|
||||
.json(format!("Disk {} not found for vm {}", path.diskid, vm.name)));
|
||||
};
|
||||
|
||||
let src_path = disk.disk_path(vm.uuid.expect("Missing VM uuid!"));
|
||||
let src_disk = DiskFileInfo::load_file(&src_path)?;
|
||||
|
||||
// Perform conversion
|
||||
handle_convert_request(src_disk, &req).await
|
||||
}
|
||||
|
||||
/// Generic controller code that performs image conversion to create a disk image file
|
||||
pub async fn handle_convert_request(
|
||||
src: DiskFileInfo,
|
||||
req: &ConvertDiskImageRequest,
|
||||
) -> HttpResult {
|
||||
// Check destination file
|
||||
if !files_utils::check_file_name(&req.dest_file_name) {
|
||||
return Ok(HttpResponse::BadRequest().json("Invalid destination file name!"));
|
||||
}
|
||||
@ -119,12 +176,6 @@ pub async fn convert(
|
||||
return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!"));
|
||||
}
|
||||
|
||||
let src_file_path = AppConfig::get()
|
||||
.disk_images_storage_path()
|
||||
.join(&p.filename);
|
||||
|
||||
let src = DiskFileInfo::load_file(&src_file_path)?;
|
||||
|
||||
let dst_file_path = AppConfig::get()
|
||||
.disk_images_storage_path()
|
||||
.join(&req.dest_file_name);
|
||||
@ -141,7 +192,7 @@ pub async fn convert(
|
||||
);
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().json(src))
|
||||
Ok(HttpResponse::Accepted().json("Successfully converted disk file"))
|
||||
}
|
||||
|
||||
/// Delete a disk image
|
||||
|
@ -356,6 +356,10 @@ async fn main() -> std::io::Result<()> {
|
||||
"/api/disk_images/{filename}",
|
||||
web::delete().to(disk_images_controller::delete),
|
||||
)
|
||||
.route(
|
||||
"/api/vm/{uid}/disk/{diskid}/backup",
|
||||
web::post().to(disk_images_controller::backup_disk),
|
||||
)
|
||||
// API tokens controller
|
||||
.route(
|
||||
"/api/token/create",
|
||||
|
@ -55,7 +55,7 @@ impl FileSize {
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self((value as f64).mul(fact) as usize))
|
||||
Ok(Self((value as f64).mul(fact).ceil() as usize))
|
||||
}
|
||||
|
||||
/// Get file size as bytes
|
||||
|
@ -13,20 +13,13 @@ enum VMDisksError {
|
||||
Config(&'static str),
|
||||
}
|
||||
|
||||
/// Type of disk allocation
|
||||
#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq)]
|
||||
pub enum VMDiskAllocType {
|
||||
Fixed,
|
||||
Sparse,
|
||||
}
|
||||
|
||||
/// Disk allocation type
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "format")]
|
||||
pub enum VMDiskFormat {
|
||||
Raw {
|
||||
/// Type of disk allocation
|
||||
alloc_type: VMDiskAllocType,
|
||||
/// Is raw file a sparse file?
|
||||
is_sparse: bool,
|
||||
},
|
||||
QCow2,
|
||||
}
|
||||
@ -61,12 +54,7 @@ impl VMFileDisk {
|
||||
},
|
||||
|
||||
format: match info.format {
|
||||
DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw {
|
||||
alloc_type: match is_sparse {
|
||||
true => VMDiskAllocType::Sparse,
|
||||
false => VMDiskAllocType::Fixed,
|
||||
},
|
||||
},
|
||||
DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw { is_sparse },
|
||||
DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2,
|
||||
_ => anyhow::bail!("Unsupported image format: {:?}", info.format),
|
||||
},
|
||||
@ -93,7 +81,7 @@ impl VMFileDisk {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get disk path
|
||||
/// Get disk path on file system
|
||||
pub fn disk_path(&self, id: XMLUuid) -> PathBuf {
|
||||
let domain_dir = AppConfig::get().vm_storage_path(id);
|
||||
let file_name = match self.format {
|
||||
@ -131,9 +119,7 @@ impl VMFileDisk {
|
||||
DiskFileInfo::create(
|
||||
&file,
|
||||
match self.format {
|
||||
VMDiskFormat::Raw { alloc_type } => DiskFileFormat::Raw {
|
||||
is_sparse: alloc_type == VMDiskAllocType::Sparse,
|
||||
},
|
||||
VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
|
||||
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
|
||||
virtual_size: self.size,
|
||||
},
|
||||
|
@ -103,6 +103,7 @@ export class APIClient {
|
||||
body: body,
|
||||
headers: headers,
|
||||
credentials: "include",
|
||||
signal: AbortSignal.timeout(50 * 1000 * 1000),
|
||||
});
|
||||
|
||||
// Process response
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { APIClient } from "./ApiClient";
|
||||
import { VMFileDisk, VMInfo } from "./VMApi";
|
||||
|
||||
export type DiskImageFormat =
|
||||
| { format: "Raw"; is_sparse: boolean }
|
||||
@ -77,6 +78,22 @@ export class DiskImageApi {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup VM disk into image disks library
|
||||
*/
|
||||
static async BackupVMDisk(
|
||||
vm: VMInfo,
|
||||
disk: VMFileDisk,
|
||||
dest_file_name: string,
|
||||
format: DiskImageFormat
|
||||
): Promise<void> {
|
||||
await APIClient.exec({
|
||||
uri: `/vm/${vm.uuid}/disk/${disk.name}/backup`,
|
||||
method: "POST",
|
||||
jsonData: { ...format, dest_file_name },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete disk image file
|
||||
*/
|
||||
|
@ -24,16 +24,14 @@ export interface BaseFileVMDisk {
|
||||
name: string;
|
||||
delete: boolean;
|
||||
|
||||
// application attribute
|
||||
// application attributes
|
||||
new?: boolean;
|
||||
deleteType?: "keepfile" | "deletefile";
|
||||
}
|
||||
|
||||
export type DiskAllocType = "Sparse" | "Fixed";
|
||||
|
||||
interface RawVMDisk {
|
||||
format: "Raw";
|
||||
alloc_type: DiskAllocType;
|
||||
is_sparse: boolean;
|
||||
}
|
||||
|
||||
interface QCow2Disk {
|
||||
|
@ -9,56 +9,69 @@ import {
|
||||
import React from "react";
|
||||
import { DiskImage, DiskImageApi, DiskImageFormat } from "../api/DiskImageApi";
|
||||
import { ServerApi } from "../api/ServerApi";
|
||||
import { VMFileDisk, VMInfo } from "../api/VMApi";
|
||||
import { useAlert } from "../hooks/providers/AlertDialogProvider";
|
||||
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
|
||||
import { FileDiskImageWidget } from "../widgets/FileDiskImageWidget";
|
||||
import { CheckboxInput } from "../widgets/forms/CheckboxInput";
|
||||
import { SelectInput } from "../widgets/forms/SelectInput";
|
||||
import { TextInput } from "../widgets/forms/TextInput";
|
||||
import { VMDiskFileWidget } from "../widgets/vms/VMDiskFileWidget";
|
||||
|
||||
export function ConvertDiskImageDialog(p: {
|
||||
image: DiskImage;
|
||||
onCancel: () => void;
|
||||
onFinished: () => void;
|
||||
}): React.ReactElement {
|
||||
export function ConvertDiskImageDialog(
|
||||
p: {
|
||||
onCancel: () => void;
|
||||
onFinished: () => void;
|
||||
} & (
|
||||
| { backup?: false; image: DiskImage }
|
||||
| { backup: true; disk: VMFileDisk; vm: VMInfo }
|
||||
)
|
||||
): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const snackbar = useSnackbar();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
|
||||
const [format, setFormat] = React.useState<DiskImageFormat>({
|
||||
format: "QCow2",
|
||||
});
|
||||
|
||||
const [filename, setFilename] = React.useState(p.image.file_name + ".qcow2");
|
||||
const origFilename = p.backup ? p.disk.name : p.image.file_name;
|
||||
|
||||
const [filename, setFilename] = React.useState(origFilename + ".qcow2");
|
||||
|
||||
const handleFormatChange = (value?: string) => {
|
||||
setFormat({ format: value ?? ("QCow2" as any) });
|
||||
|
||||
if (value === "QCow2") setFilename(`${p.image.file_name}.qcow2`);
|
||||
if (value === "CompressedQCow2")
|
||||
setFilename(`${p.image.file_name}.qcow2.gz`);
|
||||
if (value === "QCow2") setFilename(`${origFilename}.qcow2`);
|
||||
if (value === "CompressedQCow2") setFilename(`${origFilename}.qcow2.gz`);
|
||||
if (value === "Raw") {
|
||||
setFilename(`${p.image.file_name}.raw`);
|
||||
setFilename(`${origFilename}.raw`);
|
||||
// Check sparse checkbox by default
|
||||
setFormat({ format: "Raw", is_sparse: true });
|
||||
}
|
||||
if (value === "CompressedRaw") setFilename(`${p.image.file_name}.raw.gz`);
|
||||
if (value === "CompressedRaw") setFilename(`${origFilename}.raw.gz`);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
loadingMessage.show("Converting image...");
|
||||
loadingMessage.show(
|
||||
p.backup ? "Performing backup..." : "Converting image..."
|
||||
);
|
||||
|
||||
// Perform the conversion
|
||||
await DiskImageApi.Convert(p.image, filename, format);
|
||||
// Perform the conversion / backup operation
|
||||
if (p.backup)
|
||||
await DiskImageApi.BackupVMDisk(p.vm, p.disk, filename, format);
|
||||
else await DiskImageApi.Convert(p.image, filename, format);
|
||||
|
||||
p.onFinished();
|
||||
|
||||
snackbar("Conversion successful!");
|
||||
alert(p.backup ? "Backup successful!" : "Conversion successful!");
|
||||
} catch (e) {
|
||||
console.error("Failed to convert image!", e);
|
||||
alert(`Failed to convert image! ${e}`);
|
||||
console.error("Failed to perform backup/conversion!", e);
|
||||
alert(
|
||||
p.backup
|
||||
? `Failed to perform backup! ${e}`
|
||||
: `Failed to convert image! ${e}`
|
||||
);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
@ -66,13 +79,21 @@ export function ConvertDiskImageDialog(p: {
|
||||
|
||||
return (
|
||||
<Dialog open onClose={p.onCancel}>
|
||||
<DialogTitle>Convert disk image</DialogTitle>
|
||||
<DialogTitle>
|
||||
{p.backup ? `Backup disk ${p.disk.name}` : "Convert disk image"}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Select the destination format for this image:
|
||||
</DialogContentText>
|
||||
<FileDiskImageWidget image={p.image} />
|
||||
|
||||
{/* Show details of of the image */}
|
||||
{p.backup ? (
|
||||
<VMDiskFileWidget {...p} />
|
||||
) : (
|
||||
<FileDiskImageWidget {...p} />
|
||||
)}
|
||||
|
||||
{/* New image format */}
|
||||
<SelectInput
|
||||
@ -109,13 +130,13 @@ export function ConvertDiskImageDialog(p: {
|
||||
setFilename(s ?? "");
|
||||
}}
|
||||
size={ServerApi.Config.constraints.disk_image_name_size}
|
||||
helperText="The image name shall contain the proper file extension"
|
||||
helperText="The image name shall contain the proper file extension for the selected target format"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={p.onCancel}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} autoFocus>
|
||||
Convert image
|
||||
{p.backup ? "Perform backup" : "Convert image"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
@ -59,6 +59,7 @@ function VMRouteBody(p: { vm: VMInfo }): React.ReactElement {
|
||||
<VMDetails
|
||||
vm={p.vm}
|
||||
editable={false}
|
||||
state={state}
|
||||
screenshot={p.vm.vnc_access && state === "Running"}
|
||||
/>
|
||||
</VirtWebRouteContainer>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { mdiHarddisk } from "@mdi/js";
|
||||
import { mdiHarddisk, mdiHarddiskPlus } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
@ -13,17 +13,25 @@ import {
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import { filesize } from "filesize";
|
||||
import React from "react";
|
||||
import { ServerApi } from "../../api/ServerApi";
|
||||
import { VMFileDisk, VMInfo } from "../../api/VMApi";
|
||||
import { VMFileDisk, VMInfo, VMState } from "../../api/VMApi";
|
||||
import { ConvertDiskImageDialog } from "../../dialogs/ConvertDiskImageDialog";
|
||||
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
|
||||
import { CheckboxInput } from "./CheckboxInput";
|
||||
import { SelectInput } from "./SelectInput";
|
||||
import { TextInput } from "./TextInput";
|
||||
|
||||
export function VMDisksList(p: {
|
||||
vm: VMInfo;
|
||||
state?: VMState;
|
||||
onChange?: () => void;
|
||||
editable: boolean;
|
||||
}): React.ReactElement {
|
||||
const [currBackupRequest, setCurrBackupRequest] = React.useState<
|
||||
VMFileDisk | undefined
|
||||
>();
|
||||
|
||||
const addNewDisk = () => {
|
||||
p.vm.file_disks.push({
|
||||
format: "QCow2",
|
||||
@ -35,6 +43,14 @@ export function VMDisksList(p: {
|
||||
p.onChange?.();
|
||||
};
|
||||
|
||||
const handleBackupRequest = (disk: VMFileDisk) => {
|
||||
setCurrBackupRequest(disk);
|
||||
};
|
||||
|
||||
const handleFinishBackup = () => {
|
||||
setCurrBackupRequest(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* disks list */}
|
||||
@ -43,25 +59,40 @@ export function VMDisksList(p: {
|
||||
// eslint-disable-next-line react-x/no-array-index-key
|
||||
key={num}
|
||||
editable={p.editable}
|
||||
canBackup={!p.editable && !d.new && p.state !== "Running"}
|
||||
disk={d}
|
||||
onChange={p.onChange}
|
||||
removeFromList={() => {
|
||||
p.vm.file_disks.splice(num, 1);
|
||||
p.onChange?.();
|
||||
}}
|
||||
onRequestBackup={handleBackupRequest}
|
||||
/>
|
||||
))}
|
||||
|
||||
{p.editable && <Button onClick={addNewDisk}>Add new disk</Button>}
|
||||
|
||||
{/* Disk backup */}
|
||||
{currBackupRequest && (
|
||||
<ConvertDiskImageDialog
|
||||
backup
|
||||
onCancel={handleFinishBackup}
|
||||
onFinished={handleFinishBackup}
|
||||
vm={p.vm}
|
||||
disk={currBackupRequest}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DiskInfo(p: {
|
||||
editable: boolean;
|
||||
canBackup: boolean;
|
||||
disk: VMFileDisk;
|
||||
onChange?: () => void;
|
||||
removeFromList: () => void;
|
||||
onRequestBackup: (disk: VMFileDisk) => void;
|
||||
}): React.ReactElement {
|
||||
const confirm = useConfirm();
|
||||
const deleteDisk = async () => {
|
||||
@ -88,23 +119,33 @@ function DiskInfo(p: {
|
||||
return (
|
||||
<ListItem
|
||||
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.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>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
@ -126,7 +167,9 @@ function DiskInfo(p: {
|
||||
</>
|
||||
}
|
||||
secondary={`${filesize(p.disk.size)} - ${p.disk.format}${
|
||||
p.disk.format == "Raw" ? " - " + p.disk.alloc_type : ""
|
||||
p.disk.format == "Raw"
|
||||
? " - " + (p.disk.is_sparse ? "Sparse" : "Fixed")
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</ListItem>
|
||||
@ -178,21 +221,20 @@ function DiskInfo(p: {
|
||||
value={p.disk.format}
|
||||
onValueChange={(v) => {
|
||||
p.disk.format = v as any;
|
||||
|
||||
if (p.disk.format === "Raw") p.disk.is_sparse = true;
|
||||
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
|
||||
{p.disk.format === "Raw" && (
|
||||
<SelectInput
|
||||
editable={true}
|
||||
label="File allocation type"
|
||||
options={[
|
||||
{ label: "Sparse allocation", value: "Sparse" },
|
||||
{ label: "Fixed allocation", value: "Fixed" },
|
||||
]}
|
||||
value={p.disk.alloc_type}
|
||||
<CheckboxInput
|
||||
editable
|
||||
label="Sparse file"
|
||||
checked={p.disk.is_sparse}
|
||||
onValueChange={(v) => {
|
||||
if (p.disk.format === "Raw") p.disk.alloc_type = v as any;
|
||||
if (p.disk.format === "Raw") p.disk.is_sparse = v;
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
|
@ -10,7 +10,7 @@ import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi";
|
||||
import { NWFilter, NWFilterApi } from "../../api/NWFilterApi";
|
||||
import { NetworkApi, NetworkInfo } from "../../api/NetworksApi";
|
||||
import { ServerApi } from "../../api/ServerApi";
|
||||
import { VMApi, VMInfo } from "../../api/VMApi";
|
||||
import { VMApi, VMInfo, VMState } from "../../api/VMApi";
|
||||
import { useAlert } from "../../hooks/providers/AlertDialogProvider";
|
||||
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
|
||||
import { useSnackbar } from "../../hooks/providers/SnackbarProvider";
|
||||
@ -33,6 +33,7 @@ interface DetailsProps {
|
||||
editable: boolean;
|
||||
onChange?: () => void;
|
||||
screenshot?: boolean;
|
||||
state?: VMState | undefined;
|
||||
}
|
||||
|
||||
export function VMDetails(p: DetailsProps): React.ReactElement {
|
||||
|
21
virtweb_frontend/src/widgets/vms/VMDiskFileWidget.tsx
Normal file
21
virtweb_frontend/src/widgets/vms/VMDiskFileWidget.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { mdiHarddisk } from "@mdi/js";
|
||||
import { Icon } from "@mdi/react";
|
||||
import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material";
|
||||
import { filesize } from "filesize";
|
||||
import { VMFileDisk } from "../../api/VMApi";
|
||||
|
||||
export function VMDiskFileWidget(p: { disk: VMFileDisk }): React.ReactElement {
|
||||
return (
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<Icon path={mdiHarddisk} />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={p.disk.name}
|
||||
secondary={`${p.disk.format} - ${filesize(p.disk.size)}`}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user