Compare commits
35 Commits
33d70c2ee0
...
renovate/s
Author | SHA1 | Date | |
---|---|---|---|
1ad4262086 | |||
b633694f74 | |||
ab16bd7bcf | |||
1080ab5cb2 | |||
a2845ddafe | |||
c968b64b51 | |||
12833dc6da | |||
8c4f2a9f2d | |||
9a6b6cfb2d | |||
b28ca5f27d | |||
92f187bf91 | |||
9f1f4b44ca | |||
b00d46105b | |||
7d6ccd4ce6 | |||
b5371421e1 | |||
07d305c54e | |||
78c525f47a | |||
978a881372 | |||
ee733b04f3 | |||
0d8ef226c1 | |||
feca07558e | |||
0de15af10e | |||
d4bc92f562 | |||
a1439689dd | |||
63126c75fa | |||
940302a825 | |||
9c374f849b | |||
2fa4d0e11b | |||
d7796e1459 | |||
759361d9f6 | |||
b2529c250a | |||
8a7712ec42 | |||
9609cfb33a | |||
1fe7c60f36 | |||
f1339f0711 |
@ -46,8 +46,9 @@ steps:
|
||||
- cd virtweb_backend
|
||||
- mv /tmp/web_build/dist static
|
||||
- cargo build --release
|
||||
- ls -lah target/release/virtweb_backend
|
||||
- cp target/release/virtweb_backend /tmp/release
|
||||
- cargo build --release --example api_curl
|
||||
- ls -lah target/release/virtweb_backend target/release/examples/api_curl
|
||||
- cp target/release/virtweb_backend target/release/examples/api_curl /tmp/release
|
||||
|
||||
- name: gitea_release
|
||||
image: plugins/gitea-release
|
||||
|
248
virtweb_backend/Cargo.lock
generated
248
virtweb_backend/Cargo.lock
generated
@ -435,6 +435,21 @@ dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android-tzdata"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.18"
|
||||
@ -496,6 +511,9 @@ name = "arbitrary"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arg_enum_proc_macro"
|
||||
@ -715,6 +733,25 @@ dependencies = [
|
||||
"bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
|
||||
dependencies = [
|
||||
"bzip2-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.13+1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.23"
|
||||
@ -748,6 +785,20 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
|
||||
dependencies = [
|
||||
"android-tzdata",
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@ -760,9 +811,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.38"
|
||||
version = "4.5.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000"
|
||||
checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@ -770,9 +821,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.38"
|
||||
version = "4.5.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120"
|
||||
checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@ -782,9 +833,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.32"
|
||||
version = "4.5.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
|
||||
checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
@ -816,6 +867,12 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@ -981,6 +1038,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deflate64"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@ -1001,6 +1064,17 @@ dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@ -1221,6 +1295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"libz-rs-sys",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
@ -1380,9 +1455,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasi 0.14.2+wasi-0.2.4",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1641,6 +1718,30 @@ dependencies = [
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.0.0"
|
||||
@ -1997,6 +2098,45 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "liblzma"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66352d7a8ac12d4877b6e6ea5a9b7650ee094257dc40889955bea5bc5b08c1d0"
|
||||
dependencies = [
|
||||
"liblzma-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "liblzma-sys"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libyml"
|
||||
version = "0.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-rs-sys"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221"
|
||||
dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "light-openid"
|
||||
version = "1.0.4"
|
||||
@ -2419,6 +2559,16 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.5"
|
||||
@ -2788,9 +2938,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.19"
|
||||
version = "0.12.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119"
|
||||
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@ -2805,12 +2955,10 @@ dependencies = [
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls-pki-types",
|
||||
@ -3098,6 +3246,21 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yml"
|
||||
version = "0.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"libyml",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@ -3258,9 +3421,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.35.1"
|
||||
version = "0.35.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79251336d17c72d9762b8b54be4befe38d2db56fbbc0241396d70f173c39d47a"
|
||||
checksum = "3c3ffa3e4ff2b324a57f7aeb3c349656c7b127c3c189520251a648102a92496e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"memchr",
|
||||
@ -3758,6 +3921,7 @@ dependencies = [
|
||||
"actix-ws",
|
||||
"anyhow",
|
||||
"basic-jwt",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dotenvy",
|
||||
"env_logger",
|
||||
@ -3777,6 +3941,7 @@ dependencies = [
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yml",
|
||||
"sysinfo",
|
||||
"tempfile",
|
||||
"thiserror 2.0.12",
|
||||
@ -3784,6 +3949,7 @@ dependencies = [
|
||||
"url",
|
||||
"uuid",
|
||||
"virt",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -4322,6 +4488,20 @@ name = "zeroize"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
@ -4356,6 +4536,50 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af7dcdb4229c0e79c2531a24de7726a0e980417a74fb4d030a35f535665439a0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"arbitrary",
|
||||
"bzip2",
|
||||
"constant_time_eq",
|
||||
"crc32fast",
|
||||
"deflate64",
|
||||
"flate2",
|
||||
"getrandom 0.3.3",
|
||||
"hmac",
|
||||
"indexmap",
|
||||
"liblzma",
|
||||
"memchr",
|
||||
"pbkdf2",
|
||||
"sha1",
|
||||
"time",
|
||||
"zeroize",
|
||||
"zopfli",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
|
@ -8,7 +8,7 @@ edition = "2024"
|
||||
[dependencies]
|
||||
log = "0.4.27"
|
||||
env_logger = "0.11.8"
|
||||
clap = { version = "4.5.38", features = ["derive", "env"] }
|
||||
clap = { version = "4.5.40", features = ["derive", "env"] }
|
||||
light-openid = { version = "1.0.4", features = ["crypto-wrapper"] }
|
||||
lazy_static = "1.5.0"
|
||||
actix = "0.13.5"
|
||||
@ -19,18 +19,19 @@ actix-identity = "0.8.0"
|
||||
actix-cors = "0.7.1"
|
||||
actix-files = "0.6.6"
|
||||
actix-ws = "0.3.0"
|
||||
actix-http = "3.10.0"
|
||||
actix-http = "3.11.0"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
serde_yml = "0.0.12"
|
||||
quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] }
|
||||
futures-util = "0.3.31"
|
||||
anyhow = "1.0.98"
|
||||
actix-multipart = "0.7.2"
|
||||
tempfile = "3.20.0"
|
||||
reqwest = { version = "0.12.19", features = ["stream"] }
|
||||
reqwest = { version = "0.12.20", features = ["stream"] }
|
||||
url = "2.5.4"
|
||||
virt = "0.4.2"
|
||||
sysinfo = { version = "0.35.1", features = ["serde"] }
|
||||
sysinfo = { version = "0.35.2", features = ["serde"] }
|
||||
uuid = { version = "1.16.0", features = ["v4", "serde"] }
|
||||
lazy-regex = "3.4.1"
|
||||
thiserror = "2.0.12"
|
||||
@ -44,3 +45,5 @@ rust-embed = { version = "8.7.2", features = ["mime-guess"] }
|
||||
dotenvy = "0.15.7"
|
||||
nix = { version = "0.30.1", features = ["net"] }
|
||||
basic-jwt = "0.3.0"
|
||||
zip = "4.1.0"
|
||||
chrono = "0.4.41"
|
@ -182,6 +182,13 @@ impl Handler<DeleteDomainReq> for LibVirtActor {
|
||||
false => sys::VIR_DOMAIN_UNDEFINE_NVRAM,
|
||||
})?;
|
||||
|
||||
// Delete associated cloud init disk
|
||||
let cloud_init_disk = AppConfig::get().cloud_init_disk_path_for_vm(&domain_name);
|
||||
if cloud_init_disk.exists() {
|
||||
std::fs::remove_file(cloud_init_disk)?;
|
||||
}
|
||||
|
||||
// If requested, delete block storage associated with the VM
|
||||
if !msg.keep_files {
|
||||
log::info!("Delete storage associated with the domain");
|
||||
let path = AppConfig::get().vm_storage_path(msg.id);
|
||||
|
@ -250,6 +250,19 @@ impl AppConfig {
|
||||
self.storage_path().join("iso")
|
||||
}
|
||||
|
||||
/// Get the path where generated cloud init disk image are stored
|
||||
pub fn cloud_init_disk_storage_path(&self) -> PathBuf {
|
||||
self.storage_path().join("cloud_init_disks")
|
||||
}
|
||||
|
||||
/// Get the path where the disk image of a VM is stored
|
||||
pub fn cloud_init_disk_path_for_vm(&self, name: &str) -> PathBuf {
|
||||
self.cloud_init_disk_storage_path().join(format!(
|
||||
"{}-{name}.iso",
|
||||
constants::CLOUD_INIT_IMAGE_PREFIX_NAME
|
||||
))
|
||||
}
|
||||
|
||||
/// Get disk images storage directory
|
||||
pub fn disk_images_storage_path(&self) -> PathBuf {
|
||||
self.storage_path().join("disk_images")
|
||||
|
@ -30,8 +30,9 @@ pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [
|
||||
pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10);
|
||||
|
||||
/// Allowed uploaded disk images formats
|
||||
pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 3] = [
|
||||
pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 4] = [
|
||||
"application/x-qemu-disk",
|
||||
"application/x-raw-disk-image",
|
||||
"application/gzip",
|
||||
"application/octet-stream",
|
||||
];
|
||||
@ -57,6 +58,9 @@ pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50);
|
||||
/// Disk size max (B)
|
||||
pub const DISK_SIZE_MAX: FileSize = FileSize::from_gb(20000);
|
||||
|
||||
/// Cloud init generated disk image prefix
|
||||
pub const CLOUD_INIT_IMAGE_PREFIX_NAME: &str = "virtweb-cloudinit-autogen-image";
|
||||
|
||||
/// Net nat entry comment max size
|
||||
pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250;
|
||||
|
||||
@ -133,6 +137,9 @@ pub const PROGRAM_COPY: &str = "/bin/cp";
|
||||
/// Gzip program path
|
||||
pub const PROGRAM_GZIP: &str = "/usr/bin/gzip";
|
||||
|
||||
/// XZ program path
|
||||
pub const PROGRAM_XZ: &str = "/usr/bin/xz";
|
||||
|
||||
/// Bash program
|
||||
pub const PROGRAM_BASH: &str = "/usr/bin/bash";
|
||||
|
||||
|
@ -55,7 +55,15 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>)
|
||||
}
|
||||
|
||||
// Copy the file to the destination
|
||||
file.file.persist(dest_path)?;
|
||||
file.file.persist(&dest_path)?;
|
||||
|
||||
// Check if file information can be loaded
|
||||
if let Err(e) = DiskFileInfo::load_file(&dest_path) {
|
||||
log::error!("Failed to get information about uploaded disk file! {e}");
|
||||
std::fs::remove_file(&dest_path)?;
|
||||
return Ok(HttpResponse::InternalServerError()
|
||||
.json(format!("Unable to process uploaded file! {e}")));
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().json("Successfully uploaded disk image!"))
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ use actix_web::body::BoxBody;
|
||||
use actix_web::{HttpResponse, web};
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use zip::result::ZipError;
|
||||
|
||||
pub mod api_tokens_controller;
|
||||
pub mod auth_controller;
|
||||
@ -102,6 +103,12 @@ impl From<actix_web::Error> for HttpErr {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ZipError> for HttpErr {
|
||||
fn from(value: ZipError) -> Self {
|
||||
HttpErr::Err(std::io::Error::other(value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HttpResponse> for HttpErr {
|
||||
fn from(value: HttpResponse) -> Self {
|
||||
HttpErr::HTTPResponse(value)
|
||||
|
@ -1,14 +1,24 @@
|
||||
use crate::actors::vnc_tokens_actor::VNC_TOKEN_LIFETIME;
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::constants;
|
||||
use crate::constants::{DISK_NAME_MAX_LEN, DISK_NAME_MIN_LEN, DISK_SIZE_MAX, DISK_SIZE_MIN};
|
||||
use crate::controllers::{HttpResult, LibVirtReq};
|
||||
use crate::extractors::local_auth_extractor::LocalAuthEnabled;
|
||||
use crate::libvirt_rest_structures::hypervisor::HypervisorInfo;
|
||||
use crate::libvirt_rest_structures::net::NetworkInfo;
|
||||
use crate::libvirt_rest_structures::nw_filter::NetworkFilter;
|
||||
use crate::libvirt_rest_structures::vm::VMInfo;
|
||||
use crate::nat::nat_hook;
|
||||
use crate::utils::net_utils;
|
||||
use actix_web::{HttpResponse, Responder};
|
||||
use crate::utils::time_utils::{format_date, time};
|
||||
use crate::{api_tokens, constants};
|
||||
use actix_files::NamedFile;
|
||||
use actix_web::{HttpRequest, HttpResponse, Responder};
|
||||
use serde::Serialize;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use sysinfo::{Components, Disks, Networks, System};
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct StaticConfig {
|
||||
@ -199,3 +209,85 @@ pub async fn networks_list() -> HttpResult {
|
||||
pub async fn bridges_list() -> HttpResult {
|
||||
Ok(HttpResponse::Ok().json(net_utils::bridges_list()?))
|
||||
}
|
||||
|
||||
/// Add JSON file to ZIP
|
||||
fn zip_json<E: Serialize, F>(
|
||||
zip: &mut ZipWriter<File>,
|
||||
dir: &str,
|
||||
content: &Vec<E>,
|
||||
file_name: F,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
F: Fn(&E) -> String,
|
||||
{
|
||||
for entry in content {
|
||||
let file_encoded = serde_json::to_string(&entry)?;
|
||||
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o750);
|
||||
|
||||
zip.start_file(format!("{dir}/{}.json", file_name(entry)), options)?;
|
||||
zip.write_all(file_encoded.as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Export all configuration elements at once
|
||||
pub async fn export_all_configs(req: HttpRequest, client: LibVirtReq) -> HttpResult {
|
||||
// Perform extractions
|
||||
let vms = client
|
||||
.get_full_domains_list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(VMInfo::from_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let networks = client
|
||||
.get_full_networks_list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(NetworkInfo::from_xml)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let nw_filters = client
|
||||
.get_full_network_filters_list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(NetworkFilter::lib2rest)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let tokens = api_tokens::full_list().await?;
|
||||
|
||||
// Create ZIP file
|
||||
let dest_dir = tempfile::tempdir_in(&AppConfig::get().temp_dir)?;
|
||||
let zip_path = dest_dir.path().join("export.zip");
|
||||
|
||||
let file = File::create(&zip_path)?;
|
||||
let mut zip = ZipWriter::new(file);
|
||||
|
||||
// Encode entities to JSON
|
||||
zip_json(&mut zip, "vms", &vms, |v| v.name.to_string())?;
|
||||
zip_json(&mut zip, "networks", &networks, |v| v.name.0.to_string())?;
|
||||
zip_json(
|
||||
&mut zip,
|
||||
"nw_filters",
|
||||
&nw_filters,
|
||||
|v| match constants::BUILTIN_NETWORK_FILTER_RULES.contains(&v.name.0.as_str()) {
|
||||
true => format!("builtin/{}", v.name.0),
|
||||
false => v.name.0.to_string(),
|
||||
},
|
||||
)?;
|
||||
zip_json(&mut zip, "tokens", &tokens, |v| v.id.0.to_string())?;
|
||||
|
||||
// Finalize ZIP and return response
|
||||
zip.finish()?;
|
||||
let file = File::open(zip_path)?;
|
||||
|
||||
let file = NamedFile::from_file(
|
||||
file,
|
||||
format!(
|
||||
"export_{}.zip",
|
||||
format_date(time() as i64).unwrap().replace('/', "-")
|
||||
),
|
||||
)?;
|
||||
|
||||
Ok(file.into_response(&req))
|
||||
}
|
||||
|
@ -109,6 +109,28 @@ pub async fn get_single_src_def(client: LibVirtReq, id: web::Path<SingleVMUUidRe
|
||||
.body(info))
|
||||
}
|
||||
|
||||
/// Get the generated cloud init configuration disk of a vm
|
||||
pub async fn get_cloud_init_disk(client: LibVirtReq, id: web::Path<SingleVMUUidReq>) -> HttpResult {
|
||||
let info = match client.get_single_domain(id.uid).await {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
log::error!("Failed to get domain information! {e}");
|
||||
return Ok(HttpResponse::InternalServerError().json(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let vm = VMInfo::from_domain(info)?;
|
||||
let disk = vm.cloud_init.generate_nocloud_disk()?;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/x-iso9660-image")
|
||||
.insert_header((
|
||||
"Content-Disposition",
|
||||
format!("attachment; filename=\"cloud_init_{}.iso\"", vm.name),
|
||||
))
|
||||
.body(disk))
|
||||
}
|
||||
|
||||
/// Update a VM information
|
||||
pub async fn update(
|
||||
client: LibVirtReq,
|
||||
|
@ -13,4 +13,6 @@ enum LibVirtStructError {
|
||||
ParseFilteringChain(String),
|
||||
#[error("NetworkFilterExtractionError: {0}")]
|
||||
NetworkFilterExtraction(String),
|
||||
#[error("CloudInitConfigurationError: {0}")]
|
||||
CloudInitConfiguration(String),
|
||||
}
|
||||
|
@ -3,7 +3,9 @@ use crate::constants;
|
||||
use crate::libvirt_lib_structures::XMLUuid;
|
||||
use crate::libvirt_lib_structures::domain::*;
|
||||
use crate::libvirt_rest_structures::LibVirtStructError;
|
||||
use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction;
|
||||
use crate::libvirt_rest_structures::LibVirtStructError::{
|
||||
CloudInitConfiguration, StructureExtraction,
|
||||
};
|
||||
use crate::utils::cloud_init_utils::CloudInitConfig;
|
||||
use crate::utils::file_size_utils::FileSize;
|
||||
use crate::utils::files_utils;
|
||||
@ -142,9 +144,26 @@ impl VMInfo {
|
||||
return Err(StructureExtraction("Invalid number of vCPU specified!").into());
|
||||
}
|
||||
|
||||
let mut disks = vec![];
|
||||
if let Some(e) = self.cloud_init.check_error() {
|
||||
return Err(CloudInitConfiguration(e).into());
|
||||
}
|
||||
|
||||
// Add ISO files
|
||||
let mut iso_absolute_files = vec![];
|
||||
|
||||
// Process cloud init image
|
||||
if self.cloud_init.attach_config {
|
||||
let cloud_init_disk_path = AppConfig::get().cloud_init_disk_path_for_vm(&self.name);
|
||||
|
||||
// Apply latest cloud init configuration
|
||||
std::fs::write(
|
||||
&cloud_init_disk_path,
|
||||
self.cloud_init.generate_nocloud_disk()?,
|
||||
)?;
|
||||
|
||||
iso_absolute_files.push(cloud_init_disk_path);
|
||||
}
|
||||
|
||||
// Process uploaded ISO files
|
||||
for iso_file in &self.iso_files {
|
||||
if !files_utils::check_file_name(iso_file) {
|
||||
return Err(StructureExtraction("ISO filename is invalid!").into());
|
||||
@ -156,6 +175,13 @@ impl VMInfo {
|
||||
return Err(StructureExtraction("Specified ISO file does not exists!").into());
|
||||
}
|
||||
|
||||
iso_absolute_files.push(path);
|
||||
}
|
||||
|
||||
let mut disks = vec![];
|
||||
|
||||
// Add ISO disk files
|
||||
for iso_path in iso_absolute_files {
|
||||
disks.push(DiskXML {
|
||||
r#type: "file".to_string(),
|
||||
device: "cdrom".to_string(),
|
||||
@ -165,7 +191,7 @@ impl VMInfo {
|
||||
cache: "none".to_string(),
|
||||
},
|
||||
source: DiskSourceXML {
|
||||
file: path.to_string_lossy().to_string(),
|
||||
file: iso_path.to_string_lossy().to_string(),
|
||||
},
|
||||
target: DiskTargetXML {
|
||||
dev: format!(
|
||||
@ -182,6 +208,7 @@ impl VMInfo {
|
||||
})
|
||||
}
|
||||
|
||||
// Configure VNC access, if requested
|
||||
let (vnc_graphics, vnc_video) = match self.vnc_access {
|
||||
true => (
|
||||
Some(GraphicsXML {
|
||||
@ -495,6 +522,7 @@ impl VMInfo {
|
||||
.iter()
|
||||
.filter(|d| d.device == "cdrom")
|
||||
.map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string())
|
||||
.filter(|d| !d.starts_with(constants::CLOUD_INIT_IMAGE_PREFIX_NAME))
|
||||
.collect(),
|
||||
|
||||
file_disks: domain
|
||||
|
@ -61,6 +61,8 @@ async fn main() -> std::io::Result<()> {
|
||||
|
||||
log::debug!("Create required directory, if missing");
|
||||
files_utils::create_directory_if_missing(AppConfig::get().iso_storage_path()).unwrap();
|
||||
files_utils::create_directory_if_missing(AppConfig::get().cloud_init_disk_storage_path())
|
||||
.unwrap();
|
||||
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();
|
||||
@ -155,6 +157,10 @@ async fn main() -> std::io::Result<()> {
|
||||
"/api/server/bridges",
|
||||
web::get().to(server_controller::bridges_list),
|
||||
)
|
||||
.route(
|
||||
"/api/server/export_configs",
|
||||
web::get().to(server_controller::export_all_configs),
|
||||
)
|
||||
// Auth controller
|
||||
.route(
|
||||
"/api/auth/local",
|
||||
@ -202,6 +208,10 @@ async fn main() -> std::io::Result<()> {
|
||||
"/api/vm/{uid}/src",
|
||||
web::get().to(vm_controller::get_single_src_def),
|
||||
)
|
||||
.route(
|
||||
"/api/vm/{uid}/cloud_init_disk",
|
||||
web::get().to(vm_controller::get_cloud_init_disk),
|
||||
)
|
||||
.route(
|
||||
"/api/vm/{uid}/autostart",
|
||||
web::get().to(vm_controller::get_autostart),
|
||||
|
@ -1,19 +1,117 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::constants;
|
||||
use std::process::Command;
|
||||
|
||||
/// Cloud init DS Mode
|
||||
#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum CloudInitDSMode {
|
||||
/// Networking is required
|
||||
Net,
|
||||
/// Does not require networking to be up before user-data actions are run
|
||||
Local,
|
||||
}
|
||||
|
||||
/// VM Cloud Init configuration
|
||||
///
|
||||
/// RedHat documentation: https://docs.redhat.com/fr/documentation/red_hat_enterprise_linux/9/html/configuring_and_managing_cloud-init_for_rhel_9/configuring-cloud-init_cloud-content
|
||||
/// cloud-localds source code: https://github.com/canonical/cloud-utils/blob/main/bin/cloud-localds
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct CloudInitConfig {
|
||||
attach_config: bool,
|
||||
pub attach_config: bool,
|
||||
/// Main user data
|
||||
user_data: String,
|
||||
pub user_data: String,
|
||||
/// Instance ID, set in metadata file
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
instance_id: Option<String>,
|
||||
pub instance_id: Option<String>,
|
||||
/// Local hostname, set in metadata file
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
local_hostname: Option<String>,
|
||||
pub local_hostname: Option<String>,
|
||||
/// Data source mode
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dsmode: Option<CloudInitDSMode>,
|
||||
/// Network configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
network_configuration: Option<String>,
|
||||
pub network_configuration: Option<String>,
|
||||
}
|
||||
|
||||
impl CloudInitConfig {
|
||||
/// Check cloud init configuration
|
||||
pub fn check_error(&self) -> Option<String> {
|
||||
if !self.user_data.is_empty() {
|
||||
// Check YAML content
|
||||
if let Err(e) = serde_yml::from_str::<serde_json::Value>(&self.user_data) {
|
||||
return Some(format!(
|
||||
"user data is an invalid YAML file! Deserialization error: {e}"
|
||||
));
|
||||
}
|
||||
|
||||
// Check first line
|
||||
if !self.user_data.starts_with("#cloud-config\n") {
|
||||
return Some(
|
||||
"user data file MUST start with '#cloud-config' as first line!".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Generate disk image for nocloud usage
|
||||
pub fn generate_nocloud_disk(&self) -> anyhow::Result<Vec<u8>> {
|
||||
let temp_path = tempfile::tempdir_in(&AppConfig::get().temp_dir)?;
|
||||
|
||||
let mut cmd = Command::new(constants::PROGRAM_CLOUD_LOCALDS);
|
||||
|
||||
// ISO destination path
|
||||
let temp_iso = temp_path.path().join("disk.iso");
|
||||
cmd.arg(&temp_iso);
|
||||
|
||||
// Process network configuration
|
||||
if let Some(net_conf) = &self.network_configuration {
|
||||
let net_conf_path = temp_path.path().join("network");
|
||||
std::fs::write(&net_conf_path, net_conf)?;
|
||||
cmd.arg("--network-config").arg(&net_conf_path);
|
||||
}
|
||||
|
||||
// Process user data
|
||||
let user_data_path = temp_path.path().join("user-data");
|
||||
std::fs::write(&user_data_path, &self.user_data)?;
|
||||
cmd.arg(user_data_path);
|
||||
|
||||
// Process metadata
|
||||
let mut metadatas = vec![];
|
||||
if let Some(inst_id) = &self.instance_id {
|
||||
metadatas.push(format!("instance-id: {}", inst_id));
|
||||
}
|
||||
if let Some(local_hostname) = &self.local_hostname {
|
||||
metadatas.push(format!("local-hostname: {}", local_hostname));
|
||||
}
|
||||
if let Some(dsmode) = &self.dsmode {
|
||||
metadatas.push(format!(
|
||||
"dsmode: {}",
|
||||
match dsmode {
|
||||
CloudInitDSMode::Net => "net",
|
||||
CloudInitDSMode::Local => "local",
|
||||
}
|
||||
));
|
||||
}
|
||||
let meta_data_path = temp_path.path().join("meta-data");
|
||||
std::fs::write(&meta_data_path, metadatas.join("\n"))?;
|
||||
cmd.arg(meta_data_path);
|
||||
|
||||
// Execute command
|
||||
let output = cmd.output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"{} exited with status {}!\nStdout: {}\nStderr: {}",
|
||||
constants::PROGRAM_CLOUD_LOCALDS,
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
// Read generated ISO file
|
||||
Ok(std::fs::read(temp_iso)?)
|
||||
}
|
||||
}
|
||||
|
@ -28,8 +28,10 @@ pub enum DiskFileFormat {
|
||||
#[serde(default)]
|
||||
virtual_size: FileSize,
|
||||
},
|
||||
CompressedRaw,
|
||||
CompressedQCow2,
|
||||
GzCompressedRaw,
|
||||
GzCompressedQCow2,
|
||||
XzCompressedRaw,
|
||||
XzCompressedQCow2,
|
||||
}
|
||||
|
||||
impl DiskFileFormat {
|
||||
@ -37,8 +39,10 @@ impl DiskFileFormat {
|
||||
match self {
|
||||
DiskFileFormat::Raw { .. } => &["raw", ""],
|
||||
DiskFileFormat::QCow2 { .. } => &["qcow2"],
|
||||
DiskFileFormat::CompressedRaw => &["raw.gz"],
|
||||
DiskFileFormat::CompressedQCow2 => &["qcow2.gz"],
|
||||
DiskFileFormat::GzCompressedRaw => &["raw.gz"],
|
||||
DiskFileFormat::GzCompressedQCow2 => &["qcow2.gz"],
|
||||
DiskFileFormat::XzCompressedRaw => &["raw.xz"],
|
||||
DiskFileFormat::XzCompressedQCow2 => &["qcow2.xz"],
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -81,9 +85,14 @@ impl DiskFileInfo {
|
||||
},
|
||||
"gz" if name.ends_with(".qcow2") => {
|
||||
name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string();
|
||||
DiskFileFormat::CompressedQCow2
|
||||
DiskFileFormat::GzCompressedQCow2
|
||||
}
|
||||
"gz" => DiskFileFormat::CompressedRaw,
|
||||
"gz" => DiskFileFormat::GzCompressedRaw,
|
||||
"xz" if name.ends_with(".qcow2") => {
|
||||
name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string();
|
||||
DiskFileFormat::XzCompressedQCow2
|
||||
}
|
||||
"xz" => DiskFileFormat::XzCompressedRaw,
|
||||
_ => anyhow::bail!("Unsupported disk extension: {ext}!"),
|
||||
};
|
||||
|
||||
@ -159,8 +168,8 @@ impl DiskFileInfo {
|
||||
|
||||
// Prepare the conversion
|
||||
let mut cmd = match (self.format, dest_format) {
|
||||
// Decompress QCow2
|
||||
(DiskFileFormat::CompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
|
||||
// Decompress QCow2 (GZIP)
|
||||
(DiskFileFormat::GzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_GZIP);
|
||||
cmd.arg("--keep")
|
||||
.arg("--decompress")
|
||||
@ -170,8 +179,19 @@ impl DiskFileInfo {
|
||||
cmd
|
||||
}
|
||||
|
||||
// Compress QCow2
|
||||
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::CompressedQCow2) => {
|
||||
// Decompress QCow2 (XZ)
|
||||
(DiskFileFormat::XzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_XZ);
|
||||
cmd.arg("--stdout")
|
||||
.arg("--keep")
|
||||
.arg("--decompress")
|
||||
.arg(&self.file_path)
|
||||
.stdout(File::create(&temp_file)?);
|
||||
cmd
|
||||
}
|
||||
|
||||
// Compress QCow2 (Gzip)
|
||||
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::GzCompressedQCow2) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_GZIP);
|
||||
cmd.arg("--keep")
|
||||
.arg("--to-stdout")
|
||||
@ -180,6 +200,16 @@ impl DiskFileInfo {
|
||||
cmd
|
||||
}
|
||||
|
||||
// Compress QCow2 (Xz)
|
||||
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::XzCompressedQCow2) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_XZ);
|
||||
cmd.arg("--keep")
|
||||
.arg("--to-stdout")
|
||||
.arg(&self.file_path)
|
||||
.stdout(File::create(&temp_file)?);
|
||||
cmd
|
||||
}
|
||||
|
||||
// Convert QCow2 to Raw file
|
||||
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
|
||||
@ -244,8 +274,8 @@ impl DiskFileInfo {
|
||||
cmd
|
||||
}
|
||||
|
||||
// Compress Raw
|
||||
(DiskFileFormat::Raw { .. }, DiskFileFormat::CompressedRaw) => {
|
||||
// Compress Raw (Gz)
|
||||
(DiskFileFormat::Raw { .. }, DiskFileFormat::GzCompressedRaw) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_GZIP);
|
||||
cmd.arg("--keep")
|
||||
.arg("--to-stdout")
|
||||
@ -254,8 +284,18 @@ impl DiskFileInfo {
|
||||
cmd
|
||||
}
|
||||
|
||||
// Decompress Raw to not sparse file
|
||||
(DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => {
|
||||
// Compress Raw (Xz)
|
||||
(DiskFileFormat::Raw { .. }, DiskFileFormat::XzCompressedRaw) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_XZ);
|
||||
cmd.arg("--keep")
|
||||
.arg("--to-stdout")
|
||||
.arg(&self.file_path)
|
||||
.stdout(File::create(&temp_file)?);
|
||||
cmd
|
||||
}
|
||||
|
||||
// Decompress Raw (Gz) to not sparse file
|
||||
(DiskFileFormat::GzCompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_GZIP);
|
||||
cmd.arg("--keep")
|
||||
.arg("--decompress")
|
||||
@ -264,13 +304,23 @@ impl DiskFileInfo {
|
||||
.stdout(File::create(&temp_file)?);
|
||||
cmd
|
||||
}
|
||||
// Decompress Raw (Xz) to not sparse file
|
||||
(DiskFileFormat::XzCompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_XZ);
|
||||
cmd.arg("--keep")
|
||||
.arg("--decompress")
|
||||
.arg("--to-stdout")
|
||||
.arg(&self.file_path)
|
||||
.stdout(File::create(&temp_file)?);
|
||||
cmd
|
||||
}
|
||||
|
||||
// Decompress Raw to sparse file
|
||||
// Decompress Raw (Gz) to sparse file
|
||||
// https://benou.fr/www/ben/decompressing-sparse-files.html
|
||||
(DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => {
|
||||
(DiskFileFormat::GzCompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_BASH);
|
||||
cmd.arg("-c").arg(format!(
|
||||
"{} -d -c {} | {} conv=sparse of={}",
|
||||
"{} --decompress --to-stdout {} | {} conv=sparse of={}",
|
||||
constants::PROGRAM_GZIP,
|
||||
self.file_path.display(),
|
||||
constants::PROGRAM_DD,
|
||||
@ -279,6 +329,20 @@ impl DiskFileInfo {
|
||||
cmd
|
||||
}
|
||||
|
||||
// Decompress Raw (XZ) to sparse file
|
||||
// https://benou.fr/www/ben/decompressing-sparse-files.html
|
||||
(DiskFileFormat::XzCompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_BASH);
|
||||
cmd.arg("-c").arg(format!(
|
||||
"{} --decompress --to-stdout {} | {} conv=sparse of={}",
|
||||
constants::PROGRAM_XZ,
|
||||
self.file_path.display(),
|
||||
constants::PROGRAM_DD,
|
||||
temp_file.display()
|
||||
));
|
||||
cmd
|
||||
}
|
||||
|
||||
// Dumb copy of file
|
||||
(a, b) if a == b => {
|
||||
let mut cmd = Command::new(constants::PROGRAM_COPY);
|
||||
@ -330,6 +394,44 @@ impl DiskFileInfo {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get disk virtual size, if available
|
||||
pub fn virtual_size(&self) -> Option<FileSize> {
|
||||
match self.format {
|
||||
DiskFileFormat::Raw { .. } => Some(self.file_size),
|
||||
DiskFileFormat::QCow2 { virtual_size } => Some(virtual_size),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize disk
|
||||
pub fn resize(&self, new_size: FileSize) -> anyhow::Result<()> {
|
||||
if new_size <= self.virtual_size().unwrap_or(new_size) {
|
||||
anyhow::bail!("Shrinking disk image file is not supported!");
|
||||
}
|
||||
|
||||
let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
|
||||
cmd.arg("resize")
|
||||
.arg("-f")
|
||||
.arg(match self.format {
|
||||
DiskFileFormat::QCow2 { .. } => "qcow2",
|
||||
DiskFileFormat::Raw { .. } => "raw",
|
||||
f => anyhow::bail!("Unsupported disk format for resize: {f:?}"),
|
||||
})
|
||||
.arg(&self.file_path)
|
||||
.arg(new_size.as_bytes().to_string());
|
||||
|
||||
let output = cmd.output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"{} info failed, status: {}, stderr: {}",
|
||||
constants::PROGRAM_QEMU_IMAGE,
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
|
@ -1,3 +1,4 @@
|
||||
use chrono::Datelike;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Get the current time since epoch
|
||||
@ -13,3 +14,15 @@ pub fn time() -> u64 {
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// Format given UNIX time in a simple format
|
||||
pub fn format_date(time: i64) -> anyhow::Result<String> {
|
||||
let date = chrono::DateTime::from_timestamp(time, 0).ok_or(anyhow::anyhow!("invalid date"))?;
|
||||
|
||||
Ok(format!(
|
||||
"{:0>2}/{:0>2}/{}",
|
||||
date.day(),
|
||||
date.month(),
|
||||
date.year()
|
||||
))
|
||||
}
|
||||
|
@ -44,6 +44,9 @@ pub struct VMFileDisk {
|
||||
/// When creating a new disk, specify the disk image template to use
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub from_image: Option<String>,
|
||||
/// Set this variable to true to resize disk image
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resize: Option<bool>,
|
||||
/// Set this variable to true to delete the disk
|
||||
pub delete: bool,
|
||||
}
|
||||
@ -78,6 +81,7 @@ impl VMFileDisk {
|
||||
|
||||
delete: false,
|
||||
from_image: None,
|
||||
resize: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -144,28 +148,40 @@ impl VMFileDisk {
|
||||
|
||||
if file.exists() {
|
||||
log::debug!("File {file:?} does not exists, so it was not touched");
|
||||
return Ok(());
|
||||
}
|
||||
// Create disk if required
|
||||
else {
|
||||
// Determine file format
|
||||
let format = match self.format {
|
||||
VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
|
||||
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
|
||||
virtual_size: self.size,
|
||||
},
|
||||
};
|
||||
|
||||
// Create / Restore disk file
|
||||
match &self.from_image {
|
||||
// Create disk file
|
||||
None => {
|
||||
DiskFileInfo::create(&file, format, self.size)?;
|
||||
}
|
||||
|
||||
// Restore disk image template
|
||||
Some(disk_img) => {
|
||||
let src_file =
|
||||
DiskFileInfo::load_file(&AppConfig::get().disk_images_file_path(disk_img))?;
|
||||
src_file.convert(&file, format)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let format = match self.format {
|
||||
VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
|
||||
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
|
||||
virtual_size: self.size,
|
||||
},
|
||||
};
|
||||
// Resize disk file if requested
|
||||
if self.resize == Some(true) {
|
||||
let disk = DiskFileInfo::load_file(&file)?;
|
||||
|
||||
// Create / Restore disk file
|
||||
match &self.from_image {
|
||||
// Create disk file
|
||||
None => {
|
||||
DiskFileInfo::create(&file, format, self.size)?;
|
||||
}
|
||||
|
||||
// Restore disk image template
|
||||
Some(disk_img) => {
|
||||
let src_file =
|
||||
DiskFileInfo::load_file(&AppConfig::get().disk_images_file_path(disk_img))?;
|
||||
src_file.convert(&file, format)?;
|
||||
// Can only increase disk size
|
||||
if let Err(e) = disk.resize(self.size) {
|
||||
log::error!("Failed to resize disk file {}: {e:?}", self.name);
|
||||
}
|
||||
}
|
||||
|
||||
|
196
virtweb_frontend/package-lock.json
generated
196
virtweb_frontend/package-lock.json
generated
@ -10,9 +10,10 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@fontsource/roboto": "^5.2.5",
|
||||
"@fontsource/roboto": "^5.2.6",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@mui/icons-material": "^7.1.1",
|
||||
"@mui/material": "^7.1.1",
|
||||
"@mui/x-charts": "^8.3.1",
|
||||
@ -20,19 +21,22 @@
|
||||
"date-and-time": "^3.6.0",
|
||||
"filesize": "^10.1.6",
|
||||
"humanize-duration": "^3.32.2",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"monaco-yaml": "^5.4.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-vnc": "^3.1.0",
|
||||
"uuid": "^11.1.0",
|
||||
"xml-formatter": "^3.6.6"
|
||||
"xml-formatter": "^3.6.6",
|
||||
"yaml": "^2.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.27.0",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^10.0.0",
|
||||
@ -777,9 +781,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/roboto": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.5.tgz",
|
||||
"integrity": "sha512-70r2UZ0raqLn5W+sPeKhqlf8wGvUXFWlofaDlcbt/S3d06+17gXKr3VNqDODB0I1ASme3dGT5OJj9NABt7OTZQ==",
|
||||
"version": "5.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.6.tgz",
|
||||
"integrity": "sha512-hzarG7yAhMoP418smNgfY4fO7UmuUEm5JUtbxCoCcFHT0hOJB+d/qAEyoNjz7YkPU5OjM2LM8rJnW8hfm0JLaA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
@ -958,6 +962,29 @@
|
||||
"prop-types": "^15.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/loader": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz",
|
||||
"integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"state-local": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
||||
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monaco-editor": ">= 0.25.0 < 1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/core-downloads-tracker": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.1.1.tgz",
|
||||
@ -1598,9 +1625,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz",
|
||||
"integrity": "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==",
|
||||
"version": "19.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
|
||||
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
@ -3481,6 +3508,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@ -3607,6 +3640,84 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.52.2",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
|
||||
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/monaco-languageserver-types": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-languageserver-types/-/monaco-languageserver-types-0.4.0.tgz",
|
||||
"integrity": "sha512-QQ3BZiU5LYkJElGncSNb5AKoJ/LCs6YBMCJMAz9EA7v+JaOdn3kx2cXpPTcZfKA5AEsR0vc97sAw+5mdNhVBmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"monaco-types": "^0.1.0",
|
||||
"vscode-languageserver-protocol": "^3.0.0",
|
||||
"vscode-uri": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/remcohaszing"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-marker-data-provider": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/monaco-marker-data-provider/-/monaco-marker-data-provider-1.2.4.tgz",
|
||||
"integrity": "sha512-4DsPgsAqpTyUDs3humXRBPUJoihTv+L6v9aupQWD80X2YXaCXUd11mWYeSCYHuPgdUmjFaNWCEOjQ6ewf/QA1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"monaco-types": "^0.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/remcohaszing"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-types": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-types/-/monaco-types-0.1.0.tgz",
|
||||
"integrity": "sha512-aWK7SN9hAqNYi0WosPoMjenMeXJjwCxDibOqWffyQ/qXdzB/86xshGQobRferfmNz7BSNQ8GB0MD0oby9/5fTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/remcohaszing"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-worker-manager": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/monaco-worker-manager/-/monaco-worker-manager-2.0.1.tgz",
|
||||
"integrity": "sha512-kdPL0yvg5qjhKPNVjJoym331PY/5JC11aPJXtCZNwWRvBr6jhkIamvYAyiY5P1AWFmNOy0aRDRoMdZfa71h8kg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"monaco-editor": ">=0.30.0"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-yaml": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.4.0.tgz",
|
||||
"integrity": "sha512-tuBVDy1KAPrgO905GHTItu8AaA5bIzF5S4X0JVRAE/D66FpRhkDUk7tKi5bwKMVTTugtpMLsXN4ewh4CgE/FtQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"examples/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"jsonc-parser": "^3.0.0",
|
||||
"monaco-languageserver-types": "^0.4.0",
|
||||
"monaco-marker-data-provider": "^1.0.0",
|
||||
"monaco-types": "^0.1.0",
|
||||
"monaco-worker-manager": "^2.0.0",
|
||||
"path-browserify": "^1.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"vscode-languageserver-textdocument": "^1.0.0",
|
||||
"vscode-languageserver-types": "^3.0.0",
|
||||
"vscode-uri": "^3.0.0",
|
||||
"yaml": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/remcohaszing"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monaco-editor": ">=0.36"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@ -3753,6 +3864,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@ -3846,6 +3963,21 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
|
||||
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
|
||||
@ -4326,6 +4458,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/state-local": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-ts": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/string-ts/-/string-ts-2.2.1.tgz",
|
||||
@ -4714,6 +4852,43 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-protocol": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
|
||||
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vscode-jsonrpc": "8.2.0",
|
||||
"vscode-languageserver-types": "3.17.5"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-textdocument": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
|
||||
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-languageserver-types": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
||||
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@ -4781,10 +4956,7 @@
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
|
||||
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
|
@ -12,9 +12,10 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@fontsource/roboto": "^5.2.5",
|
||||
"@fontsource/roboto": "^5.2.6",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@mui/icons-material": "^7.1.1",
|
||||
"@mui/material": "^7.1.1",
|
||||
"@mui/x-charts": "^8.3.1",
|
||||
@ -22,19 +23,22 @@
|
||||
"date-and-time": "^3.6.0",
|
||||
"filesize": "^10.1.6",
|
||||
"humanize-duration": "^3.32.2",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"monaco-yaml": "^5.4.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-vnc": "^3.1.0",
|
||||
"uuid": "^11.1.0",
|
||||
"xml-formatter": "^3.6.6"
|
||||
"xml-formatter": "^3.6.6",
|
||||
"yaml": "^2.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.27.0",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
@ -4,8 +4,10 @@ import { VMFileDisk, VMInfo } from "./VMApi";
|
||||
export type DiskImageFormat =
|
||||
| { format: "Raw"; is_sparse: boolean }
|
||||
| { format: "QCow2"; virtual_size?: number }
|
||||
| { format: "CompressedQCow2" }
|
||||
| { format: "CompressedRaw" };
|
||||
| { format: "GzCompressedQCow2" }
|
||||
| { format: "GzCompressedRaw" }
|
||||
| { format: "XzCompressedQCow2" }
|
||||
| { format: "XzCompressedRaw" };
|
||||
|
||||
export type DiskImage = {
|
||||
file_size: number;
|
||||
|
@ -232,4 +232,16 @@ export class ServerApi {
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all server configs
|
||||
*/
|
||||
static async ExportServerConfigs(): Promise<Blob> {
|
||||
return (
|
||||
await APIClient.exec({
|
||||
method: "GET",
|
||||
uri: "/server/export_configs",
|
||||
})
|
||||
).data;
|
||||
}
|
||||
}
|
||||
|
@ -31,8 +31,12 @@ export interface BaseFileVMDisk {
|
||||
// For new disk only
|
||||
from_image?: string;
|
||||
|
||||
// Resize disk image after clone
|
||||
resize?: boolean;
|
||||
|
||||
// application attributes
|
||||
new?: boolean;
|
||||
originalSize?: number;
|
||||
deleteType?: "keepfile" | "deletefile";
|
||||
}
|
||||
|
||||
@ -82,6 +86,15 @@ export interface VMNetBridge {
|
||||
bridge: string;
|
||||
}
|
||||
|
||||
export interface VMCloudInit {
|
||||
attach_config: boolean;
|
||||
user_data: string;
|
||||
instance_id?: string;
|
||||
local_hostname?: string;
|
||||
dsmode?: "Net" | "Local";
|
||||
network_configuration?: string;
|
||||
}
|
||||
|
||||
export type VMBootType = "UEFI" | "UEFISecureBoot" | "Legacy";
|
||||
|
||||
interface VMInfoInterface {
|
||||
@ -101,6 +114,7 @@ interface VMInfoInterface {
|
||||
networks: VMNetInterface[];
|
||||
tpm_module: boolean;
|
||||
oem_strings: string[];
|
||||
cloud_init: VMCloudInit;
|
||||
}
|
||||
|
||||
export class VMInfo implements VMInfoInterface {
|
||||
@ -120,6 +134,7 @@ export class VMInfo implements VMInfoInterface {
|
||||
networks: VMNetInterface[];
|
||||
tpm_module: boolean;
|
||||
oem_strings: string[];
|
||||
cloud_init: VMCloudInit;
|
||||
|
||||
constructor(int: VMInfoInterface) {
|
||||
this.name = int.name;
|
||||
@ -138,6 +153,7 @@ export class VMInfo implements VMInfoInterface {
|
||||
this.networks = int.networks;
|
||||
this.tpm_module = int.tpm_module;
|
||||
this.oem_strings = int.oem_strings;
|
||||
this.cloud_init = int.cloud_init;
|
||||
}
|
||||
|
||||
static NewEmpty(): VMInfo {
|
||||
@ -153,6 +169,7 @@ export class VMInfo implements VMInfoInterface {
|
||||
networks: [],
|
||||
tpm_module: true,
|
||||
oem_strings: [],
|
||||
cloud_init: { attach_config: false, user_data: "" },
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -42,13 +42,15 @@ export function ConvertDiskImageDialog(
|
||||
setFormat({ format: value ?? ("QCow2" as any) });
|
||||
|
||||
if (value === "QCow2") setFilename(`${origFilename}.qcow2`);
|
||||
if (value === "CompressedQCow2") setFilename(`${origFilename}.qcow2.gz`);
|
||||
if (value === "GzCompressedQCow2") setFilename(`${origFilename}.qcow2.gz`);
|
||||
if (value === "XzCompressedQCow2") setFilename(`${origFilename}.qcow2.xz`);
|
||||
if (value === "Raw") {
|
||||
setFilename(`${origFilename}.raw`);
|
||||
// Check sparse checkbox by default
|
||||
setFormat({ format: "Raw", is_sparse: true });
|
||||
}
|
||||
if (value === "CompressedRaw") setFilename(`${origFilename}.raw.gz`);
|
||||
if (value === "GzCompressedRaw") setFilename(`${origFilename}.raw.gz`);
|
||||
if (value === "XzCompressedRaw") setFilename(`${origFilename}.raw.xz`);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@ -104,8 +106,10 @@ export function ConvertDiskImageDialog(
|
||||
options={[
|
||||
{ value: "QCow2" },
|
||||
{ value: "Raw" },
|
||||
{ value: "CompressedRaw" },
|
||||
{ value: "CompressedQCow2" },
|
||||
{ value: "GzCompressedRaw" },
|
||||
{ value: "XzCompressedRaw" },
|
||||
{ value: "GzCompressedQCow2" },
|
||||
{ value: "XzCompressedQCow2" },
|
||||
]}
|
||||
/>
|
||||
|
||||
|
@ -3,16 +3,44 @@ import "@fontsource/roboto/400.css";
|
||||
import "@fontsource/roboto/500.css";
|
||||
import "@fontsource/roboto/700.css";
|
||||
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider";
|
||||
import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider";
|
||||
import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider";
|
||||
import { SnackbarProvider } from "./hooks/providers/SnackbarProvider";
|
||||
import "./index.css";
|
||||
import { LoadServerConfig } from "./widgets/LoadServerConfig";
|
||||
import { ThemeProvider, createTheme } from "@mui/material";
|
||||
import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider";
|
||||
import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider";
|
||||
import { SnackbarProvider } from "./hooks/providers/SnackbarProvider";
|
||||
import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider";
|
||||
|
||||
import { loader } from "@monaco-editor/react";
|
||||
import * as monaco from "monaco-editor";
|
||||
import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
|
||||
import { configureMonacoYaml } from "monaco-yaml";
|
||||
import YamlWorker from "monaco-yaml/yaml.worker?worker";
|
||||
|
||||
// This allows to use a self hosted instance of Monaco editor
|
||||
loader.config({ monaco });
|
||||
|
||||
// Add YAML support to Monaco
|
||||
configureMonacoYaml(monaco, {
|
||||
enableSchemaRequest: false,
|
||||
});
|
||||
|
||||
/// YAML worker
|
||||
window.MonacoEnvironment = {
|
||||
getWorker(_moduleId, label) {
|
||||
switch (label) {
|
||||
case "editorWorkerService":
|
||||
return new EditorWorker();
|
||||
case "yaml":
|
||||
return new YamlWorker();
|
||||
default:
|
||||
throw new Error(`Unknown label ${label}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const darkTheme = createTheme({
|
||||
palette: {
|
||||
@ -20,9 +48,7 @@ const darkTheme = createTheme({
|
||||
},
|
||||
});
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById("root")!
|
||||
);
|
||||
const root = ReactDOM.createRoot(document.getElementById("root")!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={darkTheme}>
|
||||
|
@ -9,18 +9,21 @@ import {
|
||||
import Icon from "@mdi/react";
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { PieChart } from "@mui/x-charts";
|
||||
import { filesize } from "filesize";
|
||||
import humanizeDuration from "humanize-duration";
|
||||
import IosShareIcon from "@mui/icons-material/IosShare";
|
||||
import React from "react";
|
||||
import {
|
||||
DiskInfo,
|
||||
@ -31,6 +34,8 @@ import {
|
||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||
import { VirtWebPaper } from "../widgets/VirtWebPaper";
|
||||
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
|
||||
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
|
||||
import { useAlert } from "../hooks/providers/AlertDialogProvider";
|
||||
|
||||
export function SysInfoRoute(): React.ReactElement {
|
||||
const [info, setInfo] = React.useState<ServerSystemInfo>();
|
||||
@ -52,6 +57,23 @@ export function SysInfoRoute(): React.ReactElement {
|
||||
export function SysInfoRouteInner(p: {
|
||||
info: ServerSystemInfo;
|
||||
}): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
const downloadAllConfig = async () => {
|
||||
try {
|
||||
loadingMessage.show("Downloading server config...");
|
||||
const res = await ServerApi.ExportServerConfigs();
|
||||
|
||||
const url = URL.createObjectURL(res);
|
||||
window.location.href = url;
|
||||
} catch (e) {
|
||||
console.error("Failed to download server config!", e);
|
||||
alert(`Failed to download server config! ${e}`);
|
||||
} finally {
|
||||
loadingMessage.hide();
|
||||
}
|
||||
};
|
||||
|
||||
const sumDiskUsage = p.info.disks.reduce(
|
||||
(prev, disk) => {
|
||||
return {
|
||||
@ -63,7 +85,16 @@ export function SysInfoRouteInner(p: {
|
||||
);
|
||||
|
||||
return (
|
||||
<VirtWebRouteContainer label="Sysinfo">
|
||||
<VirtWebRouteContainer
|
||||
label="Sysinfo"
|
||||
actions={
|
||||
<Tooltip title="Export all server configs">
|
||||
<IconButton onClick={downloadAllConfig}>
|
||||
<IosShareIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Grid container spacing={2}>
|
||||
{/* Memory */}
|
||||
<Grid size={{ xs: 4 }}>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
|
||||
import {
|
||||
Alert,
|
||||
CircularProgress,
|
||||
@ -36,7 +36,9 @@ export function LoginRoute(): React.ReactElement {
|
||||
const canSubmit = username.length > 0 && password.length > 0;
|
||||
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const handleClickShowPassword = () => { setShowPassword((show) => !show); };
|
||||
const handleClickShowPassword = () => {
|
||||
setShowPassword((show) => !show);
|
||||
};
|
||||
|
||||
const handleMouseDownPassword = (
|
||||
event: React.MouseEvent<HTMLButtonElement>
|
||||
@ -105,12 +107,14 @@ export function LoginRoute(): React.ReactElement {
|
||||
label="Username"
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={(e) => { setUsername(e.target.value); }}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
}}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<FormControl fullWidth variant="outlined">
|
||||
<FormControl required fullWidth variant="outlined">
|
||||
<InputLabel htmlFor="password">Password</InputLabel>
|
||||
<OutlinedInput
|
||||
required
|
||||
@ -120,7 +124,9 @@ export function LoginRoute(): React.ReactElement {
|
||||
type={showPassword ? "text" : "password"}
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); }}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
@ -131,7 +137,11 @@ export function LoginRoute(): React.ReactElement {
|
||||
onMouseDown={handleMouseDownPassword}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||
{showPassword ? (
|
||||
<VisibilityOffIcon />
|
||||
) : (
|
||||
<VisibilityIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
|
@ -17,7 +17,9 @@ export function CheckboxInput(p: {
|
||||
<Checkbox
|
||||
disabled={!p.editable}
|
||||
checked={p.checked}
|
||||
onChange={(e) => { p.onValueChange(e.target.checked); }}
|
||||
onChange={(e) => {
|
||||
p.onValueChange(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={p.label}
|
||||
|
341
virtweb_frontend/src/widgets/forms/CloudInitEditor.tsx
Normal file
341
virtweb_frontend/src/widgets/forms/CloudInitEditor.tsx
Normal file
@ -0,0 +1,341 @@
|
||||
/* eslint-disable @typescript-eslint/no-base-to-string */
|
||||
|
||||
import Editor from "@monaco-editor/react";
|
||||
import BookIcon from "@mui/icons-material/Book";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import { Grid, IconButton, InputAdornment, Tooltip } from "@mui/material";
|
||||
import React from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import YAML from "yaml";
|
||||
import { VMInfo } from "../../api/VMApi";
|
||||
import { RouterLink } from "../RouterLink";
|
||||
import { CheckboxInput } from "./CheckboxInput";
|
||||
import { EditSection } from "./EditSection";
|
||||
import { SelectInput } from "./SelectInput";
|
||||
import { TextInput } from "./TextInput";
|
||||
|
||||
interface CloudInitProps {
|
||||
vm: VMInfo;
|
||||
onChange?: () => void;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
export function CloudInitEditor(p: CloudInitProps): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<EditSection>
|
||||
{/* Attach cloud init disk */}
|
||||
<CheckboxInput
|
||||
{...p}
|
||||
label="Attach Cloud Init disk"
|
||||
checked={p.vm.cloud_init.attach_config}
|
||||
onValueChange={(v) => {
|
||||
p.vm.cloud_init.attach_config = v;
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
</EditSection>
|
||||
<Grid container spacing={2}>
|
||||
<CloudInitMetadata
|
||||
{...p}
|
||||
editable={p.editable && p.vm.cloud_init.attach_config}
|
||||
/>
|
||||
<CloudInitRawUserData
|
||||
{...p}
|
||||
editable={p.editable && p.vm.cloud_init.attach_config}
|
||||
/>
|
||||
<CloudInitNetworkConfig
|
||||
{...p}
|
||||
editable={p.editable && p.vm.cloud_init.attach_config}
|
||||
/>
|
||||
<CloudInitUserDataAssistant
|
||||
{...p}
|
||||
editable={p.editable && p.vm.cloud_init.attach_config}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudInitMetadata(p: CloudInitProps): React.ReactElement {
|
||||
// Regenerate instance id
|
||||
const reGenerateInstanceId = () => {
|
||||
p.vm.cloud_init.instance_id = uuidv4();
|
||||
p.onChange?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<EditSection title="Metadata">
|
||||
{/* Instance ID */}
|
||||
<TextInput
|
||||
{...p}
|
||||
label="Instance ID"
|
||||
value={p.vm.cloud_init.instance_id}
|
||||
onValueChange={(v) => {
|
||||
p.vm.cloud_init.instance_id = v;
|
||||
p.onChange?.();
|
||||
}}
|
||||
endAdornment={
|
||||
p.editable ? (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip title="Generate a new instance ID">
|
||||
<IconButton onClick={reGenerateInstanceId}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Instance hostname */}
|
||||
<TextInput
|
||||
{...p}
|
||||
label="Local hostname"
|
||||
value={p.vm.cloud_init.local_hostname}
|
||||
onValueChange={(v) => {
|
||||
p.vm.cloud_init.local_hostname = v;
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Data source mode */}
|
||||
<SelectInput
|
||||
{...p}
|
||||
label="Data source mode"
|
||||
value={p.vm.cloud_init.dsmode}
|
||||
onValueChange={(v) => {
|
||||
p.vm.cloud_init.dsmode = v as any;
|
||||
p.onChange?.();
|
||||
}}
|
||||
options={[
|
||||
{ label: "None", value: undefined },
|
||||
{ value: "Net" },
|
||||
{ value: "Local" },
|
||||
]}
|
||||
/>
|
||||
</EditSection>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudInitRawUserData(p: CloudInitProps): React.ReactElement {
|
||||
return (
|
||||
<EditSection
|
||||
title="User data"
|
||||
actions={
|
||||
<RouterLink
|
||||
target="_blank"
|
||||
to="https://cloudinit.readthedocs.io/en/latest/reference/index.html"
|
||||
>
|
||||
<Tooltip title="Official reference">
|
||||
<IconButton size="small">
|
||||
<BookIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</RouterLink>
|
||||
}
|
||||
>
|
||||
<Editor
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
readOnly: !p.editable,
|
||||
quickSuggestions: { other: true, comments: true, strings: true },
|
||||
wordWrap: "on",
|
||||
}}
|
||||
language="yaml"
|
||||
height={"30vh"}
|
||||
value={p.vm.cloud_init.user_data}
|
||||
onChange={(v) => {
|
||||
p.vm.cloud_init.user_data = v ?? "";
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
</EditSection>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudInitNetworkConfig(p: CloudInitProps): React.ReactElement {
|
||||
if (!p.editable && !p.vm.cloud_init.network_configuration) return <></>;
|
||||
return (
|
||||
<EditSection
|
||||
title="Network configuration"
|
||||
actions={
|
||||
<RouterLink
|
||||
target="_blank"
|
||||
to="https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v2.html"
|
||||
>
|
||||
<Tooltip title="Official network configuration reference">
|
||||
<IconButton size="small">
|
||||
<BookIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</RouterLink>
|
||||
}
|
||||
>
|
||||
<Editor
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
readOnly: !p.editable,
|
||||
quickSuggestions: { other: true, comments: true, strings: true },
|
||||
wordWrap: "on",
|
||||
}}
|
||||
language="yaml"
|
||||
height={"30vh"}
|
||||
value={p.vm.cloud_init.network_configuration ?? ""}
|
||||
onChange={(v) => {
|
||||
if (v && v !== "") p.vm.cloud_init.network_configuration = v;
|
||||
else p.vm.cloud_init.network_configuration = undefined;
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
</EditSection>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudInitUserDataAssistant(p: CloudInitProps): React.ReactElement {
|
||||
const user_data = React.useMemo(() => {
|
||||
return YAML.parseDocument(p.vm.cloud_init.user_data);
|
||||
}, [p.vm.cloud_init.user_data]);
|
||||
|
||||
const onChange = () => {
|
||||
p.vm.cloud_init.user_data = user_data.toString();
|
||||
|
||||
if (!p.vm.cloud_init.user_data.startsWith("#cloud-config"))
|
||||
p.vm.cloud_init.user_data = `#cloud-config\n${p.vm.cloud_init.user_data}`;
|
||||
|
||||
p.onChange?.();
|
||||
};
|
||||
|
||||
const SYSTEMD_NOT_SERIAL = `/bin/sh -c "rm -f /etc/default/grub.d/50-cloudimg-settings.cfg && sed -i 's/quiet splash//g' /etc/default/grub && update-grub"`;
|
||||
|
||||
return (
|
||||
<EditSection title="User data assistant">
|
||||
<CloudInitTextInput
|
||||
editable={p.editable}
|
||||
name="Default user name"
|
||||
refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
|
||||
attrPath={["user", "name"]}
|
||||
onChange={onChange}
|
||||
yaml={user_data}
|
||||
/>
|
||||
<CloudInitTextInput
|
||||
editable={p.editable}
|
||||
name="Default user password"
|
||||
refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
|
||||
attrPath={["password"]}
|
||||
onChange={onChange}
|
||||
yaml={user_data}
|
||||
/>
|
||||
<CloudInitBooleanInput
|
||||
editable={p.editable}
|
||||
name="Expire password to require new password on next login"
|
||||
yaml={user_data}
|
||||
attrPath={["chpasswd", "expire"]}
|
||||
onChange={onChange}
|
||||
refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
|
||||
/>
|
||||
<br />
|
||||
<CloudInitBooleanInput
|
||||
editable={p.editable}
|
||||
name="Enable SSH password auth"
|
||||
yaml={user_data}
|
||||
attrPath={["ssh_pwauth"]}
|
||||
onChange={onChange}
|
||||
refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
|
||||
/>
|
||||
<CloudInitTextInput
|
||||
editable={p.editable}
|
||||
name="Keyboard layout"
|
||||
refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#keyboard"
|
||||
attrPath={["keyboard", "layout"]}
|
||||
onChange={onChange}
|
||||
yaml={user_data}
|
||||
/>
|
||||
<CloudInitTextInput
|
||||
editable={p.editable}
|
||||
name="Final message"
|
||||
refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#final-message"
|
||||
attrPath={["final_message"]}
|
||||
onChange={onChange}
|
||||
yaml={user_data}
|
||||
/>
|
||||
{/* /bin/sh -c "rm -f /etc/default/grub.d/50-cloudimg-settings.cfg && update-grub" */}
|
||||
<CheckboxInput
|
||||
editable={p.editable}
|
||||
label="Show all startup messages on tty1, not serial"
|
||||
checked={
|
||||
!!(user_data.get("runcmd") as any)?.items.find(
|
||||
(a: any) => a.value === SYSTEMD_NOT_SERIAL
|
||||
)
|
||||
}
|
||||
onValueChange={(c) => {
|
||||
if (!user_data.getIn(["runcmd"])) user_data.addIn(["runcmd"], []);
|
||||
|
||||
const runcmd = user_data.getIn(["runcmd"]) as any;
|
||||
|
||||
if (c) {
|
||||
runcmd.addIn([], SYSTEMD_NOT_SERIAL);
|
||||
} else {
|
||||
const idx = runcmd.items.findIndex(
|
||||
(o: any) => o.value === SYSTEMD_NOT_SERIAL
|
||||
);
|
||||
runcmd.items.splice(idx, 1);
|
||||
}
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</EditSection>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudInitTextInput(p: {
|
||||
editable: boolean;
|
||||
name: string;
|
||||
refUrl: string;
|
||||
attrPath: Iterable<unknown>;
|
||||
yaml: YAML.Document;
|
||||
onChange?: () => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<TextInput
|
||||
editable={p.editable}
|
||||
label={p.name}
|
||||
value={String(p.yaml.getIn(p.attrPath) ?? "")}
|
||||
onValueChange={(v) => {
|
||||
if (v !== undefined) p.yaml.setIn(p.attrPath, v);
|
||||
else p.yaml.deleteIn(p.attrPath);
|
||||
p.onChange?.();
|
||||
}}
|
||||
endAdornment={
|
||||
<RouterLink to={p.refUrl} target="_blank">
|
||||
<IconButton size="small">
|
||||
<BookIcon />
|
||||
</IconButton>
|
||||
</RouterLink>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudInitBooleanInput(p: {
|
||||
editable: boolean;
|
||||
name: string;
|
||||
refUrl: string;
|
||||
attrPath: Iterable<unknown>;
|
||||
yaml: YAML.Document;
|
||||
onChange?: () => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<CheckboxInput
|
||||
editable={p.editable}
|
||||
label={p.name}
|
||||
checked={p.yaml.getIn(p.attrPath) === true}
|
||||
onValueChange={(v) => {
|
||||
p.yaml.setIn(p.attrPath, v);
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
25
virtweb_frontend/src/widgets/forms/DiskSizeInput.tsx
Normal file
25
virtweb_frontend/src/widgets/forms/DiskSizeInput.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { ServerApi } from "../../api/ServerApi";
|
||||
import { TextInput } from "./TextInput";
|
||||
|
||||
export function DiskSizeInput(p: {
|
||||
editable: boolean;
|
||||
label?: string;
|
||||
value: number;
|
||||
onChange?: (size: number) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<TextInput
|
||||
editable={p.editable}
|
||||
label={p.label ?? "Disk size (GB)"}
|
||||
size={{
|
||||
min: ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000),
|
||||
max: ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000),
|
||||
}}
|
||||
value={(p.value / (1000 * 1000 * 1000)).toString()}
|
||||
onValueChange={(v) => {
|
||||
p.onChange?.(Number(v ?? "0") * 1000 * 1000 * 1000);
|
||||
}}
|
||||
type="number"
|
||||
/>
|
||||
);
|
||||
}
|
@ -19,13 +19,10 @@ export function EditSection(
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "15px",
|
||||
}}
|
||||
>
|
||||
{p.title && (
|
||||
<Typography variant="h5" style={{ marginBottom: "15px" }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
)}
|
||||
{p.title && <Typography variant="h5">{p.title}</Typography>}
|
||||
{p.actions}
|
||||
</span>
|
||||
)}
|
||||
|
@ -25,6 +25,8 @@ export function OEMStringFormWidget(p: {
|
||||
p.onChange?.();
|
||||
};
|
||||
|
||||
if (!p.editable && p.vm.oem_strings.length === 0) return <></>;
|
||||
|
||||
return (
|
||||
<EditSection
|
||||
title="SMBIOS OEM Strings"
|
||||
|
@ -18,6 +18,7 @@ export function TextInput(p: {
|
||||
style?: React.CSSProperties;
|
||||
helperText?: string;
|
||||
disabled?: boolean;
|
||||
endAdornment?: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
if (!p.editable && (p.value ?? "") === "") return <></>;
|
||||
|
||||
@ -51,6 +52,7 @@ export function TextInput(p: {
|
||||
input: {
|
||||
readOnly: !p.editable,
|
||||
type: p.type,
|
||||
endAdornment: p.endAdornment,
|
||||
},
|
||||
}}
|
||||
variant={"standard"}
|
||||
|
@ -2,7 +2,8 @@ import { mdiHarddiskPlus } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { Button, IconButton, Paper, Tooltip } from "@mui/material";
|
||||
import ExpandIcon from "@mui/icons-material/Expand";
|
||||
import { Button, IconButton, Paper, Tooltip, Typography } from "@mui/material";
|
||||
import React from "react";
|
||||
import { DiskImage } from "../../api/DiskImageApi";
|
||||
import { ServerApi } from "../../api/ServerApi";
|
||||
@ -13,6 +14,7 @@ import { VMDiskFileWidget } from "../vms/VMDiskFileWidget";
|
||||
import { CheckboxInput } from "./CheckboxInput";
|
||||
import { DiskBusSelect } from "./DiskBusSelect";
|
||||
import { DiskImageSelect } from "./DiskImageSelect";
|
||||
import { DiskSizeInput } from "./DiskSizeInput";
|
||||
import { SelectInput } from "./SelectInput";
|
||||
import { TextInput } from "./TextInput";
|
||||
|
||||
@ -67,6 +69,12 @@ export function VMDisksList(p: {
|
||||
/>
|
||||
))}
|
||||
|
||||
{p.vm.file_disks.length === 0 && (
|
||||
<Typography style={{ textAlign: "center", paddingTop: "25px" }}>
|
||||
No disk file yet!
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{p.editable && <Button onClick={addNewDisk}>Add new disk</Button>}
|
||||
|
||||
{/* Disk backup */}
|
||||
@ -93,6 +101,19 @@ function DiskInfo(p: {
|
||||
diskImagesList: DiskImage[];
|
||||
}): React.ReactElement {
|
||||
const confirm = useConfirm();
|
||||
|
||||
const expandDisk = () => {
|
||||
if (p.disk.resize === true) {
|
||||
p.disk.resize = false;
|
||||
p.disk.size = p.disk.originalSize!;
|
||||
} else {
|
||||
p.disk.resize = true;
|
||||
p.disk.originalSize = p.disk.size!;
|
||||
}
|
||||
|
||||
p.onChange?.();
|
||||
};
|
||||
|
||||
const deleteDisk = async () => {
|
||||
if (p.disk.deleteType) {
|
||||
p.disk.deleteType = undefined;
|
||||
@ -115,42 +136,75 @@ function DiskInfo(p: {
|
||||
|
||||
if (!p.editable || !p.disk.new)
|
||||
return (
|
||||
<VMDiskFileWidget
|
||||
{...p}
|
||||
secondaryAction={
|
||||
<>
|
||||
{p.editable && (
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="delete disk"
|
||||
onClick={deleteDisk}
|
||||
>
|
||||
{p.disk.deleteType ? (
|
||||
<Tooltip title="Cancel disk removal">
|
||||
<CheckCircleIcon />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Remove disk">
|
||||
<DeleteIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{p.canBackup && (
|
||||
<Tooltip title="Backup this disk">
|
||||
<>
|
||||
<VMDiskFileWidget
|
||||
{...p}
|
||||
secondaryAction={
|
||||
<>
|
||||
{p.editable && !p.disk.deleteType && (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
p.onRequestBackup(p.disk);
|
||||
}}
|
||||
edge="end"
|
||||
aria-label="expand disk"
|
||||
onClick={expandDisk}
|
||||
>
|
||||
<Icon path={mdiHarddiskPlus} size={1} />
|
||||
{p.disk.resize === true ? (
|
||||
<Tooltip title="Cancel disk expansion">
|
||||
<ExpandIcon color="error" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Increase disk size">
|
||||
<ExpandIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{p.editable && (
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="delete disk"
|
||||
onClick={deleteDisk}
|
||||
>
|
||||
{p.disk.deleteType ? (
|
||||
<Tooltip title="Cancel disk removal">
|
||||
<CheckCircleIcon />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Remove disk">
|
||||
<DeleteIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{p.canBackup && (
|
||||
<Tooltip title="Backup this disk">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
p.onRequestBackup(p.disk);
|
||||
}}
|
||||
>
|
||||
<Icon path={mdiHarddiskPlus} size={1} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* New disk size*/}
|
||||
{p.disk.resize && !p.disk.deleteType && (
|
||||
<DiskSizeInput
|
||||
editable
|
||||
label="New disk size (GB)"
|
||||
value={p.disk.size}
|
||||
onChange={(v) => {
|
||||
p.disk.size = v;
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -212,24 +266,32 @@ function DiskInfo(p: {
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
editable={true}
|
||||
label="Disk size (GB)"
|
||||
size={{
|
||||
min:
|
||||
ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000),
|
||||
max:
|
||||
ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000),
|
||||
}}
|
||||
value={(p.disk.size / (1000 * 1000 * 1000)).toString()}
|
||||
onValueChange={(v) => {
|
||||
p.disk.size = Number(v ?? "0") * 1000 * 1000 * 1000;
|
||||
p.onChange?.();
|
||||
}}
|
||||
type="number"
|
||||
disabled={!!p.disk.from_image}
|
||||
/>
|
||||
{/* Resize disk image */}
|
||||
{!!p.disk.from_image && (
|
||||
<CheckboxInput
|
||||
editable
|
||||
checked={p.disk.resize}
|
||||
label="Resize disk file"
|
||||
onValueChange={(v) => {
|
||||
p.disk.resize = v;
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Disk size */}
|
||||
{(!p.disk.from_image || p.disk.resize === true) && (
|
||||
<DiskSizeInput
|
||||
editable
|
||||
value={p.disk.size}
|
||||
onChange={(v) => {
|
||||
p.disk.size = v;
|
||||
p.onChange?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Disk image selection */}
|
||||
<DiskImageSelect
|
||||
label="Use disk image as template"
|
||||
list={p.diskImagesList}
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import { NWFilter } from "../../api/NWFilterApi";
|
||||
@ -49,6 +50,12 @@ export function VMNetworksList(p: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{p.vm.networks.length === 0 && (
|
||||
<Typography style={{ textAlign: "center", paddingTop: "25px" }}>
|
||||
No network interface defined yet!
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{/* networks list */}
|
||||
{p.vm.networks.map((n, num) => (
|
||||
|
@ -60,6 +60,7 @@ export function TokenRightsEditor(p: {
|
||||
<TableCell align="center">Get XML definition</TableCell>
|
||||
<TableCell align="center">Get autostart</TableCell>
|
||||
<TableCell align="center">Set autostart</TableCell>
|
||||
<TableCell align="center">Get CloudInit disk</TableCell>
|
||||
<TableCell align="center">Backup disk</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@ -84,6 +85,13 @@ export function TokenRightsEditor(p: {
|
||||
{...p}
|
||||
right={{ verb: "PUT", path: "/api/vm/*/autostart" }}
|
||||
/>
|
||||
<CellRight
|
||||
{...p}
|
||||
right={{
|
||||
verb: "GET",
|
||||
path: "/api/vm/*/cloud_init_disk",
|
||||
}}
|
||||
/>
|
||||
<CellRight
|
||||
{...p}
|
||||
right={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }}
|
||||
@ -123,7 +131,15 @@ export function TokenRightsEditor(p: {
|
||||
{...p}
|
||||
right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }}
|
||||
parent={{ verb: "PUT", path: "/api/vm/*/autostart" }}
|
||||
/>{" "}
|
||||
/>
|
||||
<CellRight
|
||||
{...p}
|
||||
right={{
|
||||
verb: "GET",
|
||||
path: `/api/vm/${v.uuid}/cloud_init_disk`,
|
||||
}}
|
||||
parent={{ verb: "GET", path: "/api/vm/*/cloud_init_disk" }}
|
||||
/>
|
||||
<CellRight
|
||||
{...p}
|
||||
right={{
|
||||
@ -783,6 +799,11 @@ export function TokenRightsEditor(p: {
|
||||
right={{ verb: "GET", path: "/api/server/bridges" }}
|
||||
label="Get list of network bridges"
|
||||
/>
|
||||
<RouteRight
|
||||
{...p}
|
||||
right={{ verb: "GET", path: "/api/server/export_configs" }}
|
||||
label="Export all configurations"
|
||||
/>
|
||||
</RightsSection>
|
||||
</>
|
||||
);
|
||||
|
@ -5,6 +5,7 @@ import Grid from "@mui/material/Grid";
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { validate as validateUUID } from "uuid";
|
||||
import { DiskImage, DiskImageApi } from "../../api/DiskImageApi";
|
||||
import { GroupApi } from "../../api/GroupApi";
|
||||
import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi";
|
||||
import { NWFilter, NWFilterApi } from "../../api/NWFilterApi";
|
||||
@ -18,6 +19,7 @@ import { AsyncWidget } from "../AsyncWidget";
|
||||
import { TabsWidget } from "../TabsWidget";
|
||||
import { XMLAsyncWidget } from "../XMLWidget";
|
||||
import { CheckboxInput } from "../forms/CheckboxInput";
|
||||
import { CloudInitEditor } from "../forms/CloudInitEditor";
|
||||
import { EditSection } from "../forms/EditSection";
|
||||
import { OEMStringFormWidget } from "../forms/OEMStringFormWidget";
|
||||
import { ResAutostartInput } from "../forms/ResAutostartInput";
|
||||
@ -27,7 +29,6 @@ import { VMDisksList } from "../forms/VMDisksList";
|
||||
import { VMNetworksList } from "../forms/VMNetworksList";
|
||||
import { VMSelectIsoInput } from "../forms/VMSelectIsoInput";
|
||||
import { VMScreenshot } from "./VMScreenshot";
|
||||
import { DiskImage, DiskImageApi } from "../../api/DiskImageApi";
|
||||
|
||||
interface DetailsProps {
|
||||
vm: VMInfo;
|
||||
@ -89,6 +90,7 @@ enum VMTab {
|
||||
General = 0,
|
||||
Storage,
|
||||
Network,
|
||||
CloudInit,
|
||||
Advanced,
|
||||
XML,
|
||||
Danger,
|
||||
@ -116,6 +118,11 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement {
|
||||
{ label: "General", value: VMTab.General, visible: true },
|
||||
{ label: "Storage", value: VMTab.Storage, visible: true },
|
||||
{ label: "Network", value: VMTab.Network, visible: true },
|
||||
{
|
||||
label: "Cloud Init",
|
||||
value: VMTab.CloudInit,
|
||||
visible: p.editable || p.vm.cloud_init.attach_config,
|
||||
},
|
||||
{ label: "Avanced", value: VMTab.Advanced, visible: true },
|
||||
|
||||
{
|
||||
@ -135,6 +142,7 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement {
|
||||
{currTab === VMTab.General && <VMDetailsTabGeneral {...p} />}
|
||||
{currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />}
|
||||
{currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />}
|
||||
{currTab === VMTab.CloudInit && <VMDetailsTabCloudInit {...p} />}
|
||||
{currTab === VMTab.Advanced && <VMDetailsTabAdvanced {...p} />}
|
||||
{currTab === VMTab.XML && <VMDetailsTabXML {...p} />}
|
||||
{currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />}
|
||||
@ -381,6 +389,10 @@ function VMDetailsTabNetwork(p: DetailsInnerProps): React.ReactElement {
|
||||
return <VMNetworksList {...p} />;
|
||||
}
|
||||
|
||||
function VMDetailsTabCloudInit(p: DetailsInnerProps): React.ReactElement {
|
||||
return <CloudInitEditor {...p} />;
|
||||
}
|
||||
|
||||
function VMDetailsTabAdvanced(p: DetailsInnerProps): React.ReactElement {
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
|
Reference in New Issue
Block a user