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::app_config::AppConfig;
|
||||||
use crate::constants;
|
use crate::constants;
|
||||||
use crate::controllers::HttpResult;
|
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 crate::utils::files_utils;
|
||||||
use actix_files::NamedFile;
|
use actix_files::NamedFile;
|
||||||
use actix_multipart::form::MultipartForm;
|
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))
|
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
|
/// Delete a disk image
|
||||||
pub async fn delete(p: web::Path<DiskFilePath>) -> HttpResult {
|
pub async fn delete(p: web::Path<DiskFilePath>) -> HttpResult {
|
||||||
if !files_utils::check_file_name(&p.filename) {
|
if !files_utils::check_file_name(&p.filename) {
|
||||||
|
@@ -46,6 +46,7 @@ struct ServerConstraints {
|
|||||||
memory_size: LenConstraints,
|
memory_size: LenConstraints,
|
||||||
disk_name_size: LenConstraints,
|
disk_name_size: LenConstraints,
|
||||||
disk_size: LenConstraints,
|
disk_size: LenConstraints,
|
||||||
|
disk_image_name_size: LenConstraints,
|
||||||
net_name_size: LenConstraints,
|
net_name_size: LenConstraints,
|
||||||
net_title_size: LenConstraints,
|
net_title_size: LenConstraints,
|
||||||
net_nat_comment_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(),
|
max: DISK_SIZE_MAX.as_bytes(),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
disk_image_name_size: LenConstraints { min: 5, max: 220 },
|
||||||
|
|
||||||
net_name_size: LenConstraints { min: 2, max: 50 },
|
net_name_size: LenConstraints { min: 2, max: 50 },
|
||||||
net_title_size: LenConstraints { min: 0, max: 50 },
|
net_title_size: LenConstraints { min: 0, max: 50 },
|
||||||
net_nat_comment_size: LenConstraints {
|
net_nat_comment_size: LenConstraints {
|
||||||
|
@@ -349,6 +349,10 @@ async fn main() -> std::io::Result<()> {
|
|||||||
"/api/disk_images/{filename}",
|
"/api/disk_images/{filename}",
|
||||||
web::get().to(disk_images_controller::download),
|
web::get().to(disk_images_controller::download),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/disk_images/{filename}/convert",
|
||||||
|
web::post().to(disk_images_controller::convert),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/disk_images/{filename}",
|
"/api/disk_images/{filename}",
|
||||||
web::delete().to(disk_images_controller::delete),
|
web::delete().to(disk_images_controller::delete),
|
||||||
|
@@ -13,15 +13,32 @@ enum DisksError {
|
|||||||
Create,
|
Create,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize, serde::Deserialize, Copy, Clone)]
|
||||||
#[serde(tag = "format")]
|
#[serde(tag = "format")]
|
||||||
pub enum DiskFileFormat {
|
pub enum DiskFileFormat {
|
||||||
Raw { is_sparse: bool },
|
Raw {
|
||||||
QCow2 { virtual_size: FileSize },
|
#[serde(default)]
|
||||||
|
is_sparse: bool,
|
||||||
|
},
|
||||||
|
QCow2 {
|
||||||
|
#[serde(default)]
|
||||||
|
virtual_size: FileSize,
|
||||||
|
},
|
||||||
CompressedRaw,
|
CompressedRaw,
|
||||||
CompressedQCow2,
|
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
|
/// Disk file information
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub struct DiskFileInfo {
|
pub struct DiskFileInfo {
|
||||||
@@ -125,6 +142,11 @@ impl DiskFileInfo {
|
|||||||
|
|
||||||
Ok(())
|
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)]
|
#[derive(serde::Deserialize)]
|
||||||
|
@@ -1,5 +1,14 @@
|
|||||||
#[derive(
|
#[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);
|
pub struct FileSize(usize);
|
||||||
|
|
||||||
|
@@ -1,16 +1,17 @@
|
|||||||
import { APIClient } from "./ApiClient";
|
import { APIClient } from "./ApiClient";
|
||||||
|
|
||||||
|
export type DiskImageFormat =
|
||||||
|
| { format: "Raw"; is_sparse: boolean }
|
||||||
|
| { format: "QCow2"; virtual_size?: number }
|
||||||
|
| { format: "CompressedQCow2" }
|
||||||
|
| { format: "CompressedRaw" };
|
||||||
|
|
||||||
export type DiskImage = {
|
export type DiskImage = {
|
||||||
file_size: number;
|
file_size: number;
|
||||||
file_name: string;
|
file_name: string;
|
||||||
name: string;
|
name: string;
|
||||||
created: number;
|
created: number;
|
||||||
} & (
|
} & DiskImageFormat;
|
||||||
| { format: "Raw"; is_sparse: boolean }
|
|
||||||
| { format: "QCow2"; virtual_size: number }
|
|
||||||
| { format: "CompressedQCow2" }
|
|
||||||
| { format: "CompressedRaw" }
|
|
||||||
);
|
|
||||||
|
|
||||||
export class DiskImageApi {
|
export class DiskImageApi {
|
||||||
/**
|
/**
|
||||||
@@ -61,6 +62,21 @@ export class DiskImageApi {
|
|||||||
).data;
|
).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
|
* Delete disk image file
|
||||||
*/
|
*/
|
||||||
|
@@ -22,6 +22,7 @@ export interface ServerConstraints {
|
|||||||
memory_size: LenConstraint;
|
memory_size: LenConstraint;
|
||||||
disk_name_size: LenConstraint;
|
disk_name_size: LenConstraint;
|
||||||
disk_size: LenConstraint;
|
disk_size: LenConstraint;
|
||||||
|
disk_image_name_size: LenConstraint;
|
||||||
net_name_size: LenConstraint;
|
net_name_size: LenConstraint;
|
||||||
net_title_size: LenConstraint;
|
net_title_size: LenConstraint;
|
||||||
net_nat_comment_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 DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import LoopIcon from "@mui/icons-material/Loop";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -25,6 +26,7 @@ import { FileInput } from "../widgets/forms/FileInput";
|
|||||||
import { VirtWebPaper } from "../widgets/VirtWebPaper";
|
import { VirtWebPaper } from "../widgets/VirtWebPaper";
|
||||||
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
|
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
|
||||||
import { downloadBlob } from "../utils/FilesUtils";
|
import { downloadBlob } from "../utils/FilesUtils";
|
||||||
|
import { ConvertDiskImageDialog } from "../dialogs/ConvertDiskImageDialog";
|
||||||
|
|
||||||
export function DiskImagesRoute(): React.ReactElement {
|
export function DiskImagesRoute(): React.ReactElement {
|
||||||
const [list, setList] = React.useState<DiskImage[] | undefined>();
|
const [list, setList] = React.useState<DiskImage[] | undefined>();
|
||||||
@@ -162,8 +164,16 @@ function DiskImageList(p: {
|
|||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const loadingMessage = useLoadingMessage();
|
const loadingMessage = useLoadingMessage();
|
||||||
|
|
||||||
|
const [currConversion, setCurrConversion] = React.useState<
|
||||||
|
DiskImage | undefined
|
||||||
|
>();
|
||||||
const [dlProgress, setDlProgress] = React.useState<undefined | number>();
|
const [dlProgress, setDlProgress] = React.useState<undefined | number>();
|
||||||
|
|
||||||
|
// Convert disk image file
|
||||||
|
const convertDiskImage = async (entry: DiskImage) => {
|
||||||
|
setCurrConversion(entry);
|
||||||
|
};
|
||||||
|
|
||||||
// Download disk image file
|
// Download disk image file
|
||||||
const downloadDiskImage = async (entry: DiskImage) => {
|
const downloadDiskImage = async (entry: DiskImage) => {
|
||||||
setDlProgress(0);
|
setDlProgress(0);
|
||||||
@@ -236,11 +246,16 @@ function DiskImageList(p: {
|
|||||||
{
|
{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "",
|
headerName: "",
|
||||||
width: 120,
|
width: 140,
|
||||||
renderCell(params) {
|
renderCell(params) {
|
||||||
return (
|
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)}>
|
<IconButton onClick={() => downloadDiskImage(params.row)}>
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -281,6 +296,20 @@ function DiskImageList(p: {
|
|||||||
</div>
|
</div>
|
||||||
</Alert>
|
</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} />
|
<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