Prepare UI for disks backups
This commit is contained in:
@@ -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
|
/// Get file size as bytes
|
||||||
|
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { APIClient } from "./ApiClient";
|
import { APIClient } from "./ApiClient";
|
||||||
|
import { DiskImageFormat } from "./DiskImageApi";
|
||||||
|
|
||||||
export type VMState =
|
export type VMState =
|
||||||
| "NoState"
|
| "NoState"
|
||||||
@@ -24,7 +25,7 @@ export interface BaseFileVMDisk {
|
|||||||
name: string;
|
name: string;
|
||||||
delete: boolean;
|
delete: boolean;
|
||||||
|
|
||||||
// application attribute
|
// application attributes
|
||||||
new?: boolean;
|
new?: boolean;
|
||||||
deleteType?: "keepfile" | "deletefile";
|
deleteType?: "keepfile" | "deletefile";
|
||||||
}
|
}
|
||||||
@@ -384,4 +385,20 @@ export class VMApi {
|
|||||||
encodeURIComponent(token)
|
encodeURIComponent(token)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup VM disk
|
||||||
|
*/
|
||||||
|
static async BackupDisk(
|
||||||
|
vm: VMInfo,
|
||||||
|
disk: VMFileDisk,
|
||||||
|
file_name: string,
|
||||||
|
format: DiskImageFormat
|
||||||
|
): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
uri: `/vm/${vm.uuid}/disk/${disk.name}/backup`,
|
||||||
|
method: "POST",
|
||||||
|
jsonData: { ...format, file_name },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -9,6 +9,7 @@ import {
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { DiskImage, DiskImageApi, DiskImageFormat } from "../api/DiskImageApi";
|
import { DiskImage, DiskImageApi, DiskImageFormat } from "../api/DiskImageApi";
|
||||||
import { ServerApi } from "../api/ServerApi";
|
import { ServerApi } from "../api/ServerApi";
|
||||||
|
import { VMApi, VMFileDisk, VMInfo } from "../api/VMApi";
|
||||||
import { useAlert } from "../hooks/providers/AlertDialogProvider";
|
import { useAlert } from "../hooks/providers/AlertDialogProvider";
|
||||||
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
|
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
|
||||||
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
|
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
|
||||||
@@ -16,12 +17,17 @@ import { FileDiskImageWidget } from "../widgets/FileDiskImageWidget";
|
|||||||
import { CheckboxInput } from "../widgets/forms/CheckboxInput";
|
import { CheckboxInput } from "../widgets/forms/CheckboxInput";
|
||||||
import { SelectInput } from "../widgets/forms/SelectInput";
|
import { SelectInput } from "../widgets/forms/SelectInput";
|
||||||
import { TextInput } from "../widgets/forms/TextInput";
|
import { TextInput } from "../widgets/forms/TextInput";
|
||||||
|
import { VMDiskFileWidget } from "../widgets/vms/VMDiskFileWidget";
|
||||||
|
|
||||||
export function ConvertDiskImageDialog(p: {
|
export function ConvertDiskImageDialog(
|
||||||
image: DiskImage;
|
p: {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onFinished: () => void;
|
onFinished: () => void;
|
||||||
}): React.ReactElement {
|
} & (
|
||||||
|
| { backup?: false; image: DiskImage }
|
||||||
|
| { backup: true; disk: VMFileDisk; vm: VMInfo }
|
||||||
|
)
|
||||||
|
): React.ReactElement {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const snackbar = useSnackbar();
|
const snackbar = useSnackbar();
|
||||||
const loadingMessage = useLoadingMessage();
|
const loadingMessage = useLoadingMessage();
|
||||||
@@ -30,35 +36,43 @@ export function ConvertDiskImageDialog(p: {
|
|||||||
format: "QCow2",
|
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) => {
|
const handleFormatChange = (value?: string) => {
|
||||||
setFormat({ format: value ?? ("QCow2" as any) });
|
setFormat({ format: value ?? ("QCow2" as any) });
|
||||||
|
|
||||||
if (value === "QCow2") setFilename(`${p.image.file_name}.qcow2`);
|
if (value === "QCow2") setFilename(`${origFilename}.qcow2`);
|
||||||
if (value === "CompressedQCow2")
|
if (value === "CompressedQCow2") setFilename(`${origFilename}.qcow2.gz`);
|
||||||
setFilename(`${p.image.file_name}.qcow2.gz`);
|
|
||||||
if (value === "Raw") {
|
if (value === "Raw") {
|
||||||
setFilename(`${p.image.file_name}.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(`${p.image.file_name}.raw.gz`);
|
if (value === "CompressedRaw") setFilename(`${origFilename}.raw.gz`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
loadingMessage.show("Converting image...");
|
loadingMessage.show(
|
||||||
|
p.backup ? "Performing backup..." : "Converting image..."
|
||||||
|
);
|
||||||
|
|
||||||
// Perform the conversion
|
// Perform the conversion / backup operation
|
||||||
await DiskImageApi.Convert(p.image, filename, format);
|
if (p.backup) await VMApi.BackupDisk(p.vm, p.disk, filename, format);
|
||||||
|
else await DiskImageApi.Convert(p.image, filename, format);
|
||||||
|
|
||||||
p.onFinished();
|
p.onFinished();
|
||||||
|
|
||||||
snackbar("Conversion successful!");
|
snackbar(p.backup ? "Backup successful!" : "Conversion successful!");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to convert image!", e);
|
console.error("Failed to perform backup/conversion!", e);
|
||||||
alert(`Failed to convert image! ${e}`);
|
alert(
|
||||||
|
p.backup
|
||||||
|
? `Failed to perform backup! ${e}`
|
||||||
|
: `Failed to convert image! ${e}`
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
loadingMessage.hide();
|
loadingMessage.hide();
|
||||||
}
|
}
|
||||||
@@ -66,13 +80,21 @@ export function ConvertDiskImageDialog(p: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onClose={p.onCancel}>
|
<Dialog open onClose={p.onCancel}>
|
||||||
<DialogTitle>Convert disk image</DialogTitle>
|
<DialogTitle>
|
||||||
|
{p.backup ? `Backup disk ${p.disk.name}` : "Convert disk image"}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>
|
<DialogContentText>
|
||||||
Select the destination format for this image:
|
Select the destination format for this image:
|
||||||
</DialogContentText>
|
</DialogContentText>
|
||||||
<FileDiskImageWidget image={p.image} />
|
|
||||||
|
{/* Show details of of the image */}
|
||||||
|
{p.backup ? (
|
||||||
|
<VMDiskFileWidget {...p} />
|
||||||
|
) : (
|
||||||
|
<FileDiskImageWidget {...p} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* New image format */}
|
{/* New image format */}
|
||||||
<SelectInput
|
<SelectInput
|
||||||
@@ -109,13 +131,13 @@ export function ConvertDiskImageDialog(p: {
|
|||||||
setFilename(s ?? "");
|
setFilename(s ?? "");
|
||||||
}}
|
}}
|
||||||
size={ServerApi.Config.constraints.disk_image_name_size}
|
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>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={p.onCancel}>Cancel</Button>
|
<Button onClick={p.onCancel}>Cancel</Button>
|
||||||
<Button onClick={handleSubmit} autoFocus>
|
<Button onClick={handleSubmit} autoFocus>
|
||||||
Convert image
|
{p.backup ? "Perform backup" : "Convert image"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
@@ -59,6 +59,7 @@ function VMRouteBody(p: { vm: VMInfo }): React.ReactElement {
|
|||||||
<VMDetails
|
<VMDetails
|
||||||
vm={p.vm}
|
vm={p.vm}
|
||||||
editable={false}
|
editable={false}
|
||||||
|
state={state}
|
||||||
screenshot={p.vm.vnc_access && state === "Running"}
|
screenshot={p.vm.vnc_access && state === "Running"}
|
||||||
/>
|
/>
|
||||||
</VirtWebRouteContainer>
|
</VirtWebRouteContainer>
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { mdiHarddisk } from "@mdi/js";
|
import { mdiHarddisk, 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";
|
||||||
@@ -14,16 +14,23 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { filesize } from "filesize";
|
import { filesize } from "filesize";
|
||||||
import { ServerApi } from "../../api/ServerApi";
|
import { ServerApi } from "../../api/ServerApi";
|
||||||
import { VMFileDisk, VMInfo } from "../../api/VMApi";
|
import { VMFileDisk, VMInfo, VMState } from "../../api/VMApi";
|
||||||
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
|
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
|
||||||
import { SelectInput } from "./SelectInput";
|
import { SelectInput } from "./SelectInput";
|
||||||
import { TextInput } from "./TextInput";
|
import { TextInput } from "./TextInput";
|
||||||
|
import React from "react";
|
||||||
|
import { ConvertDiskImageDialog } from "../../dialogs/ConvertDiskImageDialog";
|
||||||
|
|
||||||
export function VMDisksList(p: {
|
export function VMDisksList(p: {
|
||||||
vm: VMInfo;
|
vm: VMInfo;
|
||||||
|
state?: VMState;
|
||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
const [currBackupRequest, setCurrBackupRequest] = React.useState<
|
||||||
|
VMFileDisk | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
const addNewDisk = () => {
|
const addNewDisk = () => {
|
||||||
p.vm.file_disks.push({
|
p.vm.file_disks.push({
|
||||||
format: "QCow2",
|
format: "QCow2",
|
||||||
@@ -35,6 +42,14 @@ export function VMDisksList(p: {
|
|||||||
p.onChange?.();
|
p.onChange?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBackupRequest = (disk: VMFileDisk) => {
|
||||||
|
setCurrBackupRequest(disk);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinishBackup = () => {
|
||||||
|
setCurrBackupRequest(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* disks list */}
|
{/* disks list */}
|
||||||
@@ -43,25 +58,40 @@ export function VMDisksList(p: {
|
|||||||
// eslint-disable-next-line react-x/no-array-index-key
|
// eslint-disable-next-line react-x/no-array-index-key
|
||||||
key={num}
|
key={num}
|
||||||
editable={p.editable}
|
editable={p.editable}
|
||||||
|
canBackup={!p.editable && !d.new && p.state !== "Running"}
|
||||||
disk={d}
|
disk={d}
|
||||||
onChange={p.onChange}
|
onChange={p.onChange}
|
||||||
removeFromList={() => {
|
removeFromList={() => {
|
||||||
p.vm.file_disks.splice(num, 1);
|
p.vm.file_disks.splice(num, 1);
|
||||||
p.onChange?.();
|
p.onChange?.();
|
||||||
}}
|
}}
|
||||||
|
onRequestBackup={handleBackupRequest}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{p.editable && <Button onClick={addNewDisk}>Add new disk</Button>}
|
{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: {
|
function DiskInfo(p: {
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
canBackup: boolean;
|
||||||
disk: VMFileDisk;
|
disk: VMFileDisk;
|
||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
removeFromList: () => void;
|
removeFromList: () => void;
|
||||||
|
onRequestBackup: (disk: VMFileDisk) => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const deleteDisk = async () => {
|
const deleteDisk = async () => {
|
||||||
@@ -88,23 +118,33 @@ function DiskInfo(p: {
|
|||||||
return (
|
return (
|
||||||
<ListItem
|
<ListItem
|
||||||
secondaryAction={
|
secondaryAction={
|
||||||
p.editable && (
|
<>
|
||||||
<IconButton
|
{p.editable && (
|
||||||
edge="end"
|
<IconButton
|
||||||
aria-label="delete disk"
|
edge="end"
|
||||||
onClick={deleteDisk}
|
aria-label="delete disk"
|
||||||
>
|
onClick={deleteDisk}
|
||||||
{p.disk.deleteType ? (
|
>
|
||||||
<Tooltip title="Cancel disk removal">
|
{p.disk.deleteType ? (
|
||||||
<CheckCircleIcon />
|
<Tooltip title="Cancel disk removal">
|
||||||
</Tooltip>
|
<CheckCircleIcon />
|
||||||
) : (
|
</Tooltip>
|
||||||
<Tooltip title="Remove disk">
|
) : (
|
||||||
<DeleteIcon />
|
<Tooltip title="Remove disk">
|
||||||
</Tooltip>
|
<DeleteIcon />
|
||||||
)}
|
</Tooltip>
|
||||||
</IconButton>
|
)}
|
||||||
)
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.canBackup && (
|
||||||
|
<Tooltip title="Backup this disk">
|
||||||
|
<IconButton onClick={() => p.onRequestBackup(p.disk)}>
|
||||||
|
<Icon path={mdiHarddiskPlus} size={1} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
|
@@ -10,7 +10,7 @@ import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi";
|
|||||||
import { NWFilter, NWFilterApi } from "../../api/NWFilterApi";
|
import { NWFilter, NWFilterApi } from "../../api/NWFilterApi";
|
||||||
import { NetworkApi, NetworkInfo } from "../../api/NetworksApi";
|
import { NetworkApi, NetworkInfo } from "../../api/NetworksApi";
|
||||||
import { ServerApi } from "../../api/ServerApi";
|
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 { useAlert } from "../../hooks/providers/AlertDialogProvider";
|
||||||
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
|
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
|
||||||
import { useSnackbar } from "../../hooks/providers/SnackbarProvider";
|
import { useSnackbar } from "../../hooks/providers/SnackbarProvider";
|
||||||
@@ -33,6 +33,7 @@ interface DetailsProps {
|
|||||||
editable: boolean;
|
editable: boolean;
|
||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
screenshot?: boolean;
|
screenshot?: boolean;
|
||||||
|
state?: VMState | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VMDetails(p: DetailsProps): React.ReactElement {
|
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