import DeleteIcon from "@mui/icons-material/Delete"; import DownloadIcon from "@mui/icons-material/Download"; import LoopIcon from "@mui/icons-material/Loop"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import RefreshIcon from "@mui/icons-material/Refresh"; import { Alert, Button, CircularProgress, IconButton, LinearProgress, ListItemIcon, ListItemText, Menu, MenuItem, 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 { ConvertDiskImageDialog } from "../dialogs/ConvertDiskImageDialog"; 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 { downloadBlob } from "../utils/FilesUtils"; 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"; export function DiskImagesRoute(): React.ReactElement { const [list, setList] = React.useState<DiskImage[] | undefined>(); const loadKey = React.useRef(1); const load = async () => { setList(await DiskImageApi.GetList()); }; const reload = () => { loadKey.current += 1; setList(undefined); }; return ( <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={() => ( <> <UploadDiskImageCard onFileUploaded={reload} /> <DiskImageList list={list!} onReload={reload} /> </> )} /> </VirtWebRouteContainer> ); } function UploadDiskImageCard(p: { onFileUploaded: () => void; }): React.ReactElement { const alert = useAlert(); const snackbar = useSnackbar(); const [value, setValue] = React.useState<File | null>(null); const [uploadProgress, setUploadProgress] = React.useState<number | null>( null ); const handleChange = (newValue: File | null) => { if ( newValue && newValue.size > ServerApi.Config.constraints.disk_image_max_size ) { alert( `The file is too big (max size allowed: ${filesize( ServerApi.Config.constraints.disk_image_max_size )}` ); return; } if ( newValue && newValue.type.length > 0 && !ServerApi.Config.disk_images_mimetypes.includes(newValue.type) ) { alert(`Selected file mimetype is not allowed! (${newValue.type})`); return; } setValue(newValue); }; const upload = async () => { try { setUploadProgress(0); await DiskImageApi.Upload(value!, setUploadProgress); setValue(null); snackbar("The file was successfully uploaded!"); p.onFileUploaded(); } catch (e) { console.error(e); await alert(`Failed to perform file upload! ${e}`); } setUploadProgress(null); }; if (uploadProgress !== null) { return ( <VirtWebPaper label="File upload" noHorizontalMargin> <Typography variant="body1"> Upload in progress ({Math.floor(uploadProgress * 100)}%)... </Typography> <LinearProgress variant="determinate" value={uploadProgress * 100} /> </VirtWebPaper> ); } return ( <VirtWebPaper label="Disk image upload" noHorizontalMargin> <div style={{ display: "flex", alignItems: "center" }}> <FileInput value={value} onChange={handleChange} style={{ flex: 1 }} slotProps={{ htmlInput: { accept: ServerApi.Config.disk_images_mimetypes.join(","), }, }} /> {value && <Button onClick={upload}>Upload</Button>} </div> </VirtWebPaper> ); } function DiskImageList(p: { list: DiskImage[]; onReload: () => void; }): React.ReactElement { const alert = useAlert(); const snackbar = useSnackbar(); const confirm = useConfirm(); const loadingMessage = useLoadingMessage(); const [dlProgress, setDlProgress] = React.useState<undefined | number>(); const [currConversion, setCurrConversion] = React.useState< DiskImage | undefined >(); // 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); }; // Convert disk image file const convertDiskImage = (entry: DiskImage) => { setCurrConversion(entry); }; // 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, editable: true }, { field: "format", headerName: "Format", flex: 1, renderCell(params) { let content = params.row.format; if (params.row.format === "Raw") { content += params.row.is_sparse ? " (Sparse)" : " (Fixed)"; } return content; }, }, { field: "file_size", headerName: "File size", flex: 1, renderCell(params) { let res = filesize(params.row.file_size); if (params.row.format === "QCow2") { res += ` (${filesize(params.row.virtual_size!)})`; } return res; }, }, { field: "created", headerName: "Created", flex: 1, renderCell(params) { return <DateWidget time={params.row.created} />; }, }, { field: "actions", type: "actions", headerName: "", width: 55, cellClassName: "actions", editable: false, getActions: (params) => { return [ <DiskImageActionMenu key="menu" diskImage={params.row} onDownload={downloadDiskImage} onConvert={convertDiskImage} onDelete={deleteDiskImage} />, ]; }, }, ]; 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> )} {/* Disk image conversion dialog */} {currConversion && ( <ConvertDiskImageDialog image={currConversion} onCancel={() => { setCurrConversion(undefined); }} onFinished={() => { setCurrConversion(undefined); p.onReload(); }} /> )} {/* The table itself */} <DataGrid<DiskImage> getRowId={(c) => c.file_name} rows={p.list} columns={columns} processRowUpdate={async (n, o) => { try { await DiskImageApi.Rename(o, n.file_name); return n; } catch (e) { console.error("Failed to rename disk image!", e); alert(`Failed to rename disk image! ${e}`); throw e; } finally { p.onReload(); } }} /> </> ); } function DiskImageActionMenu(p: { diskImage: DiskImage; onDownload: (d: DiskImage) => void; onConvert: (d: DiskImage) => void; onDelete: (d: DiskImage) => void; }): React.ReactElement { const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); const open = Boolean(anchorEl); const handleClick = (event: React.MouseEvent<HTMLElement>) => { setAnchorEl(event.currentTarget); }; const handleClose = () => { setAnchorEl(null); }; return ( <> <IconButton aria-label="Actions" aria-haspopup="true" onClick={handleClick} > <MoreVertIcon /> </IconButton> <Menu anchorEl={anchorEl} open={open} onClose={handleClose}> {/* Download disk image */} <MenuItem onClick={() => { handleClose(); p.onDownload(p.diskImage); }} > <ListItemIcon> <DownloadIcon /> </ListItemIcon> <ListItemText secondary={"Download disk image"}> Download </ListItemText> </MenuItem> {/* Convert disk image */} <MenuItem onClick={() => { handleClose(); p.onConvert(p.diskImage); }} > <ListItemIcon> <LoopIcon /> </ListItemIcon> <ListItemText secondary={"Convert disk image"}>Convert</ListItemText> </MenuItem> {/* Delete disk image */} <MenuItem onClick={() => { handleClose(); p.onDelete(p.diskImage); }} > <ListItemIcon> <DeleteIcon color="error" /> </ListItemIcon> <ListItemText secondary={"Delete disk image"}>Delete</ListItemText> </MenuItem> </Menu> </> ); }