Prepare UI for disks backups

This commit is contained in:
Pierre HUBERT 2025-05-30 10:28:54 +02:00
parent a18310e04a
commit 83df7e1b20
7 changed files with 145 additions and 43 deletions

View File

@ -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

View File

@ -5,6 +5,7 @@
*/
import { APIClient } from "./ApiClient";
import { DiskImageFormat } from "./DiskImageApi";
export type VMState =
| "NoState"
@ -24,7 +25,7 @@ export interface BaseFileVMDisk {
name: string;
delete: boolean;
// application attribute
// application attributes
new?: boolean;
deleteType?: "keepfile" | "deletefile";
}
@ -384,4 +385,20 @@ export class VMApi {
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 },
});
}
}

View File

@ -9,6 +9,7 @@ import {
import React from "react";
import { DiskImage, DiskImageApi, DiskImageFormat } from "../api/DiskImageApi";
import { ServerApi } from "../api/ServerApi";
import { VMApi, VMFileDisk, VMInfo } from "../api/VMApi";
import { useAlert } from "../hooks/providers/AlertDialogProvider";
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
@ -16,12 +17,17 @@ 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();
@ -30,35 +36,43 @@ export function ConvertDiskImageDialog(p: {
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 VMApi.BackupDisk(p.vm, p.disk, filename, format);
else await DiskImageApi.Convert(p.image, filename, format);
p.onFinished();
snackbar("Conversion successful!");
snackbar(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 +80,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 +131,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>

View File

@ -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>

View File

@ -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";
@ -14,16 +14,23 @@ import {
} from "@mui/material";
import { filesize } from "filesize";
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 { SelectInput } from "./SelectInput";
import { TextInput } from "./TextInput";
import React from "react";
import { ConvertDiskImageDialog } from "../../dialogs/ConvertDiskImageDialog";
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 +42,14 @@ export function VMDisksList(p: {
p.onChange?.();
};
const handleBackupRequest = (disk: VMFileDisk) => {
setCurrBackupRequest(disk);
};
const handleFinishBackup = () => {
setCurrBackupRequest(undefined);
};
return (
<>
{/* disks list */}
@ -43,25 +58,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 +118,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>

View File

@ -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 {

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