Add the logic which will call disk image conversion from disk image route
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Pierre HUBERT 2025-05-29 11:37:11 +02:00
parent e7ac0198ab
commit e017fe96d5
10 changed files with 279 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -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} />
</>
);

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