37 Commits

Author SHA1 Message Date
5ad2f06773 Update Rust crate tokio to 1.46.0
Some checks are pending
continuous-integration/drone/push Build is running
continuous-integration/drone/pr Build is running
2025-07-04 00:12:35 +00:00
1dd86807fd Fix cargo clippy issues
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-03 08:01:36 +02:00
96747bda89 Update dependency eslint-plugin-react-x to ^1.52.2
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2025-06-26 00:12:11 +00:00
e15514dd4f Update dependency eslint-plugin-react-dom to ^1.52.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-06-25 00:12:04 +00:00
7556ee2c06 Update dependency @vitejs/plugin-react to ^4.6.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-06-24 00:14:13 +00:00
992a902590 Update dependency eslint to ^9.29.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-06-23 00:24:41 +00:00
100f12e7c1 Update dependency @vitejs/plugin-react to ^4.5.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-06-22 00:23:00 +00:00
3de66a5873 Fix a bug in sysinfo route with docker containers
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-21 19:06:43 +02:00
49360188f5 Update dependency @mui/x-data-grid to ^8.5.3
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-06-21 00:23:42 +00:00
35c48ba846 Update materialui to ^7.1.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-06-20 00:25:00 +00:00
1ad4262086 Update Rust crate sysinfo to 0.35.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-06-19 00:23:07 +00:00
b633694f74 Add api client to release
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-06-18 19:07:43 +02:00
ab16bd7bcf Can export entire server configuration
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-17 21:17:25 +02:00
1080ab5cb2 Fix boolean config
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-17 19:13:43 +02:00
a2845ddafe Enable word wrapping in Monaco editors
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-16 21:53:43 +02:00
c968b64b51 Fix ESLint issues
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-16 21:52:00 +02:00
12833dc6da Fix cloud init command
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-16 21:48:06 +02:00
8c4f2a9f2d Fix issue with Ubuntu cloud images
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-16 21:31:33 +02:00
9a6b6cfb2d Can change default username
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-16 19:56:04 +02:00
b28ca5f27d Add new options
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-16 19:42:57 +02:00
92f187bf91 Start to auto-fill cloudinit fields
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-16 19:31:13 +02:00
9f1f4b44ca Make network configuration editable 2025-06-16 18:51:33 +02:00
b00d46105b Update Rust crate reqwest to 0.12.20
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-06-15 00:24:45 +00:00
7d6ccd4ce6 Update dependency @types/react to ^19.1.8
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-06-14 00:25:09 +00:00
b5371421e1 Update dependency @fontsource/roboto to ^5.2.6
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-06-13 00:25:12 +00:00
07d305c54e Update Rust crate clap to 4.5.40
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-06-12 00:26:01 +00:00
78c525f47a Update Rust crate actix-http to 3.11.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-06-11 00:26:04 +00:00
978a881372 Basic check of user data structure for errors
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-10 21:57:58 +02:00
ee733b04f3 Fix ESLint issue
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-10 21:49:05 +02:00
0d8ef226c1 Can edit Cloud init user data from UI
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-10 21:46:50 +02:00
feca07558e Can customize from UI Cloud init metadata
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-10 20:30:51 +02:00
0de15af10e Normalize disk size input
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-09 18:11:49 +02:00
d4bc92f562 Do not offer to expand disk if disk deletion is request by user
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-09 18:09:55 +02:00
a1439689dd Can resize existing disks
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-09 18:00:23 +02:00
63126c75fa Prevent shrinking attempts
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-09 17:48:17 +02:00
940302a825 Can resize disk image when adding a new disk image to a VM
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-09 17:42:36 +02:00
9c374f849b Shwo a message when some lists are empty
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-09 17:17:54 +02:00
33 changed files with 2697 additions and 426 deletions

View File

@ -46,8 +46,9 @@ steps:
- cd virtweb_backend - cd virtweb_backend
- mv /tmp/web_build/dist static - mv /tmp/web_build/dist static
- cargo build --release - cargo build --release
- ls -lah target/release/virtweb_backend - cargo build --release --example api_curl
- cp target/release/virtweb_backend /tmp/release - ls -lah target/release/virtweb_backend target/release/examples/api_curl
- cp target/release/virtweb_backend target/release/examples/api_curl /tmp/release
- name: gitea_release - name: gitea_release
image: plugins/gitea-release image: plugins/gitea-release

View File

@ -435,6 +435,21 @@ dependencies = [
"alloc-no-stdlib", "alloc-no-stdlib",
] ]
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "0.6.18" version = "0.6.18"
@ -496,6 +511,9 @@ name = "arbitrary"
version = "1.4.1" version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "arg_enum_proc_macro" name = "arg_enum_proc_macro"
@ -715,6 +733,25 @@ dependencies = [
"bytes", "bytes",
] ]
[[package]]
name = "bzip2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
dependencies = [
"cc",
"pkg-config",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.23" version = "1.2.23"
@ -748,6 +785,20 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.4.4"
@ -760,9 +811,9 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.38" version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -770,9 +821,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.38" version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -782,9 +833,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.5.32" version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
@ -816,6 +867,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.4.0" version = "0.4.0"
@ -981,6 +1038,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "deflate64"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@ -1001,6 +1064,17 @@ dependencies = [
"powerfmt", "powerfmt",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.20" version = "0.99.20"
@ -1221,6 +1295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"libz-rs-sys",
"miniz_oxide", "miniz_oxide",
] ]
@ -1380,9 +1455,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"wasi 0.14.2+wasi-0.2.4", "wasi 0.14.2+wasi-0.2.4",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -1622,18 +1699,47 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf9f1e950e0d9d1d3c47184416723cf29c0d1f93bd8cccf37e4beb6b44f31710" checksum = "cf9f1e950e0d9d1d3c47184416723cf29c0d1f93bd8cccf37e4beb6b44f31710"
dependencies = [ dependencies = [
"base64 0.22.1",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
"http 1.3.1", "http 1.3.1",
"http-body", "http-body",
"hyper", "hyper",
"ipnet",
"libc", "libc",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
]
[[package]]
name = "iana-time-zone"
version = "0.1.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
] ]
[[package]] [[package]]
@ -1824,6 +1930,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "io-uring"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.11.0" version = "2.11.0"
@ -1839,6 +1956,16 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "iri-string"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
dependencies = [
"memchr",
"serde",
]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.1" version = "1.70.1"
@ -1982,6 +2109,45 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "liblzma"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66352d7a8ac12d4877b6e6ea5a9b7650ee094257dc40889955bea5bc5b08c1d0"
dependencies = [
"liblzma-sys",
]
[[package]]
name = "liblzma-sys"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "libyml"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980"
dependencies = [
"anyhow",
"version_check",
]
[[package]]
name = "libz-rs-sys"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221"
dependencies = [
"zlib-rs",
]
[[package]] [[package]]
name = "light-openid" name = "light-openid"
version = "1.0.4" version = "1.0.4"
@ -2404,6 +2570,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]] [[package]]
name = "pem" name = "pem"
version = "3.0.5" version = "3.0.5"
@ -2773,9 +2949,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.15" version = "0.12.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
@ -2790,31 +2966,28 @@ dependencies = [
"hyper-rustls", "hyper-rustls",
"hyper-tls", "hyper-tls",
"hyper-util", "hyper-util",
"ipnet",
"js-sys", "js-sys",
"log", "log",
"mime", "mime",
"native-tls", "native-tls",
"once_cell",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls-pemfile", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"system-configuration",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams",
"web-sys", "web-sys",
"windows-registry",
] ]
[[package]] [[package]]
@ -2923,15 +3096,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.12.0" version = "1.12.0"
@ -3093,6 +3257,21 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_yml"
version = "0.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd"
dependencies = [
"indexmap",
"itoa",
"libyml",
"memchr",
"ryu",
"serde",
"version_check",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.6" version = "0.10.6"
@ -3253,9 +3432,9 @@ dependencies = [
[[package]] [[package]]
name = "sysinfo" name = "sysinfo"
version = "0.35.1" version = "0.35.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79251336d17c72d9762b8b54be4befe38d2db56fbbc0241396d70f173c39d47a" checksum = "3c3ffa3e4ff2b324a57f7aeb3c349656c7b127c3c189520251a648102a92496e"
dependencies = [ dependencies = [
"libc", "libc",
"memchr", "memchr",
@ -3413,17 +3592,19 @@ dependencies = [
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.45.0" version = "1.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" checksum = "1140bb80481756a8cbe10541f37433b459c5aa1e727b4c020fbfebdc25bf3ec4"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"bytes", "bytes",
"io-uring",
"libc", "libc",
"mio", "mio",
"parking_lot", "parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
"slab",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"windows-sys 0.52.0", "windows-sys 0.52.0",
@ -3522,6 +3703,24 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "tower-http"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"bitflags 2.9.1",
"bytes",
"futures-util",
"http 1.3.1",
"http-body",
"iri-string",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
]
[[package]] [[package]]
name = "tower-layer" name = "tower-layer"
version = "0.3.3" version = "0.3.3"
@ -3735,6 +3934,7 @@ dependencies = [
"actix-ws", "actix-ws",
"anyhow", "anyhow",
"basic-jwt", "basic-jwt",
"chrono",
"clap", "clap",
"dotenvy", "dotenvy",
"env_logger", "env_logger",
@ -3754,6 +3954,7 @@ dependencies = [
"rust-embed", "rust-embed",
"serde", "serde",
"serde_json", "serde_json",
"serde_yml",
"sysinfo", "sysinfo",
"tempfile", "tempfile",
"thiserror 2.0.12", "thiserror 2.0.12",
@ -3761,6 +3962,7 @@ dependencies = [
"url", "url",
"uuid", "uuid",
"virt", "virt",
"zip",
] ]
[[package]] [[package]]
@ -4299,6 +4501,20 @@ name = "zeroize"
version = "1.8.1" version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
@ -4333,6 +4549,50 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "zip"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af7dcdb4229c0e79c2531a24de7726a0e980417a74fb4d030a35f535665439a0"
dependencies = [
"aes",
"arbitrary",
"bzip2",
"constant_time_eq",
"crc32fast",
"deflate64",
"flate2",
"getrandom 0.3.3",
"hmac",
"indexmap",
"liblzma",
"memchr",
"pbkdf2",
"sha1",
"time",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zlib-rs"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a"
[[package]]
name = "zopfli"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]] [[package]]
name = "zstd" name = "zstd"
version = "0.13.3" version = "0.13.3"

View File

@ -8,7 +8,7 @@ edition = "2024"
[dependencies] [dependencies]
log = "0.4.27" log = "0.4.27"
env_logger = "0.11.8" env_logger = "0.11.8"
clap = { version = "4.5.38", features = ["derive", "env"] } clap = { version = "4.5.40", features = ["derive", "env"] }
light-openid = { version = "1.0.4", features = ["crypto-wrapper"] } light-openid = { version = "1.0.4", features = ["crypto-wrapper"] }
lazy_static = "1.5.0" lazy_static = "1.5.0"
actix = "0.13.5" actix = "0.13.5"
@ -19,24 +19,25 @@ actix-identity = "0.8.0"
actix-cors = "0.7.1" actix-cors = "0.7.1"
actix-files = "0.6.6" actix-files = "0.6.6"
actix-ws = "0.3.0" actix-ws = "0.3.0"
actix-http = "3.10.0" actix-http = "3.11.0"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140" serde_json = "1.0.140"
serde_yml = "0.0.12"
quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] } quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] }
futures-util = "0.3.31" futures-util = "0.3.31"
anyhow = "1.0.98" anyhow = "1.0.98"
actix-multipart = "0.7.2" actix-multipart = "0.7.2"
tempfile = "3.20.0" tempfile = "3.20.0"
reqwest = { version = "0.12.15", features = ["stream"] } reqwest = { version = "0.12.20", features = ["stream"] }
url = "2.5.4" url = "2.5.4"
virt = "0.4.2" virt = "0.4.2"
sysinfo = { version = "0.35.1", features = ["serde"] } sysinfo = { version = "0.35.2", features = ["serde"] }
uuid = { version = "1.16.0", features = ["v4", "serde"] } uuid = { version = "1.16.0", features = ["v4", "serde"] }
lazy-regex = "3.4.1" lazy-regex = "3.4.1"
thiserror = "2.0.12" thiserror = "2.0.12"
image = "0.25.6" image = "0.25.6"
rand = "0.9.1" rand = "0.9.1"
tokio = { version = "1.45.0", features = ["rt", "time", "macros"] } tokio = { version = "1.46.0", features = ["rt", "time", "macros"] }
futures = "0.3.31" futures = "0.3.31"
ipnetwork = { version = "0.21.1", features = ["serde"] } ipnetwork = { version = "0.21.1", features = ["serde"] }
num = "0.4.3" num = "0.4.3"
@ -44,3 +45,5 @@ rust-embed = { version = "8.7.2", features = ["mime-guess"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
nix = { version = "0.30.1", features = ["net"] } nix = { version = "0.30.1", features = ["net"] }
basic-jwt = "0.3.0" basic-jwt = "0.3.0"
zip = "4.1.0"
chrono = "0.4.41"

View File

@ -27,10 +27,7 @@ impl LibVirtActor {
/// Connect to hypervisor /// Connect to hypervisor
pub async fn connect() -> anyhow::Result<Self> { pub async fn connect() -> anyhow::Result<Self> {
let hypervisor_uri = AppConfig::get().hypervisor_uri.as_deref().unwrap_or(""); let hypervisor_uri = AppConfig::get().hypervisor_uri.as_deref().unwrap_or("");
log::info!( log::info!("Will connect to hypvervisor at address '{hypervisor_uri}'",);
"Will connect to hypvervisor at address '{}'",
hypervisor_uri
);
let conn = Connect::open(Some(hypervisor_uri))?; let conn = Connect::open(Some(hypervisor_uri))?;
Ok(Self { m: conn }) Ok(Self { m: conn })
@ -102,7 +99,7 @@ impl Handler<GetDomainXMLReq> for LibVirtActor {
log::debug!("Get domain XML:\n{}", msg.0.as_string()); log::debug!("Get domain XML:\n{}", msg.0.as_string());
let domain = Domain::lookup_by_uuid_string(&self.m, &msg.0.as_string())?; let domain = Domain::lookup_by_uuid_string(&self.m, &msg.0.as_string())?;
let xml = domain.get_xml_desc(VIR_DOMAIN_XML_SECURE)?; let xml = domain.get_xml_desc(VIR_DOMAIN_XML_SECURE)?;
log::debug!("XML = {}", xml); log::debug!("XML = {xml}");
DomainXML::parse_xml(&xml) DomainXML::parse_xml(&xml)
} }
} }
@ -131,7 +128,7 @@ impl Handler<DefineDomainReq> for LibVirtActor {
fn handle(&mut self, mut msg: DefineDomainReq, _ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, mut msg: DefineDomainReq, _ctx: &mut Self::Context) -> Self::Result {
let xml = msg.1.as_xml()?; let xml = msg.1.as_xml()?;
log::debug!("Define domain:\n{}", xml); log::debug!("Define domain:\n{xml}");
let domain = Domain::define_xml(&self.m, &xml)?; let domain = Domain::define_xml(&self.m, &xml)?;
let uuid = XMLUuid::parse_from_str(&domain.get_uuid_string()?)?; let uuid = XMLUuid::parse_from_str(&domain.get_uuid_string()?)?;
@ -446,7 +443,7 @@ impl Handler<GetNetworkXMLReq> for LibVirtActor {
log::debug!("Get network XML:\n{}", msg.0.as_string()); log::debug!("Get network XML:\n{}", msg.0.as_string());
let network = Network::lookup_by_uuid_string(&self.m, &msg.0.as_string())?; let network = Network::lookup_by_uuid_string(&self.m, &msg.0.as_string())?;
let xml = network.get_xml_desc(0)?; let xml = network.get_xml_desc(0)?;
log::debug!("XML = {}", xml); log::debug!("XML = {xml}");
NetworkXML::parse_xml(&xml) NetworkXML::parse_xml(&xml)
} }
} }
@ -602,7 +599,7 @@ impl Handler<GetNWFilterXMLReq> for LibVirtActor {
log::debug!("Get network filter XML:\n{}", msg.0.as_string()); log::debug!("Get network filter XML:\n{}", msg.0.as_string());
let filter = NWFilter::lookup_by_uuid_string(&self.m, &msg.0.as_string())?; let filter = NWFilter::lookup_by_uuid_string(&self.m, &msg.0.as_string())?;
let xml = filter.get_xml_desc(0)?; let xml = filter.get_xml_desc(0)?;
log::debug!("XML = {}", xml); log::debug!("XML = {xml}");
NetworkFilterXML::parse_xml(xml) NetworkFilterXML::parse_xml(xml)
} }
} }
@ -617,7 +614,7 @@ impl Handler<DefineNWFilterReq> for LibVirtActor {
fn handle(&mut self, mut msg: DefineNWFilterReq, _ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, mut msg: DefineNWFilterReq, _ctx: &mut Self::Context) -> Self::Result {
let xml = msg.1.into_xml()?; let xml = msg.1.into_xml()?;
log::debug!("Define network filter:\n{}", xml); log::debug!("Define network filter:\n{xml}");
let filter = NWFilter::define_xml(&self.m, &xml)?; let filter = NWFilter::define_xml(&self.m, &xml)?;
let uuid = XMLUuid::parse_from_str(&filter.get_uuid_string()?)?; let uuid = XMLUuid::parse_from_str(&filter.get_uuid_string()?)?;

View File

@ -280,7 +280,7 @@ impl AppConfig {
/// Get VM vnc sockets path for domain /// Get VM vnc sockets path for domain
pub fn vnc_socket_for_domain(&self, name: &str) -> PathBuf { pub fn vnc_socket_for_domain(&self, name: &str) -> PathBuf {
self.vnc_sockets_path().join(format!("vnc-{}", name)) self.vnc_sockets_path().join(format!("vnc-{name}"))
} }
/// Get VM root disks storage directory /// Get VM root disks storage directory

View File

@ -34,8 +34,7 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>)
if let Some(mime_type) = file.content_type { if let Some(mime_type) = file.content_type {
if !constants::ALLOWED_DISK_IMAGES_MIME_TYPES.contains(&mime_type.as_ref()) { if !constants::ALLOWED_DISK_IMAGES_MIME_TYPES.contains(&mime_type.as_ref()) {
return Ok(HttpResponse::BadRequest().json(format!( return Ok(HttpResponse::BadRequest().json(format!(
"Unsupported file type for disk upload: {}", "Unsupported file type for disk upload: {mime_type}"
mime_type
))); )));
} }
} }

View File

@ -52,7 +52,7 @@ pub async fn upload_file(MultipartForm(mut form): MultipartForm<UploadIsoForm>)
} }
let dest_file = AppConfig::get().iso_storage_path().join(file_name); let dest_file = AppConfig::get().iso_storage_path().join(file_name);
log::info!("Will save ISO file {:?}", dest_file); log::info!("Will save ISO file {dest_file:?}");
if dest_file.exists() { if dest_file.exists() {
log::error!("Conflict with uploaded iso file name!"); log::error!("Conflict with uploaded iso file name!");

View File

@ -4,6 +4,7 @@ use actix_web::body::BoxBody;
use actix_web::{HttpResponse, web}; use actix_web::{HttpResponse, web};
use std::error::Error; use std::error::Error;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use zip::result::ZipError;
pub mod api_tokens_controller; pub mod api_tokens_controller;
pub mod auth_controller; pub mod auth_controller;
@ -42,7 +43,7 @@ impl actix_web::error::ResponseError for HttpErr {
} }
} }
fn error_response(&self) -> HttpResponse<BoxBody> { fn error_response(&self) -> HttpResponse<BoxBody> {
log::error!("Error while processing request! {}", self); log::error!("Error while processing request! {self}");
HttpResponse::InternalServerError().body("Failed to execute request!") HttpResponse::InternalServerError().body("Failed to execute request!")
} }
@ -102,6 +103,12 @@ impl From<actix_web::Error> for HttpErr {
} }
} }
impl From<ZipError> for HttpErr {
fn from(value: ZipError) -> Self {
HttpErr::Err(std::io::Error::other(value.to_string()).into())
}
}
impl From<HttpResponse> for HttpErr { impl From<HttpResponse> for HttpErr {
fn from(value: HttpResponse) -> Self { fn from(value: HttpResponse) -> Self {
HttpErr::HTTPResponse(value) HttpErr::HTTPResponse(value)

View File

@ -1,14 +1,24 @@
use crate::actors::vnc_tokens_actor::VNC_TOKEN_LIFETIME; use crate::actors::vnc_tokens_actor::VNC_TOKEN_LIFETIME;
use crate::app_config::AppConfig; use crate::app_config::AppConfig;
use crate::constants;
use crate::constants::{DISK_NAME_MAX_LEN, DISK_NAME_MIN_LEN, DISK_SIZE_MAX, DISK_SIZE_MIN}; use crate::constants::{DISK_NAME_MAX_LEN, DISK_NAME_MIN_LEN, DISK_SIZE_MAX, DISK_SIZE_MIN};
use crate::controllers::{HttpResult, LibVirtReq}; use crate::controllers::{HttpResult, LibVirtReq};
use crate::extractors::local_auth_extractor::LocalAuthEnabled; use crate::extractors::local_auth_extractor::LocalAuthEnabled;
use crate::libvirt_rest_structures::hypervisor::HypervisorInfo; use crate::libvirt_rest_structures::hypervisor::HypervisorInfo;
use crate::libvirt_rest_structures::net::NetworkInfo;
use crate::libvirt_rest_structures::nw_filter::NetworkFilter;
use crate::libvirt_rest_structures::vm::VMInfo;
use crate::nat::nat_hook; use crate::nat::nat_hook;
use crate::utils::net_utils; use crate::utils::net_utils;
use actix_web::{HttpResponse, Responder}; use crate::utils::time_utils::{format_date, time};
use crate::{api_tokens, constants};
use actix_files::NamedFile;
use actix_web::{HttpRequest, HttpResponse, Responder};
use serde::Serialize;
use std::fs::File;
use std::io::Write;
use sysinfo::{Components, Disks, Networks, System}; use sysinfo::{Components, Disks, Networks, System};
use zip::ZipWriter;
use zip::write::SimpleFileOptions;
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
struct StaticConfig { struct StaticConfig {
@ -199,3 +209,85 @@ pub async fn networks_list() -> HttpResult {
pub async fn bridges_list() -> HttpResult { pub async fn bridges_list() -> HttpResult {
Ok(HttpResponse::Ok().json(net_utils::bridges_list()?)) 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))
}

View File

@ -13,4 +13,6 @@ enum LibVirtStructError {
ParseFilteringChain(String), ParseFilteringChain(String),
#[error("NetworkFilterExtractionError: {0}")] #[error("NetworkFilterExtractionError: {0}")]
NetworkFilterExtraction(String), NetworkFilterExtraction(String),
#[error("CloudInitConfigurationError: {0}")]
CloudInitConfiguration(String),
} }

View File

@ -109,7 +109,7 @@ fn extract_ip_mask<const V: usize>(n: Option<u8>) -> anyhow::Result<Option<u8>>
fn extract_nw_filter_comment(n: &Option<String>) -> anyhow::Result<Option<String>> { fn extract_nw_filter_comment(n: &Option<String>) -> anyhow::Result<Option<String>> {
if let Some(comment) = n { if let Some(comment) = n {
if comment.len() > 256 || comment.contains('\"') || comment.contains('\n') { if comment.len() > 256 || comment.contains('\"') || comment.contains('\n') {
return Err(NetworkFilterExtraction(format!("Invalid comment! {}", comment)).into()); return Err(NetworkFilterExtraction(format!("Invalid comment! {comment}")).into());
} }
} }

View File

@ -3,7 +3,9 @@ use crate::constants;
use crate::libvirt_lib_structures::XMLUuid; use crate::libvirt_lib_structures::XMLUuid;
use crate::libvirt_lib_structures::domain::*; use crate::libvirt_lib_structures::domain::*;
use crate::libvirt_rest_structures::LibVirtStructError; use crate::libvirt_rest_structures::LibVirtStructError;
use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction; use crate::libvirt_rest_structures::LibVirtStructError::{
CloudInitConfiguration, StructureExtraction,
};
use crate::utils::cloud_init_utils::CloudInitConfig; use crate::utils::cloud_init_utils::CloudInitConfig;
use crate::utils::file_size_utils::FileSize; use crate::utils::file_size_utils::FileSize;
use crate::utils::files_utils; use crate::utils::files_utils;
@ -142,6 +144,10 @@ impl VMInfo {
return Err(StructureExtraction("Invalid number of vCPU specified!").into()); 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 iso_absolute_files = vec![]; let mut iso_absolute_files = vec![];
// Process cloud init image // Process cloud init image

View File

@ -157,6 +157,10 @@ async fn main() -> std::io::Result<()> {
"/api/server/bridges", "/api/server/bridges",
web::get().to(server_controller::bridges_list), web::get().to(server_controller::bridges_list),
) )
.route(
"/api/server/export_configs",
web::get().to(server_controller::export_all_configs),
)
// Auth controller // Auth controller
.route( .route(
"/api/auth/local", "/api/auth/local",

View File

@ -69,8 +69,7 @@ where
if !AppConfig::get().is_allowed_ip(remote_ip.0) { if !AppConfig::get().is_allowed_ip(remote_ip.0) {
log::error!( log::error!(
"An attempt to access VirtWeb from an unauthorized network has been intercepted! {:?}", "An attempt to access VirtWeb from an unauthorized network has been intercepted! {remote_ip:?}"
remote_ip
); );
return Ok(req return Ok(req
.into_response( .into_response(

View File

@ -35,6 +35,27 @@ pub struct CloudInitConfig {
} }
impl CloudInitConfig { 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 /// Generate disk image for nocloud usage
pub fn generate_nocloud_disk(&self) -> anyhow::Result<Vec<u8>> { pub fn generate_nocloud_disk(&self) -> anyhow::Result<Vec<u8>> {
let temp_path = tempfile::tempdir_in(&AppConfig::get().temp_dir)?; let temp_path = tempfile::tempdir_in(&AppConfig::get().temp_dir)?;
@ -60,10 +81,10 @@ impl CloudInitConfig {
// Process metadata // Process metadata
let mut metadatas = vec![]; let mut metadatas = vec![];
if let Some(inst_id) = &self.instance_id { if let Some(inst_id) = &self.instance_id {
metadatas.push(format!("instance-id: {}", inst_id)); metadatas.push(format!("instance-id: {inst_id}"));
} }
if let Some(local_hostname) = &self.local_hostname { if let Some(local_hostname) = &self.local_hostname {
metadatas.push(format!("local-hostname: {}", local_hostname)); metadatas.push(format!("local-hostname: {local_hostname}"));
} }
if let Some(dsmode) = &self.dsmode { if let Some(dsmode) = &self.dsmode {
metadatas.push(format!( metadatas.push(format!(

View File

@ -394,6 +394,44 @@ impl DiskFileInfo {
Ok(()) Ok(())
} }
/// Get disk virtual size, if available
pub fn virtual_size(&self) -> Option<FileSize> {
match self.format {
DiskFileFormat::Raw { .. } => Some(self.file_size),
DiskFileFormat::QCow2 { virtual_size } => Some(virtual_size),
_ => None,
}
}
/// Resize disk
pub fn resize(&self, new_size: FileSize) -> anyhow::Result<()> {
if new_size <= self.virtual_size().unwrap_or(new_size) {
anyhow::bail!("Shrinking disk image file is not supported!");
}
let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
cmd.arg("resize")
.arg("-f")
.arg(match self.format {
DiskFileFormat::QCow2 { .. } => "qcow2",
DiskFileFormat::Raw { .. } => "raw",
f => anyhow::bail!("Unsupported disk format for resize: {f:?}"),
})
.arg(&self.file_path)
.arg(new_size.as_bytes().to_string());
let output = cmd.output()?;
if !output.status.success() {
anyhow::bail!(
"{} info failed, status: {}, stderr: {}",
constants::PROGRAM_QEMU_IMAGE,
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]

View File

@ -1,3 +1,4 @@
use chrono::Datelike;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
/// Get the current time since epoch /// Get the current time since epoch
@ -13,3 +14,15 @@ pub fn time() -> u64 {
.unwrap() .unwrap()
.as_secs() .as_secs()
} }
/// Format given UNIX time in a simple format
pub fn format_date(time: i64) -> anyhow::Result<String> {
let date = chrono::DateTime::from_timestamp(time, 0).ok_or(anyhow::anyhow!("invalid date"))?;
Ok(format!(
"{:0>2}/{:0>2}/{}",
date.day(),
date.month(),
date.year()
))
}

View File

@ -44,6 +44,9 @@ pub struct VMFileDisk {
/// When creating a new disk, specify the disk image template to use /// When creating a new disk, specify the disk image template to use
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub from_image: Option<String>, 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 /// Set this variable to true to delete the disk
pub delete: bool, pub delete: bool,
} }
@ -78,6 +81,7 @@ impl VMFileDisk {
delete: false, delete: false,
from_image: None, from_image: None,
resize: None,
}) })
} }
@ -144,28 +148,40 @@ impl VMFileDisk {
if file.exists() { if file.exists() {
log::debug!("File {file:?} does not exists, so it was not touched"); log::debug!("File {file:?} does not exists, so it was not touched");
return Ok(()); }
// Create disk if required
else {
// Determine file format
let format = match self.format {
VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
virtual_size: self.size,
},
};
// Create / Restore disk file
match &self.from_image {
// Create disk file
None => {
DiskFileInfo::create(&file, format, self.size)?;
}
// Restore disk image template
Some(disk_img) => {
let src_file =
DiskFileInfo::load_file(&AppConfig::get().disk_images_file_path(disk_img))?;
src_file.convert(&file, format)?;
}
}
} }
let format = match self.format { // Resize disk file if requested
VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse }, if self.resize == Some(true) {
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 { let disk = DiskFileInfo::load_file(&file)?;
virtual_size: self.size,
},
};
// Create / Restore disk file // Can only increase disk size
match &self.from_image { if let Err(e) = disk.resize(self.size) {
// Create disk file log::error!("Failed to resize disk file {}: {e:?}", self.name);
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)?;
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -12,38 +12,42 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.2.5", "@fontsource/roboto": "^5.2.6",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
"@mui/icons-material": "^7.1.1", "@monaco-editor/react": "^4.7.0",
"@mui/material": "^7.1.1", "@mui/icons-material": "^7.1.2",
"@mui/material": "^7.1.2",
"@mui/x-charts": "^8.3.1", "@mui/x-charts": "^8.3.1",
"@mui/x-data-grid": "^8.3.1", "@mui/x-data-grid": "^8.5.3",
"date-and-time": "^3.6.0", "date-and-time": "^3.6.0",
"filesize": "^10.1.6", "filesize": "^10.1.6",
"humanize-duration": "^3.32.2", "humanize-duration": "^3.32.2",
"monaco-editor": "^0.52.2",
"monaco-yaml": "^5.4.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^7.6.2", "react-router-dom": "^7.6.2",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"react-vnc": "^3.1.0", "react-vnc": "^3.1.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"xml-formatter": "^3.6.6" "xml-formatter": "^3.6.6",
"yaml": "^2.8.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.27.0", "@eslint/js": "^9.27.0",
"@types/humanize-duration": "^3.27.4", "@types/humanize-duration": "^3.27.4",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/react": "^19.1.6", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.27.0", "eslint": "^9.29.0",
"eslint-plugin-react-dom": "^1.49.0", "eslint-plugin-react-dom": "^1.52.2",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-react-x": "^1.49.0", "eslint-plugin-react-x": "^1.52.2",
"globals": "^16.1.0", "globals": "^16.1.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.32.1", "typescript-eslint": "^8.32.1",

View File

@ -232,4 +232,16 @@ export class ServerApi {
}) })
).data; ).data;
} }
/**
* Export all server configs
*/
static async ExportServerConfigs(): Promise<Blob> {
return (
await APIClient.exec({
method: "GET",
uri: "/server/export_configs",
})
).data;
}
} }

View File

@ -31,8 +31,12 @@ export interface BaseFileVMDisk {
// For new disk only // For new disk only
from_image?: string; from_image?: string;
// Resize disk image after clone
resize?: boolean;
// application attributes // application attributes
new?: boolean; new?: boolean;
originalSize?: number;
deleteType?: "keepfile" | "deletefile"; deleteType?: "keepfile" | "deletefile";
} }

View File

@ -3,16 +3,44 @@ import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css"; import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css"; import "@fontsource/roboto/700.css";
import { ThemeProvider, createTheme } from "@mui/material";
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider";
import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider";
import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider";
import { SnackbarProvider } from "./hooks/providers/SnackbarProvider";
import "./index.css"; import "./index.css";
import { LoadServerConfig } from "./widgets/LoadServerConfig"; import { LoadServerConfig } from "./widgets/LoadServerConfig";
import { ThemeProvider, createTheme } from "@mui/material";
import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider"; import { loader } from "@monaco-editor/react";
import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider"; import * as monaco from "monaco-editor";
import { SnackbarProvider } from "./hooks/providers/SnackbarProvider"; import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider"; import { configureMonacoYaml } from "monaco-yaml";
import YamlWorker from "monaco-yaml/yaml.worker?worker";
// This allows to use a self hosted instance of Monaco editor
loader.config({ monaco });
// Add YAML support to Monaco
configureMonacoYaml(monaco, {
enableSchemaRequest: false,
});
/// YAML worker
window.MonacoEnvironment = {
getWorker(_moduleId, label) {
switch (label) {
case "editorWorkerService":
return new EditorWorker();
case "yaml":
return new YamlWorker();
default:
throw new Error(`Unknown label ${label}`);
}
},
};
const darkTheme = createTheme({ const darkTheme = createTheme({
palette: { palette: {
@ -20,9 +48,7 @@ const darkTheme = createTheme({
}, },
}); });
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(document.getElementById("root")!);
document.getElementById("root")!
);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider theme={darkTheme}> <ThemeProvider theme={darkTheme}>

View File

@ -9,18 +9,21 @@ import {
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import { import {
Box, Box,
IconButton,
LinearProgress, LinearProgress,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableHead, TableHead,
TableRow, TableRow,
Tooltip,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import Grid from "@mui/material/Grid"; import Grid from "@mui/material/Grid";
import { PieChart } from "@mui/x-charts"; import { PieChart } from "@mui/x-charts";
import { filesize } from "filesize"; import { filesize } from "filesize";
import humanizeDuration from "humanize-duration"; import humanizeDuration from "humanize-duration";
import IosShareIcon from "@mui/icons-material/IosShare";
import React from "react"; import React from "react";
import { import {
DiskInfo, DiskInfo,
@ -31,6 +34,8 @@ import {
import { AsyncWidget } from "../widgets/AsyncWidget"; import { AsyncWidget } from "../widgets/AsyncWidget";
import { VirtWebPaper } from "../widgets/VirtWebPaper"; import { VirtWebPaper } from "../widgets/VirtWebPaper";
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
import { useAlert } from "../hooks/providers/AlertDialogProvider";
export function SysInfoRoute(): React.ReactElement { export function SysInfoRoute(): React.ReactElement {
const [info, setInfo] = React.useState<ServerSystemInfo>(); const [info, setInfo] = React.useState<ServerSystemInfo>();
@ -52,6 +57,23 @@ export function SysInfoRoute(): React.ReactElement {
export function SysInfoRouteInner(p: { export function SysInfoRouteInner(p: {
info: ServerSystemInfo; info: ServerSystemInfo;
}): React.ReactElement { }): React.ReactElement {
const alert = useAlert();
const loadingMessage = useLoadingMessage();
const downloadAllConfig = async () => {
try {
loadingMessage.show("Downloading server config...");
const res = await ServerApi.ExportServerConfigs();
const url = URL.createObjectURL(res);
window.location.href = url;
} catch (e) {
console.error("Failed to download server config!", e);
alert(`Failed to download server config! ${e}`);
} finally {
loadingMessage.hide();
}
};
const sumDiskUsage = p.info.disks.reduce( const sumDiskUsage = p.info.disks.reduce(
(prev, disk) => { (prev, disk) => {
return { return {
@ -63,7 +85,16 @@ export function SysInfoRouteInner(p: {
); );
return ( return (
<VirtWebRouteContainer label="Sysinfo"> <VirtWebRouteContainer
label="Sysinfo"
actions={
<Tooltip title="Export all server configs">
<IconButton onClick={downloadAllConfig}>
<IosShareIcon />
</IconButton>
</Tooltip>
}
>
<Grid container spacing={2}> <Grid container spacing={2}>
{/* Memory */} {/* Memory */}
<Grid size={{ xs: 4 }}> <Grid size={{ xs: 4 }}>
@ -288,7 +319,7 @@ function DiskDetailsTable(p: { disks: DiskInfo[] }): React.ReactElement {
{p.disks.map((e, c) => ( {p.disks.map((e, c) => (
<TableRow hover key={c}> <TableRow hover key={c}>
<TableCell>{e.name}</TableCell> <TableCell>{e.name}</TableCell>
<TableCell>{e.DiskKind}</TableCell> <TableCell>{String(e.DiskKind)}</TableCell>
<TableCell>{e.mount_point}</TableCell> <TableCell>{e.mount_point}</TableCell>
<TableCell>{filesize(e.total_space)}</TableCell> <TableCell>{filesize(e.total_space)}</TableCell>
<TableCell>{filesize(e.available_space)}</TableCell> <TableCell>{filesize(e.available_space)}</TableCell>

View File

@ -17,7 +17,9 @@ export function CheckboxInput(p: {
<Checkbox <Checkbox
disabled={!p.editable} disabled={!p.editable}
checked={p.checked} checked={p.checked}
onChange={(e) => { p.onValueChange(e.target.checked); }} onChange={(e) => {
p.onValueChange(e.target.checked);
}}
/> />
} }
label={p.label} label={p.label}

View File

@ -0,0 +1,341 @@
/* eslint-disable @typescript-eslint/no-base-to-string */
import Editor from "@monaco-editor/react";
import BookIcon from "@mui/icons-material/Book";
import RefreshIcon from "@mui/icons-material/Refresh";
import { Grid, IconButton, InputAdornment, Tooltip } from "@mui/material";
import React from "react";
import { v4 as uuidv4 } from "uuid";
import YAML from "yaml";
import { VMInfo } from "../../api/VMApi";
import { RouterLink } from "../RouterLink";
import { CheckboxInput } from "./CheckboxInput";
import { EditSection } from "./EditSection";
import { SelectInput } from "./SelectInput";
import { TextInput } from "./TextInput";
interface CloudInitProps {
vm: VMInfo;
onChange?: () => void;
editable: boolean;
}
export function CloudInitEditor(p: CloudInitProps): React.ReactElement {
return (
<>
<EditSection>
{/* Attach cloud init disk */}
<CheckboxInput
{...p}
label="Attach Cloud Init disk"
checked={p.vm.cloud_init.attach_config}
onValueChange={(v) => {
p.vm.cloud_init.attach_config = v;
p.onChange?.();
}}
/>
</EditSection>
<Grid container spacing={2}>
<CloudInitMetadata
{...p}
editable={p.editable && p.vm.cloud_init.attach_config}
/>
<CloudInitRawUserData
{...p}
editable={p.editable && p.vm.cloud_init.attach_config}
/>
<CloudInitNetworkConfig
{...p}
editable={p.editable && p.vm.cloud_init.attach_config}
/>
<CloudInitUserDataAssistant
{...p}
editable={p.editable && p.vm.cloud_init.attach_config}
/>
</Grid>
</>
);
}
function CloudInitMetadata(p: CloudInitProps): React.ReactElement {
// Regenerate instance id
const reGenerateInstanceId = () => {
p.vm.cloud_init.instance_id = uuidv4();
p.onChange?.();
};
return (
<EditSection title="Metadata">
{/* Instance ID */}
<TextInput
{...p}
label="Instance ID"
value={p.vm.cloud_init.instance_id}
onValueChange={(v) => {
p.vm.cloud_init.instance_id = v;
p.onChange?.();
}}
endAdornment={
p.editable ? (
<InputAdornment position="end">
<Tooltip title="Generate a new instance ID">
<IconButton onClick={reGenerateInstanceId}>
<RefreshIcon />
</IconButton>
</Tooltip>
</InputAdornment>
) : (
<></>
)
}
/>
{/* Instance hostname */}
<TextInput
{...p}
label="Local hostname"
value={p.vm.cloud_init.local_hostname}
onValueChange={(v) => {
p.vm.cloud_init.local_hostname = v;
p.onChange?.();
}}
/>
{/* Data source mode */}
<SelectInput
{...p}
label="Data source mode"
value={p.vm.cloud_init.dsmode}
onValueChange={(v) => {
p.vm.cloud_init.dsmode = v as any;
p.onChange?.();
}}
options={[
{ label: "None", value: undefined },
{ value: "Net" },
{ value: "Local" },
]}
/>
</EditSection>
);
}
function CloudInitRawUserData(p: CloudInitProps): React.ReactElement {
return (
<EditSection
title="User data"
actions={
<RouterLink
target="_blank"
to="https://cloudinit.readthedocs.io/en/latest/reference/index.html"
>
<Tooltip title="Official reference">
<IconButton size="small">
<BookIcon />
</IconButton>
</Tooltip>
</RouterLink>
}
>
<Editor
theme="vs-dark"
options={{
readOnly: !p.editable,
quickSuggestions: { other: true, comments: true, strings: true },
wordWrap: "on",
}}
language="yaml"
height={"30vh"}
value={p.vm.cloud_init.user_data}
onChange={(v) => {
p.vm.cloud_init.user_data = v ?? "";
p.onChange?.();
}}
/>
</EditSection>
);
}
function CloudInitNetworkConfig(p: CloudInitProps): React.ReactElement {
if (!p.editable && !p.vm.cloud_init.network_configuration) return <></>;
return (
<EditSection
title="Network configuration"
actions={
<RouterLink
target="_blank"
to="https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v2.html"
>
<Tooltip title="Official network configuration reference">
<IconButton size="small">
<BookIcon />
</IconButton>
</Tooltip>
</RouterLink>
}
>
<Editor
theme="vs-dark"
options={{
readOnly: !p.editable,
quickSuggestions: { other: true, comments: true, strings: true },
wordWrap: "on",
}}
language="yaml"
height={"30vh"}
value={p.vm.cloud_init.network_configuration ?? ""}
onChange={(v) => {
if (v && v !== "") p.vm.cloud_init.network_configuration = v;
else p.vm.cloud_init.network_configuration = undefined;
p.onChange?.();
}}
/>
</EditSection>
);
}
function CloudInitUserDataAssistant(p: CloudInitProps): React.ReactElement {
const user_data = React.useMemo(() => {
return YAML.parseDocument(p.vm.cloud_init.user_data);
}, [p.vm.cloud_init.user_data]);
const onChange = () => {
p.vm.cloud_init.user_data = user_data.toString();
if (!p.vm.cloud_init.user_data.startsWith("#cloud-config"))
p.vm.cloud_init.user_data = `#cloud-config\n${p.vm.cloud_init.user_data}`;
p.onChange?.();
};
const SYSTEMD_NOT_SERIAL = `/bin/sh -c "rm -f /etc/default/grub.d/50-cloudimg-settings.cfg && sed -i 's/quiet splash//g' /etc/default/grub && update-grub"`;
return (
<EditSection title="User data assistant">
<CloudInitTextInput
editable={p.editable}
name="Default user name"
refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
attrPath={["user", "name"]}
onChange={onChange}
yaml={user_data}
/>
<CloudInitTextInput
editable={p.editable}
name="Default user password"
refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
attrPath={["password"]}
onChange={onChange}
yaml={user_data}
/>
<CloudInitBooleanInput
editable={p.editable}
name="Expire password to require new password on next login"
yaml={user_data}
attrPath={["chpasswd", "expire"]}
onChange={onChange}
refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
/>
<br />
<CloudInitBooleanInput
editable={p.editable}
name="Enable SSH password auth"
yaml={user_data}
attrPath={["ssh_pwauth"]}
onChange={onChange}
refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
/>
<CloudInitTextInput
editable={p.editable}
name="Keyboard layout"
refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#keyboard"
attrPath={["keyboard", "layout"]}
onChange={onChange}
yaml={user_data}
/>
<CloudInitTextInput
editable={p.editable}
name="Final message"
refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#final-message"
attrPath={["final_message"]}
onChange={onChange}
yaml={user_data}
/>
{/* /bin/sh -c "rm -f /etc/default/grub.d/50-cloudimg-settings.cfg && update-grub" */}
<CheckboxInput
editable={p.editable}
label="Show all startup messages on tty1, not serial"
checked={
!!(user_data.get("runcmd") as any)?.items.find(
(a: any) => a.value === SYSTEMD_NOT_SERIAL
)
}
onValueChange={(c) => {
if (!user_data.getIn(["runcmd"])) user_data.addIn(["runcmd"], []);
const runcmd = user_data.getIn(["runcmd"]) as any;
if (c) {
runcmd.addIn([], SYSTEMD_NOT_SERIAL);
} else {
const idx = runcmd.items.findIndex(
(o: any) => o.value === SYSTEMD_NOT_SERIAL
);
runcmd.items.splice(idx, 1);
}
onChange();
}}
/>
</EditSection>
);
}
function CloudInitTextInput(p: {
editable: boolean;
name: string;
refUrl: string;
attrPath: Iterable<unknown>;
yaml: YAML.Document;
onChange?: () => void;
}): React.ReactElement {
return (
<TextInput
editable={p.editable}
label={p.name}
value={String(p.yaml.getIn(p.attrPath) ?? "")}
onValueChange={(v) => {
if (v !== undefined) p.yaml.setIn(p.attrPath, v);
else p.yaml.deleteIn(p.attrPath);
p.onChange?.();
}}
endAdornment={
<RouterLink to={p.refUrl} target="_blank">
<IconButton size="small">
<BookIcon />
</IconButton>
</RouterLink>
}
/>
);
}
function CloudInitBooleanInput(p: {
editable: boolean;
name: string;
refUrl: string;
attrPath: Iterable<unknown>;
yaml: YAML.Document;
onChange?: () => void;
}): React.ReactElement {
return (
<CheckboxInput
editable={p.editable}
label={p.name}
checked={p.yaml.getIn(p.attrPath) === true}
onValueChange={(v) => {
p.yaml.setIn(p.attrPath, v);
p.onChange?.();
}}
/>
);
}

View File

@ -0,0 +1,25 @@
import { ServerApi } from "../../api/ServerApi";
import { TextInput } from "./TextInput";
export function DiskSizeInput(p: {
editable: boolean;
label?: string;
value: number;
onChange?: (size: number) => void;
}): React.ReactElement {
return (
<TextInput
editable={p.editable}
label={p.label ?? "Disk size (GB)"}
size={{
min: ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000),
max: ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000),
}}
value={(p.value / (1000 * 1000 * 1000)).toString()}
onValueChange={(v) => {
p.onChange?.(Number(v ?? "0") * 1000 * 1000 * 1000);
}}
type="number"
/>
);
}

View File

@ -19,13 +19,10 @@ export function EditSection(
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
marginBottom: "15px",
}} }}
> >
{p.title && ( {p.title && <Typography variant="h5">{p.title}</Typography>}
<Typography variant="h5" style={{ marginBottom: "15px" }}>
{p.title}
</Typography>
)}
{p.actions} {p.actions}
</span> </span>
)} )}

View File

@ -18,6 +18,7 @@ export function TextInput(p: {
style?: React.CSSProperties; style?: React.CSSProperties;
helperText?: string; helperText?: string;
disabled?: boolean; disabled?: boolean;
endAdornment?: React.ReactNode;
}): React.ReactElement { }): React.ReactElement {
if (!p.editable && (p.value ?? "") === "") return <></>; if (!p.editable && (p.value ?? "") === "") return <></>;
@ -51,6 +52,7 @@ export function TextInput(p: {
input: { input: {
readOnly: !p.editable, readOnly: !p.editable,
type: p.type, type: p.type,
endAdornment: p.endAdornment,
}, },
}} }}
variant={"standard"} variant={"standard"}

View File

@ -2,7 +2,8 @@ import { mdiHarddiskPlus } from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import { Button, IconButton, Paper, Tooltip } from "@mui/material"; import ExpandIcon from "@mui/icons-material/Expand";
import { Button, IconButton, Paper, Tooltip, Typography } from "@mui/material";
import React from "react"; import React from "react";
import { DiskImage } from "../../api/DiskImageApi"; import { DiskImage } from "../../api/DiskImageApi";
import { ServerApi } from "../../api/ServerApi"; import { ServerApi } from "../../api/ServerApi";
@ -13,6 +14,7 @@ import { VMDiskFileWidget } from "../vms/VMDiskFileWidget";
import { CheckboxInput } from "./CheckboxInput"; import { CheckboxInput } from "./CheckboxInput";
import { DiskBusSelect } from "./DiskBusSelect"; import { DiskBusSelect } from "./DiskBusSelect";
import { DiskImageSelect } from "./DiskImageSelect"; import { DiskImageSelect } from "./DiskImageSelect";
import { DiskSizeInput } from "./DiskSizeInput";
import { SelectInput } from "./SelectInput"; import { SelectInput } from "./SelectInput";
import { TextInput } from "./TextInput"; import { TextInput } from "./TextInput";
@ -67,6 +69,12 @@ export function VMDisksList(p: {
/> />
))} ))}
{p.vm.file_disks.length === 0 && (
<Typography style={{ textAlign: "center", paddingTop: "25px" }}>
No disk file yet!
</Typography>
)}
{p.editable && <Button onClick={addNewDisk}>Add new disk</Button>} {p.editable && <Button onClick={addNewDisk}>Add new disk</Button>}
{/* Disk backup */} {/* Disk backup */}
@ -93,6 +101,19 @@ function DiskInfo(p: {
diskImagesList: DiskImage[]; diskImagesList: DiskImage[];
}): React.ReactElement { }): React.ReactElement {
const confirm = useConfirm(); const confirm = useConfirm();
const expandDisk = () => {
if (p.disk.resize === true) {
p.disk.resize = false;
p.disk.size = p.disk.originalSize!;
} else {
p.disk.resize = true;
p.disk.originalSize = p.disk.size!;
}
p.onChange?.();
};
const deleteDisk = async () => { const deleteDisk = async () => {
if (p.disk.deleteType) { if (p.disk.deleteType) {
p.disk.deleteType = undefined; p.disk.deleteType = undefined;
@ -115,42 +136,75 @@ function DiskInfo(p: {
if (!p.editable || !p.disk.new) if (!p.editable || !p.disk.new)
return ( return (
<VMDiskFileWidget <>
{...p} <VMDiskFileWidget
secondaryAction={ {...p}
<> secondaryAction={
{p.editable && ( <>
<IconButton {p.editable && !p.disk.deleteType && (
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 <IconButton
onClick={() => { edge="end"
p.onRequestBackup(p.disk); aria-label="expand disk"
}} onClick={expandDisk}
> >
<Icon path={mdiHarddiskPlus} size={1} /> {p.disk.resize === true ? (
<Tooltip title="Cancel disk expansion">
<ExpandIcon color="error" />
</Tooltip>
) : (
<Tooltip title="Increase disk size">
<ExpandIcon />
</Tooltip>
)}
</IconButton> </IconButton>
</Tooltip> )}
)}
</> {p.editable && (
} <IconButton
/> edge="end"
aria-label="delete disk"
onClick={deleteDisk}
>
{p.disk.deleteType ? (
<Tooltip title="Cancel disk removal">
<CheckCircleIcon />
</Tooltip>
) : (
<Tooltip title="Remove disk">
<DeleteIcon />
</Tooltip>
)}
</IconButton>
)}
{p.canBackup && (
<Tooltip title="Backup this disk">
<IconButton
onClick={() => {
p.onRequestBackup(p.disk);
}}
>
<Icon path={mdiHarddiskPlus} size={1} />
</IconButton>
</Tooltip>
)}
</>
}
/>
{/* New disk size*/}
{p.disk.resize && !p.disk.deleteType && (
<DiskSizeInput
editable
label="New disk size (GB)"
value={p.disk.size}
onChange={(v) => {
p.disk.size = v;
p.onChange?.();
}}
/>
)}
</>
); );
return ( return (
@ -212,24 +266,32 @@ function DiskInfo(p: {
/> />
)} )}
<TextInput {/* Resize disk image */}
editable={true} {!!p.disk.from_image && (
label="Disk size (GB)" <CheckboxInput
size={{ editable
min: checked={p.disk.resize}
ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000), label="Resize disk file"
max: onValueChange={(v) => {
ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000), p.disk.resize = v;
}} p.onChange?.();
value={(p.disk.size / (1000 * 1000 * 1000)).toString()} }}
onValueChange={(v) => { />
p.disk.size = Number(v ?? "0") * 1000 * 1000 * 1000; )}
p.onChange?.();
}}
type="number"
disabled={!!p.disk.from_image}
/>
{/* 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 <DiskImageSelect
label="Use disk image as template" label="Use disk image as template"
list={p.diskImagesList} list={p.diskImagesList}

View File

@ -10,6 +10,7 @@ import {
ListItemAvatar, ListItemAvatar,
ListItemText, ListItemText,
Tooltip, Tooltip,
Typography,
} from "@mui/material"; } from "@mui/material";
import Grid from "@mui/material/Grid"; import Grid from "@mui/material/Grid";
import { NWFilter } from "../../api/NWFilterApi"; import { NWFilter } from "../../api/NWFilterApi";
@ -49,6 +50,12 @@ export function VMNetworksList(p: {
</div> </div>
)} )}
{p.vm.networks.length === 0 && (
<Typography style={{ textAlign: "center", paddingTop: "25px" }}>
No network interface defined yet!
</Typography>
)}
<Grid container spacing={2}> <Grid container spacing={2}>
{/* networks list */} {/* networks list */}
{p.vm.networks.map((n, num) => ( {p.vm.networks.map((n, num) => (

View File

@ -799,6 +799,11 @@ export function TokenRightsEditor(p: {
right={{ verb: "GET", path: "/api/server/bridges" }} right={{ verb: "GET", path: "/api/server/bridges" }}
label="Get list of network bridges" label="Get list of network bridges"
/> />
<RouteRight
{...p}
right={{ verb: "GET", path: "/api/server/export_configs" }}
label="Export all configurations"
/>
</RightsSection> </RightsSection>
</> </>
); );

View File

@ -5,6 +5,7 @@ import Grid from "@mui/material/Grid";
import React from "react"; import React from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { validate as validateUUID } from "uuid"; import { validate as validateUUID } from "uuid";
import { DiskImage, DiskImageApi } from "../../api/DiskImageApi";
import { GroupApi } from "../../api/GroupApi"; import { GroupApi } from "../../api/GroupApi";
import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi"; import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi";
import { NWFilter, NWFilterApi } from "../../api/NWFilterApi"; import { NWFilter, NWFilterApi } from "../../api/NWFilterApi";
@ -18,6 +19,7 @@ import { AsyncWidget } from "../AsyncWidget";
import { TabsWidget } from "../TabsWidget"; import { TabsWidget } from "../TabsWidget";
import { XMLAsyncWidget } from "../XMLWidget"; import { XMLAsyncWidget } from "../XMLWidget";
import { CheckboxInput } from "../forms/CheckboxInput"; import { CheckboxInput } from "../forms/CheckboxInput";
import { CloudInitEditor } from "../forms/CloudInitEditor";
import { EditSection } from "../forms/EditSection"; import { EditSection } from "../forms/EditSection";
import { OEMStringFormWidget } from "../forms/OEMStringFormWidget"; import { OEMStringFormWidget } from "../forms/OEMStringFormWidget";
import { ResAutostartInput } from "../forms/ResAutostartInput"; import { ResAutostartInput } from "../forms/ResAutostartInput";
@ -27,7 +29,6 @@ import { VMDisksList } from "../forms/VMDisksList";
import { VMNetworksList } from "../forms/VMNetworksList"; import { VMNetworksList } from "../forms/VMNetworksList";
import { VMSelectIsoInput } from "../forms/VMSelectIsoInput"; import { VMSelectIsoInput } from "../forms/VMSelectIsoInput";
import { VMScreenshot } from "./VMScreenshot"; import { VMScreenshot } from "./VMScreenshot";
import { DiskImage, DiskImageApi } from "../../api/DiskImageApi";
interface DetailsProps { interface DetailsProps {
vm: VMInfo; vm: VMInfo;
@ -89,6 +90,7 @@ enum VMTab {
General = 0, General = 0,
Storage, Storage,
Network, Network,
CloudInit,
Advanced, Advanced,
XML, XML,
Danger, Danger,
@ -116,6 +118,11 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement {
{ label: "General", value: VMTab.General, visible: true }, { label: "General", value: VMTab.General, visible: true },
{ label: "Storage", value: VMTab.Storage, visible: true }, { label: "Storage", value: VMTab.Storage, visible: true },
{ label: "Network", value: VMTab.Network, visible: true }, { label: "Network", value: VMTab.Network, visible: true },
{
label: "Cloud Init",
value: VMTab.CloudInit,
visible: p.editable || p.vm.cloud_init.attach_config,
},
{ label: "Avanced", value: VMTab.Advanced, visible: true }, { label: "Avanced", value: VMTab.Advanced, visible: true },
{ {
@ -135,6 +142,7 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement {
{currTab === VMTab.General && <VMDetailsTabGeneral {...p} />} {currTab === VMTab.General && <VMDetailsTabGeneral {...p} />}
{currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />} {currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />}
{currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />} {currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />}
{currTab === VMTab.CloudInit && <VMDetailsTabCloudInit {...p} />}
{currTab === VMTab.Advanced && <VMDetailsTabAdvanced {...p} />} {currTab === VMTab.Advanced && <VMDetailsTabAdvanced {...p} />}
{currTab === VMTab.XML && <VMDetailsTabXML {...p} />} {currTab === VMTab.XML && <VMDetailsTabXML {...p} />}
{currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />} {currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />}
@ -381,6 +389,10 @@ function VMDetailsTabNetwork(p: DetailsInnerProps): React.ReactElement {
return <VMNetworksList {...p} />; return <VMNetworksList {...p} />;
} }
function VMDetailsTabCloudInit(p: DetailsInnerProps): React.ReactElement {
return <CloudInitEditor {...p} />;
}
function VMDetailsTabAdvanced(p: DetailsInnerProps): React.ReactElement { function VMDetailsTabAdvanced(p: DetailsInnerProps): React.ReactElement {
return ( return (
<Grid container spacing={2}> <Grid container spacing={2}>