Compare commits
90 Commits
0671a8f2e2
...
renovate/e
Author | SHA1 | Date | |
---|---|---|---|
03053ac4de | |||
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 | |||
b3f56cea81 | |||
9bd702d60f | |||
c8b42626a9 | |||
4ef15507d9 | |||
8a4b3a4db6 | |||
8bce9ca9b7 | |||
5574037b73 | |||
d1ca9aee39 | |||
f850ca5cb7 | |||
4ee01cad4b | |||
5518b45219 | |||
0279907ca9 | |||
5fe481ffed | |||
c7cc15d8d0 | |||
22416badcf | |||
ef0d77f1d6 | |||
1d4af8c74e | |||
ec9492c933 | |||
fa03ae885f | |||
ea98aaf856 | |||
794d16bdaa | |||
a3ac56f849 | |||
6130f37336 | |||
6b6fef5ccc | |||
83df7e1b20 | |||
a18310e04a | |||
dd7f9176fa | |||
d5fbc24c96 | |||
d765f9c2c3 | |||
21fd5de139 | |||
42f22c110c | |||
9822c5a72a | |||
452a395525 | |||
80d81c34bb | |||
b9353326f5 | |||
3ffc64f129 | |||
e869517bb1 | |||
90f4bf35e9 | |||
80d6fe0298 | |||
e017fe96d5 | |||
e7ac0198ab | |||
927a51cda7 | |||
615dc1ed83 | |||
20de618568 | |||
7451f1b7b4 | |||
5814b0ab6a | |||
5a5450070a | |||
19ec9992be | |||
b55880b43c | |||
6a7af7e6c4 | |||
a8171375a8 | |||
de33c7d521 | |||
ff372800bd | |||
aac05b7faf | |||
61e5a42ef7 |
@ -46,8 +46,9 @@ steps:
|
|||||||
- cd virtweb_backend
|
- cd virtweb_backend
|
||||||
- mv /tmp/web_build/dist static
|
- mv /tmp/web_build/dist static
|
||||||
- cargo build --release
|
- cargo build --release
|
||||||
- ls -lah target/release/virtweb_backend
|
- cargo build --release --example api_curl
|
||||||
- cp target/release/virtweb_backend /tmp/release
|
- 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
|
- name: gitea_release
|
||||||
image: plugins/gitea-release
|
image: plugins/gitea-release
|
||||||
|
291
virtweb_backend/Cargo.lock
generated
291
virtweb_backend/Cargo.lock
generated
@ -435,6 +435,21 @@ dependencies = [
|
|||||||
"alloc-no-stdlib",
|
"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]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "0.6.18"
|
version = "0.6.18"
|
||||||
@ -496,6 +511,9 @@ name = "arbitrary"
|
|||||||
version = "1.4.1"
|
version = "1.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
|
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
|
||||||
|
dependencies = [
|
||||||
|
"derive_arbitrary",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arg_enum_proc_macro"
|
name = "arg_enum_proc_macro"
|
||||||
@ -715,6 +733,25 @@ dependencies = [
|
|||||||
"bytes",
|
"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]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.23"
|
version = "1.2.23"
|
||||||
@ -748,6 +785,20 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
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]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.4.4"
|
version = "0.4.4"
|
||||||
@ -760,9 +811,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.38"
|
version = "4.5.40"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000"
|
checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@ -770,9 +821,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.38"
|
version = "4.5.40"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120"
|
checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@ -782,9 +833,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.5.32"
|
version = "4.5.40"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
|
checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@ -816,6 +867,12 @@ version = "0.9.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant_time_eq"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@ -981,6 +1038,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deflate64"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@ -1001,6 +1064,17 @@ dependencies = [
|
|||||||
"powerfmt",
|
"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]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "0.99.20"
|
version = "0.99.20"
|
||||||
@ -1221,6 +1295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
|
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
|
"libz-rs-sys",
|
||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1380,9 +1455,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
|
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasi 0.14.2+wasi-0.2.4",
|
"wasi 0.14.2+wasi-0.2.4",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1622,18 +1699,47 @@ version = "0.1.12"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf9f1e950e0d9d1d3c47184416723cf29c0d1f93bd8cccf37e4beb6b44f31710"
|
checksum = "cf9f1e950e0d9d1d3c47184416723cf29c0d1f93bd8cccf37e4beb6b44f31710"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http 1.3.1",
|
"http 1.3.1",
|
||||||
"http-body",
|
"http-body",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"ipnet",
|
||||||
"libc",
|
"libc",
|
||||||
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"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]]
|
[[package]]
|
||||||
@ -1839,6 +1945,16 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iri-string"
|
||||||
|
version = "0.7.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.1"
|
version = "1.70.1"
|
||||||
@ -1982,6 +2098,45 @@ dependencies = [
|
|||||||
"cc",
|
"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]]
|
[[package]]
|
||||||
name = "light-openid"
|
name = "light-openid"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@ -2404,6 +2559,16 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
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]]
|
[[package]]
|
||||||
name = "pem"
|
name = "pem"
|
||||||
version = "3.0.5"
|
version = "3.0.5"
|
||||||
@ -2773,9 +2938,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.15"
|
version = "0.12.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb"
|
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
@ -2790,31 +2955,28 @@ dependencies = [
|
|||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
"hyper-tls",
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"ipnet",
|
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
"once_cell",
|
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustls-pemfile",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"system-configuration",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"windows-registry",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2923,15 +3085,6 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls-pemfile"
|
|
||||||
version = "2.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
|
||||||
dependencies = [
|
|
||||||
"rustls-pki-types",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@ -3093,6 +3246,21 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@ -3522,6 +3690,24 @@ dependencies = [
|
|||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-http"
|
||||||
|
version = "0.6.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.9.1",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http 1.3.1",
|
||||||
|
"http-body",
|
||||||
|
"iri-string",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-layer"
|
name = "tower-layer"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@ -3735,6 +3921,7 @@ dependencies = [
|
|||||||
"actix-ws",
|
"actix-ws",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"basic-jwt",
|
"basic-jwt",
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@ -3754,6 +3941,7 @@ dependencies = [
|
|||||||
"rust-embed",
|
"rust-embed",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yml",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.12",
|
"thiserror 2.0.12",
|
||||||
@ -3761,6 +3949,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"virt",
|
"virt",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -4299,6 +4488,20 @@ name = "zeroize"
|
|||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
|
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]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
@ -4333,6 +4536,50 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "zstd"
|
name = "zstd"
|
||||||
version = "0.13.3"
|
version = "0.13.3"
|
||||||
|
@ -8,7 +8,7 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
log = "0.4.27"
|
log = "0.4.27"
|
||||||
env_logger = "0.11.8"
|
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"] }
|
light-openid = { version = "1.0.4", features = ["crypto-wrapper"] }
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
actix = "0.13.5"
|
actix = "0.13.5"
|
||||||
@ -19,15 +19,16 @@ actix-identity = "0.8.0"
|
|||||||
actix-cors = "0.7.1"
|
actix-cors = "0.7.1"
|
||||||
actix-files = "0.6.6"
|
actix-files = "0.6.6"
|
||||||
actix-ws = "0.3.0"
|
actix-ws = "0.3.0"
|
||||||
actix-http = "3.10.0"
|
actix-http = "3.11.0"
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
serde_json = "1.0.140"
|
serde_json = "1.0.140"
|
||||||
|
serde_yml = "0.0.12"
|
||||||
quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] }
|
quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] }
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
anyhow = "1.0.98"
|
anyhow = "1.0.98"
|
||||||
actix-multipart = "0.7.2"
|
actix-multipart = "0.7.2"
|
||||||
tempfile = "3.20.0"
|
tempfile = "3.20.0"
|
||||||
reqwest = { version = "0.12.15", features = ["stream"] }
|
reqwest = { version = "0.12.20", features = ["stream"] }
|
||||||
url = "2.5.4"
|
url = "2.5.4"
|
||||||
virt = "0.4.2"
|
virt = "0.4.2"
|
||||||
sysinfo = { version = "0.35.1", features = ["serde"] }
|
sysinfo = { version = "0.35.1", features = ["serde"] }
|
||||||
@ -44,3 +45,5 @@ rust-embed = { version = "8.7.2", features = ["mime-guess"] }
|
|||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
nix = { version = "0.30.1", features = ["net"] }
|
nix = { version = "0.30.1", features = ["net"] }
|
||||||
basic-jwt = "0.3.0"
|
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,
|
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 {
|
if !msg.keep_files {
|
||||||
log::info!("Delete storage associated with the domain");
|
log::info!("Delete storage associated with the domain");
|
||||||
let path = AppConfig::get().vm_storage_path(msg.id);
|
let path = AppConfig::get().vm_storage_path(msg.id);
|
||||||
|
@ -245,11 +245,34 @@ impl AppConfig {
|
|||||||
storage_path.canonicalize().unwrap()
|
storage_path.canonicalize().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get iso storage directory
|
/// Get iso files storage directory
|
||||||
pub fn iso_storage_path(&self) -> PathBuf {
|
pub fn iso_storage_path(&self) -> PathBuf {
|
||||||
self.storage_path().join("iso")
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the path of a disk image file
|
||||||
|
pub fn disk_images_file_path(&self, name: &str) -> PathBuf {
|
||||||
|
self.disk_images_storage_path().join(name)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get VM vnc sockets directory
|
/// Get VM vnc sockets directory
|
||||||
pub fn vnc_sockets_path(&self) -> PathBuf {
|
pub fn vnc_sockets_path(&self) -> PathBuf {
|
||||||
self.storage_path().join("vnc")
|
self.storage_path().join("vnc")
|
||||||
@ -260,15 +283,17 @@ impl AppConfig {
|
|||||||
self.vnc_sockets_path().join(format!("vnc-{}", name))
|
self.vnc_sockets_path().join(format!("vnc-{}", name))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get VM vnc sockets directory
|
/// Get VM root disks storage directory
|
||||||
pub fn disks_storage_path(&self) -> PathBuf {
|
pub fn root_vm_disks_storage_path(&self) -> PathBuf {
|
||||||
self.storage_path().join("disks")
|
self.storage_path().join("disks")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get specific VM disk storage directory
|
||||||
pub fn vm_storage_path(&self, id: XMLUuid) -> PathBuf {
|
pub fn vm_storage_path(&self, id: XMLUuid) -> PathBuf {
|
||||||
self.disks_storage_path().join(id.as_string())
|
self.root_vm_disks_storage_path().join(id.as_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the path were VM definitions are backed up
|
||||||
pub fn definitions_path(&self) -> PathBuf {
|
pub fn definitions_path(&self) -> PathBuf {
|
||||||
self.storage_path().join("definitions")
|
self.storage_path().join("definitions")
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
use crate::utils::file_size_utils::FileSize;
|
||||||
|
|
||||||
/// Name of the cookie that contains session information
|
/// Name of the cookie that contains session information
|
||||||
pub const SESSION_COOKIE_NAME: &str = "X-auth-token";
|
pub const SESSION_COOKIE_NAME: &str = "X-auth-token";
|
||||||
|
|
||||||
@ -25,13 +27,24 @@ pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// ISO max size
|
/// ISO max size
|
||||||
pub const ISO_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000;
|
pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10);
|
||||||
|
|
||||||
/// Min VM memory size (MB)
|
/// Allowed uploaded disk images formats
|
||||||
pub const MIN_VM_MEMORY: usize = 100;
|
pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 4] = [
|
||||||
|
"application/x-qemu-disk",
|
||||||
|
"application/x-raw-disk-image",
|
||||||
|
"application/gzip",
|
||||||
|
"application/octet-stream",
|
||||||
|
];
|
||||||
|
|
||||||
/// Max VM memory size (MB)
|
/// Disk image max size
|
||||||
pub const MAX_VM_MEMORY: usize = 64000;
|
pub const DISK_IMAGE_MAX_SIZE: FileSize = FileSize::from_gb(10 * 1000);
|
||||||
|
|
||||||
|
/// Min VM memory size
|
||||||
|
pub const MIN_VM_MEMORY: FileSize = FileSize::from_mb(100);
|
||||||
|
|
||||||
|
/// Max VM memory size
|
||||||
|
pub const MAX_VM_MEMORY: FileSize = FileSize::from_gb(64);
|
||||||
|
|
||||||
/// Disk name min length
|
/// Disk name min length
|
||||||
pub const DISK_NAME_MIN_LEN: usize = 2;
|
pub const DISK_NAME_MIN_LEN: usize = 2;
|
||||||
@ -39,11 +52,14 @@ pub const DISK_NAME_MIN_LEN: usize = 2;
|
|||||||
/// Disk name max length
|
/// Disk name max length
|
||||||
pub const DISK_NAME_MAX_LEN: usize = 10;
|
pub const DISK_NAME_MAX_LEN: usize = 10;
|
||||||
|
|
||||||
/// Disk size min (MB)
|
/// Disk size min (B)
|
||||||
pub const DISK_SIZE_MIN: usize = 100;
|
pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50);
|
||||||
|
|
||||||
/// Disk size max (MB)
|
/// Disk size max (B)
|
||||||
pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 2;
|
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
|
/// Net nat entry comment max size
|
||||||
pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250;
|
pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250;
|
||||||
@ -108,3 +124,27 @@ pub const API_TOKEN_DESCRIPTION_MAX_LENGTH: usize = 30;
|
|||||||
|
|
||||||
/// API token right path max length
|
/// API token right path max length
|
||||||
pub const API_TOKEN_RIGHT_PATH_MAX_LENGTH: usize = 255;
|
pub const API_TOKEN_RIGHT_PATH_MAX_LENGTH: usize = 255;
|
||||||
|
|
||||||
|
/// Qemu image program path
|
||||||
|
pub const PROGRAM_QEMU_IMAGE: &str = "/usr/bin/qemu-img";
|
||||||
|
|
||||||
|
/// IP program path
|
||||||
|
pub const PROGRAM_IP: &str = "/usr/sbin/ip";
|
||||||
|
|
||||||
|
/// Copy program path
|
||||||
|
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";
|
||||||
|
|
||||||
|
/// DD program
|
||||||
|
pub const PROGRAM_DD: &str = "/usr/bin/dd";
|
||||||
|
|
||||||
|
/// cloud-localds program
|
||||||
|
pub const PROGRAM_CLOUD_LOCALDS: &str = "/usr/bin/cloud-localds";
|
||||||
|
255
virtweb_backend/src/controllers/disk_images_controller.rs
Normal file
255
virtweb_backend/src/controllers/disk_images_controller.rs
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
use crate::app_config::AppConfig;
|
||||||
|
use crate::constants;
|
||||||
|
use crate::controllers::{HttpResult, LibVirtReq};
|
||||||
|
use crate::libvirt_lib_structures::XMLUuid;
|
||||||
|
use crate::libvirt_rest_structures::vm::VMInfo;
|
||||||
|
use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo};
|
||||||
|
use crate::utils::files_utils;
|
||||||
|
use actix_files::NamedFile;
|
||||||
|
use actix_multipart::form::MultipartForm;
|
||||||
|
use actix_multipart::form::tempfile::TempFile;
|
||||||
|
use actix_web::{HttpRequest, HttpResponse, web};
|
||||||
|
|
||||||
|
#[derive(Debug, MultipartForm)]
|
||||||
|
pub struct UploadDiskImageForm {
|
||||||
|
#[multipart(rename = "file")]
|
||||||
|
files: Vec<TempFile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload disk image file
|
||||||
|
pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>) -> HttpResult {
|
||||||
|
if form.files.is_empty() {
|
||||||
|
log::error!("Missing uploaded disk file!");
|
||||||
|
return Ok(HttpResponse::BadRequest().json("Missing file!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = form.files.remove(0);
|
||||||
|
|
||||||
|
// Check uploaded file size
|
||||||
|
if file.size > constants::DISK_IMAGE_MAX_SIZE.as_bytes() {
|
||||||
|
return Ok(HttpResponse::BadRequest().json("Disk image max size exceeded!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file mime type
|
||||||
|
if let Some(mime_type) = file.content_type {
|
||||||
|
if !constants::ALLOWED_DISK_IMAGES_MIME_TYPES.contains(&mime_type.as_ref()) {
|
||||||
|
return Ok(HttpResponse::BadRequest().json(format!(
|
||||||
|
"Unsupported file type for disk upload: {}",
|
||||||
|
mime_type
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and check file name
|
||||||
|
let Some(file_name) = file.file_name else {
|
||||||
|
return Ok(HttpResponse::BadRequest().json("Missing file name of uploaded file!"));
|
||||||
|
};
|
||||||
|
if !files_utils::check_file_name(&file_name) {
|
||||||
|
return Ok(HttpResponse::BadRequest().json("Invalid uploaded file name!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a file with the same name already exists
|
||||||
|
let dest_path = AppConfig::get().disk_images_file_path(&file_name);
|
||||||
|
if dest_path.is_file() {
|
||||||
|
return Ok(HttpResponse::Conflict().json("A file with the same name already exists!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the file to the destination
|
||||||
|
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!"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get disk images list
|
||||||
|
pub async fn get_list() -> HttpResult {
|
||||||
|
let mut list = vec![];
|
||||||
|
for entry in AppConfig::get().disk_images_storage_path().read_dir()? {
|
||||||
|
let entry = entry?;
|
||||||
|
list.push(DiskFileInfo::load_file(&entry.path())?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct DiskFilePath {
|
||||||
|
filename: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download disk image
|
||||||
|
pub async fn download(p: web::Path<DiskFilePath>, req: HttpRequest) -> HttpResult {
|
||||||
|
if !files_utils::check_file_name(&p.filename) {
|
||||||
|
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_path = AppConfig::get().disk_images_file_path(&p.filename);
|
||||||
|
|
||||||
|
if !file_path.exists() {
|
||||||
|
return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(NamedFile::open(file_path)?.into_response(&req))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct ConvertDiskImageRequest {
|
||||||
|
dest_file_name: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
format: DiskFileFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert disk image into a new format
|
||||||
|
pub async fn convert(
|
||||||
|
p: web::Path<DiskFilePath>,
|
||||||
|
req: web::Json<ConvertDiskImageRequest>,
|
||||||
|
) -> HttpResult {
|
||||||
|
if !files_utils::check_file_name(&p.filename) {
|
||||||
|
return Ok(HttpResponse::BadRequest().json("Invalid src file name!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let src_file_path = AppConfig::get().disk_images_file_path(&p.filename);
|
||||||
|
|
||||||
|
let src = DiskFileInfo::load_file(&src_file_path)?;
|
||||||
|
|
||||||
|
handle_convert_request(src, &req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct BackupVMDiskPath {
|
||||||
|
uid: XMLUuid,
|
||||||
|
diskid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform disk backup
|
||||||
|
pub async fn backup_disk(
|
||||||
|
client: LibVirtReq,
|
||||||
|
path: web::Path<BackupVMDiskPath>,
|
||||||
|
req: web::Json<ConvertDiskImageRequest>,
|
||||||
|
) -> HttpResult {
|
||||||
|
// Get the VM information
|
||||||
|
let info = match client.get_single_domain(path.uid).await {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to get domain info! {e}");
|
||||||
|
return Ok(HttpResponse::InternalServerError().json(e.to_string()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let vm = VMInfo::from_domain(info)?;
|
||||||
|
|
||||||
|
// Load disk information
|
||||||
|
let Some(disk) = vm
|
||||||
|
.file_disks
|
||||||
|
.into_iter()
|
||||||
|
.find(|disk| disk.name == path.diskid)
|
||||||
|
else {
|
||||||
|
return Ok(HttpResponse::NotFound()
|
||||||
|
.json(format!("Disk {} not found for vm {}", path.diskid, vm.name)));
|
||||||
|
};
|
||||||
|
|
||||||
|
let src_path = disk.disk_path(vm.uuid.expect("Missing VM uuid!"));
|
||||||
|
let src_disk = DiskFileInfo::load_file(&src_path)?;
|
||||||
|
|
||||||
|
// Perform conversion
|
||||||
|
handle_convert_request(src_disk, &req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic controller code that performs image conversion to create a disk image file
|
||||||
|
pub async fn handle_convert_request(
|
||||||
|
src: DiskFileInfo,
|
||||||
|
req: &ConvertDiskImageRequest,
|
||||||
|
) -> HttpResult {
|
||||||
|
// Check destination file
|
||||||
|
if !files_utils::check_file_name(&req.dest_file_name) {
|
||||||
|
return Ok(HttpResponse::BadRequest().json("Invalid destination file name!"));
|
||||||
|
}
|
||||||
|
if !req
|
||||||
|
.format
|
||||||
|
.ext()
|
||||||
|
.iter()
|
||||||
|
.any(|e| req.dest_file_name.ends_with(e))
|
||||||
|
{
|
||||||
|
return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let dst_file_path = AppConfig::get().disk_images_file_path(&req.dest_file_name);
|
||||||
|
|
||||||
|
if dst_file_path.exists() {
|
||||||
|
return Ok(HttpResponse::Conflict().json("Specified destination file already exists!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform conversion
|
||||||
|
if let Err(e) = src.convert(&dst_file_path, req.format) {
|
||||||
|
log::error!("Disk file conversion error: {e}");
|
||||||
|
return Ok(
|
||||||
|
HttpResponse::InternalServerError().json(format!("Disk file conversion error: {e}"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(HttpResponse::Accepted().json("Successfully converted disk file"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct RenameDiskImageRequest {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename disk image
|
||||||
|
pub async fn rename(
|
||||||
|
p: web::Path<DiskFilePath>,
|
||||||
|
req: web::Json<RenameDiskImageRequest>,
|
||||||
|
) -> HttpResult {
|
||||||
|
// Check source
|
||||||
|
if !files_utils::check_file_name(&p.filename) {
|
||||||
|
return Ok(HttpResponse::BadRequest().json("Invalid src file name!"));
|
||||||
|
}
|
||||||
|
let src_path = AppConfig::get().disk_images_file_path(&p.filename);
|
||||||
|
if !src_path.exists() {
|
||||||
|
return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check destination
|
||||||
|
if !files_utils::check_file_name(&req.name) {
|
||||||
|
return Ok(HttpResponse::BadRequest().json("Invalid dst file name!"));
|
||||||
|
}
|
||||||
|
let dst_path = AppConfig::get().disk_images_file_path(&req.name);
|
||||||
|
if dst_path.exists() {
|
||||||
|
return Ok(HttpResponse::Conflict().json("Destination name already exists!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check extension
|
||||||
|
let disk = DiskFileInfo::load_file(&src_path)?;
|
||||||
|
if !disk.format.ext().iter().any(|e| req.name.ends_with(e)) {
|
||||||
|
return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform rename
|
||||||
|
std::fs::rename(&src_path, &dst_path)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Accepted().finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a disk image
|
||||||
|
pub async fn delete(p: web::Path<DiskFilePath>) -> HttpResult {
|
||||||
|
if !files_utils::check_file_name(&p.filename) {
|
||||||
|
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_path = AppConfig::get().disk_images_file_path(&p.filename);
|
||||||
|
|
||||||
|
if !file_path.exists() {
|
||||||
|
return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_file(file_path)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Accepted().finish())
|
||||||
|
}
|
@ -26,7 +26,7 @@ pub async fn upload_file(MultipartForm(mut form): MultipartForm<UploadIsoForm>)
|
|||||||
|
|
||||||
let file = form.files.remove(0);
|
let file = form.files.remove(0);
|
||||||
|
|
||||||
if file.size > constants::ISO_MAX_SIZE {
|
if file.size > constants::ISO_MAX_SIZE.as_bytes() {
|
||||||
log::error!("Uploaded ISO file is too large!");
|
log::error!("Uploaded ISO file is too large!");
|
||||||
return Ok(HttpResponse::BadRequest().json("File is too large!"));
|
return Ok(HttpResponse::BadRequest().json("File is too large!"));
|
||||||
}
|
}
|
||||||
@ -88,7 +88,7 @@ pub async fn upload_from_url(req: web::Json<DownloadFromURLReq>) -> HttpResult {
|
|||||||
let response = reqwest::get(&req.url).await?;
|
let response = reqwest::get(&req.url).await?;
|
||||||
|
|
||||||
if let Some(len) = response.content_length() {
|
if let Some(len) = response.content_length() {
|
||||||
if len > constants::ISO_MAX_SIZE as u64 {
|
if len > constants::ISO_MAX_SIZE.as_bytes() as u64 {
|
||||||
return Ok(HttpResponse::BadRequest().json("File is too large!"));
|
return Ok(HttpResponse::BadRequest().json("File is too large!"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -132,12 +132,12 @@ pub async fn get_list() -> HttpResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct DownloadFilePath {
|
pub struct IsoFilePath {
|
||||||
filename: String,
|
filename: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download ISO file
|
/// Download ISO file
|
||||||
pub async fn download_file(p: web::Path<DownloadFilePath>, req: HttpRequest) -> HttpResult {
|
pub async fn download_file(p: web::Path<IsoFilePath>, req: HttpRequest) -> HttpResult {
|
||||||
if !files_utils::check_file_name(&p.filename) {
|
if !files_utils::check_file_name(&p.filename) {
|
||||||
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
|
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
|
||||||
}
|
}
|
||||||
@ -152,7 +152,7 @@ pub async fn download_file(p: web::Path<DownloadFilePath>, req: HttpRequest) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete ISO file
|
/// Delete ISO file
|
||||||
pub async fn delete_file(p: web::Path<DownloadFilePath>) -> HttpResult {
|
pub async fn delete_file(p: web::Path<IsoFilePath>) -> HttpResult {
|
||||||
if !files_utils::check_file_name(&p.filename) {
|
if !files_utils::check_file_name(&p.filename) {
|
||||||
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
|
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
|
||||||
}
|
}
|
||||||
|
@ -4,9 +4,11 @@ use actix_web::body::BoxBody;
|
|||||||
use actix_web::{HttpResponse, web};
|
use actix_web::{HttpResponse, web};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fmt::{Display, Formatter};
|
use std::fmt::{Display, Formatter};
|
||||||
|
use zip::result::ZipError;
|
||||||
|
|
||||||
pub mod api_tokens_controller;
|
pub mod api_tokens_controller;
|
||||||
pub mod auth_controller;
|
pub mod auth_controller;
|
||||||
|
pub mod disk_images_controller;
|
||||||
pub mod groups_controller;
|
pub mod groups_controller;
|
||||||
pub mod iso_controller;
|
pub mod iso_controller;
|
||||||
pub mod network_controller;
|
pub mod network_controller;
|
||||||
@ -101,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 {
|
impl From<HttpResponse> for HttpErr {
|
||||||
fn from(value: HttpResponse) -> Self {
|
fn from(value: HttpResponse) -> Self {
|
||||||
HttpErr::HTTPResponse(value)
|
HttpErr::HTTPResponse(value)
|
||||||
|
@ -1,14 +1,24 @@
|
|||||||
use crate::actors::vnc_tokens_actor::VNC_TOKEN_LIFETIME;
|
use crate::actors::vnc_tokens_actor::VNC_TOKEN_LIFETIME;
|
||||||
use crate::app_config::AppConfig;
|
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::constants::{DISK_NAME_MAX_LEN, DISK_NAME_MIN_LEN, DISK_SIZE_MAX, DISK_SIZE_MIN};
|
||||||
use crate::controllers::{HttpResult, LibVirtReq};
|
use crate::controllers::{HttpResult, LibVirtReq};
|
||||||
use crate::extractors::local_auth_extractor::LocalAuthEnabled;
|
use crate::extractors::local_auth_extractor::LocalAuthEnabled;
|
||||||
use crate::libvirt_rest_structures::hypervisor::HypervisorInfo;
|
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::nat::nat_hook;
|
||||||
use crate::utils::net_utils;
|
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 sysinfo::{Components, Disks, Networks, System};
|
||||||
|
use zip::ZipWriter;
|
||||||
|
use zip::write::SimpleFileOptions;
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
struct StaticConfig {
|
struct StaticConfig {
|
||||||
@ -16,6 +26,7 @@ struct StaticConfig {
|
|||||||
local_auth_enabled: bool,
|
local_auth_enabled: bool,
|
||||||
oidc_auth_enabled: bool,
|
oidc_auth_enabled: bool,
|
||||||
iso_mimetypes: &'static [&'static str],
|
iso_mimetypes: &'static [&'static str],
|
||||||
|
disk_images_mimetypes: &'static [&'static str],
|
||||||
net_mac_prefix: &'static str,
|
net_mac_prefix: &'static str,
|
||||||
builtin_nwfilter_rules: &'static [&'static str],
|
builtin_nwfilter_rules: &'static [&'static str],
|
||||||
nwfilter_chains: &'static [&'static str],
|
nwfilter_chains: &'static [&'static str],
|
||||||
@ -37,6 +48,7 @@ struct SLenConstraints {
|
|||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
struct ServerConstraints {
|
struct ServerConstraints {
|
||||||
iso_max_size: usize,
|
iso_max_size: usize,
|
||||||
|
disk_image_max_size: usize,
|
||||||
vnc_token_duration: u64,
|
vnc_token_duration: u64,
|
||||||
vm_name_size: LenConstraints,
|
vm_name_size: LenConstraints,
|
||||||
vm_title_size: LenConstraints,
|
vm_title_size: LenConstraints,
|
||||||
@ -44,6 +56,7 @@ struct ServerConstraints {
|
|||||||
memory_size: LenConstraints,
|
memory_size: LenConstraints,
|
||||||
disk_name_size: LenConstraints,
|
disk_name_size: LenConstraints,
|
||||||
disk_size: LenConstraints,
|
disk_size: LenConstraints,
|
||||||
|
disk_image_name_size: LenConstraints,
|
||||||
net_name_size: LenConstraints,
|
net_name_size: LenConstraints,
|
||||||
net_title_size: LenConstraints,
|
net_title_size: LenConstraints,
|
||||||
net_nat_comment_size: LenConstraints,
|
net_nat_comment_size: LenConstraints,
|
||||||
@ -63,11 +76,13 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
|
|||||||
local_auth_enabled: *local_auth,
|
local_auth_enabled: *local_auth,
|
||||||
oidc_auth_enabled: !AppConfig::get().disable_oidc,
|
oidc_auth_enabled: !AppConfig::get().disable_oidc,
|
||||||
iso_mimetypes: &constants::ALLOWED_ISO_MIME_TYPES,
|
iso_mimetypes: &constants::ALLOWED_ISO_MIME_TYPES,
|
||||||
|
disk_images_mimetypes: &constants::ALLOWED_DISK_IMAGES_MIME_TYPES,
|
||||||
net_mac_prefix: constants::NET_MAC_ADDR_PREFIX,
|
net_mac_prefix: constants::NET_MAC_ADDR_PREFIX,
|
||||||
builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES,
|
builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES,
|
||||||
nwfilter_chains: &constants::NETWORK_CHAINS,
|
nwfilter_chains: &constants::NETWORK_CHAINS,
|
||||||
constraints: ServerConstraints {
|
constraints: ServerConstraints {
|
||||||
iso_max_size: constants::ISO_MAX_SIZE,
|
iso_max_size: constants::ISO_MAX_SIZE.as_bytes(),
|
||||||
|
disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE.as_bytes(),
|
||||||
|
|
||||||
vnc_token_duration: VNC_TOKEN_LIFETIME,
|
vnc_token_duration: VNC_TOKEN_LIFETIME,
|
||||||
|
|
||||||
@ -75,18 +90,20 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
|
|||||||
vm_title_size: LenConstraints { min: 0, max: 50 },
|
vm_title_size: LenConstraints { min: 0, max: 50 },
|
||||||
group_id_size: LenConstraints { min: 3, max: 50 },
|
group_id_size: LenConstraints { min: 3, max: 50 },
|
||||||
memory_size: LenConstraints {
|
memory_size: LenConstraints {
|
||||||
min: constants::MIN_VM_MEMORY,
|
min: constants::MIN_VM_MEMORY.as_bytes(),
|
||||||
max: constants::MAX_VM_MEMORY,
|
max: constants::MAX_VM_MEMORY.as_bytes(),
|
||||||
},
|
},
|
||||||
disk_name_size: LenConstraints {
|
disk_name_size: LenConstraints {
|
||||||
min: DISK_NAME_MIN_LEN,
|
min: DISK_NAME_MIN_LEN,
|
||||||
max: DISK_NAME_MAX_LEN,
|
max: DISK_NAME_MAX_LEN,
|
||||||
},
|
},
|
||||||
disk_size: LenConstraints {
|
disk_size: LenConstraints {
|
||||||
min: DISK_SIZE_MIN,
|
min: DISK_SIZE_MIN.as_bytes(),
|
||||||
max: DISK_SIZE_MAX,
|
max: DISK_SIZE_MAX.as_bytes(),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
disk_image_name_size: LenConstraints { min: 5, max: 220 },
|
||||||
|
|
||||||
net_name_size: LenConstraints { min: 2, max: 50 },
|
net_name_size: LenConstraints { min: 2, max: 50 },
|
||||||
net_title_size: LenConstraints { min: 0, max: 50 },
|
net_title_size: LenConstraints { min: 0, max: 50 },
|
||||||
net_nat_comment_size: LenConstraints {
|
net_nat_comment_size: LenConstraints {
|
||||||
@ -188,3 +205,89 @@ pub async fn number_vcpus() -> HttpResult {
|
|||||||
pub async fn networks_list() -> HttpResult {
|
pub async fn networks_list() -> HttpResult {
|
||||||
Ok(HttpResponse::Ok().json(net_utils::net_list()))
|
Ok(HttpResponse::Ok().json(net_utils::net_list()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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))
|
.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
|
/// Update a VM information
|
||||||
pub async fn update(
|
pub async fn update(
|
||||||
client: LibVirtReq,
|
client: LibVirtReq,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
use crate::libvirt_lib_structures::XMLUuid;
|
use crate::libvirt_lib_structures::XMLUuid;
|
||||||
|
use crate::utils::cloud_init_utils::CloudInitConfig;
|
||||||
|
|
||||||
/// VirtWeb specific metadata
|
/// VirtWeb specific metadata
|
||||||
#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
|
#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
|
||||||
@ -8,6 +9,8 @@ pub struct DomainMetadataVirtWebXML {
|
|||||||
pub ns: String,
|
pub ns: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub group: Option<String>,
|
pub group: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cloud_init: Option<CloudInitConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Domain metadata
|
/// Domain metadata
|
||||||
@ -22,10 +25,13 @@ pub struct DomainMetadataXML {
|
|||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(rename = "os")]
|
#[serde(rename = "os")]
|
||||||
pub struct OSXML {
|
pub struct OSXML {
|
||||||
#[serde(rename = "@firmware", default)]
|
#[serde(rename = "@firmware", default, skip_serializing_if = "Option::is_none")]
|
||||||
pub firmware: String,
|
pub firmware: Option<String>,
|
||||||
pub r#type: OSTypeXML,
|
pub r#type: OSTypeXML,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub loader: Option<OSLoaderXML>,
|
pub loader: Option<OSLoaderXML>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bootmenu: Option<OSBootMenuXML>,
|
||||||
pub smbios: Option<OSSMBiosXML>,
|
pub smbios: Option<OSSMBiosXML>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,6 +55,16 @@ pub struct OSLoaderXML {
|
|||||||
pub secure: String,
|
pub secure: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Legacy boot menu information
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename = "bootmenu")]
|
||||||
|
pub struct OSBootMenuXML {
|
||||||
|
#[serde(rename = "@enable")]
|
||||||
|
pub enable: String,
|
||||||
|
#[serde(rename = "@timeout")]
|
||||||
|
pub timeout: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// SMBIOS System information
|
/// SMBIOS System information
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(rename = "smbios")]
|
#[serde(rename = "smbios")]
|
||||||
@ -80,7 +96,9 @@ pub struct NetMacAddress {
|
|||||||
#[serde(rename = "source")]
|
#[serde(rename = "source")]
|
||||||
pub struct NetIntSourceXML {
|
pub struct NetIntSourceXML {
|
||||||
#[serde(rename = "@network")]
|
#[serde(rename = "@network")]
|
||||||
pub network: String,
|
pub network: Option<String>,
|
||||||
|
#[serde(rename = "@bridge")]
|
||||||
|
pub bridge: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
@ -13,4 +13,6 @@ enum LibVirtStructError {
|
|||||||
ParseFilteringChain(String),
|
ParseFilteringChain(String),
|
||||||
#[error("NetworkFilterExtractionError: {0}")]
|
#[error("NetworkFilterExtractionError: {0}")]
|
||||||
NetworkFilterExtraction(String),
|
NetworkFilterExtraction(String),
|
||||||
|
#[error("CloudInitConfigurationError: {0}")]
|
||||||
|
CloudInitConfiguration(String),
|
||||||
}
|
}
|
||||||
|
@ -3,10 +3,13 @@ use crate::constants;
|
|||||||
use crate::libvirt_lib_structures::XMLUuid;
|
use crate::libvirt_lib_structures::XMLUuid;
|
||||||
use crate::libvirt_lib_structures::domain::*;
|
use crate::libvirt_lib_structures::domain::*;
|
||||||
use crate::libvirt_rest_structures::LibVirtStructError;
|
use crate::libvirt_rest_structures::LibVirtStructError;
|
||||||
use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction;
|
use crate::libvirt_rest_structures::LibVirtStructError::{
|
||||||
use crate::utils::file_disks_utils::{DiskFormat, FileDisk};
|
CloudInitConfiguration, StructureExtraction,
|
||||||
|
};
|
||||||
|
use crate::utils::cloud_init_utils::CloudInitConfig;
|
||||||
|
use crate::utils::file_size_utils::FileSize;
|
||||||
use crate::utils::files_utils;
|
use crate::utils::files_utils;
|
||||||
use crate::utils::files_utils::convert_size_unit_to_mb;
|
use crate::utils::vm_file_disks_utils::{VMDiskBus, VMDiskFormat, VMFileDisk};
|
||||||
use lazy_regex::regex;
|
use lazy_regex::regex;
|
||||||
use num::Integer;
|
use num::Integer;
|
||||||
|
|
||||||
@ -17,6 +20,7 @@ pub struct VMGroupId(pub String);
|
|||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
pub enum BootType {
|
pub enum BootType {
|
||||||
|
Legacy,
|
||||||
UEFI,
|
UEFI,
|
||||||
UEFISecureBoot,
|
UEFISecureBoot,
|
||||||
}
|
}
|
||||||
@ -29,6 +33,12 @@ pub enum VMArchitecture {
|
|||||||
X86_64,
|
X86_64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum NetworkInterfaceModelType {
|
||||||
|
Virtio,
|
||||||
|
E1000,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
pub struct NWFilterParam {
|
pub struct NWFilterParam {
|
||||||
name: String,
|
name: String,
|
||||||
@ -46,6 +56,7 @@ pub struct Network {
|
|||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
r#type: NetworkType,
|
r#type: NetworkType,
|
||||||
mac: String,
|
mac: String,
|
||||||
|
model: NetworkInterfaceModelType,
|
||||||
nwfilterref: Option<NWFilterRef>,
|
nwfilterref: Option<NWFilterRef>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +64,8 @@ pub struct Network {
|
|||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum NetworkType {
|
pub enum NetworkType {
|
||||||
UserspaceSLIRPStack,
|
UserspaceSLIRPStack,
|
||||||
DefinedNetwork { network: String }, // TODO : complete network types
|
DefinedNetwork { network: String },
|
||||||
|
Bridge { bridge: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
@ -69,8 +81,8 @@ pub struct VMInfo {
|
|||||||
pub group: Option<VMGroupId>,
|
pub group: Option<VMGroupId>,
|
||||||
pub boot_type: BootType,
|
pub boot_type: BootType,
|
||||||
pub architecture: VMArchitecture,
|
pub architecture: VMArchitecture,
|
||||||
/// VM allocated memory, in megabytes
|
/// VM allocated RAM memory
|
||||||
pub memory: usize,
|
pub memory: FileSize,
|
||||||
/// Number of vCPU for the VM
|
/// Number of vCPU for the VM
|
||||||
pub number_vcpu: usize,
|
pub number_vcpu: usize,
|
||||||
/// Enable VNC access through admin console
|
/// Enable VNC access through admin console
|
||||||
@ -78,13 +90,16 @@ pub struct VMInfo {
|
|||||||
/// Attach ISO file(s)
|
/// Attach ISO file(s)
|
||||||
pub iso_files: Vec<String>,
|
pub iso_files: Vec<String>,
|
||||||
/// File Storage - https://access.redhat.com/documentation/fr-fr/red_hat_enterprise_linux/6/html/virtualization_administration_guide/sect-virtualization-virtualized_block_devices-adding_storage_devices_to_guests#sect-Virtualization-Adding_storage_devices_to_guests-Adding_file_based_storage_to_a_guest
|
/// File Storage - https://access.redhat.com/documentation/fr-fr/red_hat_enterprise_linux/6/html/virtualization_administration_guide/sect-virtualization-virtualized_block_devices-adding_storage_devices_to_guests#sect-Virtualization-Adding_storage_devices_to_guests-Adding_file_based_storage_to_a_guest
|
||||||
pub file_disks: Vec<FileDisk>,
|
pub file_disks: Vec<VMFileDisk>,
|
||||||
/// Network cards
|
/// Network cards
|
||||||
pub networks: Vec<Network>,
|
pub networks: Vec<Network>,
|
||||||
/// Add a TPM v2.0 module
|
/// Add a TPM v2.0 module
|
||||||
pub tpm_module: bool,
|
pub tpm_module: bool,
|
||||||
/// Strings injected as OEM Strings in SMBios configuration
|
/// Strings injected as OEM Strings in SMBios configuration
|
||||||
pub oem_strings: Vec<String>,
|
pub oem_strings: Vec<String>,
|
||||||
|
/// Cloud init configuration
|
||||||
|
#[serde(default)]
|
||||||
|
pub cloud_init: CloudInitConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VMInfo {
|
impl VMInfo {
|
||||||
@ -129,9 +144,26 @@ impl VMInfo {
|
|||||||
return Err(StructureExtraction("Invalid number of vCPU specified!").into());
|
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 {
|
for iso_file in &self.iso_files {
|
||||||
if !files_utils::check_file_name(iso_file) {
|
if !files_utils::check_file_name(iso_file) {
|
||||||
return Err(StructureExtraction("ISO filename is invalid!").into());
|
return Err(StructureExtraction("ISO filename is invalid!").into());
|
||||||
@ -143,6 +175,13 @@ impl VMInfo {
|
|||||||
return Err(StructureExtraction("Specified ISO file does not exists!").into());
|
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 {
|
disks.push(DiskXML {
|
||||||
r#type: "file".to_string(),
|
r#type: "file".to_string(),
|
||||||
device: "cdrom".to_string(),
|
device: "cdrom".to_string(),
|
||||||
@ -152,7 +191,7 @@ impl VMInfo {
|
|||||||
cache: "none".to_string(),
|
cache: "none".to_string(),
|
||||||
},
|
},
|
||||||
source: DiskSourceXML {
|
source: DiskSourceXML {
|
||||||
file: path.to_string_lossy().to_string(),
|
file: iso_path.to_string_lossy().to_string(),
|
||||||
},
|
},
|
||||||
target: DiskTargetXML {
|
target: DiskTargetXML {
|
||||||
dev: format!(
|
dev: format!(
|
||||||
@ -169,6 +208,7 @@ impl VMInfo {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure VNC access, if requested
|
||||||
let (vnc_graphics, vnc_video) = match self.vnc_access {
|
let (vnc_graphics, vnc_video) = match self.vnc_access {
|
||||||
true => (
|
true => (
|
||||||
Some(GraphicsXML {
|
Some(GraphicsXML {
|
||||||
@ -195,7 +235,11 @@ impl VMInfo {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let model = Some(NetIntModelXML {
|
let model = Some(NetIntModelXML {
|
||||||
r#type: "virtio".to_string(),
|
r#type: match n.model {
|
||||||
|
NetworkInterfaceModelType::Virtio => "virtio",
|
||||||
|
NetworkInterfaceModelType::E1000 => "e1000",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let filterref = if let Some(n) = &n.nwfilterref {
|
let filterref = if let Some(n) = &n.nwfilterref {
|
||||||
@ -240,7 +284,18 @@ impl VMInfo {
|
|||||||
mac,
|
mac,
|
||||||
r#type: "network".to_string(),
|
r#type: "network".to_string(),
|
||||||
source: Some(NetIntSourceXML {
|
source: Some(NetIntSourceXML {
|
||||||
network: network.to_string(),
|
network: Some(network.to_string()),
|
||||||
|
bridge: None,
|
||||||
|
}),
|
||||||
|
model,
|
||||||
|
filterref,
|
||||||
|
},
|
||||||
|
NetworkType::Bridge { bridge } => DomainNetInterfaceXML {
|
||||||
|
r#type: "bridge".to_string(),
|
||||||
|
mac,
|
||||||
|
source: Some(NetIntSourceXML {
|
||||||
|
network: None,
|
||||||
|
bridge: Some(bridge.to_string()),
|
||||||
}),
|
}),
|
||||||
model,
|
model,
|
||||||
filterref,
|
filterref,
|
||||||
@ -277,8 +332,8 @@ impl VMInfo {
|
|||||||
driver: DiskDriverXML {
|
driver: DiskDriverXML {
|
||||||
name: "qemu".to_string(),
|
name: "qemu".to_string(),
|
||||||
r#type: match disk.format {
|
r#type: match disk.format {
|
||||||
DiskFormat::Raw { .. } => "raw".to_string(),
|
VMDiskFormat::Raw { .. } => "raw".to_string(),
|
||||||
DiskFormat::QCow2 => "qcow2".to_string(),
|
VMDiskFormat::QCow2 => "qcow2".to_string(),
|
||||||
},
|
},
|
||||||
cache: "none".to_string(),
|
cache: "none".to_string(),
|
||||||
},
|
},
|
||||||
@ -290,7 +345,11 @@ impl VMInfo {
|
|||||||
"vd{}",
|
"vd{}",
|
||||||
["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"][disks.len()]
|
["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"][disks.len()]
|
||||||
),
|
),
|
||||||
bus: "virtio".to_string(),
|
bus: match disk.bus {
|
||||||
|
VMDiskBus::Virtio => "virtio",
|
||||||
|
VMDiskBus::SATA => "sata",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
},
|
},
|
||||||
readonly: None,
|
readonly: None,
|
||||||
boot: DiskBootXML {
|
boot: DiskBootXML {
|
||||||
@ -312,6 +371,7 @@ impl VMInfo {
|
|||||||
virtweb: DomainMetadataVirtWebXML {
|
virtweb: DomainMetadataVirtWebXML {
|
||||||
ns: "https://virtweb.communiquons.org".to_string(),
|
ns: "https://virtweb.communiquons.org".to_string(),
|
||||||
group: self.group.clone().map(|g| g.0),
|
group: self.group.clone().map(|g| g.0),
|
||||||
|
cloud_init: Some(self.cloud_init.clone()),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
os: OSXML {
|
os: OSXML {
|
||||||
@ -324,13 +384,26 @@ impl VMInfo {
|
|||||||
machine: "q35".to_string(),
|
machine: "q35".to_string(),
|
||||||
body: "hvm".to_string(),
|
body: "hvm".to_string(),
|
||||||
},
|
},
|
||||||
firmware: "efi".to_string(),
|
firmware: match self.boot_type {
|
||||||
loader: Some(OSLoaderXML {
|
BootType::Legacy => None,
|
||||||
secure: match self.boot_type {
|
_ => Some("efi".to_string()),
|
||||||
BootType::UEFI => "no".to_string(),
|
},
|
||||||
BootType::UEFISecureBoot => "yes".to_string(),
|
loader: match self.boot_type {
|
||||||
},
|
BootType::Legacy => None,
|
||||||
}),
|
_ => Some(OSLoaderXML {
|
||||||
|
secure: match self.boot_type {
|
||||||
|
BootType::UEFISecureBoot => "yes".to_string(),
|
||||||
|
_ => "no".to_string(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
bootmenu: match self.boot_type {
|
||||||
|
BootType::Legacy => Some(OSBootMenuXML {
|
||||||
|
enable: "yes".to_string(),
|
||||||
|
timeout: 3000,
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
smbios: Some(OSSMBiosXML {
|
smbios: Some(OSSMBiosXML {
|
||||||
mode: "sysinfo".to_string(),
|
mode: "sysinfo".to_string(),
|
||||||
}),
|
}),
|
||||||
@ -368,7 +441,7 @@ impl VMInfo {
|
|||||||
|
|
||||||
memory: DomainMemoryXML {
|
memory: DomainMemoryXML {
|
||||||
unit: "MB".to_string(),
|
unit: "MB".to_string(),
|
||||||
memory: self.memory,
|
memory: self.memory.as_mb(),
|
||||||
},
|
},
|
||||||
|
|
||||||
vcpu: DomainVCPUXML {
|
vcpu: DomainVCPUXML {
|
||||||
@ -422,9 +495,10 @@ impl VMInfo {
|
|||||||
.virtweb
|
.virtweb
|
||||||
.group
|
.group
|
||||||
.map(VMGroupId),
|
.map(VMGroupId),
|
||||||
boot_type: match domain.os.loader {
|
boot_type: match (domain.os.loader, domain.os.bootmenu) {
|
||||||
None => BootType::UEFI,
|
(_, Some(_)) => BootType::Legacy,
|
||||||
Some(l) => match l.secure.as_str() {
|
(None, _) => BootType::UEFI,
|
||||||
|
(Some(l), _) => match l.secure.as_str() {
|
||||||
"yes" => BootType::UEFISecureBoot,
|
"yes" => BootType::UEFISecureBoot,
|
||||||
_ => BootType::UEFI,
|
_ => BootType::UEFI,
|
||||||
},
|
},
|
||||||
@ -440,7 +514,7 @@ impl VMInfo {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
number_vcpu: domain.vcpu.body,
|
number_vcpu: domain.vcpu.body,
|
||||||
memory: convert_size_unit_to_mb(&domain.memory.unit, domain.memory.memory)?,
|
memory: FileSize::from_size_unit(&domain.memory.unit, domain.memory.memory)?,
|
||||||
vnc_access: domain.devices.graphics.is_some(),
|
vnc_access: domain.devices.graphics.is_some(),
|
||||||
iso_files: domain
|
iso_files: domain
|
||||||
.devices
|
.devices
|
||||||
@ -448,6 +522,7 @@ impl VMInfo {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|d| d.device == "cdrom")
|
.filter(|d| d.device == "cdrom")
|
||||||
.map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string())
|
.map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string())
|
||||||
|
.filter(|d| !d.starts_with(constants::CLOUD_INIT_IMAGE_PREFIX_NAME))
|
||||||
.collect(),
|
.collect(),
|
||||||
|
|
||||||
file_disks: domain
|
file_disks: domain
|
||||||
@ -455,7 +530,10 @@ impl VMInfo {
|
|||||||
.disks
|
.disks
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|d| d.device == "disk")
|
.filter(|d| d.device == "disk")
|
||||||
.map(|d| FileDisk::load_from_file(&d.source.file).unwrap())
|
.map(|d| {
|
||||||
|
VMFileDisk::load_from_file(&d.source.file, &d.target.bus)
|
||||||
|
.expect("Failed to load file disk information!")
|
||||||
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
|
||||||
networks: domain
|
networks: domain
|
||||||
@ -468,7 +546,34 @@ impl VMInfo {
|
|||||||
r#type: match d.r#type.as_str() {
|
r#type: match d.r#type.as_str() {
|
||||||
"user" => NetworkType::UserspaceSLIRPStack,
|
"user" => NetworkType::UserspaceSLIRPStack,
|
||||||
"network" => NetworkType::DefinedNetwork {
|
"network" => NetworkType::DefinedNetwork {
|
||||||
network: d.source.as_ref().unwrap().network.to_string(),
|
network: d
|
||||||
|
.source
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.network
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
LibVirtStructError::DomainExtraction(
|
||||||
|
"Missing source network for defined network!"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.to_string(),
|
||||||
|
},
|
||||||
|
"bridge" => NetworkType::Bridge {
|
||||||
|
bridge: d
|
||||||
|
.source
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.bridge
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
LibVirtStructError::DomainExtraction(
|
||||||
|
"Missing bridge name for bridge connection!"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.to_string(),
|
||||||
},
|
},
|
||||||
a => {
|
a => {
|
||||||
return Err(LibVirtStructError::DomainExtraction(format!(
|
return Err(LibVirtStructError::DomainExtraction(format!(
|
||||||
@ -476,6 +581,18 @@ impl VMInfo {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
model: match d.model.as_ref() {
|
||||||
|
None => NetworkInterfaceModelType::Virtio,
|
||||||
|
Some(model) => match model.r#type.as_str() {
|
||||||
|
"virtio" => NetworkInterfaceModelType::Virtio,
|
||||||
|
"e1000" => NetworkInterfaceModelType::E1000,
|
||||||
|
model => {
|
||||||
|
return Err(LibVirtStructError::DomainExtraction(format!(
|
||||||
|
"Unknown network interface model type: {model}! "
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
nwfilterref: d.filterref.as_ref().map(|f| NWFilterRef {
|
nwfilterref: d.filterref.as_ref().map(|f| NWFilterRef {
|
||||||
name: f.filter.to_string(),
|
name: f.filter.to_string(),
|
||||||
parameters: f
|
parameters: f
|
||||||
@ -498,6 +615,13 @@ impl VMInfo {
|
|||||||
.and_then(|s| s.oem_strings)
|
.and_then(|s| s.oem_strings)
|
||||||
.map(|s| s.entries.iter().map(|o| o.content.to_string()).collect())
|
.map(|s| s.entries.iter().map(|o| o.content.to_string()).collect())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
cloud_init: domain
|
||||||
|
.metadata
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.virtweb
|
||||||
|
.cloud_init
|
||||||
|
.unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ use actix_web::middleware::Logger;
|
|||||||
use actix_web::web::Data;
|
use actix_web::web::Data;
|
||||||
use actix_web::{App, HttpServer, web};
|
use actix_web::{App, HttpServer, web};
|
||||||
use light_openid::basic_state_manager::BasicStateManager;
|
use light_openid::basic_state_manager::BasicStateManager;
|
||||||
|
use std::cmp::max;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use virtweb_backend::actors::libvirt_actor::LibVirtActor;
|
use virtweb_backend::actors::libvirt_actor::LibVirtActor;
|
||||||
use virtweb_backend::actors::vnc_tokens_actor::VNCTokensManager;
|
use virtweb_backend::actors::vnc_tokens_actor::VNCTokensManager;
|
||||||
@ -22,13 +23,14 @@ use virtweb_backend::constants::{
|
|||||||
MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME,
|
MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME,
|
||||||
};
|
};
|
||||||
use virtweb_backend::controllers::{
|
use virtweb_backend::controllers::{
|
||||||
api_tokens_controller, auth_controller, groups_controller, iso_controller, network_controller,
|
api_tokens_controller, auth_controller, disk_images_controller, groups_controller,
|
||||||
nwfilter_controller, server_controller, static_controller, vm_controller,
|
iso_controller, network_controller, nwfilter_controller, server_controller, static_controller,
|
||||||
|
vm_controller,
|
||||||
};
|
};
|
||||||
use virtweb_backend::libvirt_client::LibVirtClient;
|
use virtweb_backend::libvirt_client::LibVirtClient;
|
||||||
use virtweb_backend::middlewares::auth_middleware::AuthChecker;
|
use virtweb_backend::middlewares::auth_middleware::AuthChecker;
|
||||||
use virtweb_backend::nat::nat_conf_mode;
|
use virtweb_backend::nat::nat_conf_mode;
|
||||||
use virtweb_backend::utils::files_utils;
|
use virtweb_backend::utils::{exec_utils, files_utils};
|
||||||
|
|
||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
@ -43,11 +45,29 @@ async fn main() -> std::io::Result<()> {
|
|||||||
// Load additional config from file, if requested
|
// Load additional config from file, if requested
|
||||||
AppConfig::parse_env_file().unwrap();
|
AppConfig::parse_env_file().unwrap();
|
||||||
|
|
||||||
|
log::debug!("Checking for required programs");
|
||||||
|
exec_utils::check_program(
|
||||||
|
constants::PROGRAM_QEMU_IMAGE,
|
||||||
|
"QEMU disk image utility is required to manipulate QCow2 files!",
|
||||||
|
);
|
||||||
|
exec_utils::check_program(
|
||||||
|
constants::PROGRAM_IP,
|
||||||
|
"ip is required to access bridges information!",
|
||||||
|
);
|
||||||
|
exec_utils::check_program(
|
||||||
|
constants::PROGRAM_CLOUD_LOCALDS,
|
||||||
|
"cloud-localds from package cloud-image-utils is required to build cloud-init images!",
|
||||||
|
);
|
||||||
|
|
||||||
log::debug!("Create required directory, if missing");
|
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().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::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap();
|
||||||
files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap();
|
files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap();
|
||||||
files_utils::create_directory_if_missing(AppConfig::get().disks_storage_path()).unwrap();
|
files_utils::create_directory_if_missing(AppConfig::get().root_vm_disks_storage_path())
|
||||||
|
.unwrap();
|
||||||
files_utils::create_directory_if_missing(AppConfig::get().nat_path()).unwrap();
|
files_utils::create_directory_if_missing(AppConfig::get().nat_path()).unwrap();
|
||||||
files_utils::create_directory_if_missing(AppConfig::get().definitions_path()).unwrap();
|
files_utils::create_directory_if_missing(AppConfig::get().definitions_path()).unwrap();
|
||||||
files_utils::create_directory_if_missing(AppConfig::get().api_tokens_path()).unwrap();
|
files_utils::create_directory_if_missing(AppConfig::get().api_tokens_path()).unwrap();
|
||||||
@ -108,7 +128,9 @@ async fn main() -> std::io::Result<()> {
|
|||||||
}))
|
}))
|
||||||
.app_data(conn.clone())
|
.app_data(conn.clone())
|
||||||
// Uploaded files
|
// Uploaded files
|
||||||
.app_data(MultipartFormConfig::default().total_limit(constants::ISO_MAX_SIZE))
|
.app_data(MultipartFormConfig::default().total_limit(
|
||||||
|
max(constants::DISK_IMAGE_MAX_SIZE, constants::ISO_MAX_SIZE).as_bytes(),
|
||||||
|
))
|
||||||
.app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir))
|
.app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir))
|
||||||
// Server controller
|
// Server controller
|
||||||
.route(
|
.route(
|
||||||
@ -131,6 +153,14 @@ async fn main() -> std::io::Result<()> {
|
|||||||
"/api/server/networks",
|
"/api/server/networks",
|
||||||
web::get().to(server_controller::networks_list),
|
web::get().to(server_controller::networks_list),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/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
|
// Auth controller
|
||||||
.route(
|
.route(
|
||||||
"/api/auth/local",
|
"/api/auth/local",
|
||||||
@ -178,6 +208,10 @@ async fn main() -> std::io::Result<()> {
|
|||||||
"/api/vm/{uid}/src",
|
"/api/vm/{uid}/src",
|
||||||
web::get().to(vm_controller::get_single_src_def),
|
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(
|
.route(
|
||||||
"/api/vm/{uid}/autostart",
|
"/api/vm/{uid}/autostart",
|
||||||
web::get().to(vm_controller::get_autostart),
|
web::get().to(vm_controller::get_autostart),
|
||||||
@ -315,6 +349,35 @@ async fn main() -> std::io::Result<()> {
|
|||||||
"/api/nwfilter/{uid}",
|
"/api/nwfilter/{uid}",
|
||||||
web::delete().to(nwfilter_controller::delete),
|
web::delete().to(nwfilter_controller::delete),
|
||||||
)
|
)
|
||||||
|
// Disk images library
|
||||||
|
.route(
|
||||||
|
"/api/disk_images/upload",
|
||||||
|
web::post().to(disk_images_controller::upload),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/disk_images/list",
|
||||||
|
web::get().to(disk_images_controller::get_list),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/disk_images/{filename}",
|
||||||
|
web::get().to(disk_images_controller::download),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/disk_images/{filename}/convert",
|
||||||
|
web::post().to(disk_images_controller::convert),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/disk_images/{filename}/rename",
|
||||||
|
web::post().to(disk_images_controller::rename),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/disk_images/{filename}",
|
||||||
|
web::delete().to(disk_images_controller::delete),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/vm/{uid}/disk/{diskid}/backup",
|
||||||
|
web::post().to(disk_images_controller::backup_disk),
|
||||||
|
)
|
||||||
// API tokens controller
|
// API tokens controller
|
||||||
.route(
|
.route(
|
||||||
"/api/token/create",
|
"/api/token/create",
|
||||||
|
117
virtweb_backend/src/utils/cloud_init_utils.rs
Normal file
117
virtweb_backend/src/utils/cloud_init_utils.rs
Normal file
@ -0,0 +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 {
|
||||||
|
pub attach_config: bool,
|
||||||
|
/// Main user data
|
||||||
|
pub user_data: String,
|
||||||
|
/// Instance ID, set in metadata file
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub instance_id: Option<String>,
|
||||||
|
/// Local hostname, set in metadata file
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
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")]
|
||||||
|
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)?)
|
||||||
|
}
|
||||||
|
}
|
10
virtweb_backend/src/utils/exec_utils.rs
Normal file
10
virtweb_backend/src/utils/exec_utils.rs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Check the existence of a required program
|
||||||
|
pub fn check_program(name: &str, description: &str) {
|
||||||
|
let path = Path::new(name);
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
panic!("{name} does not exist! {description}");
|
||||||
|
}
|
||||||
|
}
|
@ -1,169 +1,148 @@
|
|||||||
use crate::app_config::AppConfig;
|
use crate::app_config::AppConfig;
|
||||||
use crate::constants;
|
use crate::constants;
|
||||||
use crate::libvirt_lib_structures::XMLUuid;
|
use crate::utils::file_size_utils::FileSize;
|
||||||
use crate::utils::files_utils;
|
use std::fs::File;
|
||||||
use lazy_regex::regex;
|
|
||||||
use std::os::linux::fs::MetadataExt;
|
use std::os::linux::fs::MetadataExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use std::time::UNIX_EPOCH;
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
enum DisksError {
|
enum DisksError {
|
||||||
#[error("DiskParseError: {0}")]
|
#[error("DiskParseError: {0}")]
|
||||||
Parse(&'static str),
|
Parse(&'static str),
|
||||||
#[error("DiskConfigError: {0}")]
|
|
||||||
Config(&'static str),
|
|
||||||
#[error("DiskCreateError")]
|
#[error("DiskCreateError")]
|
||||||
Create,
|
Create,
|
||||||
|
#[error("DiskConvertError: {0}")]
|
||||||
|
Convert(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Type of disk allocation
|
#[derive(Debug, serde::Serialize, serde::Deserialize, Copy, Clone, PartialEq, Eq)]
|
||||||
#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
||||||
pub enum DiskAllocType {
|
|
||||||
Fixed,
|
|
||||||
Sparse,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disk allocation type
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
|
||||||
#[serde(tag = "format")]
|
#[serde(tag = "format")]
|
||||||
pub enum DiskFormat {
|
pub enum DiskFileFormat {
|
||||||
Raw {
|
Raw {
|
||||||
/// Type of disk allocation
|
#[serde(default)]
|
||||||
alloc_type: DiskAllocType,
|
is_sparse: bool,
|
||||||
},
|
},
|
||||||
QCow2,
|
QCow2 {
|
||||||
|
#[serde(default)]
|
||||||
|
virtual_size: FileSize,
|
||||||
|
},
|
||||||
|
GzCompressedRaw,
|
||||||
|
GzCompressedQCow2,
|
||||||
|
XzCompressedRaw,
|
||||||
|
XzCompressedQCow2,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
impl DiskFileFormat {
|
||||||
pub struct FileDisk {
|
pub fn ext(&self) -> &'static [&'static str] {
|
||||||
/// Disk name
|
match self {
|
||||||
pub name: String,
|
DiskFileFormat::Raw { .. } => &["raw", ""],
|
||||||
/// Disk size, in megabytes
|
DiskFileFormat::QCow2 { .. } => &["qcow2"],
|
||||||
pub size: usize,
|
DiskFileFormat::GzCompressedRaw => &["raw.gz"],
|
||||||
/// Disk format
|
DiskFileFormat::GzCompressedQCow2 => &["qcow2.gz"],
|
||||||
|
DiskFileFormat::XzCompressedRaw => &["raw.xz"],
|
||||||
|
DiskFileFormat::XzCompressedQCow2 => &["qcow2.xz"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disk file information
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct DiskFileInfo {
|
||||||
|
pub file_path: PathBuf,
|
||||||
|
pub file_size: FileSize,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub format: DiskFormat,
|
pub format: DiskFileFormat,
|
||||||
/// Set this variable to true to delete the disk
|
pub file_name: String,
|
||||||
pub delete: bool,
|
pub name: String,
|
||||||
|
pub created: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileDisk {
|
impl DiskFileInfo {
|
||||||
pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
|
/// Get disk image file information
|
||||||
let file = Path::new(path);
|
pub fn load_file(file: &Path) -> anyhow::Result<Self> {
|
||||||
|
|
||||||
if !file.is_file() {
|
if !file.is_file() {
|
||||||
return Err(DisksError::Parse("Path is not a file!").into());
|
return Err(DisksError::Parse("Path is not a file!").into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get file metadata
|
||||||
let metadata = file.metadata()?;
|
let metadata = file.metadata()?;
|
||||||
let name = file.file_stem().and_then(|s| s.to_str()).unwrap_or("disk");
|
let mut name = file
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("disk")
|
||||||
|
.to_string();
|
||||||
let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("raw");
|
let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("raw");
|
||||||
|
|
||||||
// Approximate raw file estimation
|
// Determine file format
|
||||||
let is_raw_sparse = metadata.len() / 512 >= metadata.st_blocks();
|
|
||||||
|
|
||||||
let format = match ext {
|
let format = match ext {
|
||||||
"qcow2" => DiskFormat::QCow2,
|
"qcow2" => DiskFileFormat::QCow2 {
|
||||||
"raw" => DiskFormat::Raw {
|
virtual_size: qcow_virt_size(file)?,
|
||||||
alloc_type: match is_raw_sparse {
|
|
||||||
true => DiskAllocType::Sparse,
|
|
||||||
false => DiskAllocType::Fixed,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
"raw" => DiskFileFormat::Raw {
|
||||||
|
is_sparse: metadata.len() / 512 >= metadata.st_blocks(),
|
||||||
|
},
|
||||||
|
"gz" if name.ends_with(".qcow2") => {
|
||||||
|
name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string();
|
||||||
|
DiskFileFormat::GzCompressedQCow2
|
||||||
|
}
|
||||||
|
"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}!"),
|
_ => anyhow::bail!("Unsupported disk extension: {ext}!"),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
name: name.to_string(),
|
file_path: file.to_path_buf(),
|
||||||
size: match format {
|
name,
|
||||||
DiskFormat::Raw { .. } => metadata.len() as usize / (1000 * 1000),
|
file_size: FileSize::from_bytes(metadata.len() as usize),
|
||||||
DiskFormat::QCow2 => qcow_virt_size(path)? / (1000 * 1000),
|
|
||||||
},
|
|
||||||
format,
|
format,
|
||||||
delete: false,
|
file_name: file
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
created: metadata
|
||||||
|
.created()?
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_config(&self) -> anyhow::Result<()> {
|
/// Create a new empty disk
|
||||||
if constants::DISK_NAME_MIN_LEN > self.name.len()
|
pub fn create(file: &Path, format: DiskFileFormat, size: FileSize) -> anyhow::Result<()> {
|
||||||
|| constants::DISK_NAME_MAX_LEN < self.name.len()
|
|
||||||
{
|
|
||||||
return Err(DisksError::Config("Disk name length is invalid").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
if !regex!("^[a-zA-Z0-9]+$").is_match(&self.name) {
|
|
||||||
return Err(DisksError::Config("Disk name contains invalid characters!").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check disk size
|
|
||||||
if !(constants::DISK_SIZE_MIN..=constants::DISK_SIZE_MAX).contains(&self.size) {
|
|
||||||
return Err(DisksError::Config("Disk size is invalid!").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get disk path
|
|
||||||
pub fn disk_path(&self, id: XMLUuid) -> PathBuf {
|
|
||||||
let domain_dir = AppConfig::get().vm_storage_path(id);
|
|
||||||
let file_name = match self.format {
|
|
||||||
DiskFormat::Raw { .. } => self.name.to_string(),
|
|
||||||
DiskFormat::QCow2 => format!("{}.qcow2", self.name),
|
|
||||||
};
|
|
||||||
domain_dir.join(&file_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply disk configuration
|
|
||||||
pub fn apply_config(&self, id: XMLUuid) -> anyhow::Result<()> {
|
|
||||||
self.check_config()?;
|
|
||||||
|
|
||||||
let file = self.disk_path(id);
|
|
||||||
files_utils::create_directory_if_missing(file.parent().unwrap())?;
|
|
||||||
|
|
||||||
// Delete file if requested
|
|
||||||
if self.delete {
|
|
||||||
if !file.exists() {
|
|
||||||
log::debug!("File {file:?} does not exists, so it was not deleted");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Deleting {file:?}");
|
|
||||||
std::fs::remove_file(file)?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if file.exists() {
|
|
||||||
log::debug!("File {file:?} does not exists, so it was not touched");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare command to create file
|
// Prepare command to create file
|
||||||
let res = match self.format {
|
let res = match format {
|
||||||
DiskFormat::Raw { alloc_type } => {
|
DiskFileFormat::Raw { is_sparse } => {
|
||||||
let mut cmd = Command::new("/usr/bin/dd");
|
let mut cmd = Command::new("/usr/bin/dd");
|
||||||
cmd.arg("if=/dev/zero")
|
cmd.arg("if=/dev/zero")
|
||||||
.arg(format!("of={}", file.to_string_lossy()))
|
.arg(format!("of={}", file.to_string_lossy()))
|
||||||
.arg("bs=1M");
|
.arg("bs=1M");
|
||||||
|
|
||||||
match alloc_type {
|
match is_sparse {
|
||||||
DiskAllocType::Fixed => cmd.arg(format!("count={}", self.size)),
|
false => cmd.arg(format!("count={}", size.as_mb())),
|
||||||
DiskAllocType::Sparse => cmd.arg(format!("seek={}", self.size)).arg("count=0"),
|
true => cmd.arg(format!("seek={}", size.as_mb())).arg("count=0"),
|
||||||
};
|
};
|
||||||
|
|
||||||
cmd.output()?
|
cmd.output()?
|
||||||
}
|
}
|
||||||
|
|
||||||
DiskFormat::QCow2 => {
|
DiskFileFormat::QCow2 { virtual_size } => {
|
||||||
let mut cmd = Command::new("/usr/bin/qemu-img");
|
let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
|
||||||
cmd.arg("create")
|
cmd.arg("create")
|
||||||
.arg("-f")
|
.arg("-f")
|
||||||
.arg("qcow2")
|
.arg("qcow2")
|
||||||
.arg(file)
|
.arg(file)
|
||||||
.arg(format!("{}M", self.size));
|
.arg(format!("{}M", virtual_size.as_mb()));
|
||||||
|
|
||||||
cmd.output()?
|
cmd.output()?
|
||||||
}
|
}
|
||||||
|
_ => anyhow::bail!("Cannot create disk file image of this format: {format:?}!"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execute Linux command
|
// Execute Linux command
|
||||||
@ -178,6 +157,281 @@ impl FileDisk {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy / convert file disk image into a new destination with optionally a new file format
|
||||||
|
pub fn convert(&self, dest_file: &Path, dest_format: DiskFileFormat) -> anyhow::Result<()> {
|
||||||
|
// Create a temporary directory to perform the operation
|
||||||
|
let temp_dir = tempfile::tempdir_in(&AppConfig::get().temp_dir)?;
|
||||||
|
let temp_file = temp_dir
|
||||||
|
.path()
|
||||||
|
.join(format!("temp_file.{}", dest_format.ext()[0]));
|
||||||
|
|
||||||
|
// Prepare the conversion
|
||||||
|
let mut cmd = match (self.format, dest_format) {
|
||||||
|
// Decompress QCow2 (GZIP)
|
||||||
|
(DiskFileFormat::GzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
|
||||||
|
let mut cmd = Command::new(constants::PROGRAM_GZIP);
|
||||||
|
cmd.arg("--keep")
|
||||||
|
.arg("--decompress")
|
||||||
|
.arg("--to-stdout")
|
||||||
|
.arg(&self.file_path)
|
||||||
|
.stdout(File::create(&temp_file)?);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
.arg(&self.file_path)
|
||||||
|
.stdout(File::create(&temp_file)?);
|
||||||
|
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);
|
||||||
|
cmd.arg("convert")
|
||||||
|
.arg("-f")
|
||||||
|
.arg("qcow2")
|
||||||
|
.arg("-O")
|
||||||
|
.arg("raw")
|
||||||
|
.arg(&self.file_path)
|
||||||
|
.arg(&temp_file);
|
||||||
|
|
||||||
|
if !is_sparse {
|
||||||
|
cmd.args(["-S", "0"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone a QCow file, using qemu-image instead of cp might improve "sparsification" of
|
||||||
|
// file
|
||||||
|
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::QCow2 { .. }) => {
|
||||||
|
let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
|
||||||
|
cmd.arg("convert")
|
||||||
|
.arg("-f")
|
||||||
|
.arg("qcow2")
|
||||||
|
.arg("-O")
|
||||||
|
.arg("qcow2")
|
||||||
|
.arg(&self.file_path)
|
||||||
|
.arg(&temp_file);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Raw to QCow2 file
|
||||||
|
(DiskFileFormat::Raw { .. }, DiskFileFormat::QCow2 { .. }) => {
|
||||||
|
let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
|
||||||
|
cmd.arg("convert")
|
||||||
|
.arg("-f")
|
||||||
|
.arg("raw")
|
||||||
|
.arg("-O")
|
||||||
|
.arg("qcow2")
|
||||||
|
.arg(&self.file_path)
|
||||||
|
.arg(&temp_file);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render raw file non sparse
|
||||||
|
(DiskFileFormat::Raw { is_sparse: true }, DiskFileFormat::Raw { is_sparse: false }) => {
|
||||||
|
let mut cmd = Command::new(constants::PROGRAM_COPY);
|
||||||
|
cmd.arg("--sparse=never")
|
||||||
|
.arg(&self.file_path)
|
||||||
|
.arg(&temp_file);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render raw file sparse
|
||||||
|
(DiskFileFormat::Raw { is_sparse: false }, DiskFileFormat::Raw { is_sparse: true }) => {
|
||||||
|
let mut cmd = Command::new(constants::PROGRAM_DD);
|
||||||
|
cmd.arg("conv=sparse")
|
||||||
|
.arg(format!("if={}", self.file_path.display()))
|
||||||
|
.arg(format!("of={}", temp_file.display()));
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress Raw (Gz)
|
||||||
|
(DiskFileFormat::Raw { .. }, DiskFileFormat::GzCompressedRaw) => {
|
||||||
|
let mut cmd = Command::new(constants::PROGRAM_GZIP);
|
||||||
|
cmd.arg("--keep")
|
||||||
|
.arg("--to-stdout")
|
||||||
|
.arg(&self.file_path)
|
||||||
|
.stdout(File::create(&temp_file)?);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
.arg("--to-stdout")
|
||||||
|
.arg(&self.file_path)
|
||||||
|
.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 (Gz) to sparse file
|
||||||
|
// https://benou.fr/www/ben/decompressing-sparse-files.html
|
||||||
|
(DiskFileFormat::GzCompressedRaw, 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_GZIP,
|
||||||
|
self.file_path.display(),
|
||||||
|
constants::PROGRAM_DD,
|
||||||
|
temp_file.display()
|
||||||
|
));
|
||||||
|
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);
|
||||||
|
cmd.arg("--sparse=auto")
|
||||||
|
.arg(&self.file_path)
|
||||||
|
.arg(&temp_file);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// By default, conversion is unsupported
|
||||||
|
(src, dest) => {
|
||||||
|
return Err(DisksError::Convert(format!(
|
||||||
|
"Conversion from {src:?} to {dest:?} is not supported!"
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute the conversion
|
||||||
|
let command_s = format!(
|
||||||
|
"{} {}",
|
||||||
|
cmd.get_program().display(),
|
||||||
|
cmd.get_args()
|
||||||
|
.map(|a| format!("'{}'", a.display()))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(" ")
|
||||||
|
);
|
||||||
|
let cmd_output = cmd.output()?;
|
||||||
|
if !cmd_output.status.success() {
|
||||||
|
return Err(DisksError::Convert(format!(
|
||||||
|
"Command failed:\n{command_s}\nStatus: {}\nstdout: {}\nstderr: {}",
|
||||||
|
cmd_output.status,
|
||||||
|
String::from_utf8_lossy(&cmd_output.stdout),
|
||||||
|
String::from_utf8_lossy(&cmd_output.stderr)
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the file was created
|
||||||
|
if !temp_file.is_file() {
|
||||||
|
return Err(DisksError::Convert(
|
||||||
|
"Temporary was not created after execution of command!".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the file to its final location
|
||||||
|
std::fs::rename(temp_file, dest_file)?;
|
||||||
|
|
||||||
|
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)]
|
#[derive(serde::Deserialize)]
|
||||||
@ -187,14 +441,21 @@ struct QCowInfoOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get QCow2 virtual size
|
/// Get QCow2 virtual size
|
||||||
fn qcow_virt_size(path: &str) -> anyhow::Result<usize> {
|
fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> {
|
||||||
// Run qemu-img
|
// Run qemu-img
|
||||||
let mut cmd = Command::new("qemu-img");
|
let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
|
||||||
cmd.args(["info", path, "--output", "json", "--force-share"]);
|
cmd.args([
|
||||||
|
"info",
|
||||||
|
path.to_str().unwrap_or(""),
|
||||||
|
"--output",
|
||||||
|
"json",
|
||||||
|
"--force-share",
|
||||||
|
]);
|
||||||
let output = cmd.output()?;
|
let output = cmd.output()?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"qemu-img info failed, status: {}, stderr: {}",
|
"{} info failed, status: {}, stderr: {}",
|
||||||
|
constants::PROGRAM_QEMU_IMAGE,
|
||||||
output.status,
|
output.status,
|
||||||
String::from_utf8_lossy(&output.stderr)
|
String::from_utf8_lossy(&output.stderr)
|
||||||
);
|
);
|
||||||
@ -203,5 +464,5 @@ fn qcow_virt_size(path: &str) -> anyhow::Result<usize> {
|
|||||||
|
|
||||||
// Decode JSON
|
// Decode JSON
|
||||||
let decoded: QCowInfoOutput = serde_json::from_str(&res_json)?;
|
let decoded: QCowInfoOutput = serde_json::from_str(&res_json)?;
|
||||||
Ok(decoded.virtual_size)
|
Ok(FileSize::from_bytes(decoded.virtual_size))
|
||||||
}
|
}
|
||||||
|
91
virtweb_backend/src/utils/file_size_utils.rs
Normal file
91
virtweb_backend/src/utils/file_size_utils.rs
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
use std::ops::Mul;
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
enum FilesSizeUtilsError {
|
||||||
|
#[error("UnitConvertError: {0}")]
|
||||||
|
UnitConvert(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds a data size, convertible in any form
|
||||||
|
#[derive(
|
||||||
|
serde::Serialize,
|
||||||
|
serde::Deserialize,
|
||||||
|
Copy,
|
||||||
|
Clone,
|
||||||
|
Debug,
|
||||||
|
Eq,
|
||||||
|
PartialEq,
|
||||||
|
PartialOrd,
|
||||||
|
Ord,
|
||||||
|
Default,
|
||||||
|
)]
|
||||||
|
pub struct FileSize(usize);
|
||||||
|
|
||||||
|
impl FileSize {
|
||||||
|
pub const fn from_bytes(size: usize) -> Self {
|
||||||
|
Self(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn from_mb(mb: usize) -> Self {
|
||||||
|
Self(mb * 1000 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn from_gb(gb: usize) -> Self {
|
||||||
|
Self(gb * 1000 * 1000 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert size unit to MB
|
||||||
|
pub fn from_size_unit(unit: &str, value: usize) -> anyhow::Result<Self> {
|
||||||
|
let fact = match unit {
|
||||||
|
"bytes" | "b" => 1f64,
|
||||||
|
"KB" => 1000f64,
|
||||||
|
"MB" => 1000f64 * 1000f64,
|
||||||
|
"GB" => 1000f64 * 1000f64 * 1000f64,
|
||||||
|
"TB" => 1000f64 * 1000f64 * 1000f64 * 1000f64,
|
||||||
|
|
||||||
|
"k" | "KiB" => 1024f64,
|
||||||
|
"M" | "MiB" => 1024f64 * 1024f64,
|
||||||
|
"G" | "GiB" => 1024f64 * 1024f64 * 1024f64,
|
||||||
|
"T" | "TiB" => 1024f64 * 1024f64 * 1024f64 * 1024f64,
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
return Err(
|
||||||
|
FilesSizeUtilsError::UnitConvert(format!("Unknown size unit: {unit}")).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self((value as f64).mul(fact).ceil() as usize))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get file size as bytes
|
||||||
|
pub fn as_bytes(&self) -> usize {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get file size as megabytes
|
||||||
|
pub fn as_mb(&self) -> usize {
|
||||||
|
self.0 / (1000 * 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::utils::file_size_utils::FileSize;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_units_mb() {
|
||||||
|
assert_eq!(FileSize::from_size_unit("MB", 1).unwrap().as_mb(), 1);
|
||||||
|
assert_eq!(FileSize::from_size_unit("MB", 1000).unwrap().as_mb(), 1000);
|
||||||
|
assert_eq!(
|
||||||
|
FileSize::from_size_unit("GB", 1000).unwrap().as_mb(),
|
||||||
|
1000 * 1000
|
||||||
|
);
|
||||||
|
assert_eq!(FileSize::from_size_unit("GB", 1).unwrap().as_mb(), 1000);
|
||||||
|
assert_eq!(FileSize::from_size_unit("GiB", 3).unwrap().as_mb(), 3221);
|
||||||
|
assert_eq!(
|
||||||
|
FileSize::from_size_unit("KiB", 488281).unwrap().as_mb(),
|
||||||
|
499
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,13 +1,6 @@
|
|||||||
use std::ops::{Div, Mul};
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
|
||||||
enum FilesUtilsError {
|
|
||||||
#[error("UnitConvertError: {0}")]
|
|
||||||
UnitConvert(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
const INVALID_CHARS: [&str; 19] = [
|
const INVALID_CHARS: [&str; 19] = [
|
||||||
"@", "\\", "/", ":", ",", "<", ">", "%", "'", "\"", "?", "{", "}", "$", "*", "|", ";", "=",
|
"@", "\\", "/", ":", ",", "<", ">", "%", "'", "\"", "?", "{", "}", "$", "*", "|", ";", "=",
|
||||||
"\t",
|
"\t",
|
||||||
@ -35,31 +28,9 @@ pub fn set_file_permission<P: AsRef<Path>>(path: P, mode: u32) -> anyhow::Result
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert size unit to MB
|
|
||||||
pub fn convert_size_unit_to_mb(unit: &str, value: usize) -> anyhow::Result<usize> {
|
|
||||||
let fact = match unit {
|
|
||||||
"bytes" | "b" => 1f64,
|
|
||||||
"KB" => 1000f64,
|
|
||||||
"MB" => 1000f64 * 1000f64,
|
|
||||||
"GB" => 1000f64 * 1000f64 * 1000f64,
|
|
||||||
"TB" => 1000f64 * 1000f64 * 1000f64 * 1000f64,
|
|
||||||
|
|
||||||
"k" | "KiB" => 1024f64,
|
|
||||||
"M" | "MiB" => 1024f64 * 1024f64,
|
|
||||||
"G" | "GiB" => 1024f64 * 1024f64 * 1024f64,
|
|
||||||
"T" | "TiB" => 1024f64 * 1024f64 * 1024f64 * 1024f64,
|
|
||||||
|
|
||||||
_ => {
|
|
||||||
return Err(FilesUtilsError::UnitConvert(format!("Unknown size unit: {unit}")).into());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((value as f64).mul(fact.div((1000 * 1000) as f64)).ceil() as usize)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use crate::utils::files_utils::{check_file_name, convert_size_unit_to_mb};
|
use crate::utils::files_utils::check_file_name;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_file_name() {
|
fn empty_file_name() {
|
||||||
@ -85,14 +56,4 @@ mod test {
|
|||||||
fn valid_file_name() {
|
fn valid_file_name() {
|
||||||
assert!(check_file_name("test.iso"));
|
assert!(check_file_name("test.iso"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn convert_units_mb() {
|
|
||||||
assert_eq!(convert_size_unit_to_mb("MB", 1).unwrap(), 1);
|
|
||||||
assert_eq!(convert_size_unit_to_mb("MB", 1000).unwrap(), 1000);
|
|
||||||
assert_eq!(convert_size_unit_to_mb("GB", 1000).unwrap(), 1000 * 1000);
|
|
||||||
assert_eq!(convert_size_unit_to_mb("GB", 1).unwrap(), 1000);
|
|
||||||
assert_eq!(convert_size_unit_to_mb("GiB", 3).unwrap(), 3222);
|
|
||||||
assert_eq!(convert_size_unit_to_mb("KiB", 488281).unwrap(), 500);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
|
pub mod cloud_init_utils;
|
||||||
|
pub mod exec_utils;
|
||||||
pub mod file_disks_utils;
|
pub mod file_disks_utils;
|
||||||
|
pub mod file_size_utils;
|
||||||
pub mod files_utils;
|
pub mod files_utils;
|
||||||
pub mod net_utils;
|
pub mod net_utils;
|
||||||
pub mod rand_utils;
|
pub mod rand_utils;
|
||||||
pub mod time_utils;
|
pub mod time_utils;
|
||||||
pub mod url_utils;
|
pub mod url_utils;
|
||||||
|
pub mod vm_file_disks_utils;
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
|
use crate::constants;
|
||||||
use nix::sys::socket::{AddressFamily, SockaddrLike};
|
use nix::sys::socket::{AddressFamily, SockaddrLike};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
|
use std::process::Command;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use sysinfo::Networks;
|
use sysinfo::Networks;
|
||||||
|
|
||||||
@ -68,7 +70,7 @@ pub fn net_list() -> Vec<String> {
|
|||||||
|
|
||||||
/// Get the list of available network interfaces associated with their IP address
|
/// Get the list of available network interfaces associated with their IP address
|
||||||
pub fn net_list_and_ips() -> anyhow::Result<HashMap<String, Vec<IpAddr>>> {
|
pub fn net_list_and_ips() -> anyhow::Result<HashMap<String, Vec<IpAddr>>> {
|
||||||
let addrs = nix::ifaddrs::getifaddrs().unwrap();
|
let addrs = nix::ifaddrs::getifaddrs()?;
|
||||||
|
|
||||||
let mut res = HashMap::new();
|
let mut res = HashMap::new();
|
||||||
|
|
||||||
@ -136,6 +138,31 @@ pub fn net_list_and_ips() -> anyhow::Result<HashMap<String, Vec<IpAddr>>> {
|
|||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct IPBridgeInfo {
|
||||||
|
ifname: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the list of bridge interfaces
|
||||||
|
pub fn bridges_list() -> anyhow::Result<Vec<String>> {
|
||||||
|
let mut cmd = Command::new(constants::PROGRAM_IP);
|
||||||
|
cmd.args(["-json", "link", "show", "type", "bridge"]);
|
||||||
|
let output = cmd.output()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"{} failed, status: {}, stderr: {}",
|
||||||
|
constants::PROGRAM_IP,
|
||||||
|
output.status,
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON result
|
||||||
|
let res: Vec<IPBridgeInfo> = serde_json::from_str(&String::from_utf8_lossy(&output.stdout))?;
|
||||||
|
|
||||||
|
Ok(res.iter().map(|ip| ip.ifname.clone()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::utils::net_utils::{
|
use crate::utils::net_utils::{
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
use chrono::Datelike;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
/// Get the current time since epoch
|
/// Get the current time since epoch
|
||||||
@ -13,3 +14,15 @@ pub fn time() -> u64 {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.as_secs()
|
.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()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
190
virtweb_backend/src/utils/vm_file_disks_utils.rs
Normal file
190
virtweb_backend/src/utils/vm_file_disks_utils.rs
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
use crate::app_config::AppConfig;
|
||||||
|
use crate::constants;
|
||||||
|
use crate::libvirt_lib_structures::XMLUuid;
|
||||||
|
use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo};
|
||||||
|
use crate::utils::file_size_utils::FileSize;
|
||||||
|
use crate::utils::files_utils;
|
||||||
|
use lazy_regex::regex;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
enum VMDisksError {
|
||||||
|
#[error("DiskConfigError: {0}")]
|
||||||
|
Config(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum VMDiskBus {
|
||||||
|
Virtio,
|
||||||
|
SATA,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disk allocation type
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(tag = "format")]
|
||||||
|
pub enum VMDiskFormat {
|
||||||
|
Raw {
|
||||||
|
/// Is raw file a sparse file?
|
||||||
|
is_sparse: bool,
|
||||||
|
},
|
||||||
|
QCow2,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct VMFileDisk {
|
||||||
|
/// Disk name
|
||||||
|
pub name: String,
|
||||||
|
/// Disk size, in bytes
|
||||||
|
pub size: FileSize,
|
||||||
|
/// Disk bus
|
||||||
|
pub bus: VMDiskBus,
|
||||||
|
/// Disk format
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub format: VMDiskFormat,
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VMFileDisk {
|
||||||
|
pub fn load_from_file(path: &str, bus: &str) -> anyhow::Result<Self> {
|
||||||
|
let file = Path::new(path);
|
||||||
|
|
||||||
|
let info = DiskFileInfo::load_file(file)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
name: info.name,
|
||||||
|
|
||||||
|
// Get only the virtual size of the file
|
||||||
|
size: match info.format {
|
||||||
|
DiskFileFormat::Raw { .. } => info.file_size,
|
||||||
|
DiskFileFormat::QCow2 { virtual_size } => virtual_size,
|
||||||
|
_ => anyhow::bail!("Unsupported image format: {:?}", info.format),
|
||||||
|
},
|
||||||
|
|
||||||
|
format: match info.format {
|
||||||
|
DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw { is_sparse },
|
||||||
|
DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2,
|
||||||
|
_ => anyhow::bail!("Unsupported image format: {:?}", info.format),
|
||||||
|
},
|
||||||
|
|
||||||
|
bus: match bus {
|
||||||
|
"virtio" => VMDiskBus::Virtio,
|
||||||
|
"sata" => VMDiskBus::SATA,
|
||||||
|
_ => anyhow::bail!("Unsupported disk bus type: {bus}"),
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: false,
|
||||||
|
from_image: None,
|
||||||
|
resize: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_config(&self) -> anyhow::Result<()> {
|
||||||
|
if constants::DISK_NAME_MIN_LEN > self.name.len()
|
||||||
|
|| constants::DISK_NAME_MAX_LEN < self.name.len()
|
||||||
|
{
|
||||||
|
return Err(VMDisksError::Config("Disk name length is invalid").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !regex!("^[a-zA-Z0-9]+$").is_match(&self.name) {
|
||||||
|
return Err(VMDisksError::Config("Disk name contains invalid characters!").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check disk size
|
||||||
|
if !(constants::DISK_SIZE_MIN..=constants::DISK_SIZE_MAX).contains(&self.size) {
|
||||||
|
return Err(VMDisksError::Config("Disk size is invalid!").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check specified disk image template
|
||||||
|
if let Some(disk_image) = &self.from_image {
|
||||||
|
if !files_utils::check_file_name(disk_image) {
|
||||||
|
return Err(VMDisksError::Config("Disk image template name is not valid!").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !AppConfig::get().disk_images_file_path(disk_image).is_file() {
|
||||||
|
return Err(
|
||||||
|
VMDisksError::Config("Specified disk image file does not exist!").into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get disk path on file system
|
||||||
|
pub fn disk_path(&self, id: XMLUuid) -> PathBuf {
|
||||||
|
let domain_dir = AppConfig::get().vm_storage_path(id);
|
||||||
|
let file_name = match self.format {
|
||||||
|
VMDiskFormat::Raw { .. } => self.name.to_string(),
|
||||||
|
VMDiskFormat::QCow2 => format!("{}.qcow2", self.name),
|
||||||
|
};
|
||||||
|
domain_dir.join(&file_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply disk configuration
|
||||||
|
pub fn apply_config(&self, id: XMLUuid) -> anyhow::Result<()> {
|
||||||
|
self.check_config()?;
|
||||||
|
|
||||||
|
let file = self.disk_path(id);
|
||||||
|
files_utils::create_directory_if_missing(file.parent().unwrap())?;
|
||||||
|
|
||||||
|
// Delete file if requested
|
||||||
|
if self.delete {
|
||||||
|
if !file.exists() {
|
||||||
|
log::debug!("File {file:?} does not exists, so it was not deleted");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Deleting {file:?}");
|
||||||
|
std::fs::remove_file(file)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.exists() {
|
||||||
|
log::debug!("File {file:?} does not exists, so it was not touched");
|
||||||
|
}
|
||||||
|
// 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)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize disk file if requested
|
||||||
|
if self.resize == Some(true) {
|
||||||
|
let disk = DiskFileInfo::load_file(&file)?;
|
||||||
|
|
||||||
|
// Can only increase disk size
|
||||||
|
if let Err(e) = disk.resize(self.size) {
|
||||||
|
log::error!("Failed to resize disk file {}: {e:?}", self.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
47
virtweb_docs/BRIDGE.md
Normal file
47
virtweb_docs/BRIDGE.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Bridges
|
||||||
|
|
||||||
|
Bridges can be used to connect virtual machines to networks.
|
||||||
|
|
||||||
|
## Setup Bridge on Ubuntu
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install bridge-utils
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Adapt your netplan configuration to set the following:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
network:
|
||||||
|
version: 2
|
||||||
|
renderer: networkd
|
||||||
|
ethernets:
|
||||||
|
enp2s0:
|
||||||
|
dhcp4: no
|
||||||
|
bridges:
|
||||||
|
br0: # Bridge name
|
||||||
|
dhcp4: yes
|
||||||
|
interfaces:
|
||||||
|
- enp2s0 # Set to your interface
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
3. Apply netplan configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo netplan apply
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
4. Get the state and the list of bridges in the system:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo brctl show
|
||||||
|
|
||||||
|
# Or
|
||||||
|
ip link show type bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
[How to Configure Network Bridge in Ubuntu](https://www.tecmint.com/create-network-bridge-in-ubuntu/)
|
@ -5,9 +5,9 @@
|
|||||||
sudo apt install libvirt-dev
|
sudo apt install libvirt-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Libvirt must also be installed:
|
2. Libvirt and cloud image utilities must also be installed:
|
||||||
```bash
|
```bash
|
||||||
sudo apt install qemu-kvm libvirt-daemon-system
|
sudo apt install qemu-kvm libvirt-daemon-system cloud-image-utils
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Allow the current user to manage VMs:
|
3. Allow the current user to manage VMs:
|
||||||
@ -34,7 +34,7 @@ docker compose up
|
|||||||
sudo mkdir /var/virtweb
|
sudo mkdir /var/virtweb
|
||||||
sudo chown $USER:$USER /var/virtweb
|
sudo chown $USER:$USER /var/virtweb
|
||||||
cd virtweb_backend
|
cd virtweb_backend
|
||||||
cargo fmt && cargo clippy && cargo run -- -s /var/virtweb --hypervisor-uri "qemu:///system"
|
cargo fmt && cargo clippy && cargo run -- -s /var/virtweb --hypervisor-uri "qemu:///system" --website-origin "http://localhost:5173"
|
||||||
```
|
```
|
||||||
|
|
||||||
7. Run the frontend
|
7. Run the frontend
|
||||||
|
@ -12,10 +12,10 @@ The release file will be available in `virtweb_backend/target/release/virtweb_ba
|
|||||||
This is the only artifact that must be copied to the server. It is recommended to copy it to the `/usr/local/bin` directory.
|
This is the only artifact that must be copied to the server. It is recommended to copy it to the `/usr/local/bin` directory.
|
||||||
|
|
||||||
## Install requirements
|
## Install requirements
|
||||||
In order to work properly, VirtWeb relies on `libvirt`, `qemu` and `kvm`:
|
In order to work properly, VirtWeb relies on `libvirt`, `qemu`, `kvm` and `cloud-localds`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt install qemu-kvm libvirt-daemon-system libvirt0 libvirt-clients libvirt-daemon bridge-utils
|
sudo apt install qemu-kvm libvirt-daemon-system libvirt0 libvirt-clients libvirt-daemon bridge-utils cloud-image-utils
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dedicated user
|
## Dedicated user
|
||||||
|
321
virtweb_frontend/package-lock.json
generated
321
virtweb_frontend/package-lock.json
generated
@ -10,36 +10,40 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@fontsource/roboto": "^5.2.5",
|
"@fontsource/roboto": "^5.2.6",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
"@mui/icons-material": "^7.1.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@mui/material": "^7.1.0",
|
"@mui/icons-material": "^7.1.1",
|
||||||
|
"@mui/material": "^7.1.1",
|
||||||
"@mui/x-charts": "^8.3.1",
|
"@mui/x-charts": "^8.3.1",
|
||||||
"@mui/x-data-grid": "^8.3.1",
|
"@mui/x-data-grid": "^8.3.1",
|
||||||
"date-and-time": "^3.6.0",
|
"date-and-time": "^3.6.0",
|
||||||
"filesize": "^10.1.6",
|
"filesize": "^10.1.6",
|
||||||
"humanize-duration": "^3.32.2",
|
"humanize-duration": "^3.32.2",
|
||||||
|
"monaco-editor": "^0.52.2",
|
||||||
|
"monaco-yaml": "^5.4.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^7.6.0",
|
"react-router-dom": "^7.6.2",
|
||||||
"react-syntax-highlighter": "^15.6.1",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"react-vnc": "^3.1.0",
|
"react-vnc": "^3.1.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"xml-formatter": "^3.6.6"
|
"xml-formatter": "^3.6.6",
|
||||||
|
"yaml": "^2.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.27.0",
|
"@eslint/js": "^9.29.0",
|
||||||
"@types/humanize-duration": "^3.27.1",
|
"@types/humanize-duration": "^3.27.4",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/react": "^19.1.5",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"eslint": "^9.27.0",
|
"eslint": "^9.27.0",
|
||||||
"eslint-plugin-react-dom": "^1.49.0",
|
"eslint-plugin-react-dom": "^1.49.0",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"eslint-plugin-react-x": "^1.49.0",
|
"eslint-plugin-react-x": "^1.49.0",
|
||||||
"globals": "^16.1.0",
|
"globals": "^16.1.0",
|
||||||
@ -740,9 +744,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/js": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "9.27.0",
|
"version": "9.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz",
|
||||||
"integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==",
|
"integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -777,9 +781,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource/roboto": {
|
"node_modules/@fontsource/roboto": {
|
||||||
"version": "5.2.5",
|
"version": "5.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.6.tgz",
|
||||||
"integrity": "sha512-70r2UZ0raqLn5W+sPeKhqlf8wGvUXFWlofaDlcbt/S3d06+17gXKr3VNqDODB0I1ASme3dGT5OJj9NABt7OTZQ==",
|
"integrity": "sha512-hzarG7yAhMoP418smNgfY4fO7UmuUEm5JUtbxCoCcFHT0hOJB+d/qAEyoNjz7YkPU5OjM2LM8rJnW8hfm0JLaA==",
|
||||||
"license": "OFL-1.1",
|
"license": "OFL-1.1",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
@ -958,10 +962,33 @@
|
|||||||
"prop-types": "^15.7.2"
|
"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": {
|
"node_modules/@mui/core-downloads-tracker": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.1.1.tgz",
|
||||||
"integrity": "sha512-E0OqhZv548Qdc0PwWhLVA2zmjJZSTvaL4ZhoswmI8NJEC1tpW2js6LLP827jrW9MEiXYdz3QS6+hask83w74yQ==",
|
"integrity": "sha512-yBckQs4aQ8mqukLnPC6ivIRv6guhaXi8snVl00VtyojBbm+l6VbVhyTSZ68Abcx7Ah8B+GZhrB7BOli+e+9LkQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@ -969,9 +996,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/icons-material": {
|
"node_modules/@mui/icons-material": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.1.1.tgz",
|
||||||
"integrity": "sha512-1mUPMAZ+Qk3jfgL5ftRR06ATH/Esi0izHl1z56H+df6cwIlCWG66RXciUqeJCttbOXOQ5y2DCjLZI/4t3Yg3LA==",
|
"integrity": "sha512-X37+Yc8QpEnl0sYmz+WcLFy2dWgNRzbswDzLPXG7QU1XDVlP5TPp1HXjdmCupOWLL/I9m1fyhcyZl8/HPpp/Cg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.1"
|
"@babel/runtime": "^7.27.1"
|
||||||
@ -984,7 +1011,7 @@
|
|||||||
"url": "https://opencollective.com/mui-org"
|
"url": "https://opencollective.com/mui-org"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@mui/material": "^7.1.0",
|
"@mui/material": "^7.1.1",
|
||||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
},
|
},
|
||||||
@ -995,16 +1022,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/material": {
|
"node_modules/@mui/material": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.1.1.tgz",
|
||||||
"integrity": "sha512-ahUJdrhEv+mCp4XHW+tHIEYzZMSRLg8z4AjUOsj44QpD1ZaMxQoVOG2xiHvLFdcsIPbgSRx1bg1eQSheHBgvtg==",
|
"integrity": "sha512-mTpdmdZCaHCGOH3SrYM41+XKvNL0iQfM9KlYgpSjgadXx/fEKhhvOktxm8++Xw6FFeOHoOiV+lzOI8X1rsv71A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.1",
|
"@babel/runtime": "^7.27.1",
|
||||||
"@mui/core-downloads-tracker": "^7.1.0",
|
"@mui/core-downloads-tracker": "^7.1.1",
|
||||||
"@mui/system": "^7.1.0",
|
"@mui/system": "^7.1.1",
|
||||||
"@mui/types": "^7.4.2",
|
"@mui/types": "^7.4.3",
|
||||||
"@mui/utils": "^7.1.0",
|
"@mui/utils": "^7.1.1",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@types/react-transition-group": "^4.4.12",
|
"@types/react-transition-group": "^4.4.12",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@ -1023,7 +1050,7 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@emotion/react": "^11.5.0",
|
"@emotion/react": "^11.5.0",
|
||||||
"@emotion/styled": "^11.3.0",
|
"@emotion/styled": "^11.3.0",
|
||||||
"@mui/material-pigment-css": "^7.1.0",
|
"@mui/material-pigment-css": "^7.1.1",
|
||||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
@ -1044,13 +1071,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/private-theming": {
|
"node_modules/@mui/private-theming": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.1.1.tgz",
|
||||||
"integrity": "sha512-4Kck4jxhqF6YxNwJdSae1WgDfXVg0lIH6JVJ7gtuFfuKcQCgomJxPvUEOySTFRPz1IZzwz5OAcToskRdffElDA==",
|
"integrity": "sha512-M8NbLUx+armk2ZuaxBkkMk11ultnWmrPlN0Xe3jUEaBChg/mcxa5HWIWS1EE4DF36WRACaAHVAvyekWlDQf0PQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.1",
|
"@babel/runtime": "^7.27.1",
|
||||||
"@mui/utils": "^7.1.0",
|
"@mui/utils": "^7.1.1",
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -1071,9 +1098,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/styled-engine": {
|
"node_modules/@mui/styled-engine": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.1.1.tgz",
|
||||||
"integrity": "sha512-m0mJ0c6iRC+f9hMeRe0W7zZX1wme3oUX0+XTVHjPG7DJz6OdQ6K/ggEOq7ZdwilcpdsDUwwMfOmvO71qDkYd2w==",
|
"integrity": "sha512-R2wpzmSN127j26HrCPYVQ53vvMcT5DaKLoWkrfwUYq3cYytL6TQrCH8JBH3z79B6g4nMZZVoaXrxO757AlShaw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.1",
|
"@babel/runtime": "^7.27.1",
|
||||||
@ -1105,16 +1132,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/system": {
|
"node_modules/@mui/system": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.1.1.tgz",
|
||||||
"integrity": "sha512-iedAWgRJMCxeMHvkEhsDlbvkK+qKf9me6ofsf7twk/jfT4P1ImVf7Rwb5VubEA0sikrVL+1SkoZM41M4+LNAVA==",
|
"integrity": "sha512-Kj1uhiqnj4Zo7PDjAOghtXJtNABunWvhcRU0O7RQJ7WOxeynoH6wXPcilphV8QTFtkKaip8EiNJRiCD+B3eROA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.1",
|
"@babel/runtime": "^7.27.1",
|
||||||
"@mui/private-theming": "^7.1.0",
|
"@mui/private-theming": "^7.1.1",
|
||||||
"@mui/styled-engine": "^7.1.0",
|
"@mui/styled-engine": "^7.1.1",
|
||||||
"@mui/types": "^7.4.2",
|
"@mui/types": "^7.4.3",
|
||||||
"@mui/utils": "^7.1.0",
|
"@mui/utils": "^7.1.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"csstype": "^3.1.3",
|
"csstype": "^3.1.3",
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
@ -1145,9 +1172,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/types": {
|
"node_modules/@mui/types": {
|
||||||
"version": "7.4.2",
|
"version": "7.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.3.tgz",
|
||||||
"integrity": "sha512-edRc5JcLPsrlNFYyTPxds+d5oUovuUxnnDtpJUbP6WMeV4+6eaX/mqai1ZIWT62lCOe0nlrON0s9HDiv5en5bA==",
|
"integrity": "sha512-2UCEiK29vtiZTeLdS2d4GndBKacVyxGvReznGXGr+CzW/YhjIX+OHUdCIczZjzcRAgKBGmE9zCIgoV9FleuyRQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.1"
|
"@babel/runtime": "^7.27.1"
|
||||||
@ -1162,13 +1189,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/utils": {
|
"node_modules/@mui/utils": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.1.tgz",
|
||||||
"integrity": "sha512-/OM3S8kSHHmWNOP+NH9xEtpYSG10upXeQ0wLZnfDgmgadTAk5F4MQfFLyZ5FCRJENB3eRzltMmaNl6UtDnPovw==",
|
"integrity": "sha512-BkOt2q7MBYl7pweY2JWwfrlahhp+uGLR8S+EhiyRaofeRYUWL2YKbSGQvN4hgSN1i8poN0PaUiii1kEMrchvzg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.1",
|
"@babel/runtime": "^7.27.1",
|
||||||
"@mui/types": "^7.4.2",
|
"@mui/types": "^7.4.3",
|
||||||
"@types/prop-types": "^15.7.14",
|
"@types/prop-types": "^15.7.14",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
@ -1598,18 +1625,18 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.1.5",
|
"version": "19.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
|
||||||
"integrity": "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==",
|
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "19.1.5",
|
"version": "19.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz",
|
||||||
"integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==",
|
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@ -2762,6 +2789,19 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint/node_modules/@eslint/js": {
|
||||||
|
"version": "9.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz",
|
||||||
|
"integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://eslint.org/donate"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/espree": {
|
"node_modules/espree": {
|
||||||
"version": "10.3.0",
|
"version": "10.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
|
||||||
@ -3481,6 +3521,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@ -3607,6 +3653,84 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@ -3753,6 +3877,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@ -3846,6 +3976,21 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/pretty-format": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
|
||||||
@ -3989,9 +4134,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.6.0",
|
"version": "7.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.2.tgz",
|
||||||
"integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==",
|
"integrity": "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@ -4011,12 +4156,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.6.0",
|
"version": "7.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.2.tgz",
|
||||||
"integrity": "sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==",
|
"integrity": "sha512-Q8zb6VlTbdYKK5JJBLQEN06oTUa/RAbG/oQS1auK1I0TbJOXktqm+QENEVJU6QvWynlXPRBXI3fiOQcSEA78rA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.6.0"
|
"react-router": "7.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@ -4326,6 +4471,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/string-ts": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/string-ts/-/string-ts-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/string-ts/-/string-ts-2.2.1.tgz",
|
||||||
@ -4714,6 +4865,43 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@ -4781,10 +4969,7 @@
|
|||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
|
||||||
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
|
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
@ -12,36 +12,40 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@fontsource/roboto": "^5.2.5",
|
"@fontsource/roboto": "^5.2.6",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
"@mui/icons-material": "^7.1.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@mui/material": "^7.1.0",
|
"@mui/icons-material": "^7.1.1",
|
||||||
|
"@mui/material": "^7.1.1",
|
||||||
"@mui/x-charts": "^8.3.1",
|
"@mui/x-charts": "^8.3.1",
|
||||||
"@mui/x-data-grid": "^8.3.1",
|
"@mui/x-data-grid": "^8.3.1",
|
||||||
"date-and-time": "^3.6.0",
|
"date-and-time": "^3.6.0",
|
||||||
"filesize": "^10.1.6",
|
"filesize": "^10.1.6",
|
||||||
"humanize-duration": "^3.32.2",
|
"humanize-duration": "^3.32.2",
|
||||||
|
"monaco-editor": "^0.52.2",
|
||||||
|
"monaco-yaml": "^5.4.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router-dom": "^7.6.0",
|
"react-router-dom": "^7.6.2",
|
||||||
"react-syntax-highlighter": "^15.6.1",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"react-vnc": "^3.1.0",
|
"react-vnc": "^3.1.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"xml-formatter": "^3.6.6"
|
"xml-formatter": "^3.6.6",
|
||||||
|
"yaml": "^2.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.27.0",
|
"@eslint/js": "^9.29.0",
|
||||||
"@types/humanize-duration": "^3.27.1",
|
"@types/humanize-duration": "^3.27.4",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/react": "^19.1.5",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"eslint": "^9.27.0",
|
"eslint": "^9.27.0",
|
||||||
"eslint-plugin-react-dom": "^1.49.0",
|
"eslint-plugin-react-dom": "^1.49.0",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"eslint-plugin-react-x": "^1.49.0",
|
"eslint-plugin-react-x": "^1.49.0",
|
||||||
"globals": "^16.1.0",
|
"globals": "^16.1.0",
|
||||||
|
@ -38,6 +38,7 @@ import { LoginRoute } from "./routes/auth/LoginRoute";
|
|||||||
import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
|
import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
|
||||||
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
||||||
import { BaseLoginPage } from "./widgets/BaseLoginPage";
|
import { BaseLoginPage } from "./widgets/BaseLoginPage";
|
||||||
|
import { DiskImagesRoute } from "./routes/DiskImagesRoute";
|
||||||
|
|
||||||
interface AuthContext {
|
interface AuthContext {
|
||||||
signedIn: boolean;
|
signedIn: boolean;
|
||||||
@ -63,6 +64,8 @@ export function App() {
|
|||||||
<Route path="*" element={<BaseAuthenticatedPage />}>
|
<Route path="*" element={<BaseAuthenticatedPage />}>
|
||||||
<Route path="" element={<HomeRoute />} />
|
<Route path="" element={<HomeRoute />} />
|
||||||
|
|
||||||
|
<Route path="disk_images" element={<DiskImagesRoute />} />
|
||||||
|
|
||||||
<Route path="iso" element={<IsoFilesRoute />} />
|
<Route path="iso" element={<IsoFilesRoute />} />
|
||||||
|
|
||||||
<Route path="vms" element={<VMListRoute />} />
|
<Route path="vms" element={<VMListRoute />} />
|
||||||
|
@ -103,6 +103,7 @@ export class APIClient {
|
|||||||
body: body,
|
body: body,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
signal: AbortSignal.timeout(50 * 1000 * 1000),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process response
|
// Process response
|
||||||
|
119
virtweb_frontend/src/api/DiskImageApi.ts
Normal file
119
virtweb_frontend/src/api/DiskImageApi.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { APIClient } from "./ApiClient";
|
||||||
|
import { VMFileDisk, VMInfo } from "./VMApi";
|
||||||
|
|
||||||
|
export type DiskImageFormat =
|
||||||
|
| { format: "Raw"; is_sparse: boolean }
|
||||||
|
| { format: "QCow2"; virtual_size?: number }
|
||||||
|
| { format: "GzCompressedQCow2" }
|
||||||
|
| { format: "GzCompressedRaw" }
|
||||||
|
| { format: "XzCompressedQCow2" }
|
||||||
|
| { format: "XzCompressedRaw" };
|
||||||
|
|
||||||
|
export type DiskImage = {
|
||||||
|
file_size: number;
|
||||||
|
file_name: string;
|
||||||
|
name: string;
|
||||||
|
created: number;
|
||||||
|
} & DiskImageFormat;
|
||||||
|
|
||||||
|
export class DiskImageApi {
|
||||||
|
/**
|
||||||
|
* Upload a new disk image file to the server
|
||||||
|
*/
|
||||||
|
static async Upload(
|
||||||
|
file: File,
|
||||||
|
progress: (progress: number) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "POST",
|
||||||
|
uri: "/disk_images/upload",
|
||||||
|
formData: fd,
|
||||||
|
upProgress: progress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of disk images
|
||||||
|
*/
|
||||||
|
static async GetList(): Promise<DiskImage[]> {
|
||||||
|
return (
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: "/disk_images/list",
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download disk image file
|
||||||
|
*/
|
||||||
|
static async Download(
|
||||||
|
file: DiskImage,
|
||||||
|
progress: (p: number) => void
|
||||||
|
): Promise<Blob> {
|
||||||
|
return (
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: `/disk_images/${file.file_name}`,
|
||||||
|
downProgress(e) {
|
||||||
|
progress(Math.floor(100 * (e.progress / e.total)));
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert disk image file
|
||||||
|
*/
|
||||||
|
static async Convert(
|
||||||
|
file: DiskImage,
|
||||||
|
dest_file_name: string,
|
||||||
|
dest_format: DiskImageFormat
|
||||||
|
): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "POST",
|
||||||
|
uri: `/disk_images/${file.file_name}/convert`,
|
||||||
|
jsonData: { ...dest_format, dest_file_name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup VM disk into image disks library
|
||||||
|
*/
|
||||||
|
static async BackupVMDisk(
|
||||||
|
vm: VMInfo,
|
||||||
|
disk: VMFileDisk,
|
||||||
|
dest_file_name: string,
|
||||||
|
format: DiskImageFormat
|
||||||
|
): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
uri: `/vm/${vm.uuid}/disk/${disk.name}/backup`,
|
||||||
|
method: "POST",
|
||||||
|
jsonData: { ...format, dest_file_name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename disk image file
|
||||||
|
*/
|
||||||
|
static async Rename(file: DiskImage, name: string): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "POST",
|
||||||
|
uri: `/disk_images/${file.file_name}/rename`,
|
||||||
|
jsonData: { name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete disk image file
|
||||||
|
*/
|
||||||
|
static async Delete(file: DiskImage): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "DELETE",
|
||||||
|
uri: `/disk_images/${file.file_name}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -5,6 +5,7 @@ export interface ServerConfig {
|
|||||||
local_auth_enabled: boolean;
|
local_auth_enabled: boolean;
|
||||||
oidc_auth_enabled: boolean;
|
oidc_auth_enabled: boolean;
|
||||||
iso_mimetypes: string[];
|
iso_mimetypes: string[];
|
||||||
|
disk_images_mimetypes: string[];
|
||||||
net_mac_prefix: string;
|
net_mac_prefix: string;
|
||||||
builtin_nwfilter_rules: string[];
|
builtin_nwfilter_rules: string[];
|
||||||
nwfilter_chains: string[];
|
nwfilter_chains: string[];
|
||||||
@ -13,6 +14,7 @@ export interface ServerConfig {
|
|||||||
|
|
||||||
export interface ServerConstraints {
|
export interface ServerConstraints {
|
||||||
iso_max_size: number;
|
iso_max_size: number;
|
||||||
|
disk_image_max_size: number;
|
||||||
vnc_token_duration: number;
|
vnc_token_duration: number;
|
||||||
vm_name_size: LenConstraint;
|
vm_name_size: LenConstraint;
|
||||||
vm_title_size: LenConstraint;
|
vm_title_size: LenConstraint;
|
||||||
@ -20,6 +22,7 @@ export interface ServerConstraints {
|
|||||||
memory_size: LenConstraint;
|
memory_size: LenConstraint;
|
||||||
disk_name_size: LenConstraint;
|
disk_name_size: LenConstraint;
|
||||||
disk_size: LenConstraint;
|
disk_size: LenConstraint;
|
||||||
|
disk_image_name_size: LenConstraint;
|
||||||
net_name_size: LenConstraint;
|
net_name_size: LenConstraint;
|
||||||
net_title_size: LenConstraint;
|
net_title_size: LenConstraint;
|
||||||
net_nat_comment_size: LenConstraint;
|
net_nat_comment_size: LenConstraint;
|
||||||
@ -217,4 +220,28 @@ export class ServerApi {
|
|||||||
})
|
})
|
||||||
).data;
|
).data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get host networks bridges list
|
||||||
|
*/
|
||||||
|
static async GetNetworksBridgesList(): Promise<string[]> {
|
||||||
|
return (
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: "/server/bridges",
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export all server configs
|
||||||
|
*/
|
||||||
|
static async ExportServerConfigs(): Promise<Blob> {
|
||||||
|
return (
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: "/server/export_configs",
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,21 +19,30 @@ export type VMState =
|
|||||||
|
|
||||||
export type VMFileDisk = BaseFileVMDisk & (RawVMDisk | QCow2Disk);
|
export type VMFileDisk = BaseFileVMDisk & (RawVMDisk | QCow2Disk);
|
||||||
|
|
||||||
|
export type DiskBusType = "Virtio" | "SATA";
|
||||||
|
|
||||||
export interface BaseFileVMDisk {
|
export interface BaseFileVMDisk {
|
||||||
size: number;
|
size: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
bus: DiskBusType;
|
||||||
|
|
||||||
delete: boolean;
|
delete: boolean;
|
||||||
|
|
||||||
// application attribute
|
// For new disk only
|
||||||
|
from_image?: string;
|
||||||
|
|
||||||
|
// Resize disk image after clone
|
||||||
|
resize?: boolean;
|
||||||
|
|
||||||
|
// application attributes
|
||||||
new?: boolean;
|
new?: boolean;
|
||||||
|
originalSize?: number;
|
||||||
deleteType?: "keepfile" | "deletefile";
|
deleteType?: "keepfile" | "deletefile";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DiskAllocType = "Sparse" | "Fixed";
|
|
||||||
|
|
||||||
interface RawVMDisk {
|
interface RawVMDisk {
|
||||||
format: "Raw";
|
format: "Raw";
|
||||||
alloc_type: DiskAllocType;
|
is_sparse: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QCow2Disk {
|
interface QCow2Disk {
|
||||||
@ -50,11 +59,16 @@ export interface VMNetInterfaceFilter {
|
|||||||
parameters: VMNetInterfaceFilterParams[];
|
parameters: VMNetInterfaceFilterParams[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VMNetInterface = (VMNetUserspaceSLIRPStack | VMNetDefinedNetwork) &
|
export type VMNetInterface = (
|
||||||
|
| VMNetUserspaceSLIRPStack
|
||||||
|
| VMNetDefinedNetwork
|
||||||
|
| VMNetBridge
|
||||||
|
) &
|
||||||
VMNetInterfaceBase;
|
VMNetInterfaceBase;
|
||||||
|
|
||||||
export interface VMNetInterfaceBase {
|
export interface VMNetInterfaceBase {
|
||||||
mac: string;
|
mac: string;
|
||||||
|
model: "Virtio" | "E1000";
|
||||||
nwfilterref?: VMNetInterfaceFilter;
|
nwfilterref?: VMNetInterfaceFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,6 +81,22 @@ export interface VMNetDefinedNetwork {
|
|||||||
network: string;
|
network: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VMNetBridge {
|
||||||
|
type: "Bridge";
|
||||||
|
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 {
|
interface VMInfoInterface {
|
||||||
name: string;
|
name: string;
|
||||||
uuid?: string;
|
uuid?: string;
|
||||||
@ -74,7 +104,7 @@ interface VMInfoInterface {
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
group?: string;
|
group?: string;
|
||||||
boot_type: "UEFI" | "UEFISecureBoot";
|
boot_type: VMBootType;
|
||||||
architecture: "i686" | "x86_64";
|
architecture: "i686" | "x86_64";
|
||||||
memory: number;
|
memory: number;
|
||||||
number_vcpu: number;
|
number_vcpu: number;
|
||||||
@ -84,6 +114,7 @@ interface VMInfoInterface {
|
|||||||
networks: VMNetInterface[];
|
networks: VMNetInterface[];
|
||||||
tpm_module: boolean;
|
tpm_module: boolean;
|
||||||
oem_strings: string[];
|
oem_strings: string[];
|
||||||
|
cloud_init: VMCloudInit;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VMInfo implements VMInfoInterface {
|
export class VMInfo implements VMInfoInterface {
|
||||||
@ -93,7 +124,7 @@ export class VMInfo implements VMInfoInterface {
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
group?: string;
|
group?: string;
|
||||||
boot_type: "UEFI" | "UEFISecureBoot";
|
boot_type: VMBootType;
|
||||||
architecture: "i686" | "x86_64";
|
architecture: "i686" | "x86_64";
|
||||||
number_vcpu: number;
|
number_vcpu: number;
|
||||||
memory: number;
|
memory: number;
|
||||||
@ -103,6 +134,7 @@ export class VMInfo implements VMInfoInterface {
|
|||||||
networks: VMNetInterface[];
|
networks: VMNetInterface[];
|
||||||
tpm_module: boolean;
|
tpm_module: boolean;
|
||||||
oem_strings: string[];
|
oem_strings: string[];
|
||||||
|
cloud_init: VMCloudInit;
|
||||||
|
|
||||||
constructor(int: VMInfoInterface) {
|
constructor(int: VMInfoInterface) {
|
||||||
this.name = int.name;
|
this.name = int.name;
|
||||||
@ -121,6 +153,7 @@ export class VMInfo implements VMInfoInterface {
|
|||||||
this.networks = int.networks;
|
this.networks = int.networks;
|
||||||
this.tpm_module = int.tpm_module;
|
this.tpm_module = int.tpm_module;
|
||||||
this.oem_strings = int.oem_strings;
|
this.oem_strings = int.oem_strings;
|
||||||
|
this.cloud_init = int.cloud_init;
|
||||||
}
|
}
|
||||||
|
|
||||||
static NewEmpty(): VMInfo {
|
static NewEmpty(): VMInfo {
|
||||||
@ -128,7 +161,7 @@ export class VMInfo implements VMInfoInterface {
|
|||||||
name: "",
|
name: "",
|
||||||
boot_type: "UEFI",
|
boot_type: "UEFI",
|
||||||
architecture: "x86_64",
|
architecture: "x86_64",
|
||||||
memory: 1024,
|
memory: 1000 * 1000 * 1000,
|
||||||
number_vcpu: 1,
|
number_vcpu: 1,
|
||||||
vnc_access: true,
|
vnc_access: true,
|
||||||
iso_files: [],
|
iso_files: [],
|
||||||
@ -136,6 +169,7 @@ export class VMInfo implements VMInfoInterface {
|
|||||||
networks: [],
|
networks: [],
|
||||||
tpm_module: true,
|
tpm_module: true,
|
||||||
oem_strings: [],
|
oem_strings: [],
|
||||||
|
cloud_init: { attach_config: false, user_data: "" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
148
virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
Normal file
148
virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
|
import { DiskImage, DiskImageApi, DiskImageFormat } from "../api/DiskImageApi";
|
||||||
|
import { ServerApi } from "../api/ServerApi";
|
||||||
|
import { VMFileDisk, VMInfo } from "../api/VMApi";
|
||||||
|
import { useAlert } from "../hooks/providers/AlertDialogProvider";
|
||||||
|
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
|
||||||
|
import { FileDiskImageWidget } from "../widgets/FileDiskImageWidget";
|
||||||
|
import { CheckboxInput } from "../widgets/forms/CheckboxInput";
|
||||||
|
import { SelectInput } from "../widgets/forms/SelectInput";
|
||||||
|
import { TextInput } from "../widgets/forms/TextInput";
|
||||||
|
import { VMDiskFileWidget } from "../widgets/vms/VMDiskFileWidget";
|
||||||
|
|
||||||
|
export function ConvertDiskImageDialog(
|
||||||
|
p: {
|
||||||
|
onCancel: () => void;
|
||||||
|
onFinished: () => void;
|
||||||
|
} & (
|
||||||
|
| { backup?: false; image: DiskImage }
|
||||||
|
| { backup: true; disk: VMFileDisk; vm: VMInfo }
|
||||||
|
)
|
||||||
|
): React.ReactElement {
|
||||||
|
const alert = useAlert();
|
||||||
|
const loadingMessage = useLoadingMessage();
|
||||||
|
|
||||||
|
const [format, setFormat] = React.useState<DiskImageFormat>({
|
||||||
|
format: "QCow2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const origFilename = p.backup ? p.disk.name : p.image.file_name;
|
||||||
|
|
||||||
|
const [filename, setFilename] = React.useState(origFilename + ".qcow2");
|
||||||
|
|
||||||
|
const handleFormatChange = (value?: string) => {
|
||||||
|
setFormat({ format: value ?? ("QCow2" as any) });
|
||||||
|
|
||||||
|
if (value === "QCow2") setFilename(`${origFilename}.qcow2`);
|
||||||
|
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 === "GzCompressedRaw") setFilename(`${origFilename}.raw.gz`);
|
||||||
|
if (value === "XzCompressedRaw") setFilename(`${origFilename}.raw.xz`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
loadingMessage.show(
|
||||||
|
p.backup ? "Performing backup..." : "Converting image..."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Perform the conversion / backup operation
|
||||||
|
if (p.backup)
|
||||||
|
await DiskImageApi.BackupVMDisk(p.vm, p.disk, filename, format);
|
||||||
|
else await DiskImageApi.Convert(p.image, filename, format);
|
||||||
|
|
||||||
|
p.onFinished();
|
||||||
|
|
||||||
|
alert(p.backup ? "Backup successful!" : "Conversion successful!");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to perform backup/conversion!", e);
|
||||||
|
alert(
|
||||||
|
p.backup
|
||||||
|
? `Failed to perform backup! ${e}`
|
||||||
|
: `Failed to convert image! ${e}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
loadingMessage.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={p.onCancel}>
|
||||||
|
<DialogTitle>
|
||||||
|
{p.backup ? `Backup disk ${p.disk.name}` : "Convert disk image"}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Select the destination format for this image:
|
||||||
|
</DialogContentText>
|
||||||
|
|
||||||
|
{/* Show details of of the image */}
|
||||||
|
{p.backup ? (
|
||||||
|
<VMDiskFileWidget {...p} />
|
||||||
|
) : (
|
||||||
|
<FileDiskImageWidget {...p} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New image format */}
|
||||||
|
<SelectInput
|
||||||
|
editable
|
||||||
|
label="Target format"
|
||||||
|
value={format.format}
|
||||||
|
onValueChange={handleFormatChange}
|
||||||
|
options={[
|
||||||
|
{ value: "QCow2" },
|
||||||
|
{ value: "Raw" },
|
||||||
|
{ value: "GzCompressedRaw" },
|
||||||
|
{ value: "XzCompressedRaw" },
|
||||||
|
{ value: "GzCompressedQCow2" },
|
||||||
|
{ value: "XzCompressedQCow2" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Check for sparse file */}
|
||||||
|
{format.format === "Raw" && (
|
||||||
|
<CheckboxInput
|
||||||
|
editable
|
||||||
|
label="Sparse file"
|
||||||
|
checked={format.is_sparse}
|
||||||
|
onValueChange={(c) => {
|
||||||
|
setFormat({ format: "Raw", is_sparse: c });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New image name */}
|
||||||
|
<TextInput
|
||||||
|
editable
|
||||||
|
label="New image name"
|
||||||
|
value={filename}
|
||||||
|
onValueChange={(s) => {
|
||||||
|
setFilename(s ?? "");
|
||||||
|
}}
|
||||||
|
size={ServerApi.Config.constraints.disk_image_name_size}
|
||||||
|
helperText="The image name shall contain the proper file extension for the selected target format"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={p.onCancel}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} autoFocus>
|
||||||
|
{p.backup ? "Perform backup" : "Convert image"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
@ -3,16 +3,44 @@ import "@fontsource/roboto/400.css";
|
|||||||
import "@fontsource/roboto/500.css";
|
import "@fontsource/roboto/500.css";
|
||||||
import "@fontsource/roboto/700.css";
|
import "@fontsource/roboto/700.css";
|
||||||
|
|
||||||
|
import { ThemeProvider, createTheme } from "@mui/material";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { App } from "./App";
|
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 "./index.css";
|
||||||
import { LoadServerConfig } from "./widgets/LoadServerConfig";
|
import { LoadServerConfig } from "./widgets/LoadServerConfig";
|
||||||
import { ThemeProvider, createTheme } from "@mui/material";
|
|
||||||
import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider";
|
import { loader } from "@monaco-editor/react";
|
||||||
import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider";
|
import * as monaco from "monaco-editor";
|
||||||
import { SnackbarProvider } from "./hooks/providers/SnackbarProvider";
|
import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
|
||||||
import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider";
|
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({
|
const darkTheme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
@ -20,9 +48,7 @@ const darkTheme = createTheme({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(document.getElementById("root")!);
|
||||||
document.getElementById("root")!
|
|
||||||
);
|
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider theme={darkTheme}>
|
<ThemeProvider theme={darkTheme}>
|
||||||
|
417
virtweb_frontend/src/routes/DiskImagesRoute.tsx
Normal file
417
virtweb_frontend/src/routes/DiskImagesRoute.tsx
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import LoopIcon from "@mui/icons-material/Loop";
|
||||||
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
IconButton,
|
||||||
|
LinearProgress,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||||
|
import { filesize } from "filesize";
|
||||||
|
import React from "react";
|
||||||
|
import { DiskImage, DiskImageApi } from "../api/DiskImageApi";
|
||||||
|
import { ServerApi } from "../api/ServerApi";
|
||||||
|
import { ConvertDiskImageDialog } from "../dialogs/ConvertDiskImageDialog";
|
||||||
|
import { useAlert } from "../hooks/providers/AlertDialogProvider";
|
||||||
|
import { useConfirm } from "../hooks/providers/ConfirmDialogProvider";
|
||||||
|
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
|
||||||
|
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
|
||||||
|
import { downloadBlob } from "../utils/FilesUtils";
|
||||||
|
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||||
|
import { DateWidget } from "../widgets/DateWidget";
|
||||||
|
import { FileInput } from "../widgets/forms/FileInput";
|
||||||
|
import { VirtWebPaper } from "../widgets/VirtWebPaper";
|
||||||
|
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
|
||||||
|
|
||||||
|
export function DiskImagesRoute(): React.ReactElement {
|
||||||
|
const [list, setList] = React.useState<DiskImage[] | undefined>();
|
||||||
|
|
||||||
|
const loadKey = React.useRef(1);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setList(await DiskImageApi.GetList());
|
||||||
|
};
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
loadKey.current += 1;
|
||||||
|
setList(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtWebRouteContainer
|
||||||
|
label="Disk images management"
|
||||||
|
actions={
|
||||||
|
<span>
|
||||||
|
<Tooltip title="Refresh Disk images list">
|
||||||
|
<IconButton onClick={reload}>
|
||||||
|
<RefreshIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AsyncWidget
|
||||||
|
loadKey={loadKey.current}
|
||||||
|
errMsg="Failed to load disk images list!"
|
||||||
|
load={load}
|
||||||
|
ready={list !== undefined}
|
||||||
|
build={() => (
|
||||||
|
<>
|
||||||
|
<UploadDiskImageCard onFileUploaded={reload} />
|
||||||
|
<DiskImageList list={list!} onReload={reload} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</VirtWebRouteContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadDiskImageCard(p: {
|
||||||
|
onFileUploaded: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const alert = useAlert();
|
||||||
|
const snackbar = useSnackbar();
|
||||||
|
|
||||||
|
const [value, setValue] = React.useState<File | null>(null);
|
||||||
|
const [uploadProgress, setUploadProgress] = React.useState<number | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = (newValue: File | null) => {
|
||||||
|
if (
|
||||||
|
newValue &&
|
||||||
|
newValue.size > ServerApi.Config.constraints.disk_image_max_size
|
||||||
|
) {
|
||||||
|
alert(
|
||||||
|
`The file is too big (max size allowed: ${filesize(
|
||||||
|
ServerApi.Config.constraints.disk_image_max_size
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
newValue &&
|
||||||
|
newValue.type.length > 0 &&
|
||||||
|
!ServerApi.Config.disk_images_mimetypes.includes(newValue.type)
|
||||||
|
) {
|
||||||
|
alert(`Selected file mimetype is not allowed! (${newValue.type})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const upload = async () => {
|
||||||
|
try {
|
||||||
|
setUploadProgress(0);
|
||||||
|
await DiskImageApi.Upload(value!, setUploadProgress);
|
||||||
|
|
||||||
|
setValue(null);
|
||||||
|
snackbar("The file was successfully uploaded!");
|
||||||
|
|
||||||
|
p.onFileUploaded();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
await alert(`Failed to perform file upload! ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadProgress(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (uploadProgress !== null) {
|
||||||
|
return (
|
||||||
|
<VirtWebPaper label="File upload" noHorizontalMargin>
|
||||||
|
<Typography variant="body1">
|
||||||
|
Upload in progress ({Math.floor(uploadProgress * 100)}%)...
|
||||||
|
</Typography>
|
||||||
|
<LinearProgress variant="determinate" value={uploadProgress * 100} />
|
||||||
|
</VirtWebPaper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtWebPaper label="Disk image upload" noHorizontalMargin>
|
||||||
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<FileInput
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: {
|
||||||
|
accept: ServerApi.Config.disk_images_mimetypes.join(","),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{value && <Button onClick={upload}>Upload</Button>}
|
||||||
|
</div>
|
||||||
|
</VirtWebPaper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiskImageList(p: {
|
||||||
|
list: DiskImage[];
|
||||||
|
onReload: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const alert = useAlert();
|
||||||
|
const snackbar = useSnackbar();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const loadingMessage = useLoadingMessage();
|
||||||
|
|
||||||
|
const [dlProgress, setDlProgress] = React.useState<undefined | number>();
|
||||||
|
|
||||||
|
const [currConversion, setCurrConversion] = React.useState<
|
||||||
|
DiskImage | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Download disk image file
|
||||||
|
const downloadDiskImage = async (entry: DiskImage) => {
|
||||||
|
setDlProgress(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await DiskImageApi.Download(entry, setDlProgress);
|
||||||
|
|
||||||
|
downloadBlob(blob, entry.file_name);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(`Failed to download disk image file! ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDlProgress(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert disk image file
|
||||||
|
const convertDiskImage = (entry: DiskImage) => {
|
||||||
|
setCurrConversion(entry);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete disk image
|
||||||
|
const deleteDiskImage = async (entry: DiskImage) => {
|
||||||
|
if (
|
||||||
|
!(await confirm(
|
||||||
|
`Do you really want to delete this disk image (${entry.file_name}) ?`
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
loadingMessage.show("Deleting disk image file...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await DiskImageApi.Delete(entry);
|
||||||
|
snackbar("The disk image has been successfully deleted!");
|
||||||
|
p.onReload();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(`Failed to delete disk image!\n${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingMessage.hide();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (p.list.length === 0)
|
||||||
|
return (
|
||||||
|
<Typography variant="body1" style={{ textAlign: "center" }}>
|
||||||
|
No disk image uploaded for now.
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: GridColDef<(typeof p.list)[number]>[] = [
|
||||||
|
{ field: "file_name", headerName: "File name", flex: 3, editable: true },
|
||||||
|
{
|
||||||
|
field: "format",
|
||||||
|
headerName: "Format",
|
||||||
|
flex: 1,
|
||||||
|
renderCell(params) {
|
||||||
|
let content = params.row.format;
|
||||||
|
|
||||||
|
if (params.row.format === "Raw") {
|
||||||
|
content += params.row.is_sparse ? " (Sparse)" : " (Fixed)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "file_size",
|
||||||
|
headerName: "File size",
|
||||||
|
flex: 1,
|
||||||
|
renderCell(params) {
|
||||||
|
let res = filesize(params.row.file_size);
|
||||||
|
|
||||||
|
if (params.row.format === "QCow2") {
|
||||||
|
res += ` (${filesize(params.row.virtual_size!)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "created",
|
||||||
|
headerName: "Created",
|
||||||
|
flex: 1,
|
||||||
|
renderCell(params) {
|
||||||
|
return <DateWidget time={params.row.created} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
type: "actions",
|
||||||
|
headerName: "",
|
||||||
|
width: 55,
|
||||||
|
cellClassName: "actions",
|
||||||
|
editable: false,
|
||||||
|
getActions: (params) => {
|
||||||
|
return [
|
||||||
|
<DiskImageActionMenu
|
||||||
|
key="menu"
|
||||||
|
diskImage={params.row}
|
||||||
|
onDownload={downloadDiskImage}
|
||||||
|
onConvert={convertDiskImage}
|
||||||
|
onDelete={deleteDiskImage}
|
||||||
|
/>,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Download notification */}
|
||||||
|
{dlProgress !== undefined && (
|
||||||
|
<Alert severity="info">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body1">
|
||||||
|
Downloading... {dlProgress}%
|
||||||
|
</Typography>
|
||||||
|
<CircularProgress
|
||||||
|
variant="determinate"
|
||||||
|
size={"1.5rem"}
|
||||||
|
style={{ marginLeft: "10px" }}
|
||||||
|
value={dlProgress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Disk image conversion dialog */}
|
||||||
|
{currConversion && (
|
||||||
|
<ConvertDiskImageDialog
|
||||||
|
image={currConversion}
|
||||||
|
onCancel={() => {
|
||||||
|
setCurrConversion(undefined);
|
||||||
|
}}
|
||||||
|
onFinished={() => {
|
||||||
|
setCurrConversion(undefined);
|
||||||
|
p.onReload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The table itself */}
|
||||||
|
<DataGrid<DiskImage>
|
||||||
|
getRowId={(c) => c.file_name}
|
||||||
|
rows={p.list}
|
||||||
|
columns={columns}
|
||||||
|
processRowUpdate={async (n, o) => {
|
||||||
|
try {
|
||||||
|
await DiskImageApi.Rename(o, n.file_name);
|
||||||
|
return n;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to rename disk image!", e);
|
||||||
|
alert(`Failed to rename disk image! ${e}`);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
p.onReload();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiskImageActionMenu(p: {
|
||||||
|
diskImage: DiskImage;
|
||||||
|
onDownload: (d: DiskImage) => void;
|
||||||
|
onConvert: (d: DiskImage) => void;
|
||||||
|
onDelete: (d: DiskImage) => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Actions"
|
||||||
|
aria-haspopup="true"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<MoreVertIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
|
||||||
|
{/* Download disk image */}
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleClose();
|
||||||
|
p.onDownload(p.diskImage);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<DownloadIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText secondary={"Download disk image"}>
|
||||||
|
Download
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
{/* Convert disk image */}
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleClose();
|
||||||
|
p.onConvert(p.diskImage);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<LoopIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText secondary={"Convert disk image"}>Convert</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
{/* Delete disk image */}
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleClose();
|
||||||
|
p.onDelete(p.diskImage);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<DeleteIcon color="error" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText secondary={"Delete disk image"}>Delete</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -3,7 +3,15 @@ import { RouterLink } from "../widgets/RouterLink";
|
|||||||
|
|
||||||
export function NotFoundRoute(): React.ReactElement {
|
export function NotFoundRoute(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: "center" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h1>404 Not found</h1>
|
<h1>404 Not found</h1>
|
||||||
<p>The page you requested was not found!</p>
|
<p>The page you requested was not found!</p>
|
||||||
<RouterLink to="/">
|
<RouterLink to="/">
|
||||||
|
@ -9,18 +9,21 @@ import {
|
|||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
IconButton,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import Grid from "@mui/material/Grid";
|
import Grid from "@mui/material/Grid";
|
||||||
import { PieChart } from "@mui/x-charts";
|
import { PieChart } from "@mui/x-charts";
|
||||||
import { filesize } from "filesize";
|
import { filesize } from "filesize";
|
||||||
import humanizeDuration from "humanize-duration";
|
import humanizeDuration from "humanize-duration";
|
||||||
|
import IosShareIcon from "@mui/icons-material/IosShare";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
DiskInfo,
|
DiskInfo,
|
||||||
@ -31,6 +34,8 @@ import {
|
|||||||
import { AsyncWidget } from "../widgets/AsyncWidget";
|
import { AsyncWidget } from "../widgets/AsyncWidget";
|
||||||
import { VirtWebPaper } from "../widgets/VirtWebPaper";
|
import { VirtWebPaper } from "../widgets/VirtWebPaper";
|
||||||
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
|
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
|
||||||
|
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
|
||||||
|
import { useAlert } from "../hooks/providers/AlertDialogProvider";
|
||||||
|
|
||||||
export function SysInfoRoute(): React.ReactElement {
|
export function SysInfoRoute(): React.ReactElement {
|
||||||
const [info, setInfo] = React.useState<ServerSystemInfo>();
|
const [info, setInfo] = React.useState<ServerSystemInfo>();
|
||||||
@ -52,6 +57,23 @@ export function SysInfoRoute(): React.ReactElement {
|
|||||||
export function SysInfoRouteInner(p: {
|
export function SysInfoRouteInner(p: {
|
||||||
info: ServerSystemInfo;
|
info: ServerSystemInfo;
|
||||||
}): React.ReactElement {
|
}): 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(
|
const sumDiskUsage = p.info.disks.reduce(
|
||||||
(prev, disk) => {
|
(prev, disk) => {
|
||||||
return {
|
return {
|
||||||
@ -63,7 +85,16 @@ export function SysInfoRouteInner(p: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtWebRouteContainer label="Sysinfo">
|
<VirtWebRouteContainer
|
||||||
|
label="Sysinfo"
|
||||||
|
actions={
|
||||||
|
<Tooltip title="Export all server configs">
|
||||||
|
<IconButton onClick={downloadAllConfig}>
|
||||||
|
<IosShareIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{/* Memory */}
|
{/* Memory */}
|
||||||
<Grid size={{ xs: 4 }}>
|
<Grid size={{ xs: 4 }}>
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
TableContainer,
|
TableContainer,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@ -58,70 +59,78 @@ export function TokensListRouteInner(p: {
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TableContainer component={Paper}>
|
{p.list.length > 0 && (
|
||||||
<Table>
|
<TableContainer component={Paper}>
|
||||||
<TableHead>
|
<Table>
|
||||||
<TableRow>
|
<TableHead>
|
||||||
<TableCell>Name</TableCell>
|
<TableRow>
|
||||||
<TableCell>Description</TableCell>
|
<TableCell>Name</TableCell>
|
||||||
<TableCell>Created</TableCell>
|
<TableCell>Description</TableCell>
|
||||||
<TableCell>Updated</TableCell>
|
<TableCell>Created</TableCell>
|
||||||
<TableCell>Last used</TableCell>
|
<TableCell>Updated</TableCell>
|
||||||
<TableCell>IP restriction</TableCell>
|
<TableCell>Last used</TableCell>
|
||||||
<TableCell>Max inactivity</TableCell>
|
<TableCell>IP restriction</TableCell>
|
||||||
<TableCell>Rights</TableCell>
|
<TableCell>Max inactivity</TableCell>
|
||||||
<TableCell>Actions</TableCell>
|
<TableCell>Rights</TableCell>
|
||||||
</TableRow>
|
<TableCell>Actions</TableCell>
|
||||||
</TableHead>
|
</TableRow>
|
||||||
<TableBody>
|
</TableHead>
|
||||||
{p.list.map((t) => {
|
<TableBody>
|
||||||
return (
|
{p.list.map((t) => {
|
||||||
<TableRow
|
return (
|
||||||
key={t.id}
|
<TableRow
|
||||||
hover
|
key={t.id}
|
||||||
onDoubleClick={() => navigate(APITokenURL(t))}
|
hover
|
||||||
style={{ backgroundColor: ExpiredAPIToken(t) ? "red" : "" }}
|
onDoubleClick={() => navigate(APITokenURL(t))}
|
||||||
>
|
style={{ backgroundColor: ExpiredAPIToken(t) ? "red" : "" }}
|
||||||
<TableCell>
|
>
|
||||||
{t.name} {ExpiredAPIToken(t) && <i>(Expired)</i>}
|
<TableCell>
|
||||||
</TableCell>
|
{t.name} {ExpiredAPIToken(t) && <i>(Expired)</i>}
|
||||||
<TableCell>{t.description}</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>{t.description}</TableCell>
|
||||||
<TimeWidget time={t.created} />
|
<TableCell>
|
||||||
</TableCell>
|
<TimeWidget time={t.created} />
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<TimeWidget time={t.updated} />
|
<TableCell>
|
||||||
</TableCell>
|
<TimeWidget time={t.updated} />
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<TimeWidget time={t.last_used} />
|
<TableCell>
|
||||||
</TableCell>
|
<TimeWidget time={t.last_used} />
|
||||||
<TableCell>{t.ip_restriction}</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>{t.ip_restriction}</TableCell>
|
||||||
{t.max_inactivity && timeDiff(0, t.max_inactivity)}
|
<TableCell>
|
||||||
</TableCell>
|
{t.max_inactivity && timeDiff(0, t.max_inactivity)}
|
||||||
<TableCell>
|
</TableCell>
|
||||||
{t.rights.map((r, n) => {
|
<TableCell>
|
||||||
return (
|
{t.rights.map((r, n) => {
|
||||||
<div key={n}>
|
return (
|
||||||
{r.verb} {r.path}
|
<div key={n}>
|
||||||
</div>
|
{r.verb} {r.path}
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
</TableCell>
|
})}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<RouterLink to={APITokenURL(t)}>
|
<RouterLink to={APITokenURL(t)}>
|
||||||
<IconButton>
|
<IconButton>
|
||||||
<VisibilityIcon />
|
<VisibilityIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.list.length === 0 && (
|
||||||
|
<Typography style={{ textAlign: "center" }}>
|
||||||
|
No API token created yet.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</VirtWebRouteContainer>
|
</VirtWebRouteContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -154,7 +154,7 @@ function VMListWidget(p: {
|
|||||||
{row.name}
|
{row.name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.description ?? ""}</TableCell>
|
<TableCell>{row.description ?? ""}</TableCell>
|
||||||
<TableCell>{vmMemoryToHuman(row.memory)}</TableCell>
|
<TableCell>{filesize(row.memory)}</TableCell>
|
||||||
<TableCell>{row.number_vcpu}</TableCell>
|
<TableCell>{row.number_vcpu}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<VMStatusWidget
|
<VMStatusWidget
|
||||||
@ -183,13 +183,13 @@ function VMListWidget(p: {
|
|||||||
<TableCell></TableCell>
|
<TableCell></TableCell>
|
||||||
<TableCell></TableCell>
|
<TableCell></TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{vmMemoryToHuman(
|
{filesize(
|
||||||
p.list
|
p.list
|
||||||
.filter((v) => runningVMs.has(v.name))
|
.filter((v) => runningVMs.has(v.name))
|
||||||
.reduce((s, v) => s + v.memory, 0)
|
.reduce((s, v) => s + v.memory, 0)
|
||||||
)}
|
)}
|
||||||
{" / "}
|
{" / "}
|
||||||
{vmMemoryToHuman(p.list.reduce((s, v) => s + v.memory, 0))}
|
{filesize(p.list.reduce((s, v) => s + v.memory, 0))}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{p.list
|
{p.list
|
||||||
@ -206,7 +206,3 @@ function VMListWidget(p: {
|
|||||||
</TableContainer>
|
</TableContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function vmMemoryToHuman(size: number): string {
|
|
||||||
return filesize(size * 1000 * 1000);
|
|
||||||
}
|
|
||||||
|
@ -59,6 +59,7 @@ function VMRouteBody(p: { vm: VMInfo }): React.ReactElement {
|
|||||||
<VMDetails
|
<VMDetails
|
||||||
vm={p.vm}
|
vm={p.vm}
|
||||||
editable={false}
|
editable={false}
|
||||||
|
state={state}
|
||||||
screenshot={p.vm.vnc_access && state === "Running"}
|
screenshot={p.vm.vnc_access && state === "Running"}
|
||||||
/>
|
/>
|
||||||
</VirtWebRouteContainer>
|
</VirtWebRouteContainer>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
@ -36,7 +36,9 @@ export function LoginRoute(): React.ReactElement {
|
|||||||
const canSubmit = username.length > 0 && password.length > 0;
|
const canSubmit = username.length > 0 && password.length > 0;
|
||||||
|
|
||||||
const [showPassword, setShowPassword] = React.useState(false);
|
const [showPassword, setShowPassword] = React.useState(false);
|
||||||
const handleClickShowPassword = () => { setShowPassword((show) => !show); };
|
const handleClickShowPassword = () => {
|
||||||
|
setShowPassword((show) => !show);
|
||||||
|
};
|
||||||
|
|
||||||
const handleMouseDownPassword = (
|
const handleMouseDownPassword = (
|
||||||
event: React.MouseEvent<HTMLButtonElement>
|
event: React.MouseEvent<HTMLButtonElement>
|
||||||
@ -105,12 +107,14 @@ export function LoginRoute(): React.ReactElement {
|
|||||||
label="Username"
|
label="Username"
|
||||||
name="username"
|
name="username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => { setUsername(e.target.value); }}
|
onChange={(e) => {
|
||||||
|
setUsername(e.target.value);
|
||||||
|
}}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormControl fullWidth variant="outlined">
|
<FormControl required fullWidth variant="outlined">
|
||||||
<InputLabel htmlFor="password">Password</InputLabel>
|
<InputLabel htmlFor="password">Password</InputLabel>
|
||||||
<OutlinedInput
|
<OutlinedInput
|
||||||
required
|
required
|
||||||
@ -120,7 +124,9 @@ export function LoginRoute(): React.ReactElement {
|
|||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
id="password"
|
id="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => { setPassword(e.target.value); }}
|
onChange={(e) => {
|
||||||
|
setPassword(e.target.value);
|
||||||
|
}}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
endAdornment={
|
endAdornment={
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
@ -131,7 +137,11 @@ export function LoginRoute(): React.ReactElement {
|
|||||||
onMouseDown={handleMouseDownPassword}
|
onMouseDown={handleMouseDownPassword}
|
||||||
edge="end"
|
edge="end"
|
||||||
>
|
>
|
||||||
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
{showPassword ? (
|
||||||
|
<VisibilityOffIcon />
|
||||||
|
) : (
|
||||||
|
<VisibilityIcon />
|
||||||
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
|
@ -2,6 +2,7 @@ import {
|
|||||||
mdiApi,
|
mdiApi,
|
||||||
mdiBoxShadow,
|
mdiBoxShadow,
|
||||||
mdiDisc,
|
mdiDisc,
|
||||||
|
mdiHarddisk,
|
||||||
mdiHome,
|
mdiHome,
|
||||||
mdiInformation,
|
mdiInformation,
|
||||||
mdiLan,
|
mdiLan,
|
||||||
@ -66,6 +67,11 @@ export function BaseAuthenticatedPage(): React.ReactElement {
|
|||||||
uri="/nwfilter"
|
uri="/nwfilter"
|
||||||
icon={<Icon path={mdiSecurityNetwork} size={1} />}
|
icon={<Icon path={mdiSecurityNetwork} size={1} />}
|
||||||
/>
|
/>
|
||||||
|
<NavLink
|
||||||
|
label="Disk images"
|
||||||
|
uri="/disk_images"
|
||||||
|
icon={<Icon path={mdiHarddisk} size={1} />}
|
||||||
|
/>
|
||||||
<NavLink
|
<NavLink
|
||||||
label="ISO files"
|
label="ISO files"
|
||||||
uri="/iso"
|
uri="/iso"
|
||||||
|
13
virtweb_frontend/src/widgets/DateWidget.tsx
Normal file
13
virtweb_frontend/src/widgets/DateWidget.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export function DateWidget(p: { time: number }): React.ReactElement {
|
||||||
|
const date = new Date(p.time * 1000);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pad(date.getDate())}/{pad(date.getMonth() + 1)}/{date.getFullYear()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(num: number): string {
|
||||||
|
return num.toString().padStart(2, "0");
|
||||||
|
}
|
23
virtweb_frontend/src/widgets/FileDiskImageWidget.tsx
Normal file
23
virtweb_frontend/src/widgets/FileDiskImageWidget.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material";
|
||||||
|
import { DiskImage } from "../api/DiskImageApi";
|
||||||
|
import { mdiHarddisk } from "@mdi/js";
|
||||||
|
import { filesize } from "filesize";
|
||||||
|
import Icon from "@mdi/react";
|
||||||
|
|
||||||
|
export function FileDiskImageWidget(p: {
|
||||||
|
image: DiskImage;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar>
|
||||||
|
<Icon path={mdiHarddisk} />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
primary={p.image.file_name}
|
||||||
|
secondary={`${p.image.format} - ${filesize(p.image.file_size)}`}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
}
|
@ -17,7 +17,9 @@ export function CheckboxInput(p: {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
disabled={!p.editable}
|
disabled={!p.editable}
|
||||||
checked={p.checked}
|
checked={p.checked}
|
||||||
onChange={(e) => { p.onValueChange(e.target.checked); }}
|
onChange={(e) => {
|
||||||
|
p.onValueChange(e.target.checked);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={p.label}
|
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?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
24
virtweb_frontend/src/widgets/forms/DiskBusSelect.tsx
Normal file
24
virtweb_frontend/src/widgets/forms/DiskBusSelect.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { DiskBusType } from "../../api/VMApi";
|
||||||
|
import { SelectInput } from "./SelectInput";
|
||||||
|
|
||||||
|
export function DiskBusSelect(p: {
|
||||||
|
editable: boolean;
|
||||||
|
value: DiskBusType;
|
||||||
|
label?: string;
|
||||||
|
onValueChange: (value: DiskBusType) => void;
|
||||||
|
size?: "medium" | "small";
|
||||||
|
disableUnderline?: boolean;
|
||||||
|
disableBottomMargin?: boolean;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<SelectInput
|
||||||
|
{...p}
|
||||||
|
label={p.label ?? "Disk bus type"}
|
||||||
|
options={[
|
||||||
|
{ label: "virtio", value: "Virtio" },
|
||||||
|
{ label: "sata", value: "SATA" },
|
||||||
|
]}
|
||||||
|
onValueChange={(v) => { p.onValueChange(v as any); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
40
virtweb_frontend/src/widgets/forms/DiskImageSelect.tsx
Normal file
40
virtweb_frontend/src/widgets/forms/DiskImageSelect.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
SelectChangeEvent,
|
||||||
|
} from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
|
import { DiskImage } from "../../api/DiskImageApi";
|
||||||
|
import { FileDiskImageWidget } from "../FileDiskImageWidget";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a disk image
|
||||||
|
*/
|
||||||
|
export function DiskImageSelect(p: {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
onValueChange: (image: string | undefined) => void;
|
||||||
|
list: DiskImage[];
|
||||||
|
}): React.ReactElement {
|
||||||
|
const handleChange = (event: SelectChangeEvent) => {
|
||||||
|
p.onValueChange(event.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl fullWidth variant="standard">
|
||||||
|
<InputLabel>{p.label}</InputLabel>
|
||||||
|
<Select value={p.value} label={p.label} onChange={handleChange}>
|
||||||
|
<MenuItem value={undefined}>
|
||||||
|
<i>None</i>
|
||||||
|
</MenuItem>
|
||||||
|
{p.list.map((d) => (
|
||||||
|
<MenuItem key={d.file_name} value={d.file_name}>
|
||||||
|
<FileDiskImageWidget image={d} />
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
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",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
marginBottom: "15px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{p.title && (
|
{p.title && <Typography variant="h5">{p.title}</Typography>}
|
||||||
<Typography variant="h5" style={{ marginBottom: "15px" }}>
|
|
||||||
{p.title}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{p.actions}
|
{p.actions}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
@ -35,7 +35,7 @@ export function FileInput(
|
|||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
<AttachFileIcon />
|
<AttachFileIcon />
|
||||||
|
|
||||||
{p.value ? p.value.name : "Insert a file"}
|
{p.value ? p.value.name : "Select a file"}
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
@ -25,6 +25,8 @@ export function OEMStringFormWidget(p: {
|
|||||||
p.onChange?.();
|
p.onChange?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!p.editable && p.vm.oem_strings.length === 0) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditSection
|
<EditSection
|
||||||
title="SMBIOS OEM Strings"
|
title="SMBIOS OEM Strings"
|
||||||
|
@ -17,8 +17,11 @@ export function SelectInput(p: {
|
|||||||
value?: string;
|
value?: string;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
size?: "medium" | "small";
|
||||||
options: SelectOption[];
|
options: SelectOption[];
|
||||||
onValueChange: (o?: string) => void;
|
onValueChange: (o?: string) => void;
|
||||||
|
disableUnderline?: boolean;
|
||||||
|
disableBottomMargin?: boolean;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
if (!p.editable && !p.value) return <></>;
|
if (!p.editable && !p.value) return <></>;
|
||||||
|
|
||||||
@ -28,12 +31,18 @@ export function SelectInput(p: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormControl fullWidth variant="standard" style={{ marginBottom: "15px" }}>
|
<FormControl
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
style={{ marginBottom: p.disableBottomMargin ? "0px" : "15px" }}
|
||||||
|
>
|
||||||
{p.label && <InputLabel>{p.label}</InputLabel>}
|
{p.label && <InputLabel>{p.label}</InputLabel>}
|
||||||
<Select
|
<Select
|
||||||
|
{...p}
|
||||||
value={p.value ?? ""}
|
value={p.value ?? ""}
|
||||||
label={p.label}
|
onChange={(e) => {
|
||||||
onChange={(e) => { p.onValueChange(e.target.value); }}
|
p.onValueChange(e.target.value);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{p.options.map((e) => (
|
{p.options.map((e) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
@ -17,6 +17,8 @@ export function TextInput(p: {
|
|||||||
type?: React.HTMLInputTypeAttribute;
|
type?: React.HTMLInputTypeAttribute;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
helperText?: string;
|
helperText?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
endAdornment?: React.ReactNode;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
if (!p.editable && (p.value ?? "") === "") return <></>;
|
if (!p.editable && (p.value ?? "") === "") return <></>;
|
||||||
|
|
||||||
@ -35,6 +37,7 @@ export function TextInput(p: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
|
disabled={p.disabled}
|
||||||
label={p.label}
|
label={p.label}
|
||||||
value={p.value ?? ""}
|
value={p.value ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@ -49,6 +52,7 @@ export function TextInput(p: {
|
|||||||
input: {
|
input: {
|
||||||
readOnly: !p.editable,
|
readOnly: !p.editable,
|
||||||
type: p.type,
|
type: p.type,
|
||||||
|
endAdornment: p.endAdornment,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
variant={"standard"}
|
variant={"standard"}
|
||||||
|
@ -1,33 +1,39 @@
|
|||||||
import { mdiHarddisk } from "@mdi/js";
|
import { mdiHarddiskPlus } from "@mdi/js";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import {
|
import ExpandIcon from "@mui/icons-material/Expand";
|
||||||
Avatar,
|
import { Button, IconButton, Paper, Tooltip, Typography } from "@mui/material";
|
||||||
Button,
|
import React from "react";
|
||||||
IconButton,
|
import { DiskImage } from "../../api/DiskImageApi";
|
||||||
ListItem,
|
|
||||||
ListItemAvatar,
|
|
||||||
ListItemText,
|
|
||||||
Paper,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { filesize } from "filesize";
|
|
||||||
import { ServerApi } from "../../api/ServerApi";
|
import { ServerApi } from "../../api/ServerApi";
|
||||||
import { VMFileDisk, VMInfo } from "../../api/VMApi";
|
import { VMFileDisk, VMInfo, VMState } from "../../api/VMApi";
|
||||||
|
import { ConvertDiskImageDialog } from "../../dialogs/ConvertDiskImageDialog";
|
||||||
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
|
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
|
||||||
|
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 { SelectInput } from "./SelectInput";
|
||||||
import { TextInput } from "./TextInput";
|
import { TextInput } from "./TextInput";
|
||||||
|
|
||||||
export function VMDisksList(p: {
|
export function VMDisksList(p: {
|
||||||
vm: VMInfo;
|
vm: VMInfo;
|
||||||
|
state?: VMState;
|
||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
diskImagesList: DiskImage[];
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
const [currBackupRequest, setCurrBackupRequest] = React.useState<
|
||||||
|
VMFileDisk | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
const addNewDisk = () => {
|
const addNewDisk = () => {
|
||||||
p.vm.file_disks.push({
|
p.vm.file_disks.push({
|
||||||
format: "QCow2",
|
format: "QCow2",
|
||||||
size: 10000,
|
size: 10000 * 1000 * 1000,
|
||||||
|
bus: "Virtio",
|
||||||
delete: false,
|
delete: false,
|
||||||
name: `disk${p.vm.file_disks.length}`,
|
name: `disk${p.vm.file_disks.length}`,
|
||||||
new: true,
|
new: true,
|
||||||
@ -35,6 +41,14 @@ export function VMDisksList(p: {
|
|||||||
p.onChange?.();
|
p.onChange?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBackupRequest = (disk: VMFileDisk) => {
|
||||||
|
setCurrBackupRequest(disk);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinishBackup = () => {
|
||||||
|
setCurrBackupRequest(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* disks list */}
|
{/* disks list */}
|
||||||
@ -43,27 +57,63 @@ export function VMDisksList(p: {
|
|||||||
// eslint-disable-next-line react-x/no-array-index-key
|
// eslint-disable-next-line react-x/no-array-index-key
|
||||||
key={num}
|
key={num}
|
||||||
editable={p.editable}
|
editable={p.editable}
|
||||||
|
canBackup={!p.editable && !d.new && p.state !== "Running"}
|
||||||
disk={d}
|
disk={d}
|
||||||
onChange={p.onChange}
|
onChange={p.onChange}
|
||||||
removeFromList={() => {
|
removeFromList={() => {
|
||||||
p.vm.file_disks.splice(num, 1);
|
p.vm.file_disks.splice(num, 1);
|
||||||
p.onChange?.();
|
p.onChange?.();
|
||||||
}}
|
}}
|
||||||
|
onRequestBackup={handleBackupRequest}
|
||||||
|
diskImagesList={p.diskImagesList}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{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>}
|
{p.editable && <Button onClick={addNewDisk}>Add new disk</Button>}
|
||||||
|
|
||||||
|
{/* Disk backup */}
|
||||||
|
{currBackupRequest && (
|
||||||
|
<ConvertDiskImageDialog
|
||||||
|
backup
|
||||||
|
onCancel={handleFinishBackup}
|
||||||
|
onFinished={handleFinishBackup}
|
||||||
|
vm={p.vm}
|
||||||
|
disk={currBackupRequest}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiskInfo(p: {
|
function DiskInfo(p: {
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
canBackup: boolean;
|
||||||
disk: VMFileDisk;
|
disk: VMFileDisk;
|
||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
removeFromList: () => void;
|
removeFromList: () => void;
|
||||||
|
onRequestBackup: (disk: VMFileDisk) => void;
|
||||||
|
diskImagesList: DiskImage[];
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const confirm = useConfirm();
|
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 () => {
|
const deleteDisk = async () => {
|
||||||
if (p.disk.deleteType) {
|
if (p.disk.deleteType) {
|
||||||
p.disk.deleteType = undefined;
|
p.disk.deleteType = undefined;
|
||||||
@ -86,50 +136,75 @@ function DiskInfo(p: {
|
|||||||
|
|
||||||
if (!p.editable || !p.disk.new)
|
if (!p.editable || !p.disk.new)
|
||||||
return (
|
return (
|
||||||
<ListItem
|
<>
|
||||||
secondaryAction={
|
<VMDiskFileWidget
|
||||||
p.editable && (
|
{...p}
|
||||||
<IconButton
|
secondaryAction={
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ListItemAvatar>
|
|
||||||
<Avatar>
|
|
||||||
<Icon path={mdiHarddisk} />
|
|
||||||
</Avatar>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText
|
|
||||||
primary={
|
|
||||||
<>
|
<>
|
||||||
{p.disk.name}{" "}
|
{p.editable && !p.disk.deleteType && (
|
||||||
{p.disk.deleteType && (
|
<IconButton
|
||||||
<span style={{ color: "red" }}>
|
edge="end"
|
||||||
{p.disk.deleteType === "deletefile"
|
aria-label="expand disk"
|
||||||
? "Remove, DELETING block file"
|
onClick={expandDisk}
|
||||||
: "Remove, keeping block file"}
|
>
|
||||||
</span>
|
{p.disk.resize === true ? (
|
||||||
|
<Tooltip title="Cancel disk expansion">
|
||||||
|
<ExpandIcon color="error" />
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip title="Increase disk size">
|
||||||
|
<ExpandIcon />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
secondary={`${filesize(p.disk.size * 1000 * 1000)} - ${
|
|
||||||
p.disk.format
|
|
||||||
}${p.disk.format == "Raw" ? " - " + p.disk.alloc_type : ""}`}
|
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
|
||||||
|
{/* 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 (
|
return (
|
||||||
@ -151,18 +226,6 @@ function DiskInfo(p: {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextInput
|
|
||||||
editable={true}
|
|
||||||
label="Disk size (MB)"
|
|
||||||
size={ServerApi.Config.constraints.disk_size}
|
|
||||||
value={p.disk.size.toString()}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
p.disk.size = Number(v ?? "0");
|
|
||||||
p.onChange?.();
|
|
||||||
}}
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SelectInput
|
<SelectInput
|
||||||
editable={true}
|
editable={true}
|
||||||
label="Disk format"
|
label="Disk format"
|
||||||
@ -173,25 +236,71 @@ function DiskInfo(p: {
|
|||||||
value={p.disk.format}
|
value={p.disk.format}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
p.disk.format = v as any;
|
p.disk.format = v as any;
|
||||||
|
|
||||||
|
if (p.disk.format === "Raw") p.disk.is_sparse = true;
|
||||||
|
|
||||||
p.onChange?.();
|
p.onChange?.();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Bus selection */}
|
||||||
|
<DiskBusSelect
|
||||||
|
editable
|
||||||
|
value={p.disk.bus}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
p.disk.bus = v;
|
||||||
|
p.onChange?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Raw disk: choose sparse mode */}
|
||||||
{p.disk.format === "Raw" && (
|
{p.disk.format === "Raw" && (
|
||||||
<SelectInput
|
<CheckboxInput
|
||||||
editable={true}
|
editable
|
||||||
label="File allocation type"
|
label="Sparse file"
|
||||||
options={[
|
checked={p.disk.is_sparse}
|
||||||
{ label: "Sparse allocation", value: "Sparse" },
|
|
||||||
{ label: "Fixed allocation", value: "Fixed" },
|
|
||||||
]}
|
|
||||||
value={p.disk.alloc_type}
|
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
if (p.disk.format === "Raw") p.disk.alloc_type = v as any;
|
if (p.disk.format === "Raw") p.disk.is_sparse = v;
|
||||||
p.onChange?.();
|
p.onChange?.();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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}
|
||||||
|
value={p.disk.from_image}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
p.disk.from_image = v;
|
||||||
|
p.onChange?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
ListItemAvatar,
|
ListItemAvatar,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import Grid from "@mui/material/Grid";
|
import Grid from "@mui/material/Grid";
|
||||||
import { NWFilter } from "../../api/NWFilterApi";
|
import { NWFilter } from "../../api/NWFilterApi";
|
||||||
@ -29,11 +30,13 @@ export function VMNetworksList(p: {
|
|||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
networksList: NetworkInfo[];
|
networksList: NetworkInfo[];
|
||||||
|
bridgesList: string[];
|
||||||
networkFiltersList: NWFilter[];
|
networkFiltersList: NWFilter[];
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const addNew = () => {
|
const addNew = () => {
|
||||||
p.vm.networks.push({
|
p.vm.networks.push({
|
||||||
type: "UserspaceSLIRPStack",
|
type: "UserspaceSLIRPStack",
|
||||||
|
model: "Virtio",
|
||||||
mac: randomMacAddress(ServerApi.Config.net_mac_prefix),
|
mac: randomMacAddress(ServerApi.Config.net_mac_prefix),
|
||||||
});
|
});
|
||||||
p.onChange?.();
|
p.onChange?.();
|
||||||
@ -47,6 +50,12 @@ export function VMNetworksList(p: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{p.vm.networks.length === 0 && (
|
||||||
|
<Typography style={{ textAlign: "center", paddingTop: "25px" }}>
|
||||||
|
No network interface defined yet!
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{/* networks list */}
|
{/* networks list */}
|
||||||
{p.vm.networks.map((n, num) => (
|
{p.vm.networks.map((n, num) => (
|
||||||
@ -72,6 +81,7 @@ function NetworkInfoWidget(p: {
|
|||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
removeFromList: () => void;
|
removeFromList: () => void;
|
||||||
networksList: NetworkInfo[];
|
networksList: NetworkInfo[];
|
||||||
|
bridgesList: string[];
|
||||||
networkFiltersList: NWFilter[];
|
networkFiltersList: NWFilter[];
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@ -130,6 +140,11 @@ function NetworkInfoWidget(p: {
|
|||||||
value: "DefinedNetwork",
|
value: "DefinedNetwork",
|
||||||
description: "Attach to a defined network",
|
description: "Attach to a defined network",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Host bridge",
|
||||||
|
value: "Bridge",
|
||||||
|
description: "Attach to an host's bridge",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -139,6 +154,7 @@ function NetworkInfoWidget(p: {
|
|||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<div style={{ marginLeft: "70px" }}>
|
<div style={{ marginLeft: "70px" }}>
|
||||||
|
{/* MAC address input */}
|
||||||
<MACInput
|
<MACInput
|
||||||
editable={p.editable}
|
editable={p.editable}
|
||||||
label="MAC Address"
|
label="MAC Address"
|
||||||
@ -149,31 +165,73 @@ function NetworkInfoWidget(p: {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* NIC model */}
|
||||||
|
<SelectInput
|
||||||
|
editable={p.editable}
|
||||||
|
label="NIC Model"
|
||||||
|
value={p.network.model}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
p.network.model = v as any;
|
||||||
|
p.onChange?.();
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ label: "e1000", value: "E1000" },
|
||||||
|
{
|
||||||
|
label: "virtio",
|
||||||
|
value: "Virtio",
|
||||||
|
description:
|
||||||
|
"Recommended model, but will require specific drivers on OS that do not support it.",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Defined network selection */}
|
||||||
{p.network.type === "DefinedNetwork" && (
|
{p.network.type === "DefinedNetwork" && (
|
||||||
|
<SelectInput
|
||||||
|
editable={p.editable}
|
||||||
|
label="Defined network"
|
||||||
|
options={p.networksList.map((n) => {
|
||||||
|
const chars = [n.forward_mode.toString()];
|
||||||
|
if (n.ip_v4) chars.push("IPv4");
|
||||||
|
if (n.ip_v6) chars.push("IPv6");
|
||||||
|
if (n.description) chars.push(n.description);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: n.name,
|
||||||
|
value: n.name,
|
||||||
|
description: chars.join(" - "),
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
value={p.network.network}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (p.network.type === "DefinedNetwork")
|
||||||
|
p.network.network = v as any;
|
||||||
|
p.onChange?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bridge selection */}
|
||||||
|
{p.network.type === "Bridge" && (
|
||||||
|
<SelectInput
|
||||||
|
editable={p.editable}
|
||||||
|
label="Host bridge"
|
||||||
|
options={p.bridgesList.map((n) => {
|
||||||
|
return {
|
||||||
|
label: n,
|
||||||
|
value: n,
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
value={p.network.bridge}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (p.network.type === "Bridge") p.network.bridge = v as any;
|
||||||
|
p.onChange?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.network.type !== "UserspaceSLIRPStack" && (
|
||||||
<>
|
<>
|
||||||
<SelectInput
|
|
||||||
editable={p.editable}
|
|
||||||
label="Defined network"
|
|
||||||
options={p.networksList.map((n) => {
|
|
||||||
const chars = [n.forward_mode.toString()];
|
|
||||||
if (n.ip_v4) chars.push("IPv4");
|
|
||||||
if (n.ip_v6) chars.push("IPv6");
|
|
||||||
if (n.description) chars.push(n.description);
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: n.name,
|
|
||||||
value: n.name,
|
|
||||||
description: chars.join(" - "),
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
value={p.network.network}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (p.network.type === "DefinedNetwork")
|
|
||||||
p.network.network = v as any;
|
|
||||||
p.onChange?.();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Network Filter */}
|
{/* Network Filter */}
|
||||||
<NWFilterSelectInput
|
<NWFilterSelectInput
|
||||||
editable={p.editable}
|
editable={p.editable}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Checkbox,
|
Checkbox,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
|
Grid,
|
||||||
Paper,
|
Paper,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -59,6 +60,8 @@ export function TokenRightsEditor(p: {
|
|||||||
<TableCell align="center">Get XML definition</TableCell>
|
<TableCell align="center">Get XML definition</TableCell>
|
||||||
<TableCell align="center">Get autostart</TableCell>
|
<TableCell align="center">Get autostart</TableCell>
|
||||||
<TableCell align="center">Set autostart</TableCell>
|
<TableCell align="center">Set autostart</TableCell>
|
||||||
|
<TableCell align="center">Get CloudInit disk</TableCell>
|
||||||
|
<TableCell align="center">Backup disk</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@ -82,6 +85,17 @@ export function TokenRightsEditor(p: {
|
|||||||
{...p}
|
{...p}
|
||||||
right={{ verb: "PUT", path: "/api/vm/*/autostart" }}
|
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" }}
|
||||||
|
/>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
||||||
{/* Per VM operations */}
|
{/* Per VM operations */}
|
||||||
@ -118,6 +132,22 @@ export function TokenRightsEditor(p: {
|
|||||||
right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }}
|
right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }}
|
||||||
parent={{ verb: "PUT", path: "/api/vm/*/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={{
|
||||||
|
verb: "POST",
|
||||||
|
path: `/api/vm/${v.uuid}/disk/*/backup`,
|
||||||
|
}}
|
||||||
|
parent={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }}
|
||||||
|
/>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@ -669,34 +699,73 @@ export function TokenRightsEditor(p: {
|
|||||||
</Table>
|
</Table>
|
||||||
</RightsSection>
|
</RightsSection>
|
||||||
|
|
||||||
{/* ISO files */}
|
<Grid container>
|
||||||
<RightsSection label="ISO files">
|
<Grid size={{ md: 6 }}>
|
||||||
<RouteRight
|
{/* Disk images */}
|
||||||
{...p}
|
<RightsSection label="Disk images">
|
||||||
right={{ verb: "POST", path: "/api/iso/upload" }}
|
<RouteRight
|
||||||
label="Upload a new ISO file"
|
{...p}
|
||||||
/>
|
right={{ verb: "POST", path: "/api/disk_images/upload" }}
|
||||||
<RouteRight
|
label="Upload a new disk image"
|
||||||
{...p}
|
/>
|
||||||
right={{ verb: "POST", path: "/api/iso/upload_from_url" }}
|
<RouteRight
|
||||||
label="Upload a new ISO file from a given URL"
|
{...p}
|
||||||
/>
|
right={{ verb: "GET", path: "/api/disk_images/list" }}
|
||||||
<RouteRight
|
label="Get the list of disk images"
|
||||||
{...p}
|
/>
|
||||||
right={{ verb: "GET", path: "/api/iso/list" }}
|
<RouteRight
|
||||||
label="Get the list of ISO files"
|
{...p}
|
||||||
/>
|
right={{ verb: "GET", path: "/api/disk_images/*" }}
|
||||||
<RouteRight
|
label="Download disk images"
|
||||||
{...p}
|
/>
|
||||||
right={{ verb: "GET", path: "/api/iso/*" }}
|
<RouteRight
|
||||||
label="Download ISO files"
|
{...p}
|
||||||
/>
|
right={{ verb: "POST", path: "/api/disk_images/*/convert" }}
|
||||||
<RouteRight
|
label="Convert disk images"
|
||||||
{...p}
|
/>
|
||||||
right={{ verb: "DELETE", path: "/api/iso/*" }}
|
<RouteRight
|
||||||
label="Delete ISO files"
|
{...p}
|
||||||
/>
|
right={{ verb: "POST", path: "/api/disk_images/*/rename" }}
|
||||||
</RightsSection>
|
label="Rename disk images"
|
||||||
|
/>
|
||||||
|
<RouteRight
|
||||||
|
{...p}
|
||||||
|
right={{ verb: "DELETE", path: "/api/disk_images/*" }}
|
||||||
|
label="Delete disk images"
|
||||||
|
/>
|
||||||
|
</RightsSection>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ md: 6 }}>
|
||||||
|
{/* ISO files */}
|
||||||
|
<RightsSection label="ISO files">
|
||||||
|
<RouteRight
|
||||||
|
{...p}
|
||||||
|
right={{ verb: "POST", path: "/api/iso/upload" }}
|
||||||
|
label="Upload a new ISO file"
|
||||||
|
/>
|
||||||
|
<RouteRight
|
||||||
|
{...p}
|
||||||
|
right={{ verb: "POST", path: "/api/iso/upload_from_url" }}
|
||||||
|
label="Upload a new ISO file from a given URL"
|
||||||
|
/>
|
||||||
|
<RouteRight
|
||||||
|
{...p}
|
||||||
|
right={{ verb: "GET", path: "/api/iso/list" }}
|
||||||
|
label="Get the list of ISO files"
|
||||||
|
/>
|
||||||
|
<RouteRight
|
||||||
|
{...p}
|
||||||
|
right={{ verb: "GET", path: "/api/iso/*" }}
|
||||||
|
label="Download ISO files"
|
||||||
|
/>
|
||||||
|
<RouteRight
|
||||||
|
{...p}
|
||||||
|
right={{ verb: "DELETE", path: "/api/iso/*" }}
|
||||||
|
label="Delete ISO files"
|
||||||
|
/>
|
||||||
|
</RightsSection>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
{/* Server general information */}
|
{/* Server general information */}
|
||||||
<RightsSection label="Server">
|
<RightsSection label="Server">
|
||||||
@ -725,6 +794,16 @@ export function TokenRightsEditor(p: {
|
|||||||
right={{ verb: "GET", path: "/api/server/networks" }}
|
right={{ verb: "GET", path: "/api/server/networks" }}
|
||||||
label="Get list of network cards"
|
label="Get list of network cards"
|
||||||
/>
|
/>
|
||||||
|
<RouteRight
|
||||||
|
{...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>
|
</RightsSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -5,12 +5,13 @@ import Grid from "@mui/material/Grid";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { validate as validateUUID } from "uuid";
|
import { validate as validateUUID } from "uuid";
|
||||||
|
import { DiskImage, DiskImageApi } from "../../api/DiskImageApi";
|
||||||
import { GroupApi } from "../../api/GroupApi";
|
import { GroupApi } from "../../api/GroupApi";
|
||||||
import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi";
|
import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi";
|
||||||
import { NWFilter, NWFilterApi } from "../../api/NWFilterApi";
|
import { NWFilter, NWFilterApi } from "../../api/NWFilterApi";
|
||||||
import { NetworkApi, NetworkInfo } from "../../api/NetworksApi";
|
import { NetworkApi, NetworkInfo } from "../../api/NetworksApi";
|
||||||
import { ServerApi } from "../../api/ServerApi";
|
import { ServerApi } from "../../api/ServerApi";
|
||||||
import { VMApi, VMInfo } from "../../api/VMApi";
|
import { VMApi, VMInfo, VMState } from "../../api/VMApi";
|
||||||
import { useAlert } from "../../hooks/providers/AlertDialogProvider";
|
import { useAlert } from "../../hooks/providers/AlertDialogProvider";
|
||||||
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
|
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
|
||||||
import { useSnackbar } from "../../hooks/providers/SnackbarProvider";
|
import { useSnackbar } from "../../hooks/providers/SnackbarProvider";
|
||||||
@ -18,6 +19,7 @@ import { AsyncWidget } from "../AsyncWidget";
|
|||||||
import { TabsWidget } from "../TabsWidget";
|
import { TabsWidget } from "../TabsWidget";
|
||||||
import { XMLAsyncWidget } from "../XMLWidget";
|
import { XMLAsyncWidget } from "../XMLWidget";
|
||||||
import { CheckboxInput } from "../forms/CheckboxInput";
|
import { CheckboxInput } from "../forms/CheckboxInput";
|
||||||
|
import { CloudInitEditor } from "../forms/CloudInitEditor";
|
||||||
import { EditSection } from "../forms/EditSection";
|
import { EditSection } from "../forms/EditSection";
|
||||||
import { OEMStringFormWidget } from "../forms/OEMStringFormWidget";
|
import { OEMStringFormWidget } from "../forms/OEMStringFormWidget";
|
||||||
import { ResAutostartInput } from "../forms/ResAutostartInput";
|
import { ResAutostartInput } from "../forms/ResAutostartInput";
|
||||||
@ -33,11 +35,16 @@ interface DetailsProps {
|
|||||||
editable: boolean;
|
editable: boolean;
|
||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
screenshot?: boolean;
|
screenshot?: boolean;
|
||||||
|
state?: VMState | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VMDetails(p: DetailsProps): React.ReactElement {
|
export function VMDetails(p: DetailsProps): React.ReactElement {
|
||||||
const [groupsList, setGroupsList] = React.useState<string[] | undefined>();
|
const [groupsList, setGroupsList] = React.useState<string[] | undefined>();
|
||||||
|
const [diskImagesList, setDiskImagesList] = React.useState<
|
||||||
|
DiskImage[] | undefined
|
||||||
|
>();
|
||||||
const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>();
|
const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>();
|
||||||
|
const [bridgesList, setBridgesList] = React.useState<string[] | undefined>();
|
||||||
const [vcpuCombinations, setVCPUCombinations] = React.useState<
|
const [vcpuCombinations, setVCPUCombinations] = React.useState<
|
||||||
number[] | undefined
|
number[] | undefined
|
||||||
>();
|
>();
|
||||||
@ -50,7 +57,9 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
|
|||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setGroupsList(await GroupApi.GetList());
|
setGroupsList(await GroupApi.GetList());
|
||||||
|
setDiskImagesList(await DiskImageApi.GetList());
|
||||||
setIsoList(await IsoFilesApi.GetList());
|
setIsoList(await IsoFilesApi.GetList());
|
||||||
|
setBridgesList(await ServerApi.GetNetworksBridgesList());
|
||||||
setVCPUCombinations(await ServerApi.NumberVCPUs());
|
setVCPUCombinations(await ServerApi.NumberVCPUs());
|
||||||
setNetworksList(await NetworkApi.GetList());
|
setNetworksList(await NetworkApi.GetList());
|
||||||
setNetworkFiltersList(await NWFilterApi.GetList());
|
setNetworkFiltersList(await NWFilterApi.GetList());
|
||||||
@ -64,7 +73,9 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
|
|||||||
build={() => (
|
build={() => (
|
||||||
<VMDetailsInner
|
<VMDetailsInner
|
||||||
groupsList={groupsList!}
|
groupsList={groupsList!}
|
||||||
|
diskImagesList={diskImagesList!}
|
||||||
isoList={isoList!}
|
isoList={isoList!}
|
||||||
|
bridgesList={bridgesList!}
|
||||||
vcpuCombinations={vcpuCombinations!}
|
vcpuCombinations={vcpuCombinations!}
|
||||||
networksList={networksList!}
|
networksList={networksList!}
|
||||||
networkFiltersList={networkFiltersList!}
|
networkFiltersList={networkFiltersList!}
|
||||||
@ -79,6 +90,7 @@ enum VMTab {
|
|||||||
General = 0,
|
General = 0,
|
||||||
Storage,
|
Storage,
|
||||||
Network,
|
Network,
|
||||||
|
CloudInit,
|
||||||
Advanced,
|
Advanced,
|
||||||
XML,
|
XML,
|
||||||
Danger,
|
Danger,
|
||||||
@ -86,7 +98,9 @@ enum VMTab {
|
|||||||
|
|
||||||
type DetailsInnerProps = DetailsProps & {
|
type DetailsInnerProps = DetailsProps & {
|
||||||
groupsList: string[];
|
groupsList: string[];
|
||||||
|
diskImagesList: DiskImage[];
|
||||||
isoList: IsoFile[];
|
isoList: IsoFile[];
|
||||||
|
bridgesList: string[];
|
||||||
vcpuCombinations: number[];
|
vcpuCombinations: number[];
|
||||||
networksList: NetworkInfo[];
|
networksList: NetworkInfo[];
|
||||||
networkFiltersList: NWFilter[];
|
networkFiltersList: NWFilter[];
|
||||||
@ -104,6 +118,11 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement {
|
|||||||
{ label: "General", value: VMTab.General, visible: true },
|
{ label: "General", value: VMTab.General, visible: true },
|
||||||
{ label: "Storage", value: VMTab.Storage, visible: true },
|
{ label: "Storage", value: VMTab.Storage, visible: true },
|
||||||
{ label: "Network", value: VMTab.Network, visible: true },
|
{ label: "Network", value: VMTab.Network, visible: true },
|
||||||
|
{
|
||||||
|
label: "Cloud Init",
|
||||||
|
value: VMTab.CloudInit,
|
||||||
|
visible: p.editable || p.vm.cloud_init.attach_config,
|
||||||
|
},
|
||||||
{ label: "Avanced", value: VMTab.Advanced, visible: true },
|
{ label: "Avanced", value: VMTab.Advanced, visible: true },
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -123,6 +142,7 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement {
|
|||||||
{currTab === VMTab.General && <VMDetailsTabGeneral {...p} />}
|
{currTab === VMTab.General && <VMDetailsTabGeneral {...p} />}
|
||||||
{currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />}
|
{currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />}
|
||||||
{currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />}
|
{currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />}
|
||||||
|
{currTab === VMTab.CloudInit && <VMDetailsTabCloudInit {...p} />}
|
||||||
{currTab === VMTab.Advanced && <VMDetailsTabAdvanced {...p} />}
|
{currTab === VMTab.Advanced && <VMDetailsTabAdvanced {...p} />}
|
||||||
{currTab === VMTab.XML && <VMDetailsTabXML {...p} />}
|
{currTab === VMTab.XML && <VMDetailsTabXML {...p} />}
|
||||||
{currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />}
|
{currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />}
|
||||||
@ -268,6 +288,7 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement {
|
|||||||
options={[
|
options={[
|
||||||
{ label: "UEFI with Secure Boot", value: "UEFISecureBoot" },
|
{ label: "UEFI with Secure Boot", value: "UEFISecureBoot" },
|
||||||
{ label: "UEFI", value: "UEFI" },
|
{ label: "UEFI", value: "UEFI" },
|
||||||
|
{ label: "Legacy", value: "Legacy" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -275,14 +296,16 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement {
|
|||||||
label="Memory (MB)"
|
label="Memory (MB)"
|
||||||
editable={p.editable}
|
editable={p.editable}
|
||||||
type="number"
|
type="number"
|
||||||
value={p.vm.memory.toString()}
|
value={Math.floor(p.vm.memory / (1000 * 1000)).toString()}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
p.vm.memory = Number(v ?? "0");
|
p.vm.memory = Number(v ?? "0") * 1000 * 1000;
|
||||||
p.onChange?.();
|
p.onChange?.();
|
||||||
}}
|
}}
|
||||||
checkValue={(v) =>
|
checkValue={(v) =>
|
||||||
Number(v) > ServerApi.Config.constraints.memory_size.min &&
|
Number(v) >
|
||||||
Number(v) < ServerApi.Config.constraints.memory_size.max
|
ServerApi.Config.constraints.memory_size.min / (1000 * 1000) &&
|
||||||
|
Number(v) <
|
||||||
|
ServerApi.Config.constraints.memory_size.max / (1000 * 1000)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -366,6 +389,10 @@ function VMDetailsTabNetwork(p: DetailsInnerProps): React.ReactElement {
|
|||||||
return <VMNetworksList {...p} />;
|
return <VMNetworksList {...p} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VMDetailsTabCloudInit(p: DetailsInnerProps): React.ReactElement {
|
||||||
|
return <CloudInitEditor {...p} />;
|
||||||
|
}
|
||||||
|
|
||||||
function VMDetailsTabAdvanced(p: DetailsInnerProps): React.ReactElement {
|
function VMDetailsTabAdvanced(p: DetailsInnerProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
|
72
virtweb_frontend/src/widgets/vms/VMDiskFileWidget.tsx
Normal file
72
virtweb_frontend/src/widgets/vms/VMDiskFileWidget.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { mdiHarddisk } from "@mdi/js";
|
||||||
|
import { Icon } from "@mdi/react";
|
||||||
|
import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material";
|
||||||
|
import { filesize } from "filesize";
|
||||||
|
import { VMFileDisk } from "../../api/VMApi";
|
||||||
|
import { DiskBusSelect } from "../forms/DiskBusSelect";
|
||||||
|
|
||||||
|
export function VMDiskFileWidget(p: {
|
||||||
|
editable?: boolean;
|
||||||
|
disk: VMFileDisk;
|
||||||
|
secondaryAction?: React.ReactElement;
|
||||||
|
onChange?: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const info = [filesize(p.disk.size), p.disk.format];
|
||||||
|
|
||||||
|
if (p.disk.format === "Raw") info.push(p.disk.is_sparse ? "Sparse" : "Fixed");
|
||||||
|
|
||||||
|
if (!p.editable) info.push(p.disk.bus);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem secondaryAction={p.secondaryAction}>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar>
|
||||||
|
<Icon path={mdiHarddisk} />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<>
|
||||||
|
{p.disk.name}{" "}
|
||||||
|
{p.disk.deleteType && (
|
||||||
|
<span style={{ color: "red" }}>
|
||||||
|
{p.disk.deleteType === "deletefile"
|
||||||
|
? "Remove, DELETING block file"
|
||||||
|
: "Remove, keeping block file"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
|
{p.editable ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: "80px",
|
||||||
|
display: "inline-block",
|
||||||
|
marginRight: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DiskBusSelect
|
||||||
|
onValueChange={(v) => {
|
||||||
|
p.disk.bus = v;
|
||||||
|
p.onChange?.();
|
||||||
|
}}
|
||||||
|
label=""
|
||||||
|
editable
|
||||||
|
value={p.disk.bus}
|
||||||
|
size="small"
|
||||||
|
disableUnderline
|
||||||
|
disableBottomMargin
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
<div style={{ height: "100%" }}>{info.join(" - ")}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
}
|
Reference in New Issue
Block a user