Compare commits

...

5 Commits

Author SHA1 Message Date
ae4a2707e5 Update dependency @types/humanize-duration to ^3.27.4
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-23 00:09:25 +00:00
dce17062a3 Can define OEMStrings from webui
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-22 22:03:57 +02:00
dcb0743cbe Fix VM creation / update by adding missing oem_string property
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-22 21:16:47 +02:00
644fd6f1bb Add backend SMBios support
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-22 21:13:00 +02:00
94ee8f8c78 Add support for QCow2 file format in web ui
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-22 18:41:04 +02:00
8 changed files with 242 additions and 46 deletions

View File

@ -26,6 +26,7 @@ pub struct OSXML {
pub firmware: String, pub firmware: String,
pub r#type: OSTypeXML, pub r#type: OSTypeXML,
pub loader: Option<OSLoaderXML>, pub loader: Option<OSLoaderXML>,
pub smbios: Option<OSSMBiosXML>,
} }
/// OS Type information /// OS Type information
@ -48,6 +49,14 @@ pub struct OSLoaderXML {
pub secure: String, pub secure: String,
} }
/// SMBIOS System information
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename = "smbios")]
pub struct OSSMBiosXML {
#[serde(rename = "@mode")]
pub mode: String,
}
/// Hypervisor features /// Hypervisor features
#[derive(serde::Serialize, serde::Deserialize, Clone, Default, Debug)] #[derive(serde::Serialize, serde::Deserialize, Clone, Default, Debug)]
#[serde(rename = "features")] #[serde(rename = "features")]
@ -305,6 +314,29 @@ pub struct DomainCPUXML {
pub topology: Option<DomainCPUTopology>, pub topology: Option<DomainCPUTopology>,
} }
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename = "entry")]
pub struct OEMStringEntryXML {
#[serde(rename = "$text", default)]
pub content: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename = "oemStrings")]
pub struct OEMStringsXML {
#[serde(rename = "entry")]
pub entries: Vec<OEMStringEntryXML>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename = "sysinfo")]
pub struct SysInfoXML {
#[serde(rename = "@type")]
pub r#type: String,
#[serde(rename = "oemStrings")]
pub oem_strings: Option<OEMStringsXML>,
}
/// Domain information, see https://libvirt.org/formatdomain.html /// Domain information, see https://libvirt.org/formatdomain.html
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename = "domain")] #[serde(rename = "domain")]
@ -335,6 +367,10 @@ pub struct DomainXML {
/// CPU information /// CPU information
pub cpu: DomainCPUXML, pub cpu: DomainCPUXML,
/// SMBios strings
pub sysinfo: Option<SysInfoXML>,
/// Behavior when guest state change
pub on_poweroff: String, pub on_poweroff: String,
pub on_reboot: String, pub on_reboot: String,
pub on_crash: String, pub on_crash: String,

View File

@ -77,12 +77,14 @@ pub struct VMInfo {
pub vnc_access: bool, pub vnc_access: bool,
/// Attach ISO file(s) /// Attach ISO file(s)
pub iso_files: Vec<String>, pub iso_files: Vec<String>,
/// Storage - https://access.redhat.com/documentation/fr-fr/red_hat_enterprise_linux/6/html/virtualization_administration_guide/sect-virtualization-virtualized_block_devices-adding_storage_devices_to_guests#sect-Virtualization-Adding_storage_devices_to_guests-Adding_file_based_storage_to_a_guest /// File Storage - https://access.redhat.com/documentation/fr-fr/red_hat_enterprise_linux/6/html/virtualization_administration_guide/sect-virtualization-virtualized_block_devices-adding_storage_devices_to_guests#sect-Virtualization-Adding_storage_devices_to_guests-Adding_file_based_storage_to_a_guest
pub disks: Vec<FileDisk>, pub file_disks: Vec<FileDisk>,
/// Network cards /// Network cards
pub networks: Vec<Network>, pub networks: Vec<Network>,
/// Add a TPM v2.0 module /// Add a TPM v2.0 module
pub tpm_module: bool, pub tpm_module: bool,
/// Strings injected as OEM Strings in SMBios configuration
pub oem_strings: Vec<String>,
} }
impl VMInfo { impl VMInfo {
@ -247,15 +249,21 @@ impl VMInfo {
} }
// Check disks name for duplicates // Check disks name for duplicates
for disk in &self.disks { for disk in &self.file_disks {
if self.disks.iter().filter(|d| d.name == disk.name).count() > 1 { if self
.file_disks
.iter()
.filter(|d| d.name == disk.name)
.count()
> 1
{
return Err(StructureExtraction("Two different disks have the same name!").into()); return Err(StructureExtraction("Two different disks have the same name!").into());
} }
} }
// Apply disks configuration. Starting from now, the function should ideally never fail due to // Apply disks configuration. Starting from now, the function should ideally never fail due to
// bad user input // bad user input
for disk in &self.disks { for disk in &self.file_disks {
disk.check_config()?; disk.check_config()?;
disk.apply_config(uuid)?; disk.apply_config(uuid)?;
@ -323,6 +331,9 @@ impl VMInfo {
BootType::UEFISecureBoot => "yes".to_string(), BootType::UEFISecureBoot => "yes".to_string(),
}, },
}), }),
smbios: Some(OSSMBiosXML {
mode: "sysinfo".to_string(),
}),
}, },
features: FeaturesXML { acpi: ACPIXML {} }, features: FeaturesXML { acpi: ACPIXML {} },
@ -379,6 +390,17 @@ impl VMInfo {
}), }),
}, },
sysinfo: Some(SysInfoXML {
r#type: "smbios".to_string(),
oem_strings: Some(OEMStringsXML {
entries: self
.oem_strings
.iter()
.map(|s| OEMStringEntryXML { content: s.clone() })
.collect(),
}),
}),
on_poweroff: "destroy".to_string(), on_poweroff: "destroy".to_string(),
on_reboot: "restart".to_string(), on_reboot: "restart".to_string(),
on_crash: "destroy".to_string(), on_crash: "destroy".to_string(),
@ -428,7 +450,7 @@ impl VMInfo {
.map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string()) .map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string())
.collect(), .collect(),
disks: domain file_disks: domain
.devices .devices
.disks .disks
.iter() .iter()
@ -470,6 +492,12 @@ impl VMInfo {
.collect::<Result<Vec<_>, _>>()?, .collect::<Result<Vec<_>, _>>()?,
tpm_module: domain.devices.tpm.is_some(), tpm_module: domain.devices.tpm.is_some(),
oem_strings: domain
.sysinfo
.and_then(|s| s.oem_strings)
.map(|s| s.entries.iter().map(|o| o.content.to_string()).collect())
.unwrap_or_default(),
}) })
} }
} }

View File

@ -30,7 +30,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.27.0", "@eslint/js": "^9.27.0",
"@types/humanize-duration": "^3.27.1", "@types/humanize-duration": "^3.27.4",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/react": "^19.1.4", "@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",

View File

@ -32,7 +32,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.27.0", "@eslint/js": "^9.27.0",
"@types/humanize-duration": "^3.27.1", "@types/humanize-duration": "^3.27.4",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/react": "^19.1.4", "@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",

View File

@ -17,12 +17,11 @@ export type VMState =
| "PowerManagementSuspended" | "PowerManagementSuspended"
| "Other"; | "Other";
export type DiskAllocType = "Sparse" | "Fixed"; export type VMFileDisk = BaseFileVMDisk & (RawVMDisk | QCow2Disk);
export interface VMDisk { export interface BaseFileVMDisk {
size: number; size: number;
name: string; name: string;
alloc_type: DiskAllocType;
delete: boolean; delete: boolean;
// application attribute // application attribute
@ -30,6 +29,17 @@ export interface VMDisk {
deleteType?: "keepfile" | "deletefile"; deleteType?: "keepfile" | "deletefile";
} }
export type DiskAllocType = "Sparse" | "Fixed";
interface RawVMDisk {
format: "Raw";
alloc_type: DiskAllocType;
}
interface QCow2Disk {
format: "QCow2";
}
export interface VMNetInterfaceFilterParams { export interface VMNetInterfaceFilterParams {
name: string; name: string;
value: string; value: string;
@ -70,9 +80,10 @@ interface VMInfoInterface {
number_vcpu: number; number_vcpu: number;
vnc_access: boolean; vnc_access: boolean;
iso_files: string[]; iso_files: string[];
disks: VMDisk[]; file_disks: VMFileDisk[];
networks: VMNetInterface[]; networks: VMNetInterface[];
tpm_module: boolean; tpm_module: boolean;
oem_strings: string[];
} }
export class VMInfo implements VMInfoInterface { export class VMInfo implements VMInfoInterface {
@ -88,9 +99,10 @@ export class VMInfo implements VMInfoInterface {
memory: number; memory: number;
vnc_access: boolean; vnc_access: boolean;
iso_files: string[]; iso_files: string[];
disks: VMDisk[]; file_disks: VMFileDisk[];
networks: VMNetInterface[]; networks: VMNetInterface[];
tpm_module: boolean; tpm_module: boolean;
oem_strings: string[];
constructor(int: VMInfoInterface) { constructor(int: VMInfoInterface) {
this.name = int.name; this.name = int.name;
@ -105,9 +117,10 @@ export class VMInfo implements VMInfoInterface {
this.memory = int.memory; this.memory = int.memory;
this.vnc_access = int.vnc_access; this.vnc_access = int.vnc_access;
this.iso_files = int.iso_files; this.iso_files = int.iso_files;
this.disks = int.disks; this.file_disks = int.file_disks;
this.networks = int.networks; this.networks = int.networks;
this.tpm_module = int.tpm_module; this.tpm_module = int.tpm_module;
this.oem_strings = int.oem_strings;
} }
static NewEmpty(): VMInfo { static NewEmpty(): VMInfo {
@ -119,9 +132,10 @@ export class VMInfo implements VMInfoInterface {
number_vcpu: 1, number_vcpu: 1,
vnc_access: true, vnc_access: true,
iso_files: [], iso_files: [],
disks: [], file_disks: [],
networks: [], networks: [],
tpm_module: true, tpm_module: true,
oem_strings: [],
}); });
} }
@ -194,8 +208,8 @@ export class VMApi {
*/ */
static async UpdateSingle(vm: VMInfo): Promise<VMInfo> { static async UpdateSingle(vm: VMInfo): Promise<VMInfo> {
// Process disks list, looking for removal // Process disks list, looking for removal
vm.disks = vm.disks.filter((d) => d.deleteType !== "keepfile"); vm.file_disks = vm.file_disks.filter((d) => d.deleteType !== "keepfile");
vm.disks.forEach((d) => { vm.file_disks.forEach((d) => {
if (d.deleteType === "deletefile") d.delete = true; if (d.deleteType === "deletefile") d.delete = true;
}); });

View File

@ -0,0 +1,89 @@
/* eslint-disable react-x/no-array-index-key */
import AddIcon from "@mui/icons-material/Add";
import ClearIcon from "@mui/icons-material/Clear";
import {
Alert,
IconButton,
InputAdornment,
TextField,
Tooltip,
} from "@mui/material";
import { VMInfo } from "../../api/VMApi";
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
import { EditSection } from "./EditSection";
export function OEMStringFormWidget(p: {
vm: VMInfo;
editable: boolean;
onChange?: () => void;
}): React.ReactElement {
const confirm = useConfirm();
const handleDeleteOEMString = async (num: number) => {
if (!(await confirm("Do you really want to delete this entry?"))) return;
p.vm.oem_strings.splice(num, 1);
p.onChange?.();
};
return (
<EditSection
title="SMBIOS OEM Strings"
actions={
p.editable ? (
<Tooltip title="Add a new string entry">
<IconButton
onClick={() => {
p.vm.oem_strings.push("");
p.onChange?.();
}}
>
<AddIcon />
</IconButton>
</Tooltip>
) : (
<></>
)
}
>
<Alert severity="info">
You can use the{" "}
<a
href="https://www.nongnu.org/dmidecode/"
target="_blank"
rel="noreferrer noopener"
style={{ color: "inherit" }}
>
<i>dmidecode</i>
</a>{" "}
tool on Linux to extract these strings on the guest.
</Alert>
{p.vm.oem_strings.map((s, num) => (
<TextField
key={num}
fullWidth
disabled={!p.editable}
value={s}
onChange={(e) => {
p.vm.oem_strings[num] = e.target.value;
p.onChange?.();
}}
style={{ marginTop: "5px" }}
slotProps={{
input: {
endAdornment: p.editable ? (
<InputAdornment position="end">
<Tooltip title="Remove entry">
<IconButton onClick={() => handleDeleteOEMString(num)}>
<ClearIcon />
</IconButton>
</Tooltip>
</InputAdornment>
) : undefined,
},
}}
/>
))}
</EditSection>
);
}

View File

@ -14,7 +14,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import { filesize } from "filesize"; import { filesize } from "filesize";
import { ServerApi } from "../../api/ServerApi"; import { ServerApi } from "../../api/ServerApi";
import { VMDisk, VMInfo } from "../../api/VMApi"; import { VMFileDisk, VMInfo } from "../../api/VMApi";
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider"; import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
import { SelectInput } from "./SelectInput"; import { SelectInput } from "./SelectInput";
import { TextInput } from "./TextInput"; import { TextInput } from "./TextInput";
@ -25,11 +25,11 @@ export function VMDisksList(p: {
editable: boolean; editable: boolean;
}): React.ReactElement { }): React.ReactElement {
const addNewDisk = () => { const addNewDisk = () => {
p.vm.disks.push({ p.vm.file_disks.push({
alloc_type: "Sparse", format: "QCow2",
size: 10000, size: 10000,
delete: false, delete: false,
name: `disk${p.vm.disks.length}`, name: `disk${p.vm.file_disks.length}`,
new: true, new: true,
}); });
p.onChange?.(); p.onChange?.();
@ -38,7 +38,7 @@ export function VMDisksList(p: {
return ( return (
<> <>
{/* disks list */} {/* disks list */}
{p.vm.disks.map((d, num) => ( {p.vm.file_disks.map((d, num) => (
<DiskInfo <DiskInfo
// eslint-disable-next-line react-x/no-array-index-key // eslint-disable-next-line react-x/no-array-index-key
key={num} key={num}
@ -46,7 +46,7 @@ export function VMDisksList(p: {
disk={d} disk={d}
onChange={p.onChange} onChange={p.onChange}
removeFromList={() => { removeFromList={() => {
p.vm.disks.splice(num, 1); p.vm.file_disks.splice(num, 1);
p.onChange?.(); p.onChange?.();
}} }}
/> />
@ -59,7 +59,7 @@ export function VMDisksList(p: {
function DiskInfo(p: { function DiskInfo(p: {
editable: boolean; editable: boolean;
disk: VMDisk; disk: VMFileDisk;
onChange?: () => void; onChange?: () => void;
removeFromList: () => void; removeFromList: () => void;
}): React.ReactElement { }): React.ReactElement {
@ -126,25 +126,30 @@ function DiskInfo(p: {
</> </>
} }
secondary={`${filesize(p.disk.size * 1000 * 1000)} - ${ secondary={`${filesize(p.disk.size * 1000 * 1000)} - ${
p.disk.alloc_type p.disk.format
}`} }${p.disk.format == "Raw" ? " - " + p.disk.alloc_type : ""}`}
/> />
</ListItem> </ListItem>
); );
return ( return (
<Paper elevation={3} style={{ margin: "10px", padding: "10px" }}> <Paper elevation={3} style={{ margin: "10px", padding: "10px" }}>
<TextInput <div style={{ display: "flex", justifyContent: "space-between" }}>
editable={true} <TextInput
label="Disk name" editable={true}
size={ServerApi.Config.constraints.disk_name_size} label="Disk name"
checkValue={(v) => /^[a-zA-Z0-9]+$/.test(v)} size={ServerApi.Config.constraints.disk_name_size}
value={p.disk.name} checkValue={(v) => /^[a-zA-Z0-9]+$/.test(v)}
onValueChange={(v) => { value={p.disk.name}
p.disk.name = v ?? ""; onValueChange={(v) => {
p.onChange?.(); p.disk.name = v ?? "";
}} p.onChange?.();
/> }}
/>
<IconButton onClick={p.removeFromList}>
<DeleteIcon />
</IconButton>
</div>
<TextInput <TextInput
editable={true} editable={true}
@ -158,7 +163,21 @@ function DiskInfo(p: {
type="number" type="number"
/> />
<div style={{ display: "flex", justifyContent: "space-between" }}> <SelectInput
editable={true}
label="Disk format"
options={[
{ label: "Raw file", value: "Raw" },
{ label: "QCow2", value: "QCow2" },
]}
value={p.disk.format}
onValueChange={(v) => {
p.disk.format = v as any;
p.onChange?.();
}}
/>
{p.disk.format === "Raw" && (
<SelectInput <SelectInput
editable={true} editable={true}
label="File allocation type" label="File allocation type"
@ -168,15 +187,11 @@ function DiskInfo(p: {
]} ]}
value={p.disk.alloc_type} value={p.disk.alloc_type}
onValueChange={(v) => { onValueChange={(v) => {
p.disk.alloc_type = v as any; if (p.disk.format === "Raw") p.disk.alloc_type = v as any;
p.onChange?.(); p.onChange?.();
}} }}
/> />
)}
<IconButton onClick={p.removeFromList}>
<DeleteIcon />
</IconButton>
</div>
</Paper> </Paper>
); );
} }

View File

@ -19,6 +19,7 @@ import { TabsWidget } from "../TabsWidget";
import { XMLAsyncWidget } from "../XMLWidget"; import { XMLAsyncWidget } from "../XMLWidget";
import { CheckboxInput } from "../forms/CheckboxInput"; import { CheckboxInput } from "../forms/CheckboxInput";
import { EditSection } from "../forms/EditSection"; import { EditSection } from "../forms/EditSection";
import { OEMStringFormWidget } from "../forms/OEMStringFormWidget";
import { ResAutostartInput } from "../forms/ResAutostartInput"; import { ResAutostartInput } from "../forms/ResAutostartInput";
import { SelectInput } from "../forms/SelectInput"; import { SelectInput } from "../forms/SelectInput";
import { TextInput } from "../forms/TextInput"; import { TextInput } from "../forms/TextInput";
@ -78,6 +79,7 @@ enum VMTab {
General = 0, General = 0,
Storage, Storage,
Network, Network,
Advanced,
XML, XML,
Danger, Danger,
} }
@ -102,6 +104,8 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement {
{ label: "General", value: VMTab.General, visible: true }, { label: "General", value: VMTab.General, visible: true },
{ label: "Storage", value: VMTab.Storage, visible: true }, { label: "Storage", value: VMTab.Storage, visible: true },
{ label: "Network", value: VMTab.Network, visible: true }, { label: "Network", value: VMTab.Network, visible: true },
{ label: "Avanced", value: VMTab.Advanced, visible: true },
{ {
label: "XML", label: "XML",
value: VMTab.XML, value: VMTab.XML,
@ -119,6 +123,7 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement {
{currTab === VMTab.General && <VMDetailsTabGeneral {...p} />} {currTab === VMTab.General && <VMDetailsTabGeneral {...p} />}
{currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />} {currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />}
{currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />} {currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />}
{currTab === VMTab.Advanced && <VMDetailsTabAdvanced {...p} />}
{currTab === VMTab.XML && <VMDetailsTabXML {...p} />} {currTab === VMTab.XML && <VMDetailsTabXML {...p} />}
{currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />} {currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />}
</> </>
@ -334,8 +339,8 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement {
function VMDetailsTabStorage(p: DetailsInnerProps): React.ReactElement { function VMDetailsTabStorage(p: DetailsInnerProps): React.ReactElement {
return ( return (
<Grid container spacing={2}> <Grid container spacing={2}>
{(p.editable || p.vm.disks.length > 0) && ( {(p.editable || p.vm.file_disks.length > 0) && (
<EditSection title="Disks storage"> <EditSection title="File disks storage">
<VMDisksList {...p} /> <VMDisksList {...p} />
</EditSection> </EditSection>
)} )}
@ -361,6 +366,15 @@ function VMDetailsTabNetwork(p: DetailsInnerProps): React.ReactElement {
return <VMNetworksList {...p} />; return <VMNetworksList {...p} />;
} }
function VMDetailsTabAdvanced(p: DetailsInnerProps): React.ReactElement {
return (
<Grid container spacing={2}>
{/* OEM strings */}
<OEMStringFormWidget {...p} />
</Grid>
);
}
function VMDetailsTabXML(p: DetailsInnerProps): React.ReactElement { function VMDetailsTabXML(p: DetailsInnerProps): React.ReactElement {
return ( return (
<XMLAsyncWidget <XMLAsyncWidget