Add the logic which will call disk image conversion from disk image route
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				continuous-integration/drone/push Build is failing
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	continuous-integration/drone/push Build is failing
				
			This commit is contained in:
		@@ -1,7 +1,7 @@
 | 
			
		||||
use crate::app_config::AppConfig;
 | 
			
		||||
use crate::constants;
 | 
			
		||||
use crate::controllers::HttpResult;
 | 
			
		||||
use crate::utils::file_disks_utils::DiskFileInfo;
 | 
			
		||||
use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo};
 | 
			
		||||
use crate::utils::files_utils;
 | 
			
		||||
use actix_files::NamedFile;
 | 
			
		||||
use actix_multipart::form::MultipartForm;
 | 
			
		||||
@@ -91,6 +91,59 @@ pub async fn download(p: web::Path<DiskFilePath>, req: HttpRequest) -> HttpResul
 | 
			
		||||
    Ok(NamedFile::open(file_path)?.into_response(&req))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(serde::Deserialize)]
 | 
			
		||||
pub struct ConvertDiskImageRequest {
 | 
			
		||||
    dest_file_name: String,
 | 
			
		||||
    #[serde(flatten)]
 | 
			
		||||
    format: DiskFileFormat,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Convert disk image into a new format
 | 
			
		||||
pub async fn convert(
 | 
			
		||||
    p: web::Path<DiskFilePath>,
 | 
			
		||||
    req: web::Json<ConvertDiskImageRequest>,
 | 
			
		||||
) -> HttpResult {
 | 
			
		||||
    if !files_utils::check_file_name(&p.filename) {
 | 
			
		||||
        return Ok(HttpResponse::BadRequest().json("Invalid src file name!"));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if !files_utils::check_file_name(&req.dest_file_name) {
 | 
			
		||||
        return Ok(HttpResponse::BadRequest().json("Invalid destination file name!"));
 | 
			
		||||
    }
 | 
			
		||||
    if !req
 | 
			
		||||
        .format
 | 
			
		||||
        .ext()
 | 
			
		||||
        .iter()
 | 
			
		||||
        .any(|e| req.dest_file_name.ends_with(e))
 | 
			
		||||
    {
 | 
			
		||||
        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);
 | 
			
		||||
 | 
			
		||||
    if dst_file_path.exists() {
 | 
			
		||||
        return Ok(HttpResponse::Conflict().json("Specified destination file already exists!"));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Perform conversion
 | 
			
		||||
    if let Err(e) = src.convert(&dst_file_path, req.format) {
 | 
			
		||||
        log::error!("Disk file conversion error: {e}");
 | 
			
		||||
        return Ok(
 | 
			
		||||
            HttpResponse::InternalServerError().json(format!("Disk file conversion error: {e}"))
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Ok(HttpResponse::Ok().json(src))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Delete a disk image
 | 
			
		||||
pub async fn delete(p: web::Path<DiskFilePath>) -> HttpResult {
 | 
			
		||||
    if !files_utils::check_file_name(&p.filename) {
 | 
			
		||||
 
 | 
			
		||||
@@ -46,6 +46,7 @@ struct ServerConstraints {
 | 
			
		||||
    memory_size: LenConstraints,
 | 
			
		||||
    disk_name_size: LenConstraints,
 | 
			
		||||
    disk_size: LenConstraints,
 | 
			
		||||
    disk_image_name_size: LenConstraints,
 | 
			
		||||
    net_name_size: LenConstraints,
 | 
			
		||||
    net_title_size: LenConstraints,
 | 
			
		||||
    net_nat_comment_size: LenConstraints,
 | 
			
		||||
@@ -91,6 +92,8 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
 | 
			
		||||
                max: DISK_SIZE_MAX.as_bytes(),
 | 
			
		||||
            },
 | 
			
		||||
 | 
			
		||||
            disk_image_name_size: LenConstraints { min: 5, max: 220 },
 | 
			
		||||
 | 
			
		||||
            net_name_size: LenConstraints { min: 2, max: 50 },
 | 
			
		||||
            net_title_size: LenConstraints { min: 0, max: 50 },
 | 
			
		||||
            net_nat_comment_size: LenConstraints {
 | 
			
		||||
 
 | 
			
		||||
@@ -349,6 +349,10 @@ async fn main() -> std::io::Result<()> {
 | 
			
		||||
                "/api/disk_images/{filename}",
 | 
			
		||||
                web::get().to(disk_images_controller::download),
 | 
			
		||||
            )
 | 
			
		||||
            .route(
 | 
			
		||||
                "/api/disk_images/{filename}/convert",
 | 
			
		||||
                web::post().to(disk_images_controller::convert),
 | 
			
		||||
            )
 | 
			
		||||
            .route(
 | 
			
		||||
                "/api/disk_images/{filename}",
 | 
			
		||||
                web::delete().to(disk_images_controller::delete),
 | 
			
		||||
 
 | 
			
		||||
@@ -13,15 +13,32 @@ enum DisksError {
 | 
			
		||||
    Create,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Debug, serde::Serialize)]
 | 
			
		||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Copy, Clone)]
 | 
			
		||||
#[serde(tag = "format")]
 | 
			
		||||
pub enum DiskFileFormat {
 | 
			
		||||
    Raw { is_sparse: bool },
 | 
			
		||||
    QCow2 { virtual_size: FileSize },
 | 
			
		||||
    Raw {
 | 
			
		||||
        #[serde(default)]
 | 
			
		||||
        is_sparse: bool,
 | 
			
		||||
    },
 | 
			
		||||
    QCow2 {
 | 
			
		||||
        #[serde(default)]
 | 
			
		||||
        virtual_size: FileSize,
 | 
			
		||||
    },
 | 
			
		||||
    CompressedRaw,
 | 
			
		||||
    CompressedQCow2,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl DiskFileFormat {
 | 
			
		||||
    pub fn ext(&self) -> &'static [&'static str] {
 | 
			
		||||
        match self {
 | 
			
		||||
            DiskFileFormat::Raw { .. } => &["", "raw"],
 | 
			
		||||
            DiskFileFormat::QCow2 { .. } => &["qcow2"],
 | 
			
		||||
            DiskFileFormat::CompressedRaw => &["raw.gz"],
 | 
			
		||||
            DiskFileFormat::CompressedQCow2 => &["qcow2.gz"],
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Disk file information
 | 
			
		||||
#[derive(serde::Serialize)]
 | 
			
		||||
pub struct DiskFileInfo {
 | 
			
		||||
@@ -125,6 +142,11 @@ impl DiskFileInfo {
 | 
			
		||||
 | 
			
		||||
        Ok(())
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Copy / convert file disk image into a new destination with optionally a new file format
 | 
			
		||||
    pub fn convert(&self, dest_file: &Path, dest_format: DiskFileFormat) -> anyhow::Result<()> {
 | 
			
		||||
        todo!()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(serde::Deserialize)]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,14 @@
 | 
			
		||||
#[derive(
 | 
			
		||||
    serde::Serialize, serde::Deserialize, Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord,
 | 
			
		||||
    serde::Serialize,
 | 
			
		||||
    serde::Deserialize,
 | 
			
		||||
    Copy,
 | 
			
		||||
    Clone,
 | 
			
		||||
    Debug,
 | 
			
		||||
    Eq,
 | 
			
		||||
    PartialEq,
 | 
			
		||||
    PartialOrd,
 | 
			
		||||
    Ord,
 | 
			
		||||
    Default,
 | 
			
		||||
)]
 | 
			
		||||
pub struct FileSize(usize);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,17 @@
 | 
			
		||||
import { APIClient } from "./ApiClient";
 | 
			
		||||
 | 
			
		||||
export type DiskImageFormat =
 | 
			
		||||
  | { format: "Raw"; is_sparse: boolean }
 | 
			
		||||
  | { format: "QCow2"; virtual_size?: number }
 | 
			
		||||
  | { format: "CompressedQCow2" }
 | 
			
		||||
  | { format: "CompressedRaw" };
 | 
			
		||||
 | 
			
		||||
export type DiskImage = {
 | 
			
		||||
  file_size: number;
 | 
			
		||||
  file_name: string;
 | 
			
		||||
  name: string;
 | 
			
		||||
  created: number;
 | 
			
		||||
} & (
 | 
			
		||||
  | { format: "Raw"; is_sparse: boolean }
 | 
			
		||||
  | { format: "QCow2"; virtual_size: number }
 | 
			
		||||
  | { format: "CompressedQCow2" }
 | 
			
		||||
  | { format: "CompressedRaw" }
 | 
			
		||||
);
 | 
			
		||||
} & DiskImageFormat;
 | 
			
		||||
 | 
			
		||||
export class DiskImageApi {
 | 
			
		||||
  /**
 | 
			
		||||
@@ -61,6 +62,21 @@ export class DiskImageApi {
 | 
			
		||||
    ).data;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Convert disk image file
 | 
			
		||||
   */
 | 
			
		||||
  static async Convert(
 | 
			
		||||
    file: DiskImage,
 | 
			
		||||
    dest_file_name: string,
 | 
			
		||||
    dest_format: DiskImageFormat
 | 
			
		||||
  ): Promise<void> {
 | 
			
		||||
    await APIClient.exec({
 | 
			
		||||
      method: "POST",
 | 
			
		||||
      uri: `/disk_images/${file.file_name}/convert`,
 | 
			
		||||
      jsonData: { ...dest_format, dest_file_name },
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Delete disk image file
 | 
			
		||||
   */
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,7 @@ export interface ServerConstraints {
 | 
			
		||||
  memory_size: LenConstraint;
 | 
			
		||||
  disk_name_size: LenConstraint;
 | 
			
		||||
  disk_size: LenConstraint;
 | 
			
		||||
  disk_image_name_size: LenConstraint;
 | 
			
		||||
  net_name_size: LenConstraint;
 | 
			
		||||
  net_title_size: LenConstraint;
 | 
			
		||||
  net_nat_comment_size: LenConstraint;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										106
									
								
								virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,106 @@
 | 
			
		||||
import {
 | 
			
		||||
  Button,
 | 
			
		||||
  Dialog,
 | 
			
		||||
  DialogActions,
 | 
			
		||||
  DialogContent,
 | 
			
		||||
  DialogContentText,
 | 
			
		||||
  DialogTitle,
 | 
			
		||||
} from "@mui/material";
 | 
			
		||||
import { DiskImage, DiskImageApi, DiskImageFormat } from "../api/DiskImageApi";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { FileDiskImageWidget } from "../widgets/FileDiskImageWidget";
 | 
			
		||||
import { FileInput } from "../widgets/forms/FileInput";
 | 
			
		||||
import { TextInput } from "../widgets/forms/TextInput";
 | 
			
		||||
import { ServerApi } from "../api/ServerApi";
 | 
			
		||||
import { SelectInput } from "../widgets/forms/SelectInput";
 | 
			
		||||
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
 | 
			
		||||
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
 | 
			
		||||
import { useAlert } from "../hooks/providers/AlertDialogProvider";
 | 
			
		||||
import { useConfirm } from "../hooks/providers/ConfirmDialogProvider";
 | 
			
		||||
 | 
			
		||||
export function ConvertDiskImageDialog(p: {
 | 
			
		||||
  image: DiskImage;
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
  onFinished: () => void;
 | 
			
		||||
}): 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 handleFormatChange = async (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 === "Raw") setFilename(`${p.image.file_name}.raw`);
 | 
			
		||||
    if (value === "CompressedRaw") setFilename(`${p.image.file_name}.raw.gz`);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSubmit = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      loadingMessage.show("Converting image...");
 | 
			
		||||
 | 
			
		||||
      // Perform the conversion
 | 
			
		||||
      await DiskImageApi.Convert(p.image, filename, format);
 | 
			
		||||
 | 
			
		||||
      p.onFinished();
 | 
			
		||||
 | 
			
		||||
      snackbar("Conversion successful!");
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      console.error("Failed to convert image!", e);
 | 
			
		||||
      alert(`Failed to convert image! ${e}`);
 | 
			
		||||
    } finally {
 | 
			
		||||
      loadingMessage.hide();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog open onClose={p.onCancel}>
 | 
			
		||||
      <DialogTitle>Convert disk image</DialogTitle>
 | 
			
		||||
 | 
			
		||||
      <DialogContent>
 | 
			
		||||
        <DialogContentText>
 | 
			
		||||
          Select the destination format for this image:
 | 
			
		||||
        </DialogContentText>
 | 
			
		||||
        <FileDiskImageWidget image={p.image} />
 | 
			
		||||
 | 
			
		||||
        {/* New image format */}
 | 
			
		||||
        <SelectInput
 | 
			
		||||
          editable
 | 
			
		||||
          label="Target format"
 | 
			
		||||
          value={format.format}
 | 
			
		||||
          onValueChange={handleFormatChange}
 | 
			
		||||
          options={[
 | 
			
		||||
            { value: "QCow2" },
 | 
			
		||||
            { value: "Raw" },
 | 
			
		||||
            { value: "CompressedRaw" },
 | 
			
		||||
            { value: "CompressedQCow2" },
 | 
			
		||||
          ]}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        {/* New image name */}
 | 
			
		||||
        <TextInput
 | 
			
		||||
          editable
 | 
			
		||||
          label="New image name"
 | 
			
		||||
          value={filename}
 | 
			
		||||
          onValueChange={(s) => setFilename(s ?? "")}
 | 
			
		||||
          size={ServerApi.Config.constraints.disk_image_name_size}
 | 
			
		||||
          helperText="The image name shall contain the proper file extension"
 | 
			
		||||
        />
 | 
			
		||||
      </DialogContent>
 | 
			
		||||
      <DialogActions>
 | 
			
		||||
        <Button onClick={p.onCancel}>Cancel</Button>
 | 
			
		||||
        <Button onClick={handleSubmit} autoFocus>
 | 
			
		||||
          Convert image
 | 
			
		||||
        </Button>
 | 
			
		||||
      </DialogActions>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import DeleteIcon from "@mui/icons-material/Delete";
 | 
			
		||||
import DownloadIcon from "@mui/icons-material/Download";
 | 
			
		||||
import RefreshIcon from "@mui/icons-material/Refresh";
 | 
			
		||||
import LoopIcon from "@mui/icons-material/Loop";
 | 
			
		||||
import {
 | 
			
		||||
  Alert,
 | 
			
		||||
  Button,
 | 
			
		||||
@@ -25,6 +26,7 @@ import { FileInput } from "../widgets/forms/FileInput";
 | 
			
		||||
import { VirtWebPaper } from "../widgets/VirtWebPaper";
 | 
			
		||||
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
 | 
			
		||||
import { downloadBlob } from "../utils/FilesUtils";
 | 
			
		||||
import { ConvertDiskImageDialog } from "../dialogs/ConvertDiskImageDialog";
 | 
			
		||||
 | 
			
		||||
export function DiskImagesRoute(): React.ReactElement {
 | 
			
		||||
  const [list, setList] = React.useState<DiskImage[] | undefined>();
 | 
			
		||||
@@ -162,8 +164,16 @@ function DiskImageList(p: {
 | 
			
		||||
  const confirm = useConfirm();
 | 
			
		||||
  const loadingMessage = useLoadingMessage();
 | 
			
		||||
 | 
			
		||||
  const [currConversion, setCurrConversion] = React.useState<
 | 
			
		||||
    DiskImage | undefined
 | 
			
		||||
  >();
 | 
			
		||||
  const [dlProgress, setDlProgress] = React.useState<undefined | number>();
 | 
			
		||||
 | 
			
		||||
  // Convert disk image file
 | 
			
		||||
  const convertDiskImage = async (entry: DiskImage) => {
 | 
			
		||||
    setCurrConversion(entry);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Download disk image file
 | 
			
		||||
  const downloadDiskImage = async (entry: DiskImage) => {
 | 
			
		||||
    setDlProgress(0);
 | 
			
		||||
@@ -236,11 +246,16 @@ function DiskImageList(p: {
 | 
			
		||||
    {
 | 
			
		||||
      field: "actions",
 | 
			
		||||
      headerName: "",
 | 
			
		||||
      width: 120,
 | 
			
		||||
      width: 140,
 | 
			
		||||
      renderCell(params) {
 | 
			
		||||
        return (
 | 
			
		||||
          <>
 | 
			
		||||
            <Tooltip title="Download image">
 | 
			
		||||
            <Tooltip title="Convert disk image">
 | 
			
		||||
              <IconButton onClick={() => convertDiskImage(params.row)}>
 | 
			
		||||
                <LoopIcon />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            </Tooltip>
 | 
			
		||||
            <Tooltip title="Download disk image">
 | 
			
		||||
              <IconButton onClick={() => downloadDiskImage(params.row)}>
 | 
			
		||||
                <DownloadIcon />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
@@ -281,6 +296,20 @@ function DiskImageList(p: {
 | 
			
		||||
          </div>
 | 
			
		||||
        </Alert>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {/* Disk image conversion dialog */}
 | 
			
		||||
      {currConversion && (
 | 
			
		||||
        <ConvertDiskImageDialog
 | 
			
		||||
          image={currConversion}
 | 
			
		||||
          onCancel={() => setCurrConversion(undefined)}
 | 
			
		||||
          onFinished={() => {
 | 
			
		||||
            setCurrConversion(undefined);
 | 
			
		||||
            p.onReload();
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {/* The table itself */}
 | 
			
		||||
      <DataGrid getRowId={(c) => c.file_name} rows={p.list} columns={columns} />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										23
									
								
								virtweb_frontend/src/widgets/FileDiskImageWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								virtweb_frontend/src/widgets/FileDiskImageWidget.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material";
 | 
			
		||||
import { DiskImage } from "../api/DiskImageApi";
 | 
			
		||||
import { mdiHarddisk } from "@mdi/js";
 | 
			
		||||
import { filesize } from "filesize";
 | 
			
		||||
import Icon from "@mdi/react";
 | 
			
		||||
 | 
			
		||||
export function FileDiskImageWidget(p: {
 | 
			
		||||
  image: DiskImage;
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  return (
 | 
			
		||||
    <ListItem>
 | 
			
		||||
      <ListItemAvatar>
 | 
			
		||||
        <Avatar>
 | 
			
		||||
          <Icon path={mdiHarddisk} />
 | 
			
		||||
        </Avatar>
 | 
			
		||||
      </ListItemAvatar>
 | 
			
		||||
      <ListItemText
 | 
			
		||||
        primary={p.image.file_name}
 | 
			
		||||
        secondary={`${p.image.format} - ${filesize(p.image.file_size)}`}
 | 
			
		||||
      />
 | 
			
		||||
    </ListItem>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user