3 Commits

Author SHA1 Message Date
e7ac0198ab Can download disk images
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-29 10:23:26 +02:00
927a51cda7 Can delete uploaded disk images 2025-05-29 08:45:38 +02:00
615dc1ed83 Display the list of uploaded disk images 2025-05-29 08:33:19 +02:00
7 changed files with 253 additions and 24 deletions

View File

@ -245,7 +245,7 @@ impl AppConfig {
storage_path.canonicalize().unwrap()
}
/// Get iso storage directory
/// Get iso files storage directory
pub fn iso_storage_path(&self) -> PathBuf {
self.storage_path().join("iso")
}
@ -265,15 +265,17 @@ impl AppConfig {
self.vnc_sockets_path().join(format!("vnc-{}", name))
}
/// Get VM vnc sockets directory
pub fn disks_storage_path(&self) -> PathBuf {
/// Get VM root disks storage directory
pub fn root_vm_disks_storage_path(&self) -> PathBuf {
self.storage_path().join("disks")
}
/// Get specific VM disk storage directory
pub fn vm_storage_path(&self, id: XMLUuid) -> PathBuf {
self.disks_storage_path().join(id.as_string())
self.root_vm_disks_storage_path().join(id.as_string())
}
/// Get the path were VM definitions are backed up
pub fn definitions_path(&self) -> PathBuf {
self.storage_path().join("definitions")
}

View File

@ -3,9 +3,10 @@ use crate::constants;
use crate::controllers::HttpResult;
use crate::utils::file_disks_utils::DiskFileInfo;
use crate::utils::files_utils;
use actix_files::NamedFile;
use actix_multipart::form::MultipartForm;
use actix_multipart::form::tempfile::TempFile;
use actix_web::HttpResponse;
use actix_web::{HttpRequest, HttpResponse, web};
#[derive(Debug, MultipartForm)]
pub struct UploadDiskImageForm {
@ -67,3 +68,44 @@ pub async fn get_list() -> HttpResult {
Ok(HttpResponse::Ok().json(list))
}
#[derive(serde::Deserialize)]
pub struct DiskFilePath {
filename: String,
}
/// Download disk image
pub async fn download(p: web::Path<DiskFilePath>, req: HttpRequest) -> HttpResult {
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
}
let file_path = AppConfig::get()
.disk_images_storage_path()
.join(&p.filename);
if !file_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
}
Ok(NamedFile::open(file_path)?.into_response(&req))
}
/// Delete a disk image
pub async fn delete(p: web::Path<DiskFilePath>) -> HttpResult {
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
}
let file_path = AppConfig::get()
.disk_images_storage_path()
.join(&p.filename);
if !file_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
}
std::fs::remove_file(file_path)?;
Ok(HttpResponse::Accepted().finish())
}

View File

@ -132,12 +132,12 @@ pub async fn get_list() -> HttpResult {
}
#[derive(serde::Deserialize)]
pub struct DownloadFilePath {
pub struct IsoFilePath {
filename: String,
}
/// Download ISO file
pub async fn download_file(p: web::Path<DownloadFilePath>, req: HttpRequest) -> HttpResult {
pub async fn download_file(p: web::Path<IsoFilePath>, req: HttpRequest) -> HttpResult {
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
}
@ -152,7 +152,7 @@ pub async fn download_file(p: web::Path<DownloadFilePath>, req: HttpRequest) ->
}
/// Delete ISO file
pub async fn delete_file(p: web::Path<DownloadFilePath>) -> HttpResult {
pub async fn delete_file(p: web::Path<IsoFilePath>) -> HttpResult {
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
}

View File

@ -60,7 +60,8 @@ async fn main() -> std::io::Result<()> {
files_utils::create_directory_if_missing(AppConfig::get().disk_images_storage_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap();
files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().disks_storage_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().root_vm_disks_storage_path())
.unwrap();
files_utils::create_directory_if_missing(AppConfig::get().nat_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().definitions_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().api_tokens_path()).unwrap();
@ -344,6 +345,14 @@ async fn main() -> std::io::Result<()> {
"/api/disk_images/list",
web::get().to(disk_images_controller::get_list),
)
.route(
"/api/disk_images/{filename}",
web::get().to(disk_images_controller::download),
)
.route(
"/api/disk_images/{filename}",
web::delete().to(disk_images_controller::delete),
)
// API tokens controller
.route(
"/api/token/create",

View File

@ -42,4 +42,32 @@ export class DiskImageApi {
})
).data;
}
/**
* Download disk image file
*/
static async Download(
file: DiskImage,
progress: (p: number) => void
): Promise<Blob> {
return (
await APIClient.exec({
method: "GET",
uri: `/disk_images/${file.file_name}`,
downProgress(e) {
progress(Math.floor(100 * (e.progress / e.total)));
},
})
).data;
}
/**
* Delete disk image file
*/
static async Delete(file: DiskImage): Promise<void> {
await APIClient.exec({
method: "DELETE",
uri: `/disk_images/${file.file_name}`,
});
}
}

View File

@ -1,21 +1,30 @@
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import RefreshIcon from "@mui/icons-material/Refresh";
import {
Alert,
Button,
CircularProgress,
IconButton,
LinearProgress,
Tooltip,
Typography,
} from "@mui/material";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { filesize } from "filesize";
import React from "react";
import { DiskImage, DiskImageApi } from "../api/DiskImageApi";
import { ServerApi } from "../api/ServerApi";
import { useAlert } from "../hooks/providers/AlertDialogProvider";
import { useConfirm } from "../hooks/providers/ConfirmDialogProvider";
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { DateWidget } from "../widgets/DateWidget";
import { FileInput } from "../widgets/forms/FileInput";
import { VirtWebPaper } from "../widgets/VirtWebPaper";
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
import { downloadBlob } from "../utils/FilesUtils";
export function DiskImagesRoute(): React.ReactElement {
const [list, setList] = React.useState<DiskImage[] | undefined>();
@ -32,28 +41,28 @@ export function DiskImagesRoute(): React.ReactElement {
};
return (
<VirtWebRouteContainer label="Disk images">
<VirtWebRouteContainer
label="Disk images management"
actions={
<span>
<Tooltip title="Refresh Disk images list">
<IconButton onClick={reload}>
<RefreshIcon />
</IconButton>
</Tooltip>
</span>
}
>
<AsyncWidget
loadKey={loadKey.current}
errMsg="Failed to load disk images list!"
load={load}
ready={list !== undefined}
build={() => (
<VirtWebRouteContainer
label="Disk images management"
actions={
<span>
<Tooltip title="Refresh Disk images list">
<IconButton onClick={reload}>
<RefreshIcon />
</IconButton>
</Tooltip>
</span>
}
>
<>
<UploadDiskImageCard onFileUploaded={reload} />
<DiskImageList list={list!} onReload={reload} />
</VirtWebRouteContainer>
</>
)}
/>
</VirtWebRouteContainer>
@ -148,5 +157,131 @@ function DiskImageList(p: {
list: DiskImage[];
onReload: () => void;
}): React.ReactElement {
return <>todo</>;
const alert = useAlert();
const snackbar = useSnackbar();
const confirm = useConfirm();
const loadingMessage = useLoadingMessage();
const [dlProgress, setDlProgress] = React.useState<undefined | number>();
// Download disk image file
const downloadDiskImage = async (entry: DiskImage) => {
setDlProgress(0);
try {
const blob = await DiskImageApi.Download(entry, setDlProgress);
downloadBlob(blob, entry.file_name);
} catch (e) {
console.error(e);
alert(`Failed to download disk image file! ${e}`);
}
setDlProgress(undefined);
};
// Delete disk image
const deleteDiskImage = async (entry: DiskImage) => {
if (
!(await confirm(
`Do you really want to delete this disk image (${entry.file_name}) ?`
))
)
return;
loadingMessage.show("Deleting disk image file...");
try {
await DiskImageApi.Delete(entry);
snackbar("The disk image has been successfully deleted!");
p.onReload();
} catch (e) {
console.error(e);
alert(`Failed to delete disk image!\n${e}`);
}
loadingMessage.hide();
};
if (p.list.length === 0)
return (
<Typography variant="body1" style={{ textAlign: "center" }}>
No disk image uploaded for now.
</Typography>
);
const columns: GridColDef<(typeof p.list)[number]>[] = [
{ field: "file_name", headerName: "File name", flex: 3 },
{
field: "format",
headerName: "Format",
flex: 1,
},
{
field: "file_size",
headerName: "File size",
flex: 1,
renderCell(params) {
return filesize(params.row.file_size);
},
},
{
field: "created",
headerName: "Created",
flex: 1,
renderCell(params) {
return <DateWidget time={params.row.created} />;
},
},
{
field: "actions",
headerName: "",
width: 120,
renderCell(params) {
return (
<>
<Tooltip title="Download image">
<IconButton onClick={() => downloadDiskImage(params.row)}>
<DownloadIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete disk image">
<IconButton onClick={() => deleteDiskImage(params.row)}>
<DeleteIcon />
</IconButton>
</Tooltip>
</>
);
},
},
];
return (
<>
{/* Download notification */}
{dlProgress !== undefined && (
<Alert severity="info">
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
overflow: "hidden",
}}
>
<Typography variant="body1">
Downloading... {dlProgress}%
</Typography>
<CircularProgress
variant="determinate"
size={"1.5rem"}
style={{ marginLeft: "10px" }}
value={dlProgress}
/>
</div>
</Alert>
)}
<DataGrid getRowId={(c) => c.file_name} rows={p.list} columns={columns} />
</>
);
}

View File

@ -0,0 +1,13 @@
export function DateWidget(p: { time: number }): React.ReactElement {
const date = new Date(p.time * 1000);
return (
<>
{pad(date.getDate())}/{pad(date.getMonth() + 1)}/{date.getFullYear()}
</>
);
}
function pad(num: number): string {
return num.toString().padStart(2, "0");
}