Prepare UI for disks backups
This commit is contained in:
		@@ -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 },
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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";
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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