diff --git a/virtweb_backend/src/controllers/server_controller.rs b/virtweb_backend/src/controllers/server_controller.rs index ebced07..53c7563 100644 --- a/virtweb_backend/src/controllers/server_controller.rs +++ b/virtweb_backend/src/controllers/server_controller.rs @@ -16,7 +16,21 @@ struct StaticConfig { local_auth_enabled: bool, oidc_auth_enabled: bool, iso_mimetypes: &'static [&'static str], + constraints: ServerConstraints, +} + +#[derive(serde::Serialize)] +struct LenConstraints { + min: usize, + max: usize, +} + +#[derive(serde::Serialize)] +struct ServerConstraints { iso_max_size: usize, + name_size: LenConstraints, + title_size: LenConstraints, + memory_size: LenConstraints, } pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { @@ -25,7 +39,16 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { local_auth_enabled: *local_auth, oidc_auth_enabled: !AppConfig::get().disable_oidc, iso_mimetypes: &constants::ALLOWED_ISO_MIME_TYPES, - iso_max_size: constants::ISO_MAX_SIZE, + constraints: ServerConstraints { + iso_max_size: constants::ISO_MAX_SIZE, + + name_size: LenConstraints { min: 2, max: 50 }, + title_size: LenConstraints { min: 0, max: 50 }, + memory_size: LenConstraints { + min: constants::MIN_VM_MEMORY, + max: constants::MAX_VM_MEMORY, + }, + }, }) } diff --git a/virtweb_backend/src/controllers/vm_controller.rs b/virtweb_backend/src/controllers/vm_controller.rs index 3875cd5..3e9cc9b 100644 --- a/virtweb_backend/src/controllers/vm_controller.rs +++ b/virtweb_backend/src/controllers/vm_controller.rs @@ -10,6 +10,11 @@ struct VMInfoAndState { state: DomainState, } +#[derive(serde::Serialize)] +struct VMUuid { + uuid: DomainXMLUuid, +} + /// Create a new VM pub async fn create(client: LibVirtReq, req: web::Json) -> HttpResult { let domain = match req.0.to_domain() { @@ -21,7 +26,7 @@ pub async fn create(client: LibVirtReq, req: web::Json) -> HttpResult { }; let id = client.update_domain(domain).await?; - Ok(HttpResponse::Ok().json(id)) + Ok(HttpResponse::Ok().json(VMUuid { uuid: id })) } /// Get the list of domains diff --git a/virtweb_frontend/package-lock.json b/virtweb_frontend/package-lock.json index 8fd68f9..09a42c5 100644 --- a/virtweb_frontend/package-lock.json +++ b/virtweb_frontend/package-lock.json @@ -25,6 +25,7 @@ "@types/node": "^16.18.48", "@types/react": "^18.2.21", "@types/react-dom": "^18.2.7", + "@types/uuid": "^9.0.5", "filesize": "^10.0.12", "humanize-duration": "^3.29.0", "mui-file-input": "^3.0.1", @@ -33,6 +34,7 @@ "react-router-dom": "^6.15.0", "react-scripts": "5.0.1", "typescript": "^4.9.5", + "uuid": "^9.0.1", "web-vitals": "^2.1.4" } }, @@ -4754,6 +4756,11 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==" }, + "node_modules/@types/uuid": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.5.tgz", + "integrity": "sha512-xfHdwa1FMJ082prjSJpoEI57GZITiQz10r3vEJCHa2khEFQjKy91aWKz6+zybzssCvXUwE1LQWgWVwZ4nYUvHQ==" + }, "node_modules/@types/ws": { "version": "8.5.5", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.5.tgz", @@ -16091,6 +16098,14 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", @@ -17255,9 +17270,13 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } @@ -21469,6 +21488,11 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==" }, + "@types/uuid": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.5.tgz", + "integrity": "sha512-xfHdwa1FMJ082prjSJpoEI57GZITiQz10r3vEJCHa2khEFQjKy91aWKz6+zybzssCvXUwE1LQWgWVwZ4nYUvHQ==" + }, "@types/ws": { "version": "8.5.5", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.5.tgz", @@ -29487,6 +29511,13 @@ "faye-websocket": "^0.11.3", "uuid": "^8.3.2", "websocket-driver": "^0.7.4" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } } }, "source-list-map": { @@ -30346,9 +30377,9 @@ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" }, "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" }, "v8-to-istanbul": { "version": "8.1.1", diff --git a/virtweb_frontend/package.json b/virtweb_frontend/package.json index ddc23dc..647fbbe 100644 --- a/virtweb_frontend/package.json +++ b/virtweb_frontend/package.json @@ -20,6 +20,7 @@ "@types/node": "^16.18.48", "@types/react": "^18.2.21", "@types/react-dom": "^18.2.7", + "@types/uuid": "^9.0.5", "filesize": "^10.0.12", "humanize-duration": "^3.29.0", "mui-file-input": "^3.0.1", @@ -28,6 +29,7 @@ "react-router-dom": "^6.15.0", "react-scripts": "5.0.1", "typescript": "^4.9.5", + "uuid": "^9.0.1", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/virtweb_frontend/src/App.tsx b/virtweb_frontend/src/App.tsx index e30d169..ea87f5f 100644 --- a/virtweb_frontend/src/App.tsx +++ b/virtweb_frontend/src/App.tsx @@ -16,6 +16,7 @@ import { IsoFilesRoute } from "./routes/IsoFilesRoute"; import { ServerApi } from "./api/ServerApi"; import { SysInfoRoute } from "./routes/SysInfoRoute"; import { VirtualMachinesRoute } from "./routes/VirtualMachinesRoute"; +import { CreateVMRoute, EditVMRoute } from "./routes/EditVMRoute"; interface AuthContext { signedIn: boolean; @@ -37,7 +38,11 @@ export function App() { signedIn || ServerApi.Config.auth_disabled ? ( }> } /> + } /> + } /> + } /> + } /> } /> diff --git a/virtweb_frontend/src/api/ServerApi.ts b/virtweb_frontend/src/api/ServerApi.ts index a6403c8..841c654 100644 --- a/virtweb_frontend/src/api/ServerApi.ts +++ b/virtweb_frontend/src/api/ServerApi.ts @@ -5,7 +5,19 @@ export interface ServerConfig { local_auth_enabled: boolean; oidc_auth_enabled: boolean; iso_mimetypes: string[]; + constraints: ServerConstraints; +} + +export interface ServerConstraints { iso_max_size: number; + name_size: LenConstraint; + title_size: LenConstraint; + memory_size: LenConstraint; +} + +export interface LenConstraint { + min: number; + max: number; } let config: ServerConfig | null = null; diff --git a/virtweb_frontend/src/api/VMApi.ts b/virtweb_frontend/src/api/VMApi.ts index 88e9bc0..cc97f6e 100644 --- a/virtweb_frontend/src/api/VMApi.ts +++ b/virtweb_frontend/src/api/VMApi.ts @@ -52,12 +52,35 @@ export class VMInfo implements VMInfoInterface { this.vnc_access = int.vnc_access; } + static NewEmpty(): VMInfo { + return new VMInfo({ + name: "", + boot_type: "UEFI", + architecture: "x86_64", + memory: 1024, + vnc_access: true, + }); + } + get ViewURL(): string { return `/api/vm/${this.uuid}`; } } export class VMApi { + /** + * Create a new virtual machine + */ + static async Create(v: VMInfo): Promise<{ uuid: string }> { + return ( + await APIClient.exec({ + uri: `/vm/create`, + method: "POST", + jsonData: v, + }) + ).data; + } + /** * Get the list of defined virtual machines */ diff --git a/virtweb_frontend/src/routes/EditVMRoute.tsx b/virtweb_frontend/src/routes/EditVMRoute.tsx new file mode 100644 index 0000000..5cecbef --- /dev/null +++ b/virtweb_frontend/src/routes/EditVMRoute.tsx @@ -0,0 +1,195 @@ +import React, { PropsWithChildren } from "react"; +import { useNavigate } from "react-router-dom"; +import { VMApi, VMInfo } from "../api/VMApi"; +import { useSnackbar } from "../hooks/providers/SnackbarProvider"; +import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; +import { Button, Paper, Typography } from "@mui/material"; +import { TextInput } from "../widgets/forms/TextInput"; +import { ServerApi } from "../api/ServerApi"; +import { validate as validateUUID } from "uuid"; +import { SelectInput } from "../widgets/forms/SelectInput"; +import { CheckboxInput } from "../widgets/forms/CheckboxInput"; + +export function CreateVMRoute(): React.ReactElement { + const snackbar = useSnackbar(); + const navigate = useNavigate(); + + const [vm] = React.useState(VMInfo.NewEmpty); + + const create = async (v: VMInfo) => { + const res = await VMApi.Create(v); + snackbar("The virtual machine was successfully created!"); + v.uuid = res.uuid; + navigate(v.ViewURL); + }; + + return ( + navigate("/vms")} + /> + ); +} + +export function EditVMRoute(): React.ReactElement { + return <>todo; +} + +function EditVMInner(p: { + vm: VMInfo; + isCreating: boolean; + onCancel: () => void; + onSave: (vm: VMInfo) => Promise; +}): React.ReactElement { + const [changed, setChanged] = React.useState(false); + + const [, updateState] = React.useState(); + const forceUpdate = React.useCallback(() => updateState({}), []); + + const valueChanged = () => { + setChanged(true); + forceUpdate(); + }; + + return ( + + {changed && ( + + )} + + + } + > + {/* Metadata section */} + + { + p.vm.name = v ?? ""; + valueChanged(); + }} + size={ServerApi.Config.constraints.name_size} + /> + + + + { + p.vm.genid = v; + valueChanged(); + }} + checkValue={(v) => validateUUID(v)} + /> + + { + p.vm.title = v; + valueChanged(); + }} + size={ServerApi.Config.constraints.title_size} + /> + + { + p.vm.description = v; + valueChanged(); + }} + multiline={true} + /> + + + {/* General section */} + + { + p.vm.architecture = v! as any; + valueChanged(); + }} + value={p.vm.architecture} + options={[ + { label: "i686", value: "i686" }, + { label: "x86_64", value: "x86_64" }, + ]} + /> + + { + p.vm.boot_type = v! as any; + valueChanged(); + }} + value={p.vm.boot_type} + options={[ + { label: "UEFI with Secure Boot", value: "UEFISecureBoot" }, + { label: "UEFI", value: "UEFI" }, + ]} + /> + + { + p.vm.memory = Number(v ?? "0"); + valueChanged(); + }} + checkValue={(v) => + Number(v) > ServerApi.Config.constraints.memory_size.min && + Number(v) < ServerApi.Config.constraints.memory_size.max + } + /> + + { + p.vm.vnc_access = v; + valueChanged(); + }} + /> + + + ); +} + +function EditSection( + p: { title: string } & PropsWithChildren +): React.ReactElement { + return ( + + + {p.title} + + {p.children} + + ); +} diff --git a/virtweb_frontend/src/routes/IsoFilesRoute.tsx b/virtweb_frontend/src/routes/IsoFilesRoute.tsx index e3eccd9..a4d5c18 100644 --- a/virtweb_frontend/src/routes/IsoFilesRoute.tsx +++ b/virtweb_frontend/src/routes/IsoFilesRoute.tsx @@ -68,10 +68,10 @@ function UploadIsoFileCard(p: { ); const handleChange = (newValue: File | null) => { - if (newValue && newValue.size > ServerApi.Config.iso_max_size) { + if (newValue && newValue.size > ServerApi.Config.constraints.iso_max_size) { alert( `The file is too big (max size allowed: ${filesize( - ServerApi.Config.iso_max_size + ServerApi.Config.constraints.iso_max_size )}` ); return; diff --git a/virtweb_frontend/src/widgets/forms/CheckboxInput.tsx b/virtweb_frontend/src/widgets/forms/CheckboxInput.tsx new file mode 100644 index 0000000..e3b6ecd --- /dev/null +++ b/virtweb_frontend/src/widgets/forms/CheckboxInput.tsx @@ -0,0 +1,25 @@ +import { Checkbox, FormControlLabel, Typography } from "@mui/material"; + +export function CheckboxInput(p: { + editable: boolean; + label: string; + checked: boolean | undefined; + onValueChange: (v: boolean) => void; +}): React.ReactElement { + if (!p.editable && p.checked) + return {p.label}; + + if (!p.editable) return <>; + + return ( + p.onValueChange(e.target.checked)} + /> + } + label={p.label} + /> + ); +} diff --git a/virtweb_frontend/src/widgets/forms/SelectInput.tsx b/virtweb_frontend/src/widgets/forms/SelectInput.tsx new file mode 100644 index 0000000..f0a9f5a --- /dev/null +++ b/virtweb_frontend/src/widgets/forms/SelectInput.tsx @@ -0,0 +1,38 @@ +import { FormControl, InputLabel, MenuItem, Select } from "@mui/material"; +import { TextInput } from "./TextInput"; + +export interface SelectOption { + value: string; + label: string; +} + +export function SelectInput(p: { + value?: string; + editing: boolean; + label: string; + options: SelectOption[]; + onValueChange: (o?: string) => void; +}): React.ReactElement { + if (!p.editing && !p.value) return <>; + + if (!p.editing) { + const value = p.options.find((o) => o.value === p.value)?.label; + return ; + } + return ( + + {p.label} + + + ); +} diff --git a/virtweb_frontend/src/widgets/forms/TextInput.tsx b/virtweb_frontend/src/widgets/forms/TextInput.tsx new file mode 100644 index 0000000..f166ebb --- /dev/null +++ b/virtweb_frontend/src/widgets/forms/TextInput.tsx @@ -0,0 +1,51 @@ +import { TextField } from "@mui/material"; +import { LenConstraint } from "../../api/ServerApi"; + +/** + * Couple / Member property edition + */ +export function TextInput(p: { + label: string; + editable: boolean; + value?: string; + onValueChange?: (newVal: string | undefined) => void; + size?: LenConstraint; + checkValue?: (s: string) => boolean; + multiline?: boolean; + minRows?: number; + maxRows?: number; + type?: React.HTMLInputTypeAttribute; +}): React.ReactElement { + if (((!p.editable && p.value) ?? "") === "") return <>; + + return ( + + p.onValueChange?.( + e.target.value.length === 0 ? undefined : e.target.value + ) + } + inputProps={{ + maxLength: p.size?.max, + }} + InputProps={{ + readOnly: !p.editable, + type: p.type, + }} + variant={p.editable ? "standard" : "standard"} + style={{ width: "100%", marginBottom: "15px" }} + multiline={p.multiline} + minRows={p.minRows} + maxRows={p.maxRows} + error={ + (p.checkValue && + p.value && + p.value.length > 0 && + !p.checkValue(p.value)) || + false + } + /> + ); +}