Compare commits
	
		
			1 Commits
		
	
	
		
			20250618
			...
			3d4750de21
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 3d4750de21 | 
| @@ -46,9 +46,8 @@ steps: | ||||
|   - cd virtweb_backend | ||||
|   - mv /tmp/web_build/dist static | ||||
|   - cargo build --release | ||||
|   - cargo build --release --example api_curl | ||||
|   - ls -lah target/release/virtweb_backend target/release/examples/api_curl | ||||
|   - cp target/release/virtweb_backend target/release/examples/api_curl /tmp/release | ||||
|   - ls -lah target/release/virtweb_backend | ||||
|   - cp target/release/virtweb_backend /tmp/release | ||||
|  | ||||
| - name: gitea_release | ||||
|   image: plugins/gitea-release | ||||
|   | ||||
							
								
								
									
										295
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										295
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @@ -435,21 +435,6 @@ dependencies = [ | ||||
|  "alloc-no-stdlib", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "android-tzdata" | ||||
| version = "0.1.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" | ||||
|  | ||||
| [[package]] | ||||
| name = "android_system_properties" | ||||
| version = "0.1.5" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" | ||||
| dependencies = [ | ||||
|  "libc", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "anstream" | ||||
| version = "0.6.18" | ||||
| @@ -511,9 +496,6 @@ name = "arbitrary" | ||||
| version = "1.4.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" | ||||
| dependencies = [ | ||||
|  "derive_arbitrary", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "arg_enum_proc_macro" | ||||
| @@ -733,25 +715,6 @@ dependencies = [ | ||||
|  "bytes", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "bzip2" | ||||
| version = "0.5.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" | ||||
| dependencies = [ | ||||
|  "bzip2-sys", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "bzip2-sys" | ||||
| version = "0.1.13+1.0.8" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" | ||||
| dependencies = [ | ||||
|  "cc", | ||||
|  "pkg-config", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "cc" | ||||
| version = "1.2.23" | ||||
| @@ -785,20 +748,6 @@ version = "0.2.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" | ||||
|  | ||||
| [[package]] | ||||
| name = "chrono" | ||||
| version = "0.4.41" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" | ||||
| dependencies = [ | ||||
|  "android-tzdata", | ||||
|  "iana-time-zone", | ||||
|  "js-sys", | ||||
|  "num-traits", | ||||
|  "wasm-bindgen", | ||||
|  "windows-link", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "cipher" | ||||
| version = "0.4.4" | ||||
| @@ -811,9 +760,9 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "clap" | ||||
| version = "4.5.40" | ||||
| version = "4.5.38" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" | ||||
| checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" | ||||
| dependencies = [ | ||||
|  "clap_builder", | ||||
|  "clap_derive", | ||||
| @@ -821,9 +770,9 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "clap_builder" | ||||
| version = "4.5.40" | ||||
| version = "4.5.38" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" | ||||
| checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" | ||||
| dependencies = [ | ||||
|  "anstream", | ||||
|  "anstyle", | ||||
| @@ -833,9 +782,9 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "clap_derive" | ||||
| version = "4.5.40" | ||||
| version = "4.5.32" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" | ||||
| checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" | ||||
| dependencies = [ | ||||
|  "heck", | ||||
|  "proc-macro2", | ||||
| @@ -867,12 +816,6 @@ version = "0.9.6" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" | ||||
|  | ||||
| [[package]] | ||||
| name = "constant_time_eq" | ||||
| version = "0.3.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" | ||||
|  | ||||
| [[package]] | ||||
| name = "convert_case" | ||||
| version = "0.4.0" | ||||
| @@ -1038,12 +981,6 @@ dependencies = [ | ||||
|  "syn", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "deflate64" | ||||
| version = "0.1.9" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" | ||||
|  | ||||
| [[package]] | ||||
| name = "der" | ||||
| version = "0.7.10" | ||||
| @@ -1064,17 +1001,6 @@ dependencies = [ | ||||
|  "powerfmt", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "derive_arbitrary" | ||||
| version = "1.4.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" | ||||
| dependencies = [ | ||||
|  "proc-macro2", | ||||
|  "quote", | ||||
|  "syn", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "derive_more" | ||||
| version = "0.99.20" | ||||
| @@ -1295,7 +1221,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" | ||||
| dependencies = [ | ||||
|  "crc32fast", | ||||
|  "libz-rs-sys", | ||||
|  "miniz_oxide", | ||||
| ] | ||||
|  | ||||
| @@ -1455,11 +1380,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" | ||||
| dependencies = [ | ||||
|  "cfg-if", | ||||
|  "js-sys", | ||||
|  "libc", | ||||
|  "r-efi", | ||||
|  "wasi 0.14.2+wasi-0.2.4", | ||||
|  "wasm-bindgen", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -1699,47 +1622,18 @@ version = "0.1.12" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "cf9f1e950e0d9d1d3c47184416723cf29c0d1f93bd8cccf37e4beb6b44f31710" | ||||
| dependencies = [ | ||||
|  "base64 0.22.1", | ||||
|  "bytes", | ||||
|  "futures-channel", | ||||
|  "futures-util", | ||||
|  "http 1.3.1", | ||||
|  "http-body", | ||||
|  "hyper", | ||||
|  "ipnet", | ||||
|  "libc", | ||||
|  "percent-encoding", | ||||
|  "pin-project-lite", | ||||
|  "socket2", | ||||
|  "system-configuration", | ||||
|  "tokio", | ||||
|  "tower-service", | ||||
|  "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]] | ||||
| @@ -1945,16 +1839,6 @@ dependencies = [ | ||||
|  "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]] | ||||
| name = "is_terminal_polyfill" | ||||
| version = "1.70.1" | ||||
| @@ -2098,45 +1982,6 @@ dependencies = [ | ||||
|  "cc", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "liblzma" | ||||
| version = "0.4.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "66352d7a8ac12d4877b6e6ea5a9b7650ee094257dc40889955bea5bc5b08c1d0" | ||||
| dependencies = [ | ||||
|  "liblzma-sys", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "liblzma-sys" | ||||
| version = "0.4.4" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736" | ||||
| dependencies = [ | ||||
|  "cc", | ||||
|  "libc", | ||||
|  "pkg-config", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "libyml" | ||||
| version = "0.0.5" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" | ||||
| dependencies = [ | ||||
|  "anyhow", | ||||
|  "version_check", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "libz-rs-sys" | ||||
| version = "0.5.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" | ||||
| dependencies = [ | ||||
|  "zlib-rs", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "light-openid" | ||||
| version = "1.0.4" | ||||
| @@ -2559,16 +2404,6 @@ version = "1.0.15" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" | ||||
|  | ||||
| [[package]] | ||||
| name = "pbkdf2" | ||||
| version = "0.12.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" | ||||
| dependencies = [ | ||||
|  "digest", | ||||
|  "hmac", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "pem" | ||||
| version = "3.0.5" | ||||
| @@ -2938,9 +2773,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" | ||||
|  | ||||
| [[package]] | ||||
| name = "reqwest" | ||||
| version = "0.12.20" | ||||
| version = "0.12.15" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" | ||||
| checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" | ||||
| dependencies = [ | ||||
|  "base64 0.22.1", | ||||
|  "bytes", | ||||
| @@ -2955,28 +2790,31 @@ dependencies = [ | ||||
|  "hyper-rustls", | ||||
|  "hyper-tls", | ||||
|  "hyper-util", | ||||
|  "ipnet", | ||||
|  "js-sys", | ||||
|  "log", | ||||
|  "mime", | ||||
|  "native-tls", | ||||
|  "once_cell", | ||||
|  "percent-encoding", | ||||
|  "pin-project-lite", | ||||
|  "rustls-pki-types", | ||||
|  "rustls-pemfile", | ||||
|  "serde", | ||||
|  "serde_json", | ||||
|  "serde_urlencoded", | ||||
|  "sync_wrapper", | ||||
|  "system-configuration", | ||||
|  "tokio", | ||||
|  "tokio-native-tls", | ||||
|  "tokio-util", | ||||
|  "tower", | ||||
|  "tower-http", | ||||
|  "tower-service", | ||||
|  "url", | ||||
|  "wasm-bindgen", | ||||
|  "wasm-bindgen-futures", | ||||
|  "wasm-streams", | ||||
|  "web-sys", | ||||
|  "windows-registry", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -3085,6 +2923,15 @@ dependencies = [ | ||||
|  "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]] | ||||
| name = "rustls-pki-types" | ||||
| version = "1.12.0" | ||||
| @@ -3246,21 +3093,6 @@ dependencies = [ | ||||
|  "serde", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "serde_yml" | ||||
| version = "0.0.12" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" | ||||
| dependencies = [ | ||||
|  "indexmap", | ||||
|  "itoa", | ||||
|  "libyml", | ||||
|  "memchr", | ||||
|  "ryu", | ||||
|  "serde", | ||||
|  "version_check", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "sha1" | ||||
| version = "0.10.6" | ||||
| @@ -3581,9 +3413,9 @@ dependencies = [ | ||||
|  | ||||
| [[package]] | ||||
| name = "tokio" | ||||
| version = "1.45.0" | ||||
| version = "1.45.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" | ||||
| checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" | ||||
| dependencies = [ | ||||
|  "backtrace", | ||||
|  "bytes", | ||||
| @@ -3690,24 +3522,6 @@ dependencies = [ | ||||
|  "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]] | ||||
| name = "tower-layer" | ||||
| version = "0.3.3" | ||||
| @@ -3921,7 +3735,6 @@ dependencies = [ | ||||
|  "actix-ws", | ||||
|  "anyhow", | ||||
|  "basic-jwt", | ||||
|  "chrono", | ||||
|  "clap", | ||||
|  "dotenvy", | ||||
|  "env_logger", | ||||
| @@ -3941,7 +3754,6 @@ dependencies = [ | ||||
|  "rust-embed", | ||||
|  "serde", | ||||
|  "serde_json", | ||||
|  "serde_yml", | ||||
|  "sysinfo", | ||||
|  "tempfile", | ||||
|  "thiserror 2.0.12", | ||||
| @@ -3949,7 +3761,6 @@ dependencies = [ | ||||
|  "url", | ||||
|  "uuid", | ||||
|  "virt", | ||||
|  "zip", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -4488,20 +4299,6 @@ name = "zeroize" | ||||
| version = "1.8.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" | ||||
| dependencies = [ | ||||
|  "zeroize_derive", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "zeroize_derive" | ||||
| version = "1.4.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" | ||||
| dependencies = [ | ||||
|  "proc-macro2", | ||||
|  "quote", | ||||
|  "syn", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "zerotrie" | ||||
| @@ -4536,50 +4333,6 @@ dependencies = [ | ||||
|  "syn", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "zip" | ||||
| version = "4.1.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "af7dcdb4229c0e79c2531a24de7726a0e980417a74fb4d030a35f535665439a0" | ||||
| dependencies = [ | ||||
|  "aes", | ||||
|  "arbitrary", | ||||
|  "bzip2", | ||||
|  "constant_time_eq", | ||||
|  "crc32fast", | ||||
|  "deflate64", | ||||
|  "flate2", | ||||
|  "getrandom 0.3.3", | ||||
|  "hmac", | ||||
|  "indexmap", | ||||
|  "liblzma", | ||||
|  "memchr", | ||||
|  "pbkdf2", | ||||
|  "sha1", | ||||
|  "time", | ||||
|  "zeroize", | ||||
|  "zopfli", | ||||
|  "zstd", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "zlib-rs" | ||||
| version = "0.5.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a" | ||||
|  | ||||
| [[package]] | ||||
| name = "zopfli" | ||||
| version = "0.8.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" | ||||
| dependencies = [ | ||||
|  "bumpalo", | ||||
|  "crc32fast", | ||||
|  "log", | ||||
|  "simd-adler32", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "zstd" | ||||
| version = "0.13.3" | ||||
|   | ||||
| @@ -8,7 +8,7 @@ edition = "2024" | ||||
| [dependencies] | ||||
| log = "0.4.27" | ||||
| env_logger = "0.11.8" | ||||
| clap = { version = "4.5.40", features = ["derive", "env"] } | ||||
| clap = { version = "4.5.38", features = ["derive", "env"] } | ||||
| light-openid = { version = "1.0.4", features = ["crypto-wrapper"] } | ||||
| lazy_static = "1.5.0" | ||||
| actix = "0.13.5" | ||||
| @@ -19,16 +19,15 @@ actix-identity = "0.8.0" | ||||
| actix-cors = "0.7.1" | ||||
| actix-files = "0.6.6" | ||||
| actix-ws = "0.3.0" | ||||
| actix-http = "3.11.0" | ||||
| actix-http = "3.10.0" | ||||
| serde = { version = "1.0.219", features = ["derive"] } | ||||
| serde_json = "1.0.140" | ||||
| serde_yml = "0.0.12" | ||||
| quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] } | ||||
| futures-util = "0.3.31" | ||||
| anyhow = "1.0.98" | ||||
| actix-multipart = "0.7.2" | ||||
| tempfile = "3.20.0" | ||||
| reqwest = { version = "0.12.20", features = ["stream"] } | ||||
| reqwest = { version = "0.12.15", features = ["stream"] } | ||||
| url = "2.5.4" | ||||
| virt = "0.4.2" | ||||
| sysinfo = { version = "0.35.1", features = ["serde"] } | ||||
| @@ -37,7 +36,7 @@ lazy-regex = "3.4.1" | ||||
| thiserror = "2.0.12" | ||||
| image = "0.25.6" | ||||
| rand = "0.9.1" | ||||
| tokio = { version = "1.45.0", features = ["rt", "time", "macros"] } | ||||
| tokio = { version = "1.45.1", features = ["rt", "time", "macros"] } | ||||
| futures = "0.3.31" | ||||
| ipnetwork = { version = "0.21.1", features = ["serde"] } | ||||
| num = "0.4.3" | ||||
| @@ -45,5 +44,3 @@ rust-embed = { version = "8.7.2", features = ["mime-guess"] } | ||||
| dotenvy = "0.15.7" | ||||
| nix = { version = "0.30.1", features = ["net"] } | ||||
| basic-jwt = "0.3.0" | ||||
| zip = "4.1.0" | ||||
| chrono = "0.4.41" | ||||
| @@ -182,13 +182,6 @@ impl Handler<DeleteDomainReq> for LibVirtActor { | ||||
|             false => sys::VIR_DOMAIN_UNDEFINE_NVRAM, | ||||
|         })?; | ||||
|  | ||||
|         // Delete associated cloud init disk | ||||
|         let cloud_init_disk = AppConfig::get().cloud_init_disk_path_for_vm(&domain_name); | ||||
|         if cloud_init_disk.exists() { | ||||
|             std::fs::remove_file(cloud_init_disk)?; | ||||
|         } | ||||
|  | ||||
|         // If requested, delete block storage associated with the VM | ||||
|         if !msg.keep_files { | ||||
|             log::info!("Delete storage associated with the domain"); | ||||
|             let path = AppConfig::get().vm_storage_path(msg.id); | ||||
|   | ||||
| @@ -245,34 +245,11 @@ impl AppConfig { | ||||
|         storage_path.canonicalize().unwrap() | ||||
|     } | ||||
|  | ||||
|     /// Get iso files storage directory | ||||
|     /// Get iso storage directory | ||||
|     pub fn iso_storage_path(&self) -> PathBuf { | ||||
|         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 | ||||
|     pub fn vnc_sockets_path(&self) -> PathBuf { | ||||
|         self.storage_path().join("vnc") | ||||
| @@ -283,17 +260,15 @@ impl AppConfig { | ||||
|         self.vnc_sockets_path().join(format!("vnc-{}", name)) | ||||
|     } | ||||
|  | ||||
|     /// Get VM root disks storage directory | ||||
|     pub fn root_vm_disks_storage_path(&self) -> PathBuf { | ||||
|     /// Get VM vnc sockets directory | ||||
|     pub fn disks_storage_path(&self) -> PathBuf { | ||||
|         self.storage_path().join("disks") | ||||
|     } | ||||
|  | ||||
|     /// Get specific VM disk storage directory | ||||
|     pub fn vm_storage_path(&self, id: XMLUuid) -> PathBuf { | ||||
|         self.root_vm_disks_storage_path().join(id.as_string()) | ||||
|         self.disks_storage_path().join(id.as_string()) | ||||
|     } | ||||
|  | ||||
|     /// Get the path were VM definitions are backed up | ||||
|     pub fn definitions_path(&self) -> PathBuf { | ||||
|         self.storage_path().join("definitions") | ||||
|     } | ||||
|   | ||||
| @@ -1,5 +1,3 @@ | ||||
| use crate::utils::file_size_utils::FileSize; | ||||
|  | ||||
| /// Name of the cookie that contains session information | ||||
| pub const SESSION_COOKIE_NAME: &str = "X-auth-token"; | ||||
|  | ||||
| @@ -27,24 +25,13 @@ pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [ | ||||
| ]; | ||||
|  | ||||
| /// ISO max size | ||||
| pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10); | ||||
| pub const ISO_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000; | ||||
|  | ||||
| /// Allowed uploaded disk images formats | ||||
| pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 4] = [ | ||||
|     "application/x-qemu-disk", | ||||
|     "application/x-raw-disk-image", | ||||
|     "application/gzip", | ||||
|     "application/octet-stream", | ||||
| ]; | ||||
| /// Min VM memory size (MB) | ||||
| pub const MIN_VM_MEMORY: usize = 100; | ||||
|  | ||||
| /// Disk image max size | ||||
| 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); | ||||
| /// Max VM memory size (MB) | ||||
| pub const MAX_VM_MEMORY: usize = 64000; | ||||
|  | ||||
| /// Disk name min length | ||||
| pub const DISK_NAME_MIN_LEN: usize = 2; | ||||
| @@ -52,14 +39,11 @@ pub const DISK_NAME_MIN_LEN: usize = 2; | ||||
| /// Disk name max length | ||||
| pub const DISK_NAME_MAX_LEN: usize = 10; | ||||
|  | ||||
| /// Disk size min (B) | ||||
| pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50); | ||||
| /// Disk size min (MB) | ||||
| pub const DISK_SIZE_MIN: usize = 100; | ||||
|  | ||||
| /// Disk size max (B) | ||||
| pub const DISK_SIZE_MAX: FileSize = FileSize::from_gb(20000); | ||||
|  | ||||
| /// Cloud init generated disk image prefix | ||||
| pub const CLOUD_INIT_IMAGE_PREFIX_NAME: &str = "virtweb-cloudinit-autogen-image"; | ||||
| /// Disk size max (MB) | ||||
| pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 2; | ||||
|  | ||||
| /// Net nat entry comment max size | ||||
| pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250; | ||||
| @@ -126,25 +110,7 @@ pub const API_TOKEN_DESCRIPTION_MAX_LENGTH: usize = 30; | ||||
| pub const API_TOKEN_RIGHT_PATH_MAX_LENGTH: usize = 255; | ||||
|  | ||||
| /// Qemu image program path | ||||
| pub const PROGRAM_QEMU_IMAGE: &str = "/usr/bin/qemu-img"; | ||||
| pub const QEMU_IMAGE_PROGRAM: &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"; | ||||
| pub const IP_PROGRAM: &str = "/usr/sbin/ip"; | ||||
|   | ||||
| @@ -1,255 +0,0 @@ | ||||
| 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); | ||||
|  | ||||
|     if file.size > constants::ISO_MAX_SIZE.as_bytes() { | ||||
|     if file.size > constants::ISO_MAX_SIZE { | ||||
|         log::error!("Uploaded ISO 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?; | ||||
|  | ||||
|     if let Some(len) = response.content_length() { | ||||
|         if len > constants::ISO_MAX_SIZE.as_bytes() as u64 { | ||||
|         if len > constants::ISO_MAX_SIZE as u64 { | ||||
|             return Ok(HttpResponse::BadRequest().json("File is too large!")); | ||||
|         } | ||||
|     } | ||||
| @@ -132,12 +132,12 @@ pub async fn get_list() -> HttpResult { | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct IsoFilePath { | ||||
| pub struct DownloadFilePath { | ||||
|     filename: String, | ||||
| } | ||||
|  | ||||
| /// Download ISO file | ||||
| pub async fn download_file(p: web::Path<IsoFilePath>, req: HttpRequest) -> HttpResult { | ||||
| pub async fn download_file(p: web::Path<DownloadFilePath>, req: HttpRequest) -> HttpResult { | ||||
|     if !files_utils::check_file_name(&p.filename) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid file name!")); | ||||
|     } | ||||
| @@ -152,7 +152,7 @@ pub async fn download_file(p: web::Path<IsoFilePath>, req: HttpRequest) -> HttpR | ||||
| } | ||||
|  | ||||
| /// Delete ISO file | ||||
| pub async fn delete_file(p: web::Path<IsoFilePath>) -> HttpResult { | ||||
| pub async fn delete_file(p: web::Path<DownloadFilePath>) -> HttpResult { | ||||
|     if !files_utils::check_file_name(&p.filename) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid file name!")); | ||||
|     } | ||||
|   | ||||
| @@ -4,11 +4,9 @@ use actix_web::body::BoxBody; | ||||
| use actix_web::{HttpResponse, web}; | ||||
| use std::error::Error; | ||||
| use std::fmt::{Display, Formatter}; | ||||
| use zip::result::ZipError; | ||||
|  | ||||
| pub mod api_tokens_controller; | ||||
| pub mod auth_controller; | ||||
| pub mod disk_images_controller; | ||||
| pub mod groups_controller; | ||||
| pub mod iso_controller; | ||||
| pub mod network_controller; | ||||
| @@ -103,12 +101,6 @@ impl From<actix_web::Error> for HttpErr { | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<ZipError> for HttpErr { | ||||
|     fn from(value: ZipError) -> Self { | ||||
|         HttpErr::Err(std::io::Error::other(value.to_string()).into()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<HttpResponse> for HttpErr { | ||||
|     fn from(value: HttpResponse) -> Self { | ||||
|         HttpErr::HTTPResponse(value) | ||||
|   | ||||
| @@ -1,24 +1,14 @@ | ||||
| use crate::actors::vnc_tokens_actor::VNC_TOKEN_LIFETIME; | ||||
| use crate::app_config::AppConfig; | ||||
| use crate::constants; | ||||
| use crate::constants::{DISK_NAME_MAX_LEN, DISK_NAME_MIN_LEN, DISK_SIZE_MAX, DISK_SIZE_MIN}; | ||||
| use crate::controllers::{HttpResult, LibVirtReq}; | ||||
| use crate::extractors::local_auth_extractor::LocalAuthEnabled; | ||||
| use crate::libvirt_rest_structures::hypervisor::HypervisorInfo; | ||||
| use crate::libvirt_rest_structures::net::NetworkInfo; | ||||
| use crate::libvirt_rest_structures::nw_filter::NetworkFilter; | ||||
| use crate::libvirt_rest_structures::vm::VMInfo; | ||||
| use crate::nat::nat_hook; | ||||
| use crate::utils::net_utils; | ||||
| use 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 actix_web::{HttpResponse, Responder}; | ||||
| use sysinfo::{Components, Disks, Networks, System}; | ||||
| use zip::ZipWriter; | ||||
| use zip::write::SimpleFileOptions; | ||||
|  | ||||
| #[derive(serde::Serialize)] | ||||
| struct StaticConfig { | ||||
| @@ -26,7 +16,6 @@ struct StaticConfig { | ||||
|     local_auth_enabled: bool, | ||||
|     oidc_auth_enabled: bool, | ||||
|     iso_mimetypes: &'static [&'static str], | ||||
|     disk_images_mimetypes: &'static [&'static str], | ||||
|     net_mac_prefix: &'static str, | ||||
|     builtin_nwfilter_rules: &'static [&'static str], | ||||
|     nwfilter_chains: &'static [&'static str], | ||||
| @@ -48,7 +37,6 @@ struct SLenConstraints { | ||||
| #[derive(serde::Serialize)] | ||||
| struct ServerConstraints { | ||||
|     iso_max_size: usize, | ||||
|     disk_image_max_size: usize, | ||||
|     vnc_token_duration: u64, | ||||
|     vm_name_size: LenConstraints, | ||||
|     vm_title_size: LenConstraints, | ||||
| @@ -56,7 +44,6 @@ struct ServerConstraints { | ||||
|     memory_size: LenConstraints, | ||||
|     disk_name_size: LenConstraints, | ||||
|     disk_size: LenConstraints, | ||||
|     disk_image_name_size: LenConstraints, | ||||
|     net_name_size: LenConstraints, | ||||
|     net_title_size: LenConstraints, | ||||
|     net_nat_comment_size: LenConstraints, | ||||
| @@ -76,13 +63,11 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { | ||||
|         local_auth_enabled: *local_auth, | ||||
|         oidc_auth_enabled: !AppConfig::get().disable_oidc, | ||||
|         iso_mimetypes: &constants::ALLOWED_ISO_MIME_TYPES, | ||||
|         disk_images_mimetypes: &constants::ALLOWED_DISK_IMAGES_MIME_TYPES, | ||||
|         net_mac_prefix: constants::NET_MAC_ADDR_PREFIX, | ||||
|         builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES, | ||||
|         nwfilter_chains: &constants::NETWORK_CHAINS, | ||||
|         constraints: ServerConstraints { | ||||
|             iso_max_size: constants::ISO_MAX_SIZE.as_bytes(), | ||||
|             disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE.as_bytes(), | ||||
|             iso_max_size: constants::ISO_MAX_SIZE, | ||||
|  | ||||
|             vnc_token_duration: VNC_TOKEN_LIFETIME, | ||||
|  | ||||
| @@ -90,20 +75,18 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { | ||||
|             vm_title_size: LenConstraints { min: 0, max: 50 }, | ||||
|             group_id_size: LenConstraints { min: 3, max: 50 }, | ||||
|             memory_size: LenConstraints { | ||||
|                 min: constants::MIN_VM_MEMORY.as_bytes(), | ||||
|                 max: constants::MAX_VM_MEMORY.as_bytes(), | ||||
|                 min: constants::MIN_VM_MEMORY, | ||||
|                 max: constants::MAX_VM_MEMORY, | ||||
|             }, | ||||
|             disk_name_size: LenConstraints { | ||||
|                 min: DISK_NAME_MIN_LEN, | ||||
|                 max: DISK_NAME_MAX_LEN, | ||||
|             }, | ||||
|             disk_size: LenConstraints { | ||||
|                 min: DISK_SIZE_MIN.as_bytes(), | ||||
|                 max: DISK_SIZE_MAX.as_bytes(), | ||||
|                 min: DISK_SIZE_MIN, | ||||
|                 max: DISK_SIZE_MAX, | ||||
|             }, | ||||
|  | ||||
|             disk_image_name_size: LenConstraints { min: 5, max: 220 }, | ||||
|  | ||||
|             net_name_size: LenConstraints { min: 2, max: 50 }, | ||||
|             net_title_size: LenConstraints { min: 0, max: 50 }, | ||||
|             net_nat_comment_size: LenConstraints { | ||||
| @@ -209,85 +192,3 @@ pub async fn networks_list() -> HttpResult { | ||||
| pub async fn bridges_list() -> HttpResult { | ||||
|     Ok(HttpResponse::Ok().json(net_utils::bridges_list()?)) | ||||
| } | ||||
|  | ||||
| /// Add JSON file to ZIP | ||||
| fn zip_json<E: Serialize, F>( | ||||
|     zip: &mut ZipWriter<File>, | ||||
|     dir: &str, | ||||
|     content: &Vec<E>, | ||||
|     file_name: F, | ||||
| ) -> anyhow::Result<()> | ||||
| where | ||||
|     F: Fn(&E) -> String, | ||||
| { | ||||
|     for entry in content { | ||||
|         let file_encoded = serde_json::to_string(&entry)?; | ||||
|  | ||||
|         let options = SimpleFileOptions::default() | ||||
|             .compression_method(zip::CompressionMethod::Deflated) | ||||
|             .unix_permissions(0o750); | ||||
|  | ||||
|         zip.start_file(format!("{dir}/{}.json", file_name(entry)), options)?; | ||||
|         zip.write_all(file_encoded.as_bytes())?; | ||||
|     } | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Export all configuration elements at once | ||||
| pub async fn export_all_configs(req: HttpRequest, client: LibVirtReq) -> HttpResult { | ||||
|     // Perform extractions | ||||
|     let vms = client | ||||
|         .get_full_domains_list() | ||||
|         .await? | ||||
|         .into_iter() | ||||
|         .map(VMInfo::from_domain) | ||||
|         .collect::<Result<Vec<_>, _>>()?; | ||||
|     let networks = client | ||||
|         .get_full_networks_list() | ||||
|         .await? | ||||
|         .into_iter() | ||||
|         .map(NetworkInfo::from_xml) | ||||
|         .collect::<Result<Vec<_>, _>>()?; | ||||
|     let nw_filters = client | ||||
|         .get_full_network_filters_list() | ||||
|         .await? | ||||
|         .into_iter() | ||||
|         .map(NetworkFilter::lib2rest) | ||||
|         .collect::<Result<Vec<_>, _>>()?; | ||||
|     let tokens = api_tokens::full_list().await?; | ||||
|  | ||||
|     // Create ZIP file | ||||
|     let dest_dir = tempfile::tempdir_in(&AppConfig::get().temp_dir)?; | ||||
|     let zip_path = dest_dir.path().join("export.zip"); | ||||
|  | ||||
|     let file = File::create(&zip_path)?; | ||||
|     let mut zip = ZipWriter::new(file); | ||||
|  | ||||
|     // Encode entities to JSON | ||||
|     zip_json(&mut zip, "vms", &vms, |v| v.name.to_string())?; | ||||
|     zip_json(&mut zip, "networks", &networks, |v| v.name.0.to_string())?; | ||||
|     zip_json( | ||||
|         &mut zip, | ||||
|         "nw_filters", | ||||
|         &nw_filters, | ||||
|         |v| match constants::BUILTIN_NETWORK_FILTER_RULES.contains(&v.name.0.as_str()) { | ||||
|             true => format!("builtin/{}", v.name.0), | ||||
|             false => v.name.0.to_string(), | ||||
|         }, | ||||
|     )?; | ||||
|     zip_json(&mut zip, "tokens", &tokens, |v| v.id.0.to_string())?; | ||||
|  | ||||
|     // Finalize ZIP and return response | ||||
|     zip.finish()?; | ||||
|     let file = File::open(zip_path)?; | ||||
|  | ||||
|     let file = NamedFile::from_file( | ||||
|         file, | ||||
|         format!( | ||||
|             "export_{}.zip", | ||||
|             format_date(time() as i64).unwrap().replace('/', "-") | ||||
|         ), | ||||
|     )?; | ||||
|  | ||||
|     Ok(file.into_response(&req)) | ||||
| } | ||||
|   | ||||
| @@ -109,28 +109,6 @@ pub async fn get_single_src_def(client: LibVirtReq, id: web::Path<SingleVMUUidRe | ||||
|         .body(info)) | ||||
| } | ||||
|  | ||||
| /// Get the generated cloud init configuration disk of a vm | ||||
| pub async fn get_cloud_init_disk(client: LibVirtReq, id: web::Path<SingleVMUUidReq>) -> HttpResult { | ||||
|     let info = match client.get_single_domain(id.uid).await { | ||||
|         Ok(i) => i, | ||||
|         Err(e) => { | ||||
|             log::error!("Failed to get domain information! {e}"); | ||||
|             return Ok(HttpResponse::InternalServerError().json(e.to_string())); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     let vm = VMInfo::from_domain(info)?; | ||||
|     let disk = vm.cloud_init.generate_nocloud_disk()?; | ||||
|  | ||||
|     Ok(HttpResponse::Ok() | ||||
|         .content_type("application/x-iso9660-image") | ||||
|         .insert_header(( | ||||
|             "Content-Disposition", | ||||
|             format!("attachment; filename=\"cloud_init_{}.iso\"", vm.name), | ||||
|         )) | ||||
|         .body(disk)) | ||||
| } | ||||
|  | ||||
| /// Update a VM information | ||||
| pub async fn update( | ||||
|     client: LibVirtReq, | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| use crate::libvirt_lib_structures::XMLUuid; | ||||
| use crate::utils::cloud_init_utils::CloudInitConfig; | ||||
|  | ||||
| /// VirtWeb specific metadata | ||||
| #[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)] | ||||
| @@ -9,8 +8,6 @@ pub struct DomainMetadataVirtWebXML { | ||||
|     pub ns: String, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub group: Option<String>, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub cloud_init: Option<CloudInitConfig>, | ||||
| } | ||||
|  | ||||
| /// Domain metadata | ||||
| @@ -25,13 +22,10 @@ pub struct DomainMetadataXML { | ||||
| #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | ||||
| #[serde(rename = "os")] | ||||
| pub struct OSXML { | ||||
|     #[serde(rename = "@firmware", default, skip_serializing_if = "Option::is_none")] | ||||
|     pub firmware: Option<String>, | ||||
|     #[serde(rename = "@firmware", default)] | ||||
|     pub firmware: String, | ||||
|     pub r#type: OSTypeXML, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub loader: Option<OSLoaderXML>, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub bootmenu: Option<OSBootMenuXML>, | ||||
|     pub smbios: Option<OSSMBiosXML>, | ||||
| } | ||||
|  | ||||
| @@ -55,16 +49,6 @@ pub struct OSLoaderXML { | ||||
|     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 | ||||
| #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | ||||
| #[serde(rename = "smbios")] | ||||
|   | ||||
| @@ -13,6 +13,4 @@ enum LibVirtStructError { | ||||
|     ParseFilteringChain(String), | ||||
|     #[error("NetworkFilterExtractionError: {0}")] | ||||
|     NetworkFilterExtraction(String), | ||||
|     #[error("CloudInitConfigurationError: {0}")] | ||||
|     CloudInitConfiguration(String), | ||||
| } | ||||
|   | ||||
| @@ -3,13 +3,10 @@ use crate::constants; | ||||
| use crate::libvirt_lib_structures::XMLUuid; | ||||
| use crate::libvirt_lib_structures::domain::*; | ||||
| use crate::libvirt_rest_structures::LibVirtStructError; | ||||
| use crate::libvirt_rest_structures::LibVirtStructError::{ | ||||
|     CloudInitConfiguration, StructureExtraction, | ||||
| }; | ||||
| use crate::utils::cloud_init_utils::CloudInitConfig; | ||||
| use crate::utils::file_size_utils::FileSize; | ||||
| use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction; | ||||
| use crate::utils::file_disks_utils::{DiskFormat, FileDisk}; | ||||
| use crate::utils::files_utils; | ||||
| use crate::utils::vm_file_disks_utils::{VMDiskBus, VMDiskFormat, VMFileDisk}; | ||||
| use crate::utils::files_utils::convert_size_unit_to_mb; | ||||
| use lazy_regex::regex; | ||||
| use num::Integer; | ||||
|  | ||||
| @@ -20,7 +17,6 @@ pub struct VMGroupId(pub String); | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| pub enum BootType { | ||||
|     Legacy, | ||||
|     UEFI, | ||||
|     UEFISecureBoot, | ||||
| } | ||||
| @@ -33,12 +29,6 @@ pub enum VMArchitecture { | ||||
|     X86_64, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| pub enum NetworkInterfaceModelType { | ||||
|     Virtio, | ||||
|     E1000, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| pub struct NWFilterParam { | ||||
|     name: String, | ||||
| @@ -56,7 +46,6 @@ pub struct Network { | ||||
|     #[serde(flatten)] | ||||
|     r#type: NetworkType, | ||||
|     mac: String, | ||||
|     model: NetworkInterfaceModelType, | ||||
|     nwfilterref: Option<NWFilterRef>, | ||||
| } | ||||
|  | ||||
| @@ -81,8 +70,8 @@ pub struct VMInfo { | ||||
|     pub group: Option<VMGroupId>, | ||||
|     pub boot_type: BootType, | ||||
|     pub architecture: VMArchitecture, | ||||
|     /// VM allocated RAM memory | ||||
|     pub memory: FileSize, | ||||
|     /// VM allocated memory, in megabytes | ||||
|     pub memory: usize, | ||||
|     /// Number of vCPU for the VM | ||||
|     pub number_vcpu: usize, | ||||
|     /// Enable VNC access through admin console | ||||
| @@ -90,16 +79,13 @@ pub struct VMInfo { | ||||
|     /// Attach ISO file(s) | ||||
|     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 | ||||
|     pub file_disks: Vec<VMFileDisk>, | ||||
|     pub file_disks: Vec<FileDisk>, | ||||
|     /// Network cards | ||||
|     pub networks: Vec<Network>, | ||||
|     /// Add a TPM v2.0 module | ||||
|     pub tpm_module: bool, | ||||
|     /// Strings injected as OEM Strings in SMBios configuration | ||||
|     pub oem_strings: Vec<String>, | ||||
|     /// Cloud init configuration | ||||
|     #[serde(default)] | ||||
|     pub cloud_init: CloudInitConfig, | ||||
| } | ||||
|  | ||||
| impl VMInfo { | ||||
| @@ -144,26 +130,9 @@ impl VMInfo { | ||||
|             return Err(StructureExtraction("Invalid number of vCPU specified!").into()); | ||||
|         } | ||||
|  | ||||
|         if let Some(e) = self.cloud_init.check_error() { | ||||
|             return Err(CloudInitConfiguration(e).into()); | ||||
|         } | ||||
|         let mut disks = vec![]; | ||||
|  | ||||
|         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 | ||||
|         // Add ISO files | ||||
|         for iso_file in &self.iso_files { | ||||
|             if !files_utils::check_file_name(iso_file) { | ||||
|                 return Err(StructureExtraction("ISO filename is invalid!").into()); | ||||
| @@ -175,13 +144,6 @@ impl VMInfo { | ||||
|                 return Err(StructureExtraction("Specified ISO file does not exists!").into()); | ||||
|             } | ||||
|  | ||||
|             iso_absolute_files.push(path); | ||||
|         } | ||||
|  | ||||
|         let mut disks = vec![]; | ||||
|  | ||||
|         // Add ISO disk files | ||||
|         for iso_path in iso_absolute_files { | ||||
|             disks.push(DiskXML { | ||||
|                 r#type: "file".to_string(), | ||||
|                 device: "cdrom".to_string(), | ||||
| @@ -191,7 +153,7 @@ impl VMInfo { | ||||
|                     cache: "none".to_string(), | ||||
|                 }, | ||||
|                 source: DiskSourceXML { | ||||
|                     file: iso_path.to_string_lossy().to_string(), | ||||
|                     file: path.to_string_lossy().to_string(), | ||||
|                 }, | ||||
|                 target: DiskTargetXML { | ||||
|                     dev: format!( | ||||
| @@ -208,7 +170,6 @@ impl VMInfo { | ||||
|             }) | ||||
|         } | ||||
|  | ||||
|         // Configure VNC access, if requested | ||||
|         let (vnc_graphics, vnc_video) = match self.vnc_access { | ||||
|             true => ( | ||||
|                 Some(GraphicsXML { | ||||
| @@ -235,11 +196,7 @@ impl VMInfo { | ||||
|             }; | ||||
|  | ||||
|             let model = Some(NetIntModelXML { | ||||
|                 r#type: match n.model { | ||||
|                     NetworkInterfaceModelType::Virtio => "virtio", | ||||
|                     NetworkInterfaceModelType::E1000 => "e1000", | ||||
|                 } | ||||
|                 .to_string(), | ||||
|                 r#type: "virtio".to_string(), | ||||
|             }); | ||||
|  | ||||
|             let filterref = if let Some(n) = &n.nwfilterref { | ||||
| @@ -332,8 +289,8 @@ impl VMInfo { | ||||
|                 driver: DiskDriverXML { | ||||
|                     name: "qemu".to_string(), | ||||
|                     r#type: match disk.format { | ||||
|                         VMDiskFormat::Raw { .. } => "raw".to_string(), | ||||
|                         VMDiskFormat::QCow2 => "qcow2".to_string(), | ||||
|                         DiskFormat::Raw { .. } => "raw".to_string(), | ||||
|                         DiskFormat::QCow2 => "qcow2".to_string(), | ||||
|                     }, | ||||
|                     cache: "none".to_string(), | ||||
|                 }, | ||||
| @@ -345,11 +302,7 @@ impl VMInfo { | ||||
|                         "vd{}", | ||||
|                         ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"][disks.len()] | ||||
|                     ), | ||||
|                     bus: match disk.bus { | ||||
|                         VMDiskBus::Virtio => "virtio", | ||||
|                         VMDiskBus::SATA => "sata", | ||||
|                     } | ||||
|                     .to_string(), | ||||
|                     bus: "virtio".to_string(), | ||||
|                 }, | ||||
|                 readonly: None, | ||||
|                 boot: DiskBootXML { | ||||
| @@ -371,7 +324,6 @@ impl VMInfo { | ||||
|                 virtweb: DomainMetadataVirtWebXML { | ||||
|                     ns: "https://virtweb.communiquons.org".to_string(), | ||||
|                     group: self.group.clone().map(|g| g.0), | ||||
|                     cloud_init: Some(self.cloud_init.clone()), | ||||
|                 }, | ||||
|             }), | ||||
|             os: OSXML { | ||||
| @@ -384,26 +336,13 @@ impl VMInfo { | ||||
|                     machine: "q35".to_string(), | ||||
|                     body: "hvm".to_string(), | ||||
|                 }, | ||||
|                 firmware: match self.boot_type { | ||||
|                     BootType::Legacy => None, | ||||
|                     _ => Some("efi".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, | ||||
|                 }, | ||||
|                 firmware: "efi".to_string(), | ||||
|                 loader: Some(OSLoaderXML { | ||||
|                     secure: match self.boot_type { | ||||
|                         BootType::UEFI => "no".to_string(), | ||||
|                         BootType::UEFISecureBoot => "yes".to_string(), | ||||
|                     }, | ||||
|                 }), | ||||
|                 smbios: Some(OSSMBiosXML { | ||||
|                     mode: "sysinfo".to_string(), | ||||
|                 }), | ||||
| @@ -441,7 +380,7 @@ impl VMInfo { | ||||
|  | ||||
|             memory: DomainMemoryXML { | ||||
|                 unit: "MB".to_string(), | ||||
|                 memory: self.memory.as_mb(), | ||||
|                 memory: self.memory, | ||||
|             }, | ||||
|  | ||||
|             vcpu: DomainVCPUXML { | ||||
| @@ -495,10 +434,9 @@ impl VMInfo { | ||||
|                 .virtweb | ||||
|                 .group | ||||
|                 .map(VMGroupId), | ||||
|             boot_type: match (domain.os.loader, domain.os.bootmenu) { | ||||
|                 (_, Some(_)) => BootType::Legacy, | ||||
|                 (None, _) => BootType::UEFI, | ||||
|                 (Some(l), _) => match l.secure.as_str() { | ||||
|             boot_type: match domain.os.loader { | ||||
|                 None => BootType::UEFI, | ||||
|                 Some(l) => match l.secure.as_str() { | ||||
|                     "yes" => BootType::UEFISecureBoot, | ||||
|                     _ => BootType::UEFI, | ||||
|                 }, | ||||
| @@ -514,7 +452,7 @@ impl VMInfo { | ||||
|                 } | ||||
|             }, | ||||
|             number_vcpu: domain.vcpu.body, | ||||
|             memory: FileSize::from_size_unit(&domain.memory.unit, domain.memory.memory)?, | ||||
|             memory: convert_size_unit_to_mb(&domain.memory.unit, domain.memory.memory)?, | ||||
|             vnc_access: domain.devices.graphics.is_some(), | ||||
|             iso_files: domain | ||||
|                 .devices | ||||
| @@ -522,7 +460,6 @@ impl VMInfo { | ||||
|                 .iter() | ||||
|                 .filter(|d| d.device == "cdrom") | ||||
|                 .map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string()) | ||||
|                 .filter(|d| !d.starts_with(constants::CLOUD_INIT_IMAGE_PREFIX_NAME)) | ||||
|                 .collect(), | ||||
|  | ||||
|             file_disks: domain | ||||
| @@ -530,10 +467,7 @@ impl VMInfo { | ||||
|                 .disks | ||||
|                 .iter() | ||||
|                 .filter(|d| d.device == "disk") | ||||
|                 .map(|d| { | ||||
|                     VMFileDisk::load_from_file(&d.source.file, &d.target.bus) | ||||
|                         .expect("Failed to load file disk information!") | ||||
|                 }) | ||||
|                 .map(|d| FileDisk::load_from_file(&d.source.file).unwrap()) | ||||
|                 .collect(), | ||||
|  | ||||
|             networks: domain | ||||
| @@ -581,18 +515,6 @@ 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 { | ||||
|                             name: f.filter.to_string(), | ||||
|                             parameters: f | ||||
| @@ -615,13 +537,6 @@ impl VMInfo { | ||||
|                 .and_then(|s| s.oem_strings) | ||||
|                 .map(|s| s.entries.iter().map(|o| o.content.to_string()).collect()) | ||||
|                 .unwrap_or_default(), | ||||
|             cloud_init: domain | ||||
|                 .metadata | ||||
|                 .clone() | ||||
|                 .unwrap_or_default() | ||||
|                 .virtweb | ||||
|                 .cloud_init | ||||
|                 .unwrap_or_default(), | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -13,7 +13,6 @@ use actix_web::middleware::Logger; | ||||
| use actix_web::web::Data; | ||||
| use actix_web::{App, HttpServer, web}; | ||||
| use light_openid::basic_state_manager::BasicStateManager; | ||||
| use std::cmp::max; | ||||
| use std::time::Duration; | ||||
| use virtweb_backend::actors::libvirt_actor::LibVirtActor; | ||||
| use virtweb_backend::actors::vnc_tokens_actor::VNCTokensManager; | ||||
| @@ -23,9 +22,8 @@ use virtweb_backend::constants::{ | ||||
|     MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME, | ||||
| }; | ||||
| use virtweb_backend::controllers::{ | ||||
|     api_tokens_controller, auth_controller, disk_images_controller, groups_controller, | ||||
|     iso_controller, network_controller, nwfilter_controller, server_controller, static_controller, | ||||
|     vm_controller, | ||||
|     api_tokens_controller, auth_controller, groups_controller, iso_controller, network_controller, | ||||
|     nwfilter_controller, server_controller, static_controller, vm_controller, | ||||
| }; | ||||
| use virtweb_backend::libvirt_client::LibVirtClient; | ||||
| use virtweb_backend::middlewares::auth_middleware::AuthChecker; | ||||
| @@ -47,27 +45,19 @@ async fn main() -> std::io::Result<()> { | ||||
|  | ||||
|     log::debug!("Checking for required programs"); | ||||
|     exec_utils::check_program( | ||||
|         constants::PROGRAM_QEMU_IMAGE, | ||||
|         constants::QEMU_IMAGE_PROGRAM, | ||||
|         "QEMU disk image utility is required to manipulate QCow2 files!", | ||||
|     ); | ||||
|     exec_utils::check_program( | ||||
|         constants::PROGRAM_IP, | ||||
|         constants::IP_PROGRAM, | ||||
|         "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"); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().iso_storage_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().cloud_init_disk_storage_path()) | ||||
|         .unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().disk_images_storage_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap(); | ||||
|     files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().root_vm_disks_storage_path()) | ||||
|         .unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().disks_storage_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().api_tokens_path()).unwrap(); | ||||
| @@ -128,9 +118,7 @@ async fn main() -> std::io::Result<()> { | ||||
|             })) | ||||
|             .app_data(conn.clone()) | ||||
|             // Uploaded files | ||||
|             .app_data(MultipartFormConfig::default().total_limit( | ||||
|                 max(constants::DISK_IMAGE_MAX_SIZE, constants::ISO_MAX_SIZE).as_bytes(), | ||||
|             )) | ||||
|             .app_data(MultipartFormConfig::default().total_limit(constants::ISO_MAX_SIZE)) | ||||
|             .app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir)) | ||||
|             // Server controller | ||||
|             .route( | ||||
| @@ -157,10 +145,6 @@ async fn main() -> std::io::Result<()> { | ||||
|                 "/api/server/bridges", | ||||
|                 web::get().to(server_controller::bridges_list), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/server/export_configs", | ||||
|                 web::get().to(server_controller::export_all_configs), | ||||
|             ) | ||||
|             // Auth controller | ||||
|             .route( | ||||
|                 "/api/auth/local", | ||||
| @@ -208,10 +192,6 @@ async fn main() -> std::io::Result<()> { | ||||
|                 "/api/vm/{uid}/src", | ||||
|                 web::get().to(vm_controller::get_single_src_def), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/vm/{uid}/cloud_init_disk", | ||||
|                 web::get().to(vm_controller::get_cloud_init_disk), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/vm/{uid}/autostart", | ||||
|                 web::get().to(vm_controller::get_autostart), | ||||
| @@ -349,35 +329,6 @@ async fn main() -> std::io::Result<()> { | ||||
|                 "/api/nwfilter/{uid}", | ||||
|                 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 | ||||
|             .route( | ||||
|                 "/api/token/create", | ||||
|   | ||||
| @@ -1,117 +0,0 @@ | ||||
| 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)?) | ||||
|     } | ||||
| } | ||||
| @@ -1,148 +1,169 @@ | ||||
| use crate::app_config::AppConfig; | ||||
| use crate::constants; | ||||
| use crate::utils::file_size_utils::FileSize; | ||||
| use std::fs::File; | ||||
| use crate::libvirt_lib_structures::XMLUuid; | ||||
| use crate::utils::files_utils; | ||||
| use lazy_regex::regex; | ||||
| use std::os::linux::fs::MetadataExt; | ||||
| use std::path::{Path, PathBuf}; | ||||
| use std::process::Command; | ||||
| use std::time::UNIX_EPOCH; | ||||
|  | ||||
| #[derive(thiserror::Error, Debug)] | ||||
| enum DisksError { | ||||
|     #[error("DiskParseError: {0}")] | ||||
|     Parse(&'static str), | ||||
|     #[error("DiskConfigError: {0}")] | ||||
|     Config(&'static str), | ||||
|     #[error("DiskCreateError")] | ||||
|     Create, | ||||
|     #[error("DiskConvertError: {0}")] | ||||
|     Convert(String), | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Serialize, serde::Deserialize, Copy, Clone, PartialEq, Eq)] | ||||
| /// Type of disk allocation | ||||
| #[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)] | ||||
| pub enum DiskAllocType { | ||||
|     Fixed, | ||||
|     Sparse, | ||||
| } | ||||
|  | ||||
| /// Disk allocation type | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| #[serde(tag = "format")] | ||||
| pub enum DiskFileFormat { | ||||
| pub enum DiskFormat { | ||||
|     Raw { | ||||
|         #[serde(default)] | ||||
|         is_sparse: bool, | ||||
|         /// Type of disk allocation | ||||
|         alloc_type: DiskAllocType, | ||||
|     }, | ||||
|     QCow2 { | ||||
|         #[serde(default)] | ||||
|         virtual_size: FileSize, | ||||
|     }, | ||||
|     GzCompressedRaw, | ||||
|     GzCompressedQCow2, | ||||
|     XzCompressedRaw, | ||||
|     XzCompressedQCow2, | ||||
|     QCow2, | ||||
| } | ||||
|  | ||||
| impl DiskFileFormat { | ||||
|     pub fn ext(&self) -> &'static [&'static str] { | ||||
|         match self { | ||||
|             DiskFileFormat::Raw { .. } => &["raw", ""], | ||||
|             DiskFileFormat::QCow2 { .. } => &["qcow2"], | ||||
|             DiskFileFormat::GzCompressedRaw => &["raw.gz"], | ||||
|             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)] | ||||
|     pub format: DiskFileFormat, | ||||
|     pub file_name: String, | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| pub struct FileDisk { | ||||
|     /// Disk name | ||||
|     pub name: String, | ||||
|     pub created: u64, | ||||
|     /// Disk size, in megabytes | ||||
|     pub size: usize, | ||||
|     /// Disk format | ||||
|     #[serde(flatten)] | ||||
|     pub format: DiskFormat, | ||||
|     /// Set this variable to true to delete the disk | ||||
|     pub delete: bool, | ||||
| } | ||||
|  | ||||
| impl DiskFileInfo { | ||||
|     /// Get disk image file information | ||||
|     pub fn load_file(file: &Path) -> anyhow::Result<Self> { | ||||
| impl FileDisk { | ||||
|     pub fn load_from_file(path: &str) -> anyhow::Result<Self> { | ||||
|         let file = Path::new(path); | ||||
|  | ||||
|         if !file.is_file() { | ||||
|             return Err(DisksError::Parse("Path is not a file!").into()); | ||||
|         } | ||||
|  | ||||
|         // Get file metadata | ||||
|         let metadata = file.metadata()?; | ||||
|         let mut name = file | ||||
|             .file_stem() | ||||
|             .and_then(|s| s.to_str()) | ||||
|             .unwrap_or("disk") | ||||
|             .to_string(); | ||||
|         let name = file.file_stem().and_then(|s| s.to_str()).unwrap_or("disk"); | ||||
|         let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("raw"); | ||||
|  | ||||
|         // Determine file format | ||||
|         // Approximate raw file estimation | ||||
|         let is_raw_sparse = metadata.len() / 512 >= metadata.st_blocks(); | ||||
|  | ||||
|         let format = match ext { | ||||
|             "qcow2" => DiskFileFormat::QCow2 { | ||||
|                 virtual_size: qcow_virt_size(file)?, | ||||
|             "qcow2" => DiskFormat::QCow2, | ||||
|             "raw" => DiskFormat::Raw { | ||||
|                 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}!"), | ||||
|         }; | ||||
|  | ||||
|         Ok(Self { | ||||
|             file_path: file.to_path_buf(), | ||||
|             name, | ||||
|             file_size: FileSize::from_bytes(metadata.len() as usize), | ||||
|             name: name.to_string(), | ||||
|             size: match format { | ||||
|                 DiskFormat::Raw { .. } => metadata.len() as usize / (1000 * 1000), | ||||
|                 DiskFormat::QCow2 => qcow_virt_size(path)? / (1000 * 1000), | ||||
|             }, | ||||
|             format, | ||||
|             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(), | ||||
|             delete: false, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     /// Create a new empty disk | ||||
|     pub fn create(file: &Path, format: DiskFileFormat, size: FileSize) -> anyhow::Result<()> { | ||||
|     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(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 | ||||
|         let res = match format { | ||||
|             DiskFileFormat::Raw { is_sparse } => { | ||||
|         let res = match self.format { | ||||
|             DiskFormat::Raw { alloc_type } => { | ||||
|                 let mut cmd = Command::new("/usr/bin/dd"); | ||||
|                 cmd.arg("if=/dev/zero") | ||||
|                     .arg(format!("of={}", file.to_string_lossy())) | ||||
|                     .arg("bs=1M"); | ||||
|  | ||||
|                 match is_sparse { | ||||
|                     false => cmd.arg(format!("count={}", size.as_mb())), | ||||
|                     true => cmd.arg(format!("seek={}", size.as_mb())).arg("count=0"), | ||||
|                 match alloc_type { | ||||
|                     DiskAllocType::Fixed => cmd.arg(format!("count={}", self.size)), | ||||
|                     DiskAllocType::Sparse => cmd.arg(format!("seek={}", self.size)).arg("count=0"), | ||||
|                 }; | ||||
|  | ||||
|                 cmd.output()? | ||||
|             } | ||||
|  | ||||
|             DiskFileFormat::QCow2 { virtual_size } => { | ||||
|                 let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); | ||||
|             DiskFormat::QCow2 => { | ||||
|                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|                 cmd.arg("create") | ||||
|                     .arg("-f") | ||||
|                     .arg("qcow2") | ||||
|                     .arg(file) | ||||
|                     .arg(format!("{}M", virtual_size.as_mb())); | ||||
|                     .arg(format!("{}M", self.size)); | ||||
|  | ||||
|                 cmd.output()? | ||||
|             } | ||||
|             _ => anyhow::bail!("Cannot create disk file image of this format: {format:?}!"), | ||||
|         }; | ||||
|  | ||||
|         // Execute Linux command | ||||
| @@ -157,281 +178,6 @@ impl DiskFileInfo { | ||||
|  | ||||
|         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)] | ||||
| @@ -441,21 +187,15 @@ struct QCowInfoOutput { | ||||
| } | ||||
|  | ||||
| /// Get QCow2 virtual size | ||||
| fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> { | ||||
| fn qcow_virt_size(path: &str) -> anyhow::Result<usize> { | ||||
|     // Run qemu-img | ||||
|     let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); | ||||
|     cmd.args([ | ||||
|         "info", | ||||
|         path.to_str().unwrap_or(""), | ||||
|         "--output", | ||||
|         "json", | ||||
|         "--force-share", | ||||
|     ]); | ||||
|     let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||
|     cmd.args(["info", path, "--output", "json", "--force-share"]); | ||||
|     let output = cmd.output()?; | ||||
|     if !output.status.success() { | ||||
|         anyhow::bail!( | ||||
|             "{} info failed, status: {}, stderr: {}", | ||||
|             constants::PROGRAM_QEMU_IMAGE, | ||||
|             constants::QEMU_IMAGE_PROGRAM, | ||||
|             output.status, | ||||
|             String::from_utf8_lossy(&output.stderr) | ||||
|         ); | ||||
| @@ -464,5 +204,5 @@ fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> { | ||||
|  | ||||
|     // Decode JSON | ||||
|     let decoded: QCowInfoOutput = serde_json::from_str(&res_json)?; | ||||
|     Ok(FileSize::from_bytes(decoded.virtual_size)) | ||||
|     Ok(decoded.virtual_size) | ||||
| } | ||||
|   | ||||
| @@ -1,91 +0,0 @@ | ||||
| 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,6 +1,13 @@ | ||||
| use std::ops::{Div, Mul}; | ||||
| use std::os::unix::fs::PermissionsExt; | ||||
| use std::path::Path; | ||||
|  | ||||
| #[derive(thiserror::Error, Debug)] | ||||
| enum FilesUtilsError { | ||||
|     #[error("UnitConvertError: {0}")] | ||||
|     UnitConvert(String), | ||||
| } | ||||
|  | ||||
| const INVALID_CHARS: [&str; 19] = [ | ||||
|     "@", "\\", "/", ":", ",", "<", ">", "%", "'", "\"", "?", "{", "}", "$", "*", "|", ";", "=", | ||||
|     "\t", | ||||
| @@ -28,9 +35,31 @@ pub fn set_file_permission<P: AsRef<Path>>(path: P, mode: u32) -> anyhow::Result | ||||
|     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)] | ||||
| mod test { | ||||
|     use crate::utils::files_utils::check_file_name; | ||||
|     use crate::utils::files_utils::{check_file_name, convert_size_unit_to_mb}; | ||||
|  | ||||
|     #[test] | ||||
|     fn empty_file_name() { | ||||
| @@ -56,4 +85,14 @@ mod test { | ||||
|     fn valid_file_name() { | ||||
|         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,10 +1,7 @@ | ||||
| pub mod cloud_init_utils; | ||||
| pub mod exec_utils; | ||||
| pub mod file_disks_utils; | ||||
| pub mod file_size_utils; | ||||
| pub mod files_utils; | ||||
| pub mod net_utils; | ||||
| pub mod rand_utils; | ||||
| pub mod time_utils; | ||||
| pub mod url_utils; | ||||
| pub mod vm_file_disks_utils; | ||||
|   | ||||
| @@ -145,13 +145,13 @@ struct IPBridgeInfo { | ||||
|  | ||||
| /// Get the list of bridge interfaces | ||||
| pub fn bridges_list() -> anyhow::Result<Vec<String>> { | ||||
|     let mut cmd = Command::new(constants::PROGRAM_IP); | ||||
|     let mut cmd = Command::new(constants::IP_PROGRAM); | ||||
|     cmd.args(["-json", "link", "show", "type", "bridge"]); | ||||
|     let output = cmd.output()?; | ||||
|     if !output.status.success() { | ||||
|         anyhow::bail!( | ||||
|             "{} failed, status: {}, stderr: {}", | ||||
|             constants::PROGRAM_IP, | ||||
|             constants::IP_PROGRAM, | ||||
|             output.status, | ||||
|             String::from_utf8_lossy(&output.stderr) | ||||
|         ); | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| use chrono::Datelike; | ||||
| use std::time::{SystemTime, UNIX_EPOCH}; | ||||
|  | ||||
| /// Get the current time since epoch | ||||
| @@ -14,15 +13,3 @@ pub fn time() -> u64 { | ||||
|         .unwrap() | ||||
|         .as_secs() | ||||
| } | ||||
|  | ||||
| /// Format given UNIX time in a simple format | ||||
| pub fn format_date(time: i64) -> anyhow::Result<String> { | ||||
|     let date = chrono::DateTime::from_timestamp(time, 0).ok_or(anyhow::anyhow!("invalid date"))?; | ||||
|  | ||||
|     Ok(format!( | ||||
|         "{:0>2}/{:0>2}/{}", | ||||
|         date.day(), | ||||
|         date.month(), | ||||
|         date.year() | ||||
|     )) | ||||
| } | ||||
|   | ||||
| @@ -1,190 +0,0 @@ | ||||
| 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(()) | ||||
|     } | ||||
| } | ||||
| @@ -5,9 +5,9 @@ | ||||
| sudo apt install libvirt-dev | ||||
| ``` | ||||
|  | ||||
| 2. Libvirt and cloud image utilities must also be installed: | ||||
| 2. Libvirt must also be installed: | ||||
| ```bash | ||||
| sudo apt install qemu-kvm libvirt-daemon-system cloud-image-utils | ||||
| sudo apt install qemu-kvm libvirt-daemon-system | ||||
| ``` | ||||
|  | ||||
| 3. Allow the current user to manage VMs: | ||||
| @@ -34,7 +34,7 @@ docker compose up | ||||
| sudo mkdir /var/virtweb | ||||
| sudo chown $USER:$USER /var/virtweb | ||||
| cd virtweb_backend | ||||
| cargo fmt && cargo clippy && cargo run -- -s /var/virtweb --hypervisor-uri "qemu:///system" --website-origin "http://localhost:5173" | ||||
| cargo fmt && cargo clippy && cargo run -- -s /var/virtweb --hypervisor-uri "qemu:///system" | ||||
| ``` | ||||
|  | ||||
| 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. | ||||
|  | ||||
| ## Install requirements | ||||
| In order to work properly, VirtWeb relies on `libvirt`, `qemu`, `kvm` and `cloud-localds`: | ||||
| In order to work properly, VirtWeb relies on `libvirt`, `qemu` and `kvm`: | ||||
|  | ||||
| ```bash | ||||
| sudo apt install qemu-kvm libvirt-daemon-system libvirt0 libvirt-clients libvirt-daemon bridge-utils cloud-image-utils | ||||
| sudo apt install qemu-kvm libvirt-daemon-system libvirt0 libvirt-clients libvirt-daemon bridge-utils | ||||
| ``` | ||||
|  | ||||
| ## Dedicated user | ||||
|   | ||||
							
								
								
									
										298
									
								
								virtweb_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										298
									
								
								virtweb_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -10,40 +10,36 @@ | ||||
|       "dependencies": { | ||||
|         "@emotion/react": "^11.14.0", | ||||
|         "@emotion/styled": "^11.14.0", | ||||
|         "@fontsource/roboto": "^5.2.6", | ||||
|         "@fontsource/roboto": "^5.2.5", | ||||
|         "@mdi/js": "^7.4.47", | ||||
|         "@mdi/react": "^1.6.1", | ||||
|         "@monaco-editor/react": "^4.7.0", | ||||
|         "@mui/icons-material": "^7.1.1", | ||||
|         "@mui/material": "^7.1.1", | ||||
|         "@mui/icons-material": "^7.1.0", | ||||
|         "@mui/material": "^7.1.0", | ||||
|         "@mui/x-charts": "^8.3.1", | ||||
|         "@mui/x-data-grid": "^8.3.1", | ||||
|         "date-and-time": "^3.6.0", | ||||
|         "filesize": "^10.1.6", | ||||
|         "humanize-duration": "^3.32.2", | ||||
|         "monaco-editor": "^0.52.2", | ||||
|         "monaco-yaml": "^5.4.0", | ||||
|         "react": "^19.1.0", | ||||
|         "react-dom": "^19.1.0", | ||||
|         "react-router-dom": "^7.6.2", | ||||
|         "react-router-dom": "^7.6.0", | ||||
|         "react-syntax-highlighter": "^15.6.1", | ||||
|         "react-vnc": "^3.1.0", | ||||
|         "uuid": "^11.1.0", | ||||
|         "xml-formatter": "^3.6.6", | ||||
|         "yaml": "^2.8.0" | ||||
|         "xml-formatter": "^3.6.6" | ||||
|       }, | ||||
|       "devDependencies": { | ||||
|         "@eslint/js": "^9.27.0", | ||||
|         "@types/humanize-duration": "^3.27.4", | ||||
|         "@types/jest": "^29.5.14", | ||||
|         "@types/react": "^19.1.8", | ||||
|         "@types/react-dom": "^19.1.6", | ||||
|         "@types/react": "^19.1.5", | ||||
|         "@types/react-dom": "^19.1.5", | ||||
|         "@types/react-syntax-highlighter": "^15.5.13", | ||||
|         "@types/uuid": "^10.0.0", | ||||
|         "@vitejs/plugin-react": "^4.4.1", | ||||
|         "eslint": "^9.27.0", | ||||
|         "eslint-plugin-react-dom": "^1.49.0", | ||||
|         "eslint-plugin-react-hooks": "^5.2.0", | ||||
|         "eslint-plugin-react-hooks": "^5.1.0", | ||||
|         "eslint-plugin-react-refresh": "^0.4.20", | ||||
|         "eslint-plugin-react-x": "^1.49.0", | ||||
|         "globals": "^16.1.0", | ||||
| @@ -781,9 +777,9 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@fontsource/roboto": { | ||||
|       "version": "5.2.6", | ||||
|       "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.6.tgz", | ||||
|       "integrity": "sha512-hzarG7yAhMoP418smNgfY4fO7UmuUEm5JUtbxCoCcFHT0hOJB+d/qAEyoNjz7YkPU5OjM2LM8rJnW8hfm0JLaA==", | ||||
|       "version": "5.2.5", | ||||
|       "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.5.tgz", | ||||
|       "integrity": "sha512-70r2UZ0raqLn5W+sPeKhqlf8wGvUXFWlofaDlcbt/S3d06+17gXKr3VNqDODB0I1ASme3dGT5OJj9NABt7OTZQ==", | ||||
|       "license": "OFL-1.1", | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/ayuhito" | ||||
| @@ -962,33 +958,10 @@ | ||||
|         "prop-types": "^15.7.2" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@monaco-editor/loader": { | ||||
|       "version": "1.5.0", | ||||
|       "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz", | ||||
|       "integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "state-local": "^1.0.6" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@monaco-editor/react": { | ||||
|       "version": "4.7.0", | ||||
|       "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", | ||||
|       "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@monaco-editor/loader": "^1.5.0" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "monaco-editor": ">= 0.25.0 < 1", | ||||
|         "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", | ||||
|         "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/core-downloads-tracker": { | ||||
|       "version": "7.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.1.1.tgz", | ||||
|       "integrity": "sha512-yBckQs4aQ8mqukLnPC6ivIRv6guhaXi8snVl00VtyojBbm+l6VbVhyTSZ68Abcx7Ah8B+GZhrB7BOli+e+9LkQ==", | ||||
|       "version": "7.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.1.0.tgz", | ||||
|       "integrity": "sha512-E0OqhZv548Qdc0PwWhLVA2zmjJZSTvaL4ZhoswmI8NJEC1tpW2js6LLP827jrW9MEiXYdz3QS6+hask83w74yQ==", | ||||
|       "license": "MIT", | ||||
|       "funding": { | ||||
|         "type": "opencollective", | ||||
| @@ -996,9 +969,9 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/icons-material": { | ||||
|       "version": "7.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.1.1.tgz", | ||||
|       "integrity": "sha512-X37+Yc8QpEnl0sYmz+WcLFy2dWgNRzbswDzLPXG7QU1XDVlP5TPp1HXjdmCupOWLL/I9m1fyhcyZl8/HPpp/Cg==", | ||||
|       "version": "7.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.1.0.tgz", | ||||
|       "integrity": "sha512-1mUPMAZ+Qk3jfgL5ftRR06ATH/Esi0izHl1z56H+df6cwIlCWG66RXciUqeJCttbOXOQ5y2DCjLZI/4t3Yg3LA==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1" | ||||
| @@ -1011,7 +984,7 @@ | ||||
|         "url": "https://opencollective.com/mui-org" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "@mui/material": "^7.1.1", | ||||
|         "@mui/material": "^7.1.0", | ||||
|         "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", | ||||
|         "react": "^17.0.0 || ^18.0.0 || ^19.0.0" | ||||
|       }, | ||||
| @@ -1022,16 +995,16 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/material": { | ||||
|       "version": "7.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.1.1.tgz", | ||||
|       "integrity": "sha512-mTpdmdZCaHCGOH3SrYM41+XKvNL0iQfM9KlYgpSjgadXx/fEKhhvOktxm8++Xw6FFeOHoOiV+lzOI8X1rsv71A==", | ||||
|       "version": "7.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.1.0.tgz", | ||||
|       "integrity": "sha512-ahUJdrhEv+mCp4XHW+tHIEYzZMSRLg8z4AjUOsj44QpD1ZaMxQoVOG2xiHvLFdcsIPbgSRx1bg1eQSheHBgvtg==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1", | ||||
|         "@mui/core-downloads-tracker": "^7.1.1", | ||||
|         "@mui/system": "^7.1.1", | ||||
|         "@mui/types": "^7.4.3", | ||||
|         "@mui/utils": "^7.1.1", | ||||
|         "@mui/core-downloads-tracker": "^7.1.0", | ||||
|         "@mui/system": "^7.1.0", | ||||
|         "@mui/types": "^7.4.2", | ||||
|         "@mui/utils": "^7.1.0", | ||||
|         "@popperjs/core": "^2.11.8", | ||||
|         "@types/react-transition-group": "^4.4.12", | ||||
|         "clsx": "^2.1.1", | ||||
| @@ -1050,7 +1023,7 @@ | ||||
|       "peerDependencies": { | ||||
|         "@emotion/react": "^11.5.0", | ||||
|         "@emotion/styled": "^11.3.0", | ||||
|         "@mui/material-pigment-css": "^7.1.1", | ||||
|         "@mui/material-pigment-css": "^7.1.0", | ||||
|         "@types/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" | ||||
| @@ -1071,13 +1044,13 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/private-theming": { | ||||
|       "version": "7.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.1.1.tgz", | ||||
|       "integrity": "sha512-M8NbLUx+armk2ZuaxBkkMk11ultnWmrPlN0Xe3jUEaBChg/mcxa5HWIWS1EE4DF36WRACaAHVAvyekWlDQf0PQ==", | ||||
|       "version": "7.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.1.0.tgz", | ||||
|       "integrity": "sha512-4Kck4jxhqF6YxNwJdSae1WgDfXVg0lIH6JVJ7gtuFfuKcQCgomJxPvUEOySTFRPz1IZzwz5OAcToskRdffElDA==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1", | ||||
|         "@mui/utils": "^7.1.1", | ||||
|         "@mui/utils": "^7.1.0", | ||||
|         "prop-types": "^15.8.1" | ||||
|       }, | ||||
|       "engines": { | ||||
| @@ -1098,9 +1071,9 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/styled-engine": { | ||||
|       "version": "7.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.1.1.tgz", | ||||
|       "integrity": "sha512-R2wpzmSN127j26HrCPYVQ53vvMcT5DaKLoWkrfwUYq3cYytL6TQrCH8JBH3z79B6g4nMZZVoaXrxO757AlShaw==", | ||||
|       "version": "7.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.1.0.tgz", | ||||
|       "integrity": "sha512-m0mJ0c6iRC+f9hMeRe0W7zZX1wme3oUX0+XTVHjPG7DJz6OdQ6K/ggEOq7ZdwilcpdsDUwwMfOmvO71qDkYd2w==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1", | ||||
| @@ -1132,16 +1105,16 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/system": { | ||||
|       "version": "7.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.1.1.tgz", | ||||
|       "integrity": "sha512-Kj1uhiqnj4Zo7PDjAOghtXJtNABunWvhcRU0O7RQJ7WOxeynoH6wXPcilphV8QTFtkKaip8EiNJRiCD+B3eROA==", | ||||
|       "version": "7.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.1.0.tgz", | ||||
|       "integrity": "sha512-iedAWgRJMCxeMHvkEhsDlbvkK+qKf9me6ofsf7twk/jfT4P1ImVf7Rwb5VubEA0sikrVL+1SkoZM41M4+LNAVA==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1", | ||||
|         "@mui/private-theming": "^7.1.1", | ||||
|         "@mui/styled-engine": "^7.1.1", | ||||
|         "@mui/types": "^7.4.3", | ||||
|         "@mui/utils": "^7.1.1", | ||||
|         "@mui/private-theming": "^7.1.0", | ||||
|         "@mui/styled-engine": "^7.1.0", | ||||
|         "@mui/types": "^7.4.2", | ||||
|         "@mui/utils": "^7.1.0", | ||||
|         "clsx": "^2.1.1", | ||||
|         "csstype": "^3.1.3", | ||||
|         "prop-types": "^15.8.1" | ||||
| @@ -1172,9 +1145,9 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/types": { | ||||
|       "version": "7.4.3", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.3.tgz", | ||||
|       "integrity": "sha512-2UCEiK29vtiZTeLdS2d4GndBKacVyxGvReznGXGr+CzW/YhjIX+OHUdCIczZjzcRAgKBGmE9zCIgoV9FleuyRQ==", | ||||
|       "version": "7.4.2", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.2.tgz", | ||||
|       "integrity": "sha512-edRc5JcLPsrlNFYyTPxds+d5oUovuUxnnDtpJUbP6WMeV4+6eaX/mqai1ZIWT62lCOe0nlrON0s9HDiv5en5bA==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1" | ||||
| @@ -1189,13 +1162,13 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/utils": { | ||||
|       "version": "7.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.1.tgz", | ||||
|       "integrity": "sha512-BkOt2q7MBYl7pweY2JWwfrlahhp+uGLR8S+EhiyRaofeRYUWL2YKbSGQvN4hgSN1i8poN0PaUiii1kEMrchvzg==", | ||||
|       "version": "7.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.0.tgz", | ||||
|       "integrity": "sha512-/OM3S8kSHHmWNOP+NH9xEtpYSG10upXeQ0wLZnfDgmgadTAk5F4MQfFLyZ5FCRJENB3eRzltMmaNl6UtDnPovw==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.27.1", | ||||
|         "@mui/types": "^7.4.3", | ||||
|         "@mui/types": "^7.4.2", | ||||
|         "@types/prop-types": "^15.7.14", | ||||
|         "clsx": "^2.1.1", | ||||
|         "prop-types": "^15.8.1", | ||||
| @@ -1625,18 +1598,18 @@ | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@types/react": { | ||||
|       "version": "19.1.8", | ||||
|       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", | ||||
|       "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", | ||||
|       "version": "19.1.5", | ||||
|       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.5.tgz", | ||||
|       "integrity": "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "csstype": "^3.0.2" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@types/react-dom": { | ||||
|       "version": "19.1.6", | ||||
|       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", | ||||
|       "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", | ||||
|       "version": "19.1.5", | ||||
|       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz", | ||||
|       "integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==", | ||||
|       "dev": true, | ||||
|       "license": "MIT", | ||||
|       "peerDependencies": { | ||||
| @@ -3508,12 +3481,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": { | ||||
|       "version": "4.5.4", | ||||
|       "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", | ||||
| @@ -3640,84 +3607,6 @@ | ||||
|         "node": "*" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/monaco-editor": { | ||||
|       "version": "0.52.2", | ||||
|       "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", | ||||
|       "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/monaco-languageserver-types": { | ||||
|       "version": "0.4.0", | ||||
|       "resolved": "https://registry.npmjs.org/monaco-languageserver-types/-/monaco-languageserver-types-0.4.0.tgz", | ||||
|       "integrity": "sha512-QQ3BZiU5LYkJElGncSNb5AKoJ/LCs6YBMCJMAz9EA7v+JaOdn3kx2cXpPTcZfKA5AEsR0vc97sAw+5mdNhVBmw==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "monaco-types": "^0.1.0", | ||||
|         "vscode-languageserver-protocol": "^3.0.0", | ||||
|         "vscode-uri": "^3.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/remcohaszing" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/monaco-marker-data-provider": { | ||||
|       "version": "1.2.4", | ||||
|       "resolved": "https://registry.npmjs.org/monaco-marker-data-provider/-/monaco-marker-data-provider-1.2.4.tgz", | ||||
|       "integrity": "sha512-4DsPgsAqpTyUDs3humXRBPUJoihTv+L6v9aupQWD80X2YXaCXUd11mWYeSCYHuPgdUmjFaNWCEOjQ6ewf/QA1Q==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "monaco-types": "^0.1.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/remcohaszing" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/monaco-types": { | ||||
|       "version": "0.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/monaco-types/-/monaco-types-0.1.0.tgz", | ||||
|       "integrity": "sha512-aWK7SN9hAqNYi0WosPoMjenMeXJjwCxDibOqWffyQ/qXdzB/86xshGQobRferfmNz7BSNQ8GB0MD0oby9/5fTQ==", | ||||
|       "license": "MIT", | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/remcohaszing" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/monaco-worker-manager": { | ||||
|       "version": "2.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/monaco-worker-manager/-/monaco-worker-manager-2.0.1.tgz", | ||||
|       "integrity": "sha512-kdPL0yvg5qjhKPNVjJoym331PY/5JC11aPJXtCZNwWRvBr6jhkIamvYAyiY5P1AWFmNOy0aRDRoMdZfa71h8kg==", | ||||
|       "license": "MIT", | ||||
|       "peerDependencies": { | ||||
|         "monaco-editor": ">=0.30.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/monaco-yaml": { | ||||
|       "version": "5.4.0", | ||||
|       "resolved": "https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.4.0.tgz", | ||||
|       "integrity": "sha512-tuBVDy1KAPrgO905GHTItu8AaA5bIzF5S4X0JVRAE/D66FpRhkDUk7tKi5bwKMVTTugtpMLsXN4ewh4CgE/FtQ==", | ||||
|       "license": "MIT", | ||||
|       "workspaces": [ | ||||
|         "examples/*" | ||||
|       ], | ||||
|       "dependencies": { | ||||
|         "jsonc-parser": "^3.0.0", | ||||
|         "monaco-languageserver-types": "^0.4.0", | ||||
|         "monaco-marker-data-provider": "^1.0.0", | ||||
|         "monaco-types": "^0.1.0", | ||||
|         "monaco-worker-manager": "^2.0.0", | ||||
|         "path-browserify": "^1.0.0", | ||||
|         "prettier": "^3.0.0", | ||||
|         "vscode-languageserver-textdocument": "^1.0.0", | ||||
|         "vscode-languageserver-types": "^3.0.0", | ||||
|         "vscode-uri": "^3.0.0", | ||||
|         "yaml": "^2.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/remcohaszing" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "monaco-editor": ">=0.36" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/ms": { | ||||
|       "version": "2.1.3", | ||||
|       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", | ||||
| @@ -3864,12 +3753,6 @@ | ||||
|         "url": "https://github.com/sponsors/sindresorhus" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/path-browserify": { | ||||
|       "version": "1.0.1", | ||||
|       "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", | ||||
|       "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/path-exists": { | ||||
|       "version": "4.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", | ||||
| @@ -3963,21 +3846,6 @@ | ||||
|         "node": ">= 0.8.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/prettier": { | ||||
|       "version": "3.5.3", | ||||
|       "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", | ||||
|       "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", | ||||
|       "license": "MIT", | ||||
|       "bin": { | ||||
|         "prettier": "bin/prettier.cjs" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=14" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/prettier/prettier?sponsor=1" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/pretty-format": { | ||||
|       "version": "29.7.0", | ||||
|       "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", | ||||
| @@ -4121,9 +3989,9 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/react-router": { | ||||
|       "version": "7.6.2", | ||||
|       "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.2.tgz", | ||||
|       "integrity": "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w==", | ||||
|       "version": "7.6.0", | ||||
|       "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz", | ||||
|       "integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "cookie": "^1.0.1", | ||||
| @@ -4143,12 +4011,12 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/react-router-dom": { | ||||
|       "version": "7.6.2", | ||||
|       "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.2.tgz", | ||||
|       "integrity": "sha512-Q8zb6VlTbdYKK5JJBLQEN06oTUa/RAbG/oQS1auK1I0TbJOXktqm+QENEVJU6QvWynlXPRBXI3fiOQcSEA78rA==", | ||||
|       "version": "7.6.0", | ||||
|       "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.0.tgz", | ||||
|       "integrity": "sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "react-router": "7.6.2" | ||||
|         "react-router": "7.6.0" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=20.0.0" | ||||
| @@ -4458,12 +4326,6 @@ | ||||
|         "node": ">=8" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/state-local": { | ||||
|       "version": "1.0.7", | ||||
|       "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", | ||||
|       "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/string-ts": { | ||||
|       "version": "2.2.1", | ||||
|       "resolved": "https://registry.npmjs.org/string-ts/-/string-ts-2.2.1.tgz", | ||||
| @@ -4852,43 +4714,6 @@ | ||||
|         "url": "https://github.com/sponsors/jonschlinkert" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/vscode-jsonrpc": { | ||||
|       "version": "8.2.0", | ||||
|       "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", | ||||
|       "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", | ||||
|       "license": "MIT", | ||||
|       "engines": { | ||||
|         "node": ">=14.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/vscode-languageserver-protocol": { | ||||
|       "version": "3.17.5", | ||||
|       "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", | ||||
|       "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "vscode-jsonrpc": "8.2.0", | ||||
|         "vscode-languageserver-types": "3.17.5" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/vscode-languageserver-textdocument": { | ||||
|       "version": "1.0.12", | ||||
|       "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", | ||||
|       "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/vscode-languageserver-types": { | ||||
|       "version": "3.17.5", | ||||
|       "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", | ||||
|       "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/vscode-uri": { | ||||
|       "version": "3.1.0", | ||||
|       "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", | ||||
|       "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/which": { | ||||
|       "version": "2.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", | ||||
| @@ -4956,7 +4781,10 @@ | ||||
|       "version": "2.8.0", | ||||
|       "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", | ||||
|       "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", | ||||
|       "dev": true, | ||||
|       "license": "ISC", | ||||
|       "optional": true, | ||||
|       "peer": true, | ||||
|       "bin": { | ||||
|         "yaml": "bin.mjs" | ||||
|       }, | ||||
|   | ||||
| @@ -12,40 +12,36 @@ | ||||
|   "dependencies": { | ||||
|     "@emotion/react": "^11.14.0", | ||||
|     "@emotion/styled": "^11.14.0", | ||||
|     "@fontsource/roboto": "^5.2.6", | ||||
|     "@fontsource/roboto": "^5.2.5", | ||||
|     "@mdi/js": "^7.4.47", | ||||
|     "@mdi/react": "^1.6.1", | ||||
|     "@monaco-editor/react": "^4.7.0", | ||||
|     "@mui/icons-material": "^7.1.1", | ||||
|     "@mui/material": "^7.1.1", | ||||
|     "@mui/icons-material": "^7.1.0", | ||||
|     "@mui/material": "^7.1.0", | ||||
|     "@mui/x-charts": "^8.3.1", | ||||
|     "@mui/x-data-grid": "^8.3.1", | ||||
|     "date-and-time": "^3.6.0", | ||||
|     "filesize": "^10.1.6", | ||||
|     "humanize-duration": "^3.32.2", | ||||
|     "monaco-editor": "^0.52.2", | ||||
|     "monaco-yaml": "^5.4.0", | ||||
|     "react": "^19.1.0", | ||||
|     "react-dom": "^19.1.0", | ||||
|     "react-router-dom": "^7.6.2", | ||||
|     "react-router-dom": "^7.6.0", | ||||
|     "react-syntax-highlighter": "^15.6.1", | ||||
|     "react-vnc": "^3.1.0", | ||||
|     "uuid": "^11.1.0", | ||||
|     "xml-formatter": "^3.6.6", | ||||
|     "yaml": "^2.8.0" | ||||
|     "xml-formatter": "^3.6.6" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@eslint/js": "^9.27.0", | ||||
|     "@types/humanize-duration": "^3.27.4", | ||||
|     "@types/jest": "^29.5.14", | ||||
|     "@types/react": "^19.1.8", | ||||
|     "@types/react-dom": "^19.1.6", | ||||
|     "@types/react": "^19.1.5", | ||||
|     "@types/react-dom": "^19.1.5", | ||||
|     "@types/react-syntax-highlighter": "^15.5.13", | ||||
|     "@types/uuid": "^10.0.0", | ||||
|     "@vitejs/plugin-react": "^4.4.1", | ||||
|     "eslint": "^9.27.0", | ||||
|     "eslint-plugin-react-dom": "^1.49.0", | ||||
|     "eslint-plugin-react-hooks": "^5.2.0", | ||||
|     "eslint-plugin-react-hooks": "^5.1.0", | ||||
|     "eslint-plugin-react-refresh": "^0.4.20", | ||||
|     "eslint-plugin-react-x": "^1.49.0", | ||||
|     "globals": "^16.1.0", | ||||
|   | ||||
| @@ -38,7 +38,6 @@ import { LoginRoute } from "./routes/auth/LoginRoute"; | ||||
| import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute"; | ||||
| import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; | ||||
| import { BaseLoginPage } from "./widgets/BaseLoginPage"; | ||||
| import { DiskImagesRoute } from "./routes/DiskImagesRoute"; | ||||
|  | ||||
| interface AuthContext { | ||||
|   signedIn: boolean; | ||||
| @@ -64,8 +63,6 @@ export function App() { | ||||
|         <Route path="*" element={<BaseAuthenticatedPage />}> | ||||
|           <Route path="" element={<HomeRoute />} /> | ||||
|  | ||||
|           <Route path="disk_images" element={<DiskImagesRoute />} /> | ||||
|  | ||||
|           <Route path="iso" element={<IsoFilesRoute />} /> | ||||
|  | ||||
|           <Route path="vms" element={<VMListRoute />} /> | ||||
|   | ||||
| @@ -103,7 +103,6 @@ export class APIClient { | ||||
|         body: body, | ||||
|         headers: headers, | ||||
|         credentials: "include", | ||||
|         signal: AbortSignal.timeout(50 * 1000 * 1000), | ||||
|       }); | ||||
|  | ||||
|       // Process response | ||||
|   | ||||
| @@ -1,119 +0,0 @@ | ||||
| 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,7 +5,6 @@ export interface ServerConfig { | ||||
|   local_auth_enabled: boolean; | ||||
|   oidc_auth_enabled: boolean; | ||||
|   iso_mimetypes: string[]; | ||||
|   disk_images_mimetypes: string[]; | ||||
|   net_mac_prefix: string; | ||||
|   builtin_nwfilter_rules: string[]; | ||||
|   nwfilter_chains: string[]; | ||||
| @@ -14,7 +13,6 @@ export interface ServerConfig { | ||||
|  | ||||
| export interface ServerConstraints { | ||||
|   iso_max_size: number; | ||||
|   disk_image_max_size: number; | ||||
|   vnc_token_duration: number; | ||||
|   vm_name_size: LenConstraint; | ||||
|   vm_title_size: LenConstraint; | ||||
| @@ -22,7 +20,6 @@ export interface ServerConstraints { | ||||
|   memory_size: LenConstraint; | ||||
|   disk_name_size: LenConstraint; | ||||
|   disk_size: LenConstraint; | ||||
|   disk_image_name_size: LenConstraint; | ||||
|   net_name_size: LenConstraint; | ||||
|   net_title_size: LenConstraint; | ||||
|   net_nat_comment_size: LenConstraint; | ||||
| @@ -232,16 +229,4 @@ export class ServerApi { | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Export all server configs | ||||
|    */ | ||||
|   static async ExportServerConfigs(): Promise<Blob> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: "/server/export_configs", | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -19,30 +19,21 @@ export type VMState = | ||||
|  | ||||
| export type VMFileDisk = BaseFileVMDisk & (RawVMDisk | QCow2Disk); | ||||
|  | ||||
| export type DiskBusType = "Virtio" | "SATA"; | ||||
|  | ||||
| export interface BaseFileVMDisk { | ||||
|   size: number; | ||||
|   name: string; | ||||
|   bus: DiskBusType; | ||||
|  | ||||
|   delete: boolean; | ||||
|  | ||||
|   // For new disk only | ||||
|   from_image?: string; | ||||
|  | ||||
|   // Resize disk image after clone | ||||
|   resize?: boolean; | ||||
|  | ||||
|   // application attributes | ||||
|   // application attribute | ||||
|   new?: boolean; | ||||
|   originalSize?: number; | ||||
|   deleteType?: "keepfile" | "deletefile"; | ||||
| } | ||||
|  | ||||
| export type DiskAllocType = "Sparse" | "Fixed"; | ||||
|  | ||||
| interface RawVMDisk { | ||||
|   format: "Raw"; | ||||
|   is_sparse: boolean; | ||||
|   alloc_type: DiskAllocType; | ||||
| } | ||||
|  | ||||
| interface QCow2Disk { | ||||
| @@ -68,7 +59,6 @@ export type VMNetInterface = ( | ||||
|  | ||||
| export interface VMNetInterfaceBase { | ||||
|   mac: string; | ||||
|   model: "Virtio" | "E1000"; | ||||
|   nwfilterref?: VMNetInterfaceFilter; | ||||
| } | ||||
|  | ||||
| @@ -86,17 +76,6 @@ export interface VMNetBridge { | ||||
|   bridge: string; | ||||
| } | ||||
|  | ||||
| export interface VMCloudInit { | ||||
|   attach_config: boolean; | ||||
|   user_data: string; | ||||
|   instance_id?: string; | ||||
|   local_hostname?: string; | ||||
|   dsmode?: "Net" | "Local"; | ||||
|   network_configuration?: string; | ||||
| } | ||||
|  | ||||
| export type VMBootType = "UEFI" | "UEFISecureBoot" | "Legacy"; | ||||
|  | ||||
| interface VMInfoInterface { | ||||
|   name: string; | ||||
|   uuid?: string; | ||||
| @@ -104,7 +83,7 @@ interface VMInfoInterface { | ||||
|   title?: string; | ||||
|   description?: string; | ||||
|   group?: string; | ||||
|   boot_type: VMBootType; | ||||
|   boot_type: "UEFI" | "UEFISecureBoot"; | ||||
|   architecture: "i686" | "x86_64"; | ||||
|   memory: number; | ||||
|   number_vcpu: number; | ||||
| @@ -114,7 +93,6 @@ interface VMInfoInterface { | ||||
|   networks: VMNetInterface[]; | ||||
|   tpm_module: boolean; | ||||
|   oem_strings: string[]; | ||||
|   cloud_init: VMCloudInit; | ||||
| } | ||||
|  | ||||
| export class VMInfo implements VMInfoInterface { | ||||
| @@ -124,7 +102,7 @@ export class VMInfo implements VMInfoInterface { | ||||
|   title?: string; | ||||
|   description?: string; | ||||
|   group?: string; | ||||
|   boot_type: VMBootType; | ||||
|   boot_type: "UEFI" | "UEFISecureBoot"; | ||||
|   architecture: "i686" | "x86_64"; | ||||
|   number_vcpu: number; | ||||
|   memory: number; | ||||
| @@ -134,7 +112,6 @@ export class VMInfo implements VMInfoInterface { | ||||
|   networks: VMNetInterface[]; | ||||
|   tpm_module: boolean; | ||||
|   oem_strings: string[]; | ||||
|   cloud_init: VMCloudInit; | ||||
|  | ||||
|   constructor(int: VMInfoInterface) { | ||||
|     this.name = int.name; | ||||
| @@ -153,7 +130,6 @@ export class VMInfo implements VMInfoInterface { | ||||
|     this.networks = int.networks; | ||||
|     this.tpm_module = int.tpm_module; | ||||
|     this.oem_strings = int.oem_strings; | ||||
|     this.cloud_init = int.cloud_init; | ||||
|   } | ||||
|  | ||||
|   static NewEmpty(): VMInfo { | ||||
| @@ -161,7 +137,7 @@ export class VMInfo implements VMInfoInterface { | ||||
|       name: "", | ||||
|       boot_type: "UEFI", | ||||
|       architecture: "x86_64", | ||||
|       memory: 1000 * 1000 * 1000, | ||||
|       memory: 1024, | ||||
|       number_vcpu: 1, | ||||
|       vnc_access: true, | ||||
|       iso_files: [], | ||||
| @@ -169,7 +145,6 @@ export class VMInfo implements VMInfoInterface { | ||||
|       networks: [], | ||||
|       tpm_module: true, | ||||
|       oem_strings: [], | ||||
|       cloud_init: { attach_config: false, user_data: "" }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,148 +0,0 @@ | ||||
| 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,44 +3,16 @@ import "@fontsource/roboto/400.css"; | ||||
| import "@fontsource/roboto/500.css"; | ||||
| import "@fontsource/roboto/700.css"; | ||||
|  | ||||
| import { ThemeProvider, createTheme } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import ReactDOM from "react-dom/client"; | ||||
| import { App } from "./App"; | ||||
| import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider"; | ||||
| import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider"; | ||||
| import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider"; | ||||
| import { SnackbarProvider } from "./hooks/providers/SnackbarProvider"; | ||||
| import "./index.css"; | ||||
| import { LoadServerConfig } from "./widgets/LoadServerConfig"; | ||||
|  | ||||
| import { loader } from "@monaco-editor/react"; | ||||
| import * as monaco from "monaco-editor"; | ||||
| import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; | ||||
| import { configureMonacoYaml } from "monaco-yaml"; | ||||
| import YamlWorker from "monaco-yaml/yaml.worker?worker"; | ||||
|  | ||||
| // This allows to use a self hosted instance of Monaco editor | ||||
| loader.config({ monaco }); | ||||
|  | ||||
| // Add YAML support to Monaco | ||||
| configureMonacoYaml(monaco, { | ||||
|   enableSchemaRequest: false, | ||||
| }); | ||||
|  | ||||
| /// YAML worker | ||||
| window.MonacoEnvironment = { | ||||
|   getWorker(_moduleId, label) { | ||||
|     switch (label) { | ||||
|       case "editorWorkerService": | ||||
|         return new EditorWorker(); | ||||
|       case "yaml": | ||||
|         return new YamlWorker(); | ||||
|       default: | ||||
|         throw new Error(`Unknown label ${label}`); | ||||
|     } | ||||
|   }, | ||||
| }; | ||||
| import { ThemeProvider, createTheme } from "@mui/material"; | ||||
| import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider"; | ||||
| import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider"; | ||||
| import { SnackbarProvider } from "./hooks/providers/SnackbarProvider"; | ||||
| import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider"; | ||||
|  | ||||
| const darkTheme = createTheme({ | ||||
|   palette: { | ||||
| @@ -48,7 +20,9 @@ const darkTheme = createTheme({ | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const root = ReactDOM.createRoot(document.getElementById("root")!); | ||||
| const root = ReactDOM.createRoot( | ||||
|   document.getElementById("root")! | ||||
| ); | ||||
| root.render( | ||||
|   <React.StrictMode> | ||||
|     <ThemeProvider theme={darkTheme}> | ||||
|   | ||||
| @@ -1,417 +0,0 @@ | ||||
| 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,15 +3,7 @@ import { RouterLink } from "../widgets/RouterLink"; | ||||
|  | ||||
| export function NotFoundRoute(): React.ReactElement { | ||||
|   return ( | ||||
|     <div | ||||
|       style={{ | ||||
|         textAlign: "center", | ||||
|         flex: 1, | ||||
|         justifyContent: "center", | ||||
|         display: "flex", | ||||
|         flexDirection: "column", | ||||
|       }} | ||||
|     > | ||||
|     <div style={{ textAlign: "center" }}> | ||||
|       <h1>404 Not found</h1> | ||||
|       <p>The page you requested was not found!</p> | ||||
|       <RouterLink to="/"> | ||||
|   | ||||
| @@ -9,21 +9,18 @@ import { | ||||
| import Icon from "@mdi/react"; | ||||
| import { | ||||
|   Box, | ||||
|   IconButton, | ||||
|   LinearProgress, | ||||
|   Table, | ||||
|   TableBody, | ||||
|   TableCell, | ||||
|   TableHead, | ||||
|   TableRow, | ||||
|   Tooltip, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| import Grid from "@mui/material/Grid"; | ||||
| import { PieChart } from "@mui/x-charts"; | ||||
| import { filesize } from "filesize"; | ||||
| import humanizeDuration from "humanize-duration"; | ||||
| import IosShareIcon from "@mui/icons-material/IosShare"; | ||||
| import React from "react"; | ||||
| import { | ||||
|   DiskInfo, | ||||
| @@ -34,8 +31,6 @@ import { | ||||
| import { AsyncWidget } from "../widgets/AsyncWidget"; | ||||
| import { VirtWebPaper } from "../widgets/VirtWebPaper"; | ||||
| import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; | ||||
| import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider"; | ||||
| import { useAlert } from "../hooks/providers/AlertDialogProvider"; | ||||
|  | ||||
| export function SysInfoRoute(): React.ReactElement { | ||||
|   const [info, setInfo] = React.useState<ServerSystemInfo>(); | ||||
| @@ -57,23 +52,6 @@ export function SysInfoRoute(): React.ReactElement { | ||||
| export function SysInfoRouteInner(p: { | ||||
|   info: ServerSystemInfo; | ||||
| }): React.ReactElement { | ||||
|   const alert = useAlert(); | ||||
|   const loadingMessage = useLoadingMessage(); | ||||
|   const downloadAllConfig = async () => { | ||||
|     try { | ||||
|       loadingMessage.show("Downloading server config..."); | ||||
|       const res = await ServerApi.ExportServerConfigs(); | ||||
|  | ||||
|       const url = URL.createObjectURL(res); | ||||
|       window.location.href = url; | ||||
|     } catch (e) { | ||||
|       console.error("Failed to download server config!", e); | ||||
|       alert(`Failed to download server config! ${e}`); | ||||
|     } finally { | ||||
|       loadingMessage.hide(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const sumDiskUsage = p.info.disks.reduce( | ||||
|     (prev, disk) => { | ||||
|       return { | ||||
| @@ -85,16 +63,7 @@ export function SysInfoRouteInner(p: { | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <VirtWebRouteContainer | ||||
|       label="Sysinfo" | ||||
|       actions={ | ||||
|         <Tooltip title="Export all server configs"> | ||||
|           <IconButton onClick={downloadAllConfig}> | ||||
|             <IosShareIcon /> | ||||
|           </IconButton> | ||||
|         </Tooltip> | ||||
|       } | ||||
|     > | ||||
|     <VirtWebRouteContainer label="Sysinfo"> | ||||
|       <Grid container spacing={2}> | ||||
|         {/* Memory */} | ||||
|         <Grid size={{ xs: 4 }}> | ||||
|   | ||||
| @@ -10,7 +10,6 @@ import { | ||||
|   TableContainer, | ||||
|   TableHead, | ||||
|   TableRow, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| @@ -59,78 +58,70 @@ export function TokensListRouteInner(p: { | ||||
|         </RouterLink> | ||||
|       } | ||||
|     > | ||||
|       {p.list.length > 0 && ( | ||||
|         <TableContainer component={Paper}> | ||||
|           <Table> | ||||
|             <TableHead> | ||||
|               <TableRow> | ||||
|                 <TableCell>Name</TableCell> | ||||
|                 <TableCell>Description</TableCell> | ||||
|                 <TableCell>Created</TableCell> | ||||
|                 <TableCell>Updated</TableCell> | ||||
|                 <TableCell>Last used</TableCell> | ||||
|                 <TableCell>IP restriction</TableCell> | ||||
|                 <TableCell>Max inactivity</TableCell> | ||||
|                 <TableCell>Rights</TableCell> | ||||
|                 <TableCell>Actions</TableCell> | ||||
|               </TableRow> | ||||
|             </TableHead> | ||||
|             <TableBody> | ||||
|               {p.list.map((t) => { | ||||
|                 return ( | ||||
|                   <TableRow | ||||
|                     key={t.id} | ||||
|                     hover | ||||
|                     onDoubleClick={() => navigate(APITokenURL(t))} | ||||
|                     style={{ backgroundColor: ExpiredAPIToken(t) ? "red" : "" }} | ||||
|                   > | ||||
|                     <TableCell> | ||||
|                       {t.name} {ExpiredAPIToken(t) && <i>(Expired)</i>} | ||||
|                     </TableCell> | ||||
|                     <TableCell>{t.description}</TableCell> | ||||
|                     <TableCell> | ||||
|                       <TimeWidget time={t.created} /> | ||||
|                     </TableCell> | ||||
|                     <TableCell> | ||||
|                       <TimeWidget time={t.updated} /> | ||||
|                     </TableCell> | ||||
|                     <TableCell> | ||||
|                       <TimeWidget time={t.last_used} /> | ||||
|                     </TableCell> | ||||
|                     <TableCell>{t.ip_restriction}</TableCell> | ||||
|                     <TableCell> | ||||
|                       {t.max_inactivity && timeDiff(0, t.max_inactivity)} | ||||
|                     </TableCell> | ||||
|                     <TableCell> | ||||
|                       {t.rights.map((r, n) => { | ||||
|                         return ( | ||||
|                           <div key={n}> | ||||
|                             {r.verb} {r.path} | ||||
|                           </div> | ||||
|                         ); | ||||
|                       })} | ||||
|                     </TableCell> | ||||
|       <TableContainer component={Paper}> | ||||
|         <Table> | ||||
|           <TableHead> | ||||
|             <TableRow> | ||||
|               <TableCell>Name</TableCell> | ||||
|               <TableCell>Description</TableCell> | ||||
|               <TableCell>Created</TableCell> | ||||
|               <TableCell>Updated</TableCell> | ||||
|               <TableCell>Last used</TableCell> | ||||
|               <TableCell>IP restriction</TableCell> | ||||
|               <TableCell>Max inactivity</TableCell> | ||||
|               <TableCell>Rights</TableCell> | ||||
|               <TableCell>Actions</TableCell> | ||||
|             </TableRow> | ||||
|           </TableHead> | ||||
|           <TableBody> | ||||
|             {p.list.map((t) => { | ||||
|               return ( | ||||
|                 <TableRow | ||||
|                   key={t.id} | ||||
|                   hover | ||||
|                   onDoubleClick={() => navigate(APITokenURL(t))} | ||||
|                   style={{ backgroundColor: ExpiredAPIToken(t) ? "red" : "" }} | ||||
|                 > | ||||
|                   <TableCell> | ||||
|                     {t.name} {ExpiredAPIToken(t) && <i>(Expired)</i>} | ||||
|                   </TableCell> | ||||
|                   <TableCell>{t.description}</TableCell> | ||||
|                   <TableCell> | ||||
|                     <TimeWidget time={t.created} /> | ||||
|                   </TableCell> | ||||
|                   <TableCell> | ||||
|                     <TimeWidget time={t.updated} /> | ||||
|                   </TableCell> | ||||
|                   <TableCell> | ||||
|                     <TimeWidget time={t.last_used} /> | ||||
|                   </TableCell> | ||||
|                   <TableCell>{t.ip_restriction}</TableCell> | ||||
|                   <TableCell> | ||||
|                     {t.max_inactivity && timeDiff(0, t.max_inactivity)} | ||||
|                   </TableCell> | ||||
|                   <TableCell> | ||||
|                     {t.rights.map((r, n) => { | ||||
|                       return ( | ||||
|                         <div key={n}> | ||||
|                           {r.verb} {r.path} | ||||
|                         </div> | ||||
|                       ); | ||||
|                     })} | ||||
|                   </TableCell> | ||||
|  | ||||
|                     <TableCell> | ||||
|                       <RouterLink to={APITokenURL(t)}> | ||||
|                         <IconButton> | ||||
|                           <VisibilityIcon /> | ||||
|                         </IconButton> | ||||
|                       </RouterLink> | ||||
|                     </TableCell> | ||||
|                   </TableRow> | ||||
|                 ); | ||||
|               })} | ||||
|             </TableBody> | ||||
|           </Table> | ||||
|         </TableContainer> | ||||
|       )} | ||||
|  | ||||
|       {p.list.length === 0 && ( | ||||
|         <Typography style={{ textAlign: "center" }}> | ||||
|           No API token created yet. | ||||
|         </Typography> | ||||
|       )} | ||||
|                   <TableCell> | ||||
|                     <RouterLink to={APITokenURL(t)}> | ||||
|                       <IconButton> | ||||
|                         <VisibilityIcon /> | ||||
|                       </IconButton> | ||||
|                     </RouterLink> | ||||
|                   </TableCell> | ||||
|                 </TableRow> | ||||
|               ); | ||||
|             })} | ||||
|           </TableBody> | ||||
|         </Table> | ||||
|       </TableContainer> | ||||
|     </VirtWebRouteContainer> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -154,7 +154,7 @@ function VMListWidget(p: { | ||||
|                         {row.name} | ||||
|                       </TableCell> | ||||
|                       <TableCell>{row.description ?? ""}</TableCell> | ||||
|                       <TableCell>{filesize(row.memory)}</TableCell> | ||||
|                       <TableCell>{vmMemoryToHuman(row.memory)}</TableCell> | ||||
|                       <TableCell>{row.number_vcpu}</TableCell> | ||||
|                       <TableCell> | ||||
|                         <VMStatusWidget | ||||
| @@ -183,13 +183,13 @@ function VMListWidget(p: { | ||||
|             <TableCell></TableCell> | ||||
|             <TableCell></TableCell> | ||||
|             <TableCell> | ||||
|               {filesize( | ||||
|               {vmMemoryToHuman( | ||||
|                 p.list | ||||
|                   .filter((v) => runningVMs.has(v.name)) | ||||
|                   .reduce((s, v) => s + v.memory, 0) | ||||
|               )} | ||||
|               {" / "} | ||||
|               {filesize(p.list.reduce((s, v) => s + v.memory, 0))} | ||||
|               {vmMemoryToHuman(p.list.reduce((s, v) => s + v.memory, 0))} | ||||
|             </TableCell> | ||||
|             <TableCell> | ||||
|               {p.list | ||||
| @@ -206,3 +206,7 @@ function VMListWidget(p: { | ||||
|     </TableContainer> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function vmMemoryToHuman(size: number): string { | ||||
|   return filesize(size * 1000 * 1000); | ||||
| } | ||||
|   | ||||
| @@ -59,7 +59,6 @@ function VMRouteBody(p: { vm: VMInfo }): React.ReactElement { | ||||
|       <VMDetails | ||||
|         vm={p.vm} | ||||
|         editable={false} | ||||
|         state={state} | ||||
|         screenshot={p.vm.vnc_access && state === "Running"} | ||||
|       /> | ||||
|     </VirtWebRouteContainer> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import VisibilityIcon from "@mui/icons-material/Visibility"; | ||||
| import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; | ||||
| import VisibilityIcon from '@mui/icons-material/Visibility'; | ||||
| import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; | ||||
| import { | ||||
|   Alert, | ||||
|   CircularProgress, | ||||
| @@ -36,9 +36,7 @@ export function LoginRoute(): React.ReactElement { | ||||
|   const canSubmit = username.length > 0 && password.length > 0; | ||||
|  | ||||
|   const [showPassword, setShowPassword] = React.useState(false); | ||||
|   const handleClickShowPassword = () => { | ||||
|     setShowPassword((show) => !show); | ||||
|   }; | ||||
|   const handleClickShowPassword = () => { setShowPassword((show) => !show); }; | ||||
|  | ||||
|   const handleMouseDownPassword = ( | ||||
|     event: React.MouseEvent<HTMLButtonElement> | ||||
| @@ -107,14 +105,12 @@ export function LoginRoute(): React.ReactElement { | ||||
|               label="Username" | ||||
|               name="username" | ||||
|               value={username} | ||||
|               onChange={(e) => { | ||||
|                 setUsername(e.target.value); | ||||
|               }} | ||||
|               onChange={(e) => { setUsername(e.target.value); }} | ||||
|               autoComplete="username" | ||||
|               autoFocus | ||||
|             /> | ||||
|  | ||||
|             <FormControl required fullWidth variant="outlined"> | ||||
|             <FormControl fullWidth variant="outlined"> | ||||
|               <InputLabel htmlFor="password">Password</InputLabel> | ||||
|               <OutlinedInput | ||||
|                 required | ||||
| @@ -124,9 +120,7 @@ export function LoginRoute(): React.ReactElement { | ||||
|                 type={showPassword ? "text" : "password"} | ||||
|                 id="password" | ||||
|                 value={password} | ||||
|                 onChange={(e) => { | ||||
|                   setPassword(e.target.value); | ||||
|                 }} | ||||
|                 onChange={(e) => { setPassword(e.target.value); }} | ||||
|                 autoComplete="current-password" | ||||
|                 endAdornment={ | ||||
|                   <InputAdornment position="end"> | ||||
| @@ -137,11 +131,7 @@ export function LoginRoute(): React.ReactElement { | ||||
|                         onMouseDown={handleMouseDownPassword} | ||||
|                         edge="end" | ||||
|                       > | ||||
|                         {showPassword ? ( | ||||
|                           <VisibilityOffIcon /> | ||||
|                         ) : ( | ||||
|                           <VisibilityIcon /> | ||||
|                         )} | ||||
|                         {showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />} | ||||
|                       </IconButton> | ||||
|                     </Tooltip> | ||||
|                   </InputAdornment> | ||||
|   | ||||
| @@ -2,7 +2,6 @@ import { | ||||
|   mdiApi, | ||||
|   mdiBoxShadow, | ||||
|   mdiDisc, | ||||
|   mdiHarddisk, | ||||
|   mdiHome, | ||||
|   mdiInformation, | ||||
|   mdiLan, | ||||
| @@ -67,11 +66,6 @@ export function BaseAuthenticatedPage(): React.ReactElement { | ||||
|             uri="/nwfilter" | ||||
|             icon={<Icon path={mdiSecurityNetwork} size={1} />} | ||||
|           /> | ||||
|           <NavLink | ||||
|             label="Disk images" | ||||
|             uri="/disk_images" | ||||
|             icon={<Icon path={mdiHarddisk} size={1} />} | ||||
|           /> | ||||
|           <NavLink | ||||
|             label="ISO files" | ||||
|             uri="/iso" | ||||
|   | ||||
| @@ -1,13 +0,0 @@ | ||||
| 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"); | ||||
| } | ||||
| @@ -1,23 +0,0 @@ | ||||
| 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,9 +17,7 @@ export function CheckboxInput(p: { | ||||
|         <Checkbox | ||||
|           disabled={!p.editable} | ||||
|           checked={p.checked} | ||||
|           onChange={(e) => { | ||||
|             p.onValueChange(e.target.checked); | ||||
|           }} | ||||
|           onChange={(e) => { p.onValueChange(e.target.checked); }} | ||||
|         /> | ||||
|       } | ||||
|       label={p.label} | ||||
|   | ||||
| @@ -1,341 +0,0 @@ | ||||
| /* 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?.(); | ||||
|       }} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| @@ -1,24 +0,0 @@ | ||||
| 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); }} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| @@ -1,40 +0,0 @@ | ||||
| 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> | ||||
|   ); | ||||
| } | ||||
| @@ -1,25 +0,0 @@ | ||||
| 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,10 +19,13 @@ export function EditSection( | ||||
|               display: "flex", | ||||
|               justifyContent: "space-between", | ||||
|               alignItems: "center", | ||||
|               marginBottom: "15px", | ||||
|             }} | ||||
|           > | ||||
|             {p.title && <Typography variant="h5">{p.title}</Typography>} | ||||
|             {p.title && ( | ||||
|               <Typography variant="h5" style={{ marginBottom: "15px" }}> | ||||
|                 {p.title} | ||||
|               </Typography> | ||||
|             )} | ||||
|             {p.actions} | ||||
|           </span> | ||||
|         )} | ||||
|   | ||||
| @@ -35,7 +35,7 @@ export function FileInput( | ||||
|               <InputAdornment position="start"> | ||||
|                 <AttachFileIcon /> | ||||
|                    | ||||
|                 {p.value ? p.value.name : "Select a file"} | ||||
|                 {p.value ? p.value.name : "Insert a file"} | ||||
|               </InputAdornment> | ||||
|             </> | ||||
|           ), | ||||
|   | ||||
| @@ -25,8 +25,6 @@ export function OEMStringFormWidget(p: { | ||||
|     p.onChange?.(); | ||||
|   }; | ||||
|  | ||||
|   if (!p.editable && p.vm.oem_strings.length === 0) return <></>; | ||||
|  | ||||
|   return ( | ||||
|     <EditSection | ||||
|       title="SMBIOS OEM Strings" | ||||
|   | ||||
| @@ -17,11 +17,8 @@ export function SelectInput(p: { | ||||
|   value?: string; | ||||
|   editable: boolean; | ||||
|   label?: string; | ||||
|   size?: "medium" | "small"; | ||||
|   options: SelectOption[]; | ||||
|   onValueChange: (o?: string) => void; | ||||
|   disableUnderline?: boolean; | ||||
|   disableBottomMargin?: boolean; | ||||
| }): React.ReactElement { | ||||
|   if (!p.editable && !p.value) return <></>; | ||||
|  | ||||
| @@ -31,18 +28,12 @@ export function SelectInput(p: { | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <FormControl | ||||
|       fullWidth | ||||
|       variant="standard" | ||||
|       style={{ marginBottom: p.disableBottomMargin ? "0px" : "15px" }} | ||||
|     > | ||||
|     <FormControl fullWidth variant="standard" style={{ marginBottom: "15px" }}> | ||||
|       {p.label && <InputLabel>{p.label}</InputLabel>} | ||||
|       <Select | ||||
|         {...p} | ||||
|         value={p.value ?? ""} | ||||
|         onChange={(e) => { | ||||
|           p.onValueChange(e.target.value); | ||||
|         }} | ||||
|         label={p.label} | ||||
|         onChange={(e) => { p.onValueChange(e.target.value); }} | ||||
|       > | ||||
|         {p.options.map((e) => ( | ||||
|           <MenuItem | ||||
|   | ||||
| @@ -17,8 +17,6 @@ export function TextInput(p: { | ||||
|   type?: React.HTMLInputTypeAttribute; | ||||
|   style?: React.CSSProperties; | ||||
|   helperText?: string; | ||||
|   disabled?: boolean; | ||||
|   endAdornment?: React.ReactNode; | ||||
| }): React.ReactElement { | ||||
|   if (!p.editable && (p.value ?? "") === "") return <></>; | ||||
|  | ||||
| @@ -37,7 +35,6 @@ export function TextInput(p: { | ||||
|  | ||||
|   return ( | ||||
|     <TextField | ||||
|       disabled={p.disabled} | ||||
|       label={p.label} | ||||
|       value={p.value ?? ""} | ||||
|       onChange={(e) => | ||||
| @@ -52,7 +49,6 @@ export function TextInput(p: { | ||||
|         input: { | ||||
|           readOnly: !p.editable, | ||||
|           type: p.type, | ||||
|           endAdornment: p.endAdornment, | ||||
|         }, | ||||
|       }} | ||||
|       variant={"standard"} | ||||
|   | ||||
| @@ -1,39 +1,33 @@ | ||||
| import { mdiHarddiskPlus } from "@mdi/js"; | ||||
| import { mdiHarddisk } from "@mdi/js"; | ||||
| import Icon from "@mdi/react"; | ||||
| import CheckCircleIcon from "@mui/icons-material/CheckCircle"; | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import ExpandIcon from "@mui/icons-material/Expand"; | ||||
| import { Button, IconButton, Paper, Tooltip, Typography } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { DiskImage } from "../../api/DiskImageApi"; | ||||
| import { | ||||
|   Avatar, | ||||
|   Button, | ||||
|   IconButton, | ||||
|   ListItem, | ||||
|   ListItemAvatar, | ||||
|   ListItemText, | ||||
|   Paper, | ||||
|   Tooltip, | ||||
| } from "@mui/material"; | ||||
| import { filesize } from "filesize"; | ||||
| import { ServerApi } from "../../api/ServerApi"; | ||||
| import { VMFileDisk, VMInfo, VMState } from "../../api/VMApi"; | ||||
| import { ConvertDiskImageDialog } from "../../dialogs/ConvertDiskImageDialog"; | ||||
| import { VMFileDisk, VMInfo } from "../../api/VMApi"; | ||||
| 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 { TextInput } from "./TextInput"; | ||||
|  | ||||
| export function VMDisksList(p: { | ||||
|   vm: VMInfo; | ||||
|   state?: VMState; | ||||
|   onChange?: () => void; | ||||
|   editable: boolean; | ||||
|   diskImagesList: DiskImage[]; | ||||
| }): React.ReactElement { | ||||
|   const [currBackupRequest, setCurrBackupRequest] = React.useState< | ||||
|     VMFileDisk | undefined | ||||
|   >(); | ||||
|  | ||||
|   const addNewDisk = () => { | ||||
|     p.vm.file_disks.push({ | ||||
|       format: "QCow2", | ||||
|       size: 10000 * 1000 * 1000, | ||||
|       bus: "Virtio", | ||||
|       size: 10000, | ||||
|       delete: false, | ||||
|       name: `disk${p.vm.file_disks.length}`, | ||||
|       new: true, | ||||
| @@ -41,14 +35,6 @@ export function VMDisksList(p: { | ||||
|     p.onChange?.(); | ||||
|   }; | ||||
|  | ||||
|   const handleBackupRequest = (disk: VMFileDisk) => { | ||||
|     setCurrBackupRequest(disk); | ||||
|   }; | ||||
|  | ||||
|   const handleFinishBackup = () => { | ||||
|     setCurrBackupRequest(undefined); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {/* disks list */} | ||||
| @@ -57,63 +43,27 @@ export function VMDisksList(p: { | ||||
|           // eslint-disable-next-line react-x/no-array-index-key | ||||
|           key={num} | ||||
|           editable={p.editable} | ||||
|           canBackup={!p.editable && !d.new && p.state !== "Running"} | ||||
|           disk={d} | ||||
|           onChange={p.onChange} | ||||
|           removeFromList={() => { | ||||
|             p.vm.file_disks.splice(num, 1); | ||||
|             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>} | ||||
|  | ||||
|       {/* Disk backup */} | ||||
|       {currBackupRequest && ( | ||||
|         <ConvertDiskImageDialog | ||||
|           backup | ||||
|           onCancel={handleFinishBackup} | ||||
|           onFinished={handleFinishBackup} | ||||
|           vm={p.vm} | ||||
|           disk={currBackupRequest} | ||||
|         /> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function DiskInfo(p: { | ||||
|   editable: boolean; | ||||
|   canBackup: boolean; | ||||
|   disk: VMFileDisk; | ||||
|   onChange?: () => void; | ||||
|   removeFromList: () => void; | ||||
|   onRequestBackup: (disk: VMFileDisk) => void; | ||||
|   diskImagesList: DiskImage[]; | ||||
| }): React.ReactElement { | ||||
|   const confirm = useConfirm(); | ||||
|  | ||||
|   const expandDisk = () => { | ||||
|     if (p.disk.resize === true) { | ||||
|       p.disk.resize = false; | ||||
|       p.disk.size = p.disk.originalSize!; | ||||
|     } else { | ||||
|       p.disk.resize = true; | ||||
|       p.disk.originalSize = p.disk.size!; | ||||
|     } | ||||
|  | ||||
|     p.onChange?.(); | ||||
|   }; | ||||
|  | ||||
|   const deleteDisk = async () => { | ||||
|     if (p.disk.deleteType) { | ||||
|       p.disk.deleteType = undefined; | ||||
| @@ -136,75 +86,50 @@ function DiskInfo(p: { | ||||
|  | ||||
|   if (!p.editable || !p.disk.new) | ||||
|     return ( | ||||
|       <> | ||||
|         <VMDiskFileWidget | ||||
|           {...p} | ||||
|           secondaryAction={ | ||||
|             <> | ||||
|               {p.editable && !p.disk.deleteType && ( | ||||
|                 <IconButton | ||||
|                   edge="end" | ||||
|                   aria-label="expand disk" | ||||
|                   onClick={expandDisk} | ||||
|                 > | ||||
|                   {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> | ||||
|       <ListItem | ||||
|         secondaryAction={ | ||||
|           p.editable && ( | ||||
|             <IconButton | ||||
|               edge="end" | ||||
|               aria-label="delete disk" | ||||
|               onClick={deleteDisk} | ||||
|             > | ||||
|               {p.disk.deleteType ? ( | ||||
|                 <Tooltip title="Cancel disk removal"> | ||||
|                   <CheckCircleIcon /> | ||||
|                 </Tooltip> | ||||
|               ) : ( | ||||
|                 <Tooltip title="Remove disk"> | ||||
|                   <DeleteIcon /> | ||||
|                 </Tooltip> | ||||
|               )} | ||||
|             </IconButton> | ||||
|           ) | ||||
|         } | ||||
|       > | ||||
|         <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={`${filesize(p.disk.size * 1000 * 1000)} - ${ | ||||
|             p.disk.format | ||||
|           }${p.disk.format == "Raw" ? " - " + p.disk.alloc_type : ""}`} | ||||
|         /> | ||||
|  | ||||
|         {/* 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?.(); | ||||
|             }} | ||||
|           /> | ||||
|         )} | ||||
|       </> | ||||
|       </ListItem> | ||||
|     ); | ||||
|  | ||||
|   return ( | ||||
| @@ -226,6 +151,18 @@ function DiskInfo(p: { | ||||
|         </IconButton> | ||||
|       </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 | ||||
|         editable={true} | ||||
|         label="Disk format" | ||||
| @@ -236,71 +173,25 @@ function DiskInfo(p: { | ||||
|         value={p.disk.format} | ||||
|         onValueChange={(v) => { | ||||
|           p.disk.format = v as any; | ||||
|  | ||||
|           if (p.disk.format === "Raw") p.disk.is_sparse = true; | ||||
|  | ||||
|           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" && ( | ||||
|         <CheckboxInput | ||||
|           editable | ||||
|           label="Sparse file" | ||||
|           checked={p.disk.is_sparse} | ||||
|         <SelectInput | ||||
|           editable={true} | ||||
|           label="File allocation type" | ||||
|           options={[ | ||||
|             { label: "Sparse allocation", value: "Sparse" }, | ||||
|             { label: "Fixed allocation", value: "Fixed" }, | ||||
|           ]} | ||||
|           value={p.disk.alloc_type} | ||||
|           onValueChange={(v) => { | ||||
|             if (p.disk.format === "Raw") p.disk.is_sparse = v; | ||||
|             if (p.disk.format === "Raw") p.disk.alloc_type = v as any; | ||||
|             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> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -10,7 +10,6 @@ import { | ||||
|   ListItemAvatar, | ||||
|   ListItemText, | ||||
|   Tooltip, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| import Grid from "@mui/material/Grid"; | ||||
| import { NWFilter } from "../../api/NWFilterApi"; | ||||
| @@ -36,7 +35,6 @@ export function VMNetworksList(p: { | ||||
|   const addNew = () => { | ||||
|     p.vm.networks.push({ | ||||
|       type: "UserspaceSLIRPStack", | ||||
|       model: "Virtio", | ||||
|       mac: randomMacAddress(ServerApi.Config.net_mac_prefix), | ||||
|     }); | ||||
|     p.onChange?.(); | ||||
| @@ -50,12 +48,6 @@ export function VMNetworksList(p: { | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {p.vm.networks.length === 0 && ( | ||||
|         <Typography style={{ textAlign: "center", paddingTop: "25px" }}> | ||||
|           No network interface defined yet! | ||||
|         </Typography> | ||||
|       )} | ||||
|  | ||||
|       <Grid container spacing={2}> | ||||
|         {/* networks list */} | ||||
|         {p.vm.networks.map((n, num) => ( | ||||
| @@ -154,7 +146,6 @@ function NetworkInfoWidget(p: { | ||||
|         /> | ||||
|       </ListItem> | ||||
|       <div style={{ marginLeft: "70px" }}> | ||||
|         {/* MAC address input */} | ||||
|         <MACInput | ||||
|           editable={p.editable} | ||||
|           label="MAC Address" | ||||
| @@ -165,26 +156,6 @@ 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" && ( | ||||
|           <SelectInput | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import { | ||||
|   Checkbox, | ||||
|   FormControlLabel, | ||||
|   Grid, | ||||
|   Paper, | ||||
|   Table, | ||||
|   TableBody, | ||||
| @@ -60,8 +59,6 @@ export function TokenRightsEditor(p: { | ||||
|               <TableCell align="center">Get XML definition</TableCell> | ||||
|               <TableCell align="center">Get autostart</TableCell> | ||||
|               <TableCell align="center">Set autostart</TableCell> | ||||
|               <TableCell align="center">Get CloudInit disk</TableCell> | ||||
|               <TableCell align="center">Backup disk</TableCell> | ||||
|             </TableRow> | ||||
|           </TableHead> | ||||
|           <TableBody> | ||||
| @@ -85,17 +82,6 @@ export function TokenRightsEditor(p: { | ||||
|                 {...p} | ||||
|                 right={{ verb: "PUT", path: "/api/vm/*/autostart" }} | ||||
|               /> | ||||
|               <CellRight | ||||
|                 {...p} | ||||
|                 right={{ | ||||
|                   verb: "GET", | ||||
|                   path: "/api/vm/*/cloud_init_disk", | ||||
|                 }} | ||||
|               /> | ||||
|               <CellRight | ||||
|                 {...p} | ||||
|                 right={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }} | ||||
|               /> | ||||
|             </TableRow> | ||||
|  | ||||
|             {/* Per VM operations */} | ||||
| @@ -132,22 +118,6 @@ export function TokenRightsEditor(p: { | ||||
|                   right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }} | ||||
|                   parent={{ verb: "PUT", path: "/api/vm/*/autostart" }} | ||||
|                 /> | ||||
|                 <CellRight | ||||
|                   {...p} | ||||
|                   right={{ | ||||
|                     verb: "GET", | ||||
|                     path: `/api/vm/${v.uuid}/cloud_init_disk`, | ||||
|                   }} | ||||
|                   parent={{ verb: "GET", path: "/api/vm/*/cloud_init_disk" }} | ||||
|                 /> | ||||
|                 <CellRight | ||||
|                   {...p} | ||||
|                   right={{ | ||||
|                     verb: "POST", | ||||
|                     path: `/api/vm/${v.uuid}/disk/*/backup`, | ||||
|                   }} | ||||
|                   parent={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }} | ||||
|                 /> | ||||
|               </TableRow> | ||||
|             ))} | ||||
|           </TableBody> | ||||
| @@ -699,73 +669,34 @@ export function TokenRightsEditor(p: { | ||||
|         </Table> | ||||
|       </RightsSection> | ||||
|  | ||||
|       <Grid container> | ||||
|         <Grid size={{ md: 6 }}> | ||||
|           {/* Disk images */} | ||||
|           <RightsSection label="Disk images"> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "POST", path: "/api/disk_images/upload" }} | ||||
|               label="Upload a new disk image" | ||||
|             /> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "GET", path: "/api/disk_images/list" }} | ||||
|               label="Get the list of disk images" | ||||
|             /> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "GET", path: "/api/disk_images/*" }} | ||||
|               label="Download disk images" | ||||
|             /> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "POST", path: "/api/disk_images/*/convert" }} | ||||
|               label="Convert disk images" | ||||
|             /> | ||||
|             <RouteRight | ||||
|               {...p} | ||||
|               right={{ verb: "POST", path: "/api/disk_images/*/rename" }} | ||||
|               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> | ||||
|       {/* 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> | ||||
|  | ||||
|       {/* Server general information */} | ||||
|       <RightsSection label="Server"> | ||||
| @@ -799,11 +730,6 @@ export function TokenRightsEditor(p: { | ||||
|           right={{ verb: "GET", path: "/api/server/bridges" }} | ||||
|           label="Get list of network bridges" | ||||
|         /> | ||||
|         <RouteRight | ||||
|           {...p} | ||||
|           right={{ verb: "GET", path: "/api/server/export_configs" }} | ||||
|           label="Export all configurations" | ||||
|         /> | ||||
|       </RightsSection> | ||||
|     </> | ||||
|   ); | ||||
|   | ||||
| @@ -5,13 +5,12 @@ import Grid from "@mui/material/Grid"; | ||||
| import React from "react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { validate as validateUUID } from "uuid"; | ||||
| import { DiskImage, DiskImageApi } from "../../api/DiskImageApi"; | ||||
| import { GroupApi } from "../../api/GroupApi"; | ||||
| import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi"; | ||||
| import { NWFilter, NWFilterApi } from "../../api/NWFilterApi"; | ||||
| import { NetworkApi, NetworkInfo } from "../../api/NetworksApi"; | ||||
| import { ServerApi } from "../../api/ServerApi"; | ||||
| import { VMApi, VMInfo, VMState } from "../../api/VMApi"; | ||||
| import { VMApi, VMInfo } from "../../api/VMApi"; | ||||
| import { useAlert } from "../../hooks/providers/AlertDialogProvider"; | ||||
| import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider"; | ||||
| import { useSnackbar } from "../../hooks/providers/SnackbarProvider"; | ||||
| @@ -19,7 +18,6 @@ import { AsyncWidget } from "../AsyncWidget"; | ||||
| import { TabsWidget } from "../TabsWidget"; | ||||
| import { XMLAsyncWidget } from "../XMLWidget"; | ||||
| import { CheckboxInput } from "../forms/CheckboxInput"; | ||||
| import { CloudInitEditor } from "../forms/CloudInitEditor"; | ||||
| import { EditSection } from "../forms/EditSection"; | ||||
| import { OEMStringFormWidget } from "../forms/OEMStringFormWidget"; | ||||
| import { ResAutostartInput } from "../forms/ResAutostartInput"; | ||||
| @@ -35,14 +33,10 @@ interface DetailsProps { | ||||
|   editable: boolean; | ||||
|   onChange?: () => void; | ||||
|   screenshot?: boolean; | ||||
|   state?: VMState | undefined; | ||||
| } | ||||
|  | ||||
| export function VMDetails(p: DetailsProps): React.ReactElement { | ||||
|   const [groupsList, setGroupsList] = React.useState<string[] | undefined>(); | ||||
|   const [diskImagesList, setDiskImagesList] = React.useState< | ||||
|     DiskImage[] | undefined | ||||
|   >(); | ||||
|   const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>(); | ||||
|   const [bridgesList, setBridgesList] = React.useState<string[] | undefined>(); | ||||
|   const [vcpuCombinations, setVCPUCombinations] = React.useState< | ||||
| @@ -57,7 +51,6 @@ export function VMDetails(p: DetailsProps): React.ReactElement { | ||||
|  | ||||
|   const load = async () => { | ||||
|     setGroupsList(await GroupApi.GetList()); | ||||
|     setDiskImagesList(await DiskImageApi.GetList()); | ||||
|     setIsoList(await IsoFilesApi.GetList()); | ||||
|     setBridgesList(await ServerApi.GetNetworksBridgesList()); | ||||
|     setVCPUCombinations(await ServerApi.NumberVCPUs()); | ||||
| @@ -73,7 +66,6 @@ export function VMDetails(p: DetailsProps): React.ReactElement { | ||||
|       build={() => ( | ||||
|         <VMDetailsInner | ||||
|           groupsList={groupsList!} | ||||
|           diskImagesList={diskImagesList!} | ||||
|           isoList={isoList!} | ||||
|           bridgesList={bridgesList!} | ||||
|           vcpuCombinations={vcpuCombinations!} | ||||
| @@ -90,7 +82,6 @@ enum VMTab { | ||||
|   General = 0, | ||||
|   Storage, | ||||
|   Network, | ||||
|   CloudInit, | ||||
|   Advanced, | ||||
|   XML, | ||||
|   Danger, | ||||
| @@ -98,7 +89,6 @@ enum VMTab { | ||||
|  | ||||
| type DetailsInnerProps = DetailsProps & { | ||||
|   groupsList: string[]; | ||||
|   diskImagesList: DiskImage[]; | ||||
|   isoList: IsoFile[]; | ||||
|   bridgesList: string[]; | ||||
|   vcpuCombinations: number[]; | ||||
| @@ -118,11 +108,6 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement { | ||||
|           { label: "General", value: VMTab.General, visible: true }, | ||||
|           { label: "Storage", value: VMTab.Storage, visible: true }, | ||||
|           { label: "Network", value: VMTab.Network, visible: true }, | ||||
|           { | ||||
|             label: "Cloud Init", | ||||
|             value: VMTab.CloudInit, | ||||
|             visible: p.editable || p.vm.cloud_init.attach_config, | ||||
|           }, | ||||
|           { label: "Avanced", value: VMTab.Advanced, visible: true }, | ||||
|  | ||||
|           { | ||||
| @@ -142,7 +127,6 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement { | ||||
|       {currTab === VMTab.General && <VMDetailsTabGeneral {...p} />} | ||||
|       {currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />} | ||||
|       {currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />} | ||||
|       {currTab === VMTab.CloudInit && <VMDetailsTabCloudInit {...p} />} | ||||
|       {currTab === VMTab.Advanced && <VMDetailsTabAdvanced {...p} />} | ||||
|       {currTab === VMTab.XML && <VMDetailsTabXML {...p} />} | ||||
|       {currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />} | ||||
| @@ -288,7 +272,6 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement { | ||||
|           options={[ | ||||
|             { label: "UEFI with Secure Boot", value: "UEFISecureBoot" }, | ||||
|             { label: "UEFI", value: "UEFI" }, | ||||
|             { label: "Legacy", value: "Legacy" }, | ||||
|           ]} | ||||
|         /> | ||||
|  | ||||
| @@ -296,16 +279,14 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement { | ||||
|           label="Memory (MB)" | ||||
|           editable={p.editable} | ||||
|           type="number" | ||||
|           value={Math.floor(p.vm.memory / (1000 * 1000)).toString()} | ||||
|           value={p.vm.memory.toString()} | ||||
|           onValueChange={(v) => { | ||||
|             p.vm.memory = Number(v ?? "0") * 1000 * 1000; | ||||
|             p.vm.memory = Number(v ?? "0"); | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|           checkValue={(v) => | ||||
|             Number(v) > | ||||
|               ServerApi.Config.constraints.memory_size.min / (1000 * 1000) && | ||||
|             Number(v) < | ||||
|               ServerApi.Config.constraints.memory_size.max / (1000 * 1000) | ||||
|             Number(v) > ServerApi.Config.constraints.memory_size.min && | ||||
|             Number(v) < ServerApi.Config.constraints.memory_size.max | ||||
|           } | ||||
|         /> | ||||
|  | ||||
| @@ -389,10 +370,6 @@ function VMDetailsTabNetwork(p: DetailsInnerProps): React.ReactElement { | ||||
|   return <VMNetworksList {...p} />; | ||||
| } | ||||
|  | ||||
| function VMDetailsTabCloudInit(p: DetailsInnerProps): React.ReactElement { | ||||
|   return <CloudInitEditor {...p} />; | ||||
| } | ||||
|  | ||||
| function VMDetailsTabAdvanced(p: DetailsInnerProps): React.ReactElement { | ||||
|   return ( | ||||
|     <Grid container spacing={2}> | ||||
|   | ||||
| @@ -1,72 +0,0 @@ | ||||
| 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