Compare commits
	
		
			113 Commits
		
	
	
		
			20240407
			...
			60a4e6b996
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 60a4e6b996 | |||
| e15514dd4f | |||
| 7556ee2c06 | |||
| 992a902590 | |||
| 100f12e7c1 | |||
| 3de66a5873 | |||
| 49360188f5 | |||
| 35c48ba846 | |||
| 1ad4262086 | |||
| b633694f74 | |||
| ab16bd7bcf | |||
| 1080ab5cb2 | |||
| a2845ddafe | |||
| c968b64b51 | |||
| 12833dc6da | |||
| 8c4f2a9f2d | |||
| 9a6b6cfb2d | |||
| b28ca5f27d | |||
| 92f187bf91 | |||
| 9f1f4b44ca | |||
| b00d46105b | |||
| 7d6ccd4ce6 | |||
| b5371421e1 | |||
| 07d305c54e | |||
| 78c525f47a | |||
| 978a881372 | |||
| ee733b04f3 | |||
| 0d8ef226c1 | |||
| feca07558e | |||
| 0de15af10e | |||
| d4bc92f562 | |||
| a1439689dd | |||
| 63126c75fa | |||
| 940302a825 | |||
| 9c374f849b | |||
| 2fa4d0e11b | |||
| d7796e1459 | |||
| 759361d9f6 | |||
| b2529c250a | |||
| 8a7712ec42 | |||
| 9609cfb33a | |||
| 1fe7c60f36 | |||
| f1339f0711 | |||
| b3f56cea81 | |||
| 9bd702d60f | |||
| c8b42626a9 | |||
| 4ef15507d9 | |||
| 8a4b3a4db6 | |||
| 8bce9ca9b7 | |||
| 5574037b73 | |||
| d1ca9aee39 | |||
| f850ca5cb7 | |||
| 4ee01cad4b | |||
| 5518b45219 | |||
| 0279907ca9 | |||
| 5fe481ffed | |||
| c7cc15d8d0 | |||
| 22416badcf | |||
| ef0d77f1d6 | |||
| 1d4af8c74e | |||
| ec9492c933 | |||
| fa03ae885f | |||
| ea98aaf856 | |||
| 794d16bdaa | |||
| a3ac56f849 | |||
| 6130f37336 | |||
| 6b6fef5ccc | |||
| 83df7e1b20 | |||
| a18310e04a | |||
| dd7f9176fa | |||
| d5fbc24c96 | |||
| d765f9c2c3 | |||
| 21fd5de139 | |||
| 42f22c110c | |||
| 9822c5a72a | |||
| 452a395525 | |||
| 80d81c34bb | |||
| b9353326f5 | |||
| 3ffc64f129 | |||
| e869517bb1 | |||
| 90f4bf35e9 | |||
| 80d6fe0298 | |||
| e017fe96d5 | |||
| e7ac0198ab | |||
| 927a51cda7 | |||
| 615dc1ed83 | |||
| 20de618568 | |||
| 7451f1b7b4 | |||
| 5814b0ab6a | |||
| 5a5450070a | |||
| 19ec9992be | |||
| b55880b43c | |||
| 6a7af7e6c4 | |||
| a8171375a8 | |||
| de33c7d521 | |||
| ff372800bd | |||
| aac05b7faf | |||
| 61e5a42ef7 | |||
| 19f2f6d730 | |||
| dce17062a3 | |||
| dcb0743cbe | |||
| 644fd6f1bb | |||
| 94ee8f8c78 | |||
| 53a8963fc4 | |||
| 56ab7065ac | |||
| 1e8394b3c4 | |||
| 01f26c1a79 | |||
| 8c27010396 | |||
| 35e7f4b59c | |||
| d08516a72d | |||
| 479cc1fa0f | |||
| 3c636406af | |||
| 578f1432a0 | 
@@ -11,7 +11,7 @@ steps:
 | 
				
			|||||||
      path: /tmp/web_build
 | 
					      path: /tmp/web_build
 | 
				
			||||||
  commands:
 | 
					  commands:
 | 
				
			||||||
  - cd virtweb_frontend
 | 
					  - cd virtweb_frontend
 | 
				
			||||||
  - npm install --legacy-peer-deps # TODO : remove when mui-file-input is updated
 | 
					  - npm install
 | 
				
			||||||
  - npm run lint
 | 
					  - npm run lint
 | 
				
			||||||
  - npm run build
 | 
					  - npm run build
 | 
				
			||||||
  - mv dist /tmp/web_build
 | 
					  - mv dist /tmp/web_build
 | 
				
			||||||
@@ -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
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										858
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										858
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -7,42 +7,43 @@ edition = "2024"
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
[dependencies]
 | 
					[dependencies]
 | 
				
			||||||
log = "0.4.27"
 | 
					log = "0.4.27"
 | 
				
			||||||
env_logger = "0.11.7"
 | 
					env_logger = "0.11.8"
 | 
				
			||||||
clap = { version = "4.5.34", 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"
 | 
				
			||||||
actix-web = "4.10.2"
 | 
					actix-web = "4.11.0"
 | 
				
			||||||
actix-remote-ip = "0.1.0"
 | 
					actix-remote-ip = "0.1.0"
 | 
				
			||||||
actix-session = { version = "0.10.1", features = ["cookie-session"] }
 | 
					actix-session = { version = "0.10.1", features = ["cookie-session"] }
 | 
				
			||||||
actix-identity = "0.8.0"
 | 
					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"
 | 
				
			||||||
quick-xml = { version = "0.37.3", features = ["serialize", "overlapped-lists"] }
 | 
					serde_yml = "0.0.12"
 | 
				
			||||||
 | 
					quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] }
 | 
				
			||||||
futures-util = "0.3.31"
 | 
					futures-util = "0.3.31"
 | 
				
			||||||
anyhow = "1.0.97"
 | 
					anyhow = "1.0.98"
 | 
				
			||||||
actix-multipart = "0.7.2"
 | 
					actix-multipart = "0.7.2"
 | 
				
			||||||
tempfile = "3.19.1"
 | 
					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.34.2", 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.0"
 | 
					rand = "0.9.1"
 | 
				
			||||||
bytes = "1.10.1"
 | 
					tokio = { version = "1.45.0", features = ["rt", "time", "macros"] }
 | 
				
			||||||
tokio = { version = "1.44.1", 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"
 | 
				
			||||||
rust-embed = { version = "8.6.0" }
 | 
					rust-embed = { version = "8.7.2", features = ["mime-guess"] }
 | 
				
			||||||
mime_guess = "2.0.5"
 | 
					 | 
				
			||||||
dotenvy = "0.15.7"
 | 
					dotenvy = "0.15.7"
 | 
				
			||||||
nix = { version = "0.29.0", 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"
 | 
				
			||||||
							
								
								
									
										86
									
								
								virtweb_backend/assets/img/debian.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								virtweb_backend/assets/img/debian.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,86 @@
 | 
				
			|||||||
 | 
					<?xml version="1.0" encoding="utf-8"?>
 | 
				
			||||||
 | 
					<!-- Generator: Adobe Illustrator 10.0, SVG Export Plug-In . SVG Version: 3.0.0 Build 77)  -->
 | 
				
			||||||
 | 
					<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN"    "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd" [
 | 
				
			||||||
 | 
						<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
 | 
				
			||||||
 | 
						<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
 | 
				
			||||||
 | 
						<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
 | 
				
			||||||
 | 
						<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
 | 
				
			||||||
 | 
						<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
 | 
				
			||||||
 | 
						<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
 | 
				
			||||||
 | 
						<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
 | 
				
			||||||
 | 
						<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
 | 
				
			||||||
 | 
						<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
 | 
				
			||||||
 | 
						<!ENTITY ns_svg "http://www.w3.org/2000/svg">
 | 
				
			||||||
 | 
						<!ENTITY ns_xlink "http://www.w3.org/1999/xlink">
 | 
				
			||||||
 | 
					]>
 | 
				
			||||||
 | 
					<svg 
 | 
				
			||||||
 | 
						 xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;" i:viewOrigin="262 450" i:rulerOrigin="0 0" i:pageBounds="0 792 612 0"
 | 
				
			||||||
 | 
						 xmlns="&ns_svg;" xmlns:xlink="&ns_xlink;" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
 | 
				
			||||||
 | 
						 width="87.041" height="108.445" viewBox="0 0 87.041 108.445" overflow="visible" enable-background="new 0 0 87.041 108.445"
 | 
				
			||||||
 | 
						 xml:space="preserve">
 | 
				
			||||||
 | 
						<metadata>
 | 
				
			||||||
 | 
							<variableSets  xmlns="&ns_vars;">
 | 
				
			||||||
 | 
								<variableSet  varSetName="binding1" locked="none">
 | 
				
			||||||
 | 
									<variables></variables>
 | 
				
			||||||
 | 
									<v:sampleDataSets  xmlns="&ns_custom;" xmlns:v="&ns_vars;"></v:sampleDataSets>
 | 
				
			||||||
 | 
								</variableSet>
 | 
				
			||||||
 | 
							</variableSets>
 | 
				
			||||||
 | 
							<sfw  xmlns="&ns_sfw;">
 | 
				
			||||||
 | 
								<slices></slices>
 | 
				
			||||||
 | 
								<sliceSourceBounds  y="341.555" x="262" width="87.041" height="108.445" bottomLeftOrigin="true"></sliceSourceBounds>
 | 
				
			||||||
 | 
							</sfw>
 | 
				
			||||||
 | 
						</metadata>
 | 
				
			||||||
 | 
						<g id="Layer_1" i:layer="yes" i:dimmedPercent="50" i:rgbTrio="#4F008000FFFF">
 | 
				
			||||||
 | 
							<g>
 | 
				
			||||||
 | 
								<path i:knockout="Off" fill="#A80030" d="M51.986,57.297c-1.797,0.025,0.34,0.926,2.686,1.287
 | 
				
			||||||
 | 
									c0.648-0.506,1.236-1.018,1.76-1.516C54.971,57.426,53.484,57.434,51.986,57.297"/>
 | 
				
			||||||
 | 
								<path i:knockout="Off" fill="#A80030" d="M61.631,54.893c1.07-1.477,1.85-3.094,2.125-4.766c-0.24,1.192-0.887,2.221-1.496,3.307
 | 
				
			||||||
 | 
									c-3.359,2.115-0.316-1.256-0.002-2.537C58.646,55.443,61.762,53.623,61.631,54.893"/>
 | 
				
			||||||
 | 
								<path i:knockout="Off" fill="#A80030" d="M65.191,45.629c0.217-3.236-0.637-2.213-0.924-0.978
 | 
				
			||||||
 | 
									C64.602,44.825,64.867,46.932,65.191,45.629"/>
 | 
				
			||||||
 | 
								<path i:knockout="Off" fill="#A80030" d="M45.172,1.399c0.959,0.172,2.072,0.304,1.916,0.533
 | 
				
			||||||
 | 
									C48.137,1.702,48.375,1.49,45.172,1.399"/>
 | 
				
			||||||
 | 
								<path i:knockout="Off" fill="#A80030" d="M47.088,1.932l-0.678,0.14l0.631-0.056L47.088,1.932"/>
 | 
				
			||||||
 | 
								<path i:knockout="Off" fill="#A80030" d="M76.992,46.856c0.107,2.906-0.85,4.316-1.713,6.812l-1.553,0.776
 | 
				
			||||||
 | 
									c-1.271,2.468,0.123,1.567-0.787,3.53c-1.984,1.764-6.021,5.52-7.313,5.863c-0.943-0.021,0.639-1.113,0.846-1.541
 | 
				
			||||||
 | 
									c-2.656,1.824-2.131,2.738-6.193,3.846l-0.119-0.264c-10.018,4.713-23.934-4.627-23.751-17.371
 | 
				
			||||||
 | 
									c-0.107,0.809-0.304,0.607-0.526,0.934c-0.517-6.557,3.028-13.143,9.007-15.832c5.848-2.895,12.704-1.707,16.893,2.197
 | 
				
			||||||
 | 
									c-2.301-3.014-6.881-6.209-12.309-5.91c-5.317,0.084-10.291,3.463-11.951,7.131c-2.724,1.715-3.04,6.611-4.227,7.507
 | 
				
			||||||
 | 
									C31.699,56.271,36.3,61.342,44.083,67.307c1.225,0.826,0.345,0.951,0.511,1.58c-2.586-1.211-4.954-3.039-6.901-5.277
 | 
				
			||||||
 | 
									c1.033,1.512,2.148,2.982,3.589,4.137c-2.438-0.826-5.695-5.908-6.646-6.115c4.203,7.525,17.052,13.197,23.78,10.383
 | 
				
			||||||
 | 
									c-3.113,0.115-7.068,0.064-10.566-1.229c-1.469-0.756-3.467-2.322-3.11-2.615c9.182,3.43,18.667,2.598,26.612-3.771
 | 
				
			||||||
 | 
									c2.021-1.574,4.229-4.252,4.867-4.289c-0.961,1.445,0.164,0.695-0.574,1.971c2.014-3.248-0.875-1.322,2.082-5.609l1.092,1.504
 | 
				
			||||||
 | 
									c-0.406-2.696,3.348-5.97,2.967-10.234c0.861-1.304,0.961,1.403,0.047,4.403c1.268-3.328,0.334-3.863,0.66-6.609
 | 
				
			||||||
 | 
									c0.352,0.923,0.814,1.904,1.051,2.878c-0.826-3.216,0.848-5.416,1.262-7.285c-0.408-0.181-1.275,1.422-1.473-2.377
 | 
				
			||||||
 | 
									c0.029-1.65,0.459-0.865,0.625-1.271c-0.324-0.186-1.174-1.451-1.691-3.877c0.375-0.57,1.002,1.478,1.512,1.562
 | 
				
			||||||
 | 
									c-0.328-1.929-0.893-3.4-0.916-4.88c-1.49-3.114-0.527,0.415-1.736-1.337c-1.586-4.947,1.316-1.148,1.512-3.396
 | 
				
			||||||
 | 
									c2.404,3.483,3.775,8.881,4.404,11.117c-0.48-2.726-1.256-5.367-2.203-7.922c0.73,0.307-1.176-5.609,0.949-1.691
 | 
				
			||||||
 | 
									c-2.27-8.352-9.715-16.156-16.564-19.818c0.838,0.767,1.896,1.73,1.516,1.881c-3.406-2.028-2.807-2.186-3.295-3.043
 | 
				
			||||||
 | 
									c-2.775-1.129-2.957,0.091-4.795,0.002c-5.23-2.774-6.238-2.479-11.051-4.217l0.219,1.023c-3.465-1.154-4.037,0.438-7.782,0.004
 | 
				
			||||||
 | 
									c-0.228-0.178,1.2-0.644,2.375-0.815c-3.35,0.442-3.193-0.66-6.471,0.122c0.808-0.567,1.662-0.942,2.524-1.424
 | 
				
			||||||
 | 
									c-2.732,0.166-6.522,1.59-5.352,0.295c-4.456,1.988-12.37,4.779-16.811,8.943l-0.14-0.933c-2.035,2.443-8.874,7.296-9.419,10.46
 | 
				
			||||||
 | 
									l-0.544,0.127c-1.059,1.793-1.744,3.825-2.584,5.67c-1.385,2.36-2.03,0.908-1.833,1.278c-2.724,5.523-4.077,10.164-5.246,13.97
 | 
				
			||||||
 | 
									c0.833,1.245,0.02,7.495,0.335,12.497c-1.368,24.704,17.338,48.69,37.785,54.228c2.997,1.072,7.454,1.031,11.245,1.141
 | 
				
			||||||
 | 
									c-4.473-1.279-5.051-0.678-9.408-2.197c-3.143-1.48-3.832-3.17-6.058-5.102l0.881,1.557c-4.366-1.545-2.539-1.912-6.091-3.037
 | 
				
			||||||
 | 
									l0.941-1.229c-1.415-0.107-3.748-2.385-4.386-3.646l-1.548,0.061c-1.86-2.295-2.851-3.949-2.779-5.23l-0.5,0.891
 | 
				
			||||||
 | 
									c-0.567-0.973-6.843-8.607-3.587-6.83c-0.605-0.553-1.409-0.9-2.281-2.484l0.663-0.758c-1.567-2.016-2.884-4.6-2.784-5.461
 | 
				
			||||||
 | 
									c0.836,1.129,1.416,1.34,1.99,1.533c-3.957-9.818-4.179-0.541-7.176-9.994l0.634-0.051c-0.486-0.732-0.781-1.527-1.172-2.307
 | 
				
			||||||
 | 
									l0.276-2.75C4.667,58.121,6.719,47.409,7.13,41.534c0.285-2.389,2.378-4.932,3.97-8.92l-0.97-0.167
 | 
				
			||||||
 | 
									c1.854-3.234,10.586-12.988,14.63-12.486c1.959-2.461-0.389-0.009-0.772-0.629c4.303-4.453,5.656-3.146,8.56-3.947
 | 
				
			||||||
 | 
									c3.132-1.859-2.688,0.725-1.203-0.709c5.414-1.383,3.837-3.144,10.9-3.846c0.745,0.424-1.729,0.655-2.35,1.205
 | 
				
			||||||
 | 
									c4.511-2.207,14.275-1.705,20.617,1.225c7.359,3.439,15.627,13.605,15.953,23.17l0.371,0.1
 | 
				
			||||||
 | 
									c-0.188,3.802,0.582,8.199-0.752,12.238L76.992,46.856"/>
 | 
				
			||||||
 | 
								<path i:knockout="Off" fill="#A80030" d="M32.372,59.764l-0.252,1.26c1.181,1.604,2.118,3.342,3.626,4.596
 | 
				
			||||||
 | 
									C34.661,63.502,33.855,62.627,32.372,59.764"/>
 | 
				
			||||||
 | 
								<path i:knockout="Off" fill="#A80030" d="M35.164,59.654c-0.625-0.691-0.995-1.523-1.409-2.352
 | 
				
			||||||
 | 
									c0.396,1.457,1.207,2.709,1.962,3.982L35.164,59.654"/>
 | 
				
			||||||
 | 
								<path i:knockout="Off" fill="#A80030" d="M84.568,48.916l-0.264,0.662c-0.484,3.438-1.529,6.84-3.131,9.994
 | 
				
			||||||
 | 
									C82.943,56.244,84.088,52.604,84.568,48.916"/>
 | 
				
			||||||
 | 
								<path i:knockout="Off" fill="#A80030" d="M45.527,0.537C46.742,0.092,48.514,0.293,49.803,0c-1.68,0.141-3.352,0.225-5.003,0.438
 | 
				
			||||||
 | 
									L45.527,0.537"/>
 | 
				
			||||||
 | 
								<path i:knockout="Off" fill="#A80030" d="M2.872,23.219c0.28,2.592-1.95,3.598,0.494,1.889
 | 
				
			||||||
 | 
									C4.676,22.157,2.854,24.293,2.872,23.219"/>
 | 
				
			||||||
 | 
								<path i:knockout="Off" fill="#A80030" d="M0,35.215c0.563-1.728,0.665-2.766,0.88-3.766C-0.676,33.438,0.164,33.862,0,35.215"/>
 | 
				
			||||||
 | 
							</g>
 | 
				
			||||||
 | 
						</g>
 | 
				
			||||||
 | 
					</svg>
 | 
				
			||||||
| 
		 After Width: | Height: | Size: 6.7 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								virtweb_backend/assets/img/kvm.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								virtweb_backend/assets/img/kvm.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 194 KiB  | 
							
								
								
									
										8
									
								
								virtweb_backend/assets/img/ubuntu.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								virtweb_backend/assets/img/ubuntu.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 100">
 | 
				
			||||||
 | 
					<circle fill="#f47421" cy="50" cx="50" r="45"/>
 | 
				
			||||||
 | 
					<circle fill="none" stroke="#ffffff" stroke-width="8.55" cx="50" cy="50" r="21.825"/>
 | 
				
			||||||
 | 
					<g id="friend"><circle fill="#f47421" cx="19.4" cy="50" r="8.4376"/>
 | 
				
			||||||
 | 
					<path stroke="#f47421" stroke-width="3.2378" d="M67,50H77"/>
 | 
				
			||||||
 | 
					<circle fill="#ffffff" cx="19.4" cy="50" r="6.00745"/></g>
 | 
				
			||||||
 | 
					<use xlink:href="#friend" transform="rotate(120,50,50)"/>
 | 
				
			||||||
 | 
					<use xlink:href="#friend" transform="rotate(240,50,50)"/></svg>
 | 
				
			||||||
| 
		 After Width: | Height: | Size: 550 B  | 
							
								
								
									
										1
									
								
								virtweb_backend/assets/img/windows.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								virtweb_backend/assets/img/windows.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					<svg xmlns="http://www.w3.org/2000/svg" height="88" width="88" xmlns:v="https://vecta.io/nano"><path d="M0 12.402l35.687-4.86.016 34.423-35.67.203zm35.67 33.529l.028 34.453L.028 75.48.026 45.7zm4.326-39.025L87.314 0v41.527l-47.318.376zm47.329 39.349l-.011 41.34-47.318-6.678-.066-34.739z" fill="#00adef"/></svg>
 | 
				
			||||||
| 
		 After Width: | Height: | Size: 311 B  | 
							
								
								
									
										47
									
								
								virtweb_backend/assets/iso_catalog.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								virtweb_backend/assets/iso_catalog.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
				
			|||||||
 | 
					[
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Ubuntu releases",
 | 
				
			||||||
 | 
					    "url": "https://releases.ubuntu.com",
 | 
				
			||||||
 | 
					    "image": "/assets/img/ubuntu.svg"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Old ubuntu releases",
 | 
				
			||||||
 | 
					    "url": "https://old-releases.ubuntu.com/releases/",
 | 
				
			||||||
 | 
					    "image": "/assets/img/ubuntu.svg"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Current Debian releases (amd64)",
 | 
				
			||||||
 | 
					    "url": "https://cdimage.debian.org/mirror/cdimage/release/current/amd64/iso-dvd/",
 | 
				
			||||||
 | 
					    "image": "/assets/img/debian.svg"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Old Debian releases",
 | 
				
			||||||
 | 
					    "url": "https://cdimage.debian.org/mirror/cdimage/archive/",
 | 
				
			||||||
 | 
					    "image": "/assets/img/debian.svg"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Latest stable Virtio driver",
 | 
				
			||||||
 | 
					    "url": "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso",
 | 
				
			||||||
 | 
					    "image": "/assets/img/kvm.png"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Windows server 2025",
 | 
				
			||||||
 | 
					    "url": "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2025",
 | 
				
			||||||
 | 
					    "image": "/assets/img/windows.svg"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Windows server 2022",
 | 
				
			||||||
 | 
					    "url": "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2022",
 | 
				
			||||||
 | 
					    "image": "/assets/img/windows.svg"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Windows 11",
 | 
				
			||||||
 | 
					    "url": "https://www.microsoft.com/en-us/software-download/windows11",
 | 
				
			||||||
 | 
					    "image": "/assets/img/windows.svg"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    "name": "Windows 11 Iot Enterprise LTSC 2024",
 | 
				
			||||||
 | 
					    "url": "https://www.microsoft.com/en-us/evalcenter/download-windows-11-iot-enterprise-ltsc-eval",
 | 
				
			||||||
 | 
					    "image": "/assets/img/windows.svg"
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
@@ -182,6 +182,13 @@ impl Handler<DeleteDomainReq> for LibVirtActor {
 | 
				
			|||||||
            false => sys::VIR_DOMAIN_UNDEFINE_NVRAM,
 | 
					            false => sys::VIR_DOMAIN_UNDEFINE_NVRAM,
 | 
				
			||||||
        })?;
 | 
					        })?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Delete associated cloud init disk
 | 
				
			||||||
 | 
					        let cloud_init_disk = AppConfig::get().cloud_init_disk_path_for_vm(&domain_name);
 | 
				
			||||||
 | 
					        if cloud_init_disk.exists() {
 | 
				
			||||||
 | 
					            std::fs::remove_file(cloud_init_disk)?;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // If requested, delete block storage associated with the VM
 | 
				
			||||||
        if !msg.keep_files {
 | 
					        if !msg.keep_files {
 | 
				
			||||||
            log::info!("Delete storage associated with the domain");
 | 
					            log::info!("Delete storage associated with the domain");
 | 
				
			||||||
            let path = AppConfig::get().vm_storage_path(msg.id);
 | 
					            let path = AppConfig::get().vm_storage_path(msg.id);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -245,11 +245,34 @@ impl AppConfig {
 | 
				
			|||||||
        storage_path.canonicalize().unwrap()
 | 
					        storage_path.canonicalize().unwrap()
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /// Get iso storage directory
 | 
					    /// Get iso files storage directory
 | 
				
			||||||
    pub fn iso_storage_path(&self) -> PathBuf {
 | 
					    pub fn iso_storage_path(&self) -> PathBuf {
 | 
				
			||||||
        self.storage_path().join("iso")
 | 
					        self.storage_path().join("iso")
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Get the path where generated cloud init disk image are stored
 | 
				
			||||||
 | 
					    pub fn cloud_init_disk_storage_path(&self) -> PathBuf {
 | 
				
			||||||
 | 
					        self.storage_path().join("cloud_init_disks")
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Get the path where the disk image of a VM is stored
 | 
				
			||||||
 | 
					    pub fn cloud_init_disk_path_for_vm(&self, name: &str) -> PathBuf {
 | 
				
			||||||
 | 
					        self.cloud_init_disk_storage_path().join(format!(
 | 
				
			||||||
 | 
					            "{}-{name}.iso",
 | 
				
			||||||
 | 
					            constants::CLOUD_INIT_IMAGE_PREFIX_NAME
 | 
				
			||||||
 | 
					        ))
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Get disk images storage directory
 | 
				
			||||||
 | 
					    pub fn disk_images_storage_path(&self) -> PathBuf {
 | 
				
			||||||
 | 
					        self.storage_path().join("disk_images")
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Get the path of a disk image file
 | 
				
			||||||
 | 
					    pub fn disk_images_file_path(&self, name: &str) -> PathBuf {
 | 
				
			||||||
 | 
					        self.disk_images_storage_path().join(name)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /// Get VM vnc sockets directory
 | 
					    /// Get VM vnc sockets directory
 | 
				
			||||||
    pub fn vnc_sockets_path(&self) -> PathBuf {
 | 
					    pub fn vnc_sockets_path(&self) -> PathBuf {
 | 
				
			||||||
        self.storage_path().join("vnc")
 | 
					        self.storage_path().join("vnc")
 | 
				
			||||||
@@ -260,15 +283,17 @@ impl AppConfig {
 | 
				
			|||||||
        self.vnc_sockets_path().join(format!("vnc-{}", name))
 | 
					        self.vnc_sockets_path().join(format!("vnc-{}", name))
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /// Get VM vnc sockets directory
 | 
					    /// Get VM root disks storage directory
 | 
				
			||||||
    pub fn disks_storage_path(&self) -> PathBuf {
 | 
					    pub fn root_vm_disks_storage_path(&self) -> PathBuf {
 | 
				
			||||||
        self.storage_path().join("disks")
 | 
					        self.storage_path().join("disks")
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Get specific VM disk storage directory
 | 
				
			||||||
    pub fn vm_storage_path(&self, id: XMLUuid) -> PathBuf {
 | 
					    pub fn vm_storage_path(&self, id: XMLUuid) -> PathBuf {
 | 
				
			||||||
        self.disks_storage_path().join(id.as_string())
 | 
					        self.root_vm_disks_storage_path().join(id.as_string())
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Get the path were VM definitions are backed up
 | 
				
			||||||
    pub fn definitions_path(&self) -> PathBuf {
 | 
					    pub fn definitions_path(&self) -> PathBuf {
 | 
				
			||||||
        self.storage_path().join("definitions")
 | 
					        self.storage_path().join("definitions")
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,5 @@
 | 
				
			|||||||
 | 
					use crate::utils::file_size_utils::FileSize;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Name of the cookie that contains session information
 | 
					/// Name of the cookie that contains session information
 | 
				
			||||||
pub const SESSION_COOKIE_NAME: &str = "X-auth-token";
 | 
					pub const SESSION_COOKIE_NAME: &str = "X-auth-token";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -17,20 +19,32 @@ pub const ROUTES_WITHOUT_AUTH: [&str; 5] = [
 | 
				
			|||||||
];
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Allowed ISO mimetypes
 | 
					/// Allowed ISO mimetypes
 | 
				
			||||||
pub const ALLOWED_ISO_MIME_TYPES: [&str; 3] = [
 | 
					pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [
 | 
				
			||||||
    "application/x-cd-image",
 | 
					    "application/x-cd-image",
 | 
				
			||||||
    "application/x-iso9660-image",
 | 
					    "application/x-iso9660-image",
 | 
				
			||||||
    "application/octet-stream",
 | 
					    "application/octet-stream",
 | 
				
			||||||
 | 
					    "application/vnd.efi.iso",
 | 
				
			||||||
];
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// ISO max size
 | 
					/// ISO max size
 | 
				
			||||||
pub const ISO_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000;
 | 
					pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Min VM memory size (MB)
 | 
					/// Allowed uploaded disk images formats
 | 
				
			||||||
pub const MIN_VM_MEMORY: usize = 100;
 | 
					pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 4] = [
 | 
				
			||||||
 | 
					    "application/x-qemu-disk",
 | 
				
			||||||
 | 
					    "application/x-raw-disk-image",
 | 
				
			||||||
 | 
					    "application/gzip",
 | 
				
			||||||
 | 
					    "application/octet-stream",
 | 
				
			||||||
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Max VM memory size (MB)
 | 
					/// Disk image max size
 | 
				
			||||||
pub const MAX_VM_MEMORY: usize = 64000;
 | 
					pub const DISK_IMAGE_MAX_SIZE: FileSize = FileSize::from_gb(10 * 1000);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Min VM memory size
 | 
				
			||||||
 | 
					pub const MIN_VM_MEMORY: FileSize = FileSize::from_mb(100);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Max VM memory size
 | 
				
			||||||
 | 
					pub const MAX_VM_MEMORY: FileSize = FileSize::from_gb(64);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Disk name min length
 | 
					/// Disk name min length
 | 
				
			||||||
pub const DISK_NAME_MIN_LEN: usize = 2;
 | 
					pub const DISK_NAME_MIN_LEN: usize = 2;
 | 
				
			||||||
@@ -38,11 +52,14 @@ pub const DISK_NAME_MIN_LEN: usize = 2;
 | 
				
			|||||||
/// Disk name max length
 | 
					/// Disk name max length
 | 
				
			||||||
pub const DISK_NAME_MAX_LEN: usize = 10;
 | 
					pub const DISK_NAME_MAX_LEN: usize = 10;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Disk size min (MB)
 | 
					/// Disk size min (B)
 | 
				
			||||||
pub const DISK_SIZE_MIN: usize = 100;
 | 
					pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Disk size max (MB)
 | 
					/// Disk size max (B)
 | 
				
			||||||
pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 2;
 | 
					pub const DISK_SIZE_MAX: FileSize = FileSize::from_gb(20000);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Cloud init generated disk image prefix
 | 
				
			||||||
 | 
					pub const CLOUD_INIT_IMAGE_PREFIX_NAME: &str = "virtweb-cloudinit-autogen-image";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Net nat entry comment max size
 | 
					/// Net nat entry comment max size
 | 
				
			||||||
pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250;
 | 
					pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250;
 | 
				
			||||||
@@ -107,3 +124,27 @@ pub const API_TOKEN_DESCRIPTION_MAX_LENGTH: usize = 30;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
/// API token right path max length
 | 
					/// API token right path max length
 | 
				
			||||||
pub const API_TOKEN_RIGHT_PATH_MAX_LENGTH: usize = 255;
 | 
					pub const API_TOKEN_RIGHT_PATH_MAX_LENGTH: usize = 255;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Qemu image program path
 | 
				
			||||||
 | 
					pub const PROGRAM_QEMU_IMAGE: &str = "/usr/bin/qemu-img";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// IP program path
 | 
				
			||||||
 | 
					pub const PROGRAM_IP: &str = "/usr/sbin/ip";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Copy program path
 | 
				
			||||||
 | 
					pub const PROGRAM_COPY: &str = "/bin/cp";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Gzip program path
 | 
				
			||||||
 | 
					pub const PROGRAM_GZIP: &str = "/usr/bin/gzip";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// XZ program path
 | 
				
			||||||
 | 
					pub const PROGRAM_XZ: &str = "/usr/bin/xz";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Bash program
 | 
				
			||||||
 | 
					pub const PROGRAM_BASH: &str = "/usr/bin/bash";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// DD program
 | 
				
			||||||
 | 
					pub const PROGRAM_DD: &str = "/usr/bin/dd";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// cloud-localds program
 | 
				
			||||||
 | 
					pub const PROGRAM_CLOUD_LOCALDS: &str = "/usr/bin/cloud-localds";
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										255
									
								
								virtweb_backend/src/controllers/disk_images_controller.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								virtweb_backend/src/controllers/disk_images_controller.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,255 @@
 | 
				
			|||||||
 | 
					use crate::app_config::AppConfig;
 | 
				
			||||||
 | 
					use crate::constants;
 | 
				
			||||||
 | 
					use crate::controllers::{HttpResult, LibVirtReq};
 | 
				
			||||||
 | 
					use crate::libvirt_lib_structures::XMLUuid;
 | 
				
			||||||
 | 
					use crate::libvirt_rest_structures::vm::VMInfo;
 | 
				
			||||||
 | 
					use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo};
 | 
				
			||||||
 | 
					use crate::utils::files_utils;
 | 
				
			||||||
 | 
					use actix_files::NamedFile;
 | 
				
			||||||
 | 
					use actix_multipart::form::MultipartForm;
 | 
				
			||||||
 | 
					use actix_multipart::form::tempfile::TempFile;
 | 
				
			||||||
 | 
					use actix_web::{HttpRequest, HttpResponse, web};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(Debug, MultipartForm)]
 | 
				
			||||||
 | 
					pub struct UploadDiskImageForm {
 | 
				
			||||||
 | 
					    #[multipart(rename = "file")]
 | 
				
			||||||
 | 
					    files: Vec<TempFile>,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Upload disk image file
 | 
				
			||||||
 | 
					pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>) -> HttpResult {
 | 
				
			||||||
 | 
					    if form.files.is_empty() {
 | 
				
			||||||
 | 
					        log::error!("Missing uploaded disk file!");
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::BadRequest().json("Missing file!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let file = form.files.remove(0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check uploaded file size
 | 
				
			||||||
 | 
					    if file.size > constants::DISK_IMAGE_MAX_SIZE.as_bytes() {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::BadRequest().json("Disk image max size exceeded!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check file mime type
 | 
				
			||||||
 | 
					    if let Some(mime_type) = file.content_type {
 | 
				
			||||||
 | 
					        if !constants::ALLOWED_DISK_IMAGES_MIME_TYPES.contains(&mime_type.as_ref()) {
 | 
				
			||||||
 | 
					            return Ok(HttpResponse::BadRequest().json(format!(
 | 
				
			||||||
 | 
					                "Unsupported file type for disk upload: {}",
 | 
				
			||||||
 | 
					                mime_type
 | 
				
			||||||
 | 
					            )));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Extract and check file name
 | 
				
			||||||
 | 
					    let Some(file_name) = file.file_name else {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::BadRequest().json("Missing file name of uploaded file!"));
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    if !files_utils::check_file_name(&file_name) {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::BadRequest().json("Invalid uploaded file name!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check if a file with the same name already exists
 | 
				
			||||||
 | 
					    let dest_path = AppConfig::get().disk_images_file_path(&file_name);
 | 
				
			||||||
 | 
					    if dest_path.is_file() {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::Conflict().json("A file with the same name already exists!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Copy the file to the destination
 | 
				
			||||||
 | 
					    file.file.persist(&dest_path)?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check if file information can be loaded
 | 
				
			||||||
 | 
					    if let Err(e) = DiskFileInfo::load_file(&dest_path) {
 | 
				
			||||||
 | 
					        log::error!("Failed to get information about uploaded disk file! {e}");
 | 
				
			||||||
 | 
					        std::fs::remove_file(&dest_path)?;
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::InternalServerError()
 | 
				
			||||||
 | 
					            .json(format!("Unable to process uploaded file! {e}")));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(HttpResponse::Ok().json("Successfully uploaded disk image!"))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Get disk images list
 | 
				
			||||||
 | 
					pub async fn get_list() -> HttpResult {
 | 
				
			||||||
 | 
					    let mut list = vec![];
 | 
				
			||||||
 | 
					    for entry in AppConfig::get().disk_images_storage_path().read_dir()? {
 | 
				
			||||||
 | 
					        let entry = entry?;
 | 
				
			||||||
 | 
					        list.push(DiskFileInfo::load_file(&entry.path())?);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(HttpResponse::Ok().json(list))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(serde::Deserialize)]
 | 
				
			||||||
 | 
					pub struct DiskFilePath {
 | 
				
			||||||
 | 
					    filename: String,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Download disk image
 | 
				
			||||||
 | 
					pub async fn download(p: web::Path<DiskFilePath>, req: HttpRequest) -> HttpResult {
 | 
				
			||||||
 | 
					    if !files_utils::check_file_name(&p.filename) {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let file_path = AppConfig::get().disk_images_file_path(&p.filename);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if !file_path.exists() {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(NamedFile::open(file_path)?.into_response(&req))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(serde::Deserialize)]
 | 
				
			||||||
 | 
					pub struct ConvertDiskImageRequest {
 | 
				
			||||||
 | 
					    dest_file_name: String,
 | 
				
			||||||
 | 
					    #[serde(flatten)]
 | 
				
			||||||
 | 
					    format: DiskFileFormat,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Convert disk image into a new format
 | 
				
			||||||
 | 
					pub async fn convert(
 | 
				
			||||||
 | 
					    p: web::Path<DiskFilePath>,
 | 
				
			||||||
 | 
					    req: web::Json<ConvertDiskImageRequest>,
 | 
				
			||||||
 | 
					) -> HttpResult {
 | 
				
			||||||
 | 
					    if !files_utils::check_file_name(&p.filename) {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::BadRequest().json("Invalid src file name!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let src_file_path = AppConfig::get().disk_images_file_path(&p.filename);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let src = DiskFileInfo::load_file(&src_file_path)?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    handle_convert_request(src, &req).await
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(serde::Deserialize)]
 | 
				
			||||||
 | 
					pub struct BackupVMDiskPath {
 | 
				
			||||||
 | 
					    uid: XMLUuid,
 | 
				
			||||||
 | 
					    diskid: String,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Perform disk backup
 | 
				
			||||||
 | 
					pub async fn backup_disk(
 | 
				
			||||||
 | 
					    client: LibVirtReq,
 | 
				
			||||||
 | 
					    path: web::Path<BackupVMDiskPath>,
 | 
				
			||||||
 | 
					    req: web::Json<ConvertDiskImageRequest>,
 | 
				
			||||||
 | 
					) -> HttpResult {
 | 
				
			||||||
 | 
					    // Get the VM information
 | 
				
			||||||
 | 
					    let info = match client.get_single_domain(path.uid).await {
 | 
				
			||||||
 | 
					        Ok(i) => i,
 | 
				
			||||||
 | 
					        Err(e) => {
 | 
				
			||||||
 | 
					            log::error!("Failed to get domain info! {e}");
 | 
				
			||||||
 | 
					            return Ok(HttpResponse::InternalServerError().json(e.to_string()));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let vm = VMInfo::from_domain(info)?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Load disk information
 | 
				
			||||||
 | 
					    let Some(disk) = vm
 | 
				
			||||||
 | 
					        .file_disks
 | 
				
			||||||
 | 
					        .into_iter()
 | 
				
			||||||
 | 
					        .find(|disk| disk.name == path.diskid)
 | 
				
			||||||
 | 
					    else {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::NotFound()
 | 
				
			||||||
 | 
					            .json(format!("Disk {} not found for vm {}", path.diskid, vm.name)));
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let src_path = disk.disk_path(vm.uuid.expect("Missing VM uuid!"));
 | 
				
			||||||
 | 
					    let src_disk = DiskFileInfo::load_file(&src_path)?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Perform conversion
 | 
				
			||||||
 | 
					    handle_convert_request(src_disk, &req).await
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Generic controller code that performs image conversion to create a disk image file
 | 
				
			||||||
 | 
					pub async fn handle_convert_request(
 | 
				
			||||||
 | 
					    src: DiskFileInfo,
 | 
				
			||||||
 | 
					    req: &ConvertDiskImageRequest,
 | 
				
			||||||
 | 
					) -> HttpResult {
 | 
				
			||||||
 | 
					    // Check destination file
 | 
				
			||||||
 | 
					    if !files_utils::check_file_name(&req.dest_file_name) {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::BadRequest().json("Invalid destination file name!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if !req
 | 
				
			||||||
 | 
					        .format
 | 
				
			||||||
 | 
					        .ext()
 | 
				
			||||||
 | 
					        .iter()
 | 
				
			||||||
 | 
					        .any(|e| req.dest_file_name.ends_with(e))
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let dst_file_path = AppConfig::get().disk_images_file_path(&req.dest_file_name);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if dst_file_path.exists() {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::Conflict().json("Specified destination file already exists!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Perform conversion
 | 
				
			||||||
 | 
					    if let Err(e) = src.convert(&dst_file_path, req.format) {
 | 
				
			||||||
 | 
					        log::error!("Disk file conversion error: {e}");
 | 
				
			||||||
 | 
					        return Ok(
 | 
				
			||||||
 | 
					            HttpResponse::InternalServerError().json(format!("Disk file conversion error: {e}"))
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(HttpResponse::Accepted().json("Successfully converted disk file"))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(serde::Deserialize)]
 | 
				
			||||||
 | 
					pub struct RenameDiskImageRequest {
 | 
				
			||||||
 | 
					    name: String,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Rename disk image
 | 
				
			||||||
 | 
					pub async fn rename(
 | 
				
			||||||
 | 
					    p: web::Path<DiskFilePath>,
 | 
				
			||||||
 | 
					    req: web::Json<RenameDiskImageRequest>,
 | 
				
			||||||
 | 
					) -> HttpResult {
 | 
				
			||||||
 | 
					    // Check source
 | 
				
			||||||
 | 
					    if !files_utils::check_file_name(&p.filename) {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::BadRequest().json("Invalid src file name!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    let src_path = AppConfig::get().disk_images_file_path(&p.filename);
 | 
				
			||||||
 | 
					    if !src_path.exists() {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check destination
 | 
				
			||||||
 | 
					    if !files_utils::check_file_name(&req.name) {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::BadRequest().json("Invalid dst file name!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    let dst_path = AppConfig::get().disk_images_file_path(&req.name);
 | 
				
			||||||
 | 
					    if dst_path.exists() {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::Conflict().json("Destination name already exists!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check extension
 | 
				
			||||||
 | 
					    let disk = DiskFileInfo::load_file(&src_path)?;
 | 
				
			||||||
 | 
					    if !disk.format.ext().iter().any(|e| req.name.ends_with(e)) {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Perform rename
 | 
				
			||||||
 | 
					    std::fs::rename(&src_path, &dst_path)?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(HttpResponse::Accepted().finish())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Delete a disk image
 | 
				
			||||||
 | 
					pub async fn delete(p: web::Path<DiskFilePath>) -> HttpResult {
 | 
				
			||||||
 | 
					    if !files_utils::check_file_name(&p.filename) {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let file_path = AppConfig::get().disk_images_file_path(&p.filename);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if !file_path.exists() {
 | 
				
			||||||
 | 
					        return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    std::fs::remove_file(file_path)?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(HttpResponse::Accepted().finish())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -26,7 +26,7 @@ pub async fn upload_file(MultipartForm(mut form): MultipartForm<UploadIsoForm>)
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    let file = form.files.remove(0);
 | 
					    let file = form.files.remove(0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if file.size > constants::ISO_MAX_SIZE {
 | 
					    if file.size > constants::ISO_MAX_SIZE.as_bytes() {
 | 
				
			||||||
        log::error!("Uploaded ISO file is too large!");
 | 
					        log::error!("Uploaded ISO file is too large!");
 | 
				
			||||||
        return Ok(HttpResponse::BadRequest().json("File is too large!"));
 | 
					        return Ok(HttpResponse::BadRequest().json("File is too large!"));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -88,7 +88,7 @@ pub async fn upload_from_url(req: web::Json<DownloadFromURLReq>) -> HttpResult {
 | 
				
			|||||||
    let response = reqwest::get(&req.url).await?;
 | 
					    let response = reqwest::get(&req.url).await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if let Some(len) = response.content_length() {
 | 
					    if let Some(len) = response.content_length() {
 | 
				
			||||||
        if len > constants::ISO_MAX_SIZE as u64 {
 | 
					        if len > constants::ISO_MAX_SIZE.as_bytes() as u64 {
 | 
				
			||||||
            return Ok(HttpResponse::BadRequest().json("File is too large!"));
 | 
					            return Ok(HttpResponse::BadRequest().json("File is too large!"));
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -132,12 +132,12 @@ pub async fn get_list() -> HttpResult {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[derive(serde::Deserialize)]
 | 
					#[derive(serde::Deserialize)]
 | 
				
			||||||
pub struct DownloadFilePath {
 | 
					pub struct IsoFilePath {
 | 
				
			||||||
    filename: String,
 | 
					    filename: String,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Download ISO file
 | 
					/// Download ISO file
 | 
				
			||||||
pub async fn download_file(p: web::Path<DownloadFilePath>, req: HttpRequest) -> HttpResult {
 | 
					pub async fn download_file(p: web::Path<IsoFilePath>, req: HttpRequest) -> HttpResult {
 | 
				
			||||||
    if !files_utils::check_file_name(&p.filename) {
 | 
					    if !files_utils::check_file_name(&p.filename) {
 | 
				
			||||||
        return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
 | 
					        return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -152,7 +152,7 @@ pub async fn download_file(p: web::Path<DownloadFilePath>, req: HttpRequest) ->
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Delete ISO file
 | 
					/// Delete ISO file
 | 
				
			||||||
pub async fn delete_file(p: web::Path<DownloadFilePath>) -> HttpResult {
 | 
					pub async fn delete_file(p: web::Path<IsoFilePath>) -> HttpResult {
 | 
				
			||||||
    if !files_utils::check_file_name(&p.filename) {
 | 
					    if !files_utils::check_file_name(&p.filename) {
 | 
				
			||||||
        return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
 | 
					        return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,10 +4,11 @@ use actix_web::body::BoxBody;
 | 
				
			|||||||
use actix_web::{HttpResponse, web};
 | 
					use actix_web::{HttpResponse, web};
 | 
				
			||||||
use std::error::Error;
 | 
					use std::error::Error;
 | 
				
			||||||
use std::fmt::{Display, Formatter};
 | 
					use std::fmt::{Display, Formatter};
 | 
				
			||||||
use std::io::ErrorKind;
 | 
					use zip::result::ZipError;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
pub mod api_tokens_controller;
 | 
					pub mod api_tokens_controller;
 | 
				
			||||||
pub mod auth_controller;
 | 
					pub mod auth_controller;
 | 
				
			||||||
 | 
					pub mod disk_images_controller;
 | 
				
			||||||
pub mod groups_controller;
 | 
					pub mod groups_controller;
 | 
				
			||||||
pub mod iso_controller;
 | 
					pub mod iso_controller;
 | 
				
			||||||
pub mod network_controller;
 | 
					pub mod network_controller;
 | 
				
			||||||
@@ -62,7 +63,7 @@ impl From<serde_json::Error> for HttpErr {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
impl From<Box<dyn Error>> for HttpErr {
 | 
					impl From<Box<dyn Error>> for HttpErr {
 | 
				
			||||||
    fn from(value: Box<dyn Error>) -> Self {
 | 
					    fn from(value: Box<dyn Error>) -> Self {
 | 
				
			||||||
        HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
 | 
					        HttpErr::Err(std::io::Error::other(value.to_string()).into())
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -98,7 +99,13 @@ impl From<reqwest::header::ToStrError> for HttpErr {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
impl From<actix_web::Error> for HttpErr {
 | 
					impl From<actix_web::Error> for HttpErr {
 | 
				
			||||||
    fn from(value: actix_web::Error) -> Self {
 | 
					    fn from(value: actix_web::Error) -> Self {
 | 
				
			||||||
        HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
 | 
					        HttpErr::Err(std::io::Error::other(value.to_string()).into())
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					impl From<ZipError> for HttpErr {
 | 
				
			||||||
 | 
					    fn from(value: ZipError) -> Self {
 | 
				
			||||||
 | 
					        HttpErr::Err(std::io::Error::other(value.to_string()).into())
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,14 +1,24 @@
 | 
				
			|||||||
use crate::actors::vnc_tokens_actor::VNC_TOKEN_LIFETIME;
 | 
					use crate::actors::vnc_tokens_actor::VNC_TOKEN_LIFETIME;
 | 
				
			||||||
use crate::app_config::AppConfig;
 | 
					use crate::app_config::AppConfig;
 | 
				
			||||||
use crate::constants;
 | 
					 | 
				
			||||||
use crate::constants::{DISK_NAME_MAX_LEN, DISK_NAME_MIN_LEN, DISK_SIZE_MAX, DISK_SIZE_MIN};
 | 
					use crate::constants::{DISK_NAME_MAX_LEN, DISK_NAME_MIN_LEN, DISK_SIZE_MAX, DISK_SIZE_MIN};
 | 
				
			||||||
use crate::controllers::{HttpResult, LibVirtReq};
 | 
					use crate::controllers::{HttpResult, LibVirtReq};
 | 
				
			||||||
use crate::extractors::local_auth_extractor::LocalAuthEnabled;
 | 
					use crate::extractors::local_auth_extractor::LocalAuthEnabled;
 | 
				
			||||||
use crate::libvirt_rest_structures::hypervisor::HypervisorInfo;
 | 
					use crate::libvirt_rest_structures::hypervisor::HypervisorInfo;
 | 
				
			||||||
 | 
					use crate::libvirt_rest_structures::net::NetworkInfo;
 | 
				
			||||||
 | 
					use crate::libvirt_rest_structures::nw_filter::NetworkFilter;
 | 
				
			||||||
 | 
					use crate::libvirt_rest_structures::vm::VMInfo;
 | 
				
			||||||
use crate::nat::nat_hook;
 | 
					use crate::nat::nat_hook;
 | 
				
			||||||
use crate::utils::net_utils;
 | 
					use crate::utils::net_utils;
 | 
				
			||||||
use actix_web::{HttpResponse, Responder};
 | 
					use crate::utils::time_utils::{format_date, time};
 | 
				
			||||||
 | 
					use crate::{api_tokens, constants};
 | 
				
			||||||
 | 
					use actix_files::NamedFile;
 | 
				
			||||||
 | 
					use actix_web::{HttpRequest, HttpResponse, Responder};
 | 
				
			||||||
 | 
					use serde::Serialize;
 | 
				
			||||||
 | 
					use std::fs::File;
 | 
				
			||||||
 | 
					use std::io::Write;
 | 
				
			||||||
use sysinfo::{Components, Disks, Networks, System};
 | 
					use sysinfo::{Components, Disks, Networks, System};
 | 
				
			||||||
 | 
					use zip::ZipWriter;
 | 
				
			||||||
 | 
					use zip::write::SimpleFileOptions;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[derive(serde::Serialize)]
 | 
					#[derive(serde::Serialize)]
 | 
				
			||||||
struct StaticConfig {
 | 
					struct StaticConfig {
 | 
				
			||||||
@@ -16,6 +26,7 @@ struct StaticConfig {
 | 
				
			|||||||
    local_auth_enabled: bool,
 | 
					    local_auth_enabled: bool,
 | 
				
			||||||
    oidc_auth_enabled: bool,
 | 
					    oidc_auth_enabled: bool,
 | 
				
			||||||
    iso_mimetypes: &'static [&'static str],
 | 
					    iso_mimetypes: &'static [&'static str],
 | 
				
			||||||
 | 
					    disk_images_mimetypes: &'static [&'static str],
 | 
				
			||||||
    net_mac_prefix: &'static str,
 | 
					    net_mac_prefix: &'static str,
 | 
				
			||||||
    builtin_nwfilter_rules: &'static [&'static str],
 | 
					    builtin_nwfilter_rules: &'static [&'static str],
 | 
				
			||||||
    nwfilter_chains: &'static [&'static str],
 | 
					    nwfilter_chains: &'static [&'static str],
 | 
				
			||||||
@@ -37,6 +48,7 @@ struct SLenConstraints {
 | 
				
			|||||||
#[derive(serde::Serialize)]
 | 
					#[derive(serde::Serialize)]
 | 
				
			||||||
struct ServerConstraints {
 | 
					struct ServerConstraints {
 | 
				
			||||||
    iso_max_size: usize,
 | 
					    iso_max_size: usize,
 | 
				
			||||||
 | 
					    disk_image_max_size: usize,
 | 
				
			||||||
    vnc_token_duration: u64,
 | 
					    vnc_token_duration: u64,
 | 
				
			||||||
    vm_name_size: LenConstraints,
 | 
					    vm_name_size: LenConstraints,
 | 
				
			||||||
    vm_title_size: LenConstraints,
 | 
					    vm_title_size: LenConstraints,
 | 
				
			||||||
@@ -44,6 +56,7 @@ struct ServerConstraints {
 | 
				
			|||||||
    memory_size: LenConstraints,
 | 
					    memory_size: LenConstraints,
 | 
				
			||||||
    disk_name_size: LenConstraints,
 | 
					    disk_name_size: LenConstraints,
 | 
				
			||||||
    disk_size: LenConstraints,
 | 
					    disk_size: LenConstraints,
 | 
				
			||||||
 | 
					    disk_image_name_size: LenConstraints,
 | 
				
			||||||
    net_name_size: LenConstraints,
 | 
					    net_name_size: LenConstraints,
 | 
				
			||||||
    net_title_size: LenConstraints,
 | 
					    net_title_size: LenConstraints,
 | 
				
			||||||
    net_nat_comment_size: LenConstraints,
 | 
					    net_nat_comment_size: LenConstraints,
 | 
				
			||||||
@@ -63,11 +76,13 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
 | 
				
			|||||||
        local_auth_enabled: *local_auth,
 | 
					        local_auth_enabled: *local_auth,
 | 
				
			||||||
        oidc_auth_enabled: !AppConfig::get().disable_oidc,
 | 
					        oidc_auth_enabled: !AppConfig::get().disable_oidc,
 | 
				
			||||||
        iso_mimetypes: &constants::ALLOWED_ISO_MIME_TYPES,
 | 
					        iso_mimetypes: &constants::ALLOWED_ISO_MIME_TYPES,
 | 
				
			||||||
 | 
					        disk_images_mimetypes: &constants::ALLOWED_DISK_IMAGES_MIME_TYPES,
 | 
				
			||||||
        net_mac_prefix: constants::NET_MAC_ADDR_PREFIX,
 | 
					        net_mac_prefix: constants::NET_MAC_ADDR_PREFIX,
 | 
				
			||||||
        builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES,
 | 
					        builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES,
 | 
				
			||||||
        nwfilter_chains: &constants::NETWORK_CHAINS,
 | 
					        nwfilter_chains: &constants::NETWORK_CHAINS,
 | 
				
			||||||
        constraints: ServerConstraints {
 | 
					        constraints: ServerConstraints {
 | 
				
			||||||
            iso_max_size: constants::ISO_MAX_SIZE,
 | 
					            iso_max_size: constants::ISO_MAX_SIZE.as_bytes(),
 | 
				
			||||||
 | 
					            disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE.as_bytes(),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            vnc_token_duration: VNC_TOKEN_LIFETIME,
 | 
					            vnc_token_duration: VNC_TOKEN_LIFETIME,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -75,18 +90,20 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
 | 
				
			|||||||
            vm_title_size: LenConstraints { min: 0, max: 50 },
 | 
					            vm_title_size: LenConstraints { min: 0, max: 50 },
 | 
				
			||||||
            group_id_size: LenConstraints { min: 3, max: 50 },
 | 
					            group_id_size: LenConstraints { min: 3, max: 50 },
 | 
				
			||||||
            memory_size: LenConstraints {
 | 
					            memory_size: LenConstraints {
 | 
				
			||||||
                min: constants::MIN_VM_MEMORY,
 | 
					                min: constants::MIN_VM_MEMORY.as_bytes(),
 | 
				
			||||||
                max: constants::MAX_VM_MEMORY,
 | 
					                max: constants::MAX_VM_MEMORY.as_bytes(),
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            disk_name_size: LenConstraints {
 | 
					            disk_name_size: LenConstraints {
 | 
				
			||||||
                min: DISK_NAME_MIN_LEN,
 | 
					                min: DISK_NAME_MIN_LEN,
 | 
				
			||||||
                max: DISK_NAME_MAX_LEN,
 | 
					                max: DISK_NAME_MAX_LEN,
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            disk_size: LenConstraints {
 | 
					            disk_size: LenConstraints {
 | 
				
			||||||
                min: DISK_SIZE_MIN,
 | 
					                min: DISK_SIZE_MIN.as_bytes(),
 | 
				
			||||||
                max: DISK_SIZE_MAX,
 | 
					                max: DISK_SIZE_MAX.as_bytes(),
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            disk_image_name_size: LenConstraints { min: 5, max: 220 },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            net_name_size: LenConstraints { min: 2, max: 50 },
 | 
					            net_name_size: LenConstraints { min: 2, max: 50 },
 | 
				
			||||||
            net_title_size: LenConstraints { min: 0, max: 50 },
 | 
					            net_title_size: LenConstraints { min: 0, max: 50 },
 | 
				
			||||||
            net_nat_comment_size: LenConstraints {
 | 
					            net_nat_comment_size: LenConstraints {
 | 
				
			||||||
@@ -188,3 +205,89 @@ pub async fn number_vcpus() -> HttpResult {
 | 
				
			|||||||
pub async fn networks_list() -> HttpResult {
 | 
					pub async fn networks_list() -> HttpResult {
 | 
				
			||||||
    Ok(HttpResponse::Ok().json(net_utils::net_list()))
 | 
					    Ok(HttpResponse::Ok().json(net_utils::net_list()))
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					pub async fn bridges_list() -> HttpResult {
 | 
				
			||||||
 | 
					    Ok(HttpResponse::Ok().json(net_utils::bridges_list()?))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Add JSON file to ZIP
 | 
				
			||||||
 | 
					fn zip_json<E: Serialize, F>(
 | 
				
			||||||
 | 
					    zip: &mut ZipWriter<File>,
 | 
				
			||||||
 | 
					    dir: &str,
 | 
				
			||||||
 | 
					    content: &Vec<E>,
 | 
				
			||||||
 | 
					    file_name: F,
 | 
				
			||||||
 | 
					) -> anyhow::Result<()>
 | 
				
			||||||
 | 
					where
 | 
				
			||||||
 | 
					    F: Fn(&E) -> String,
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					    for entry in content {
 | 
				
			||||||
 | 
					        let file_encoded = serde_json::to_string(&entry)?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let options = SimpleFileOptions::default()
 | 
				
			||||||
 | 
					            .compression_method(zip::CompressionMethod::Deflated)
 | 
				
			||||||
 | 
					            .unix_permissions(0o750);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        zip.start_file(format!("{dir}/{}.json", file_name(entry)), options)?;
 | 
				
			||||||
 | 
					        zip.write_all(file_encoded.as_bytes())?;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    Ok(())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Export all configuration elements at once
 | 
				
			||||||
 | 
					pub async fn export_all_configs(req: HttpRequest, client: LibVirtReq) -> HttpResult {
 | 
				
			||||||
 | 
					    // Perform extractions
 | 
				
			||||||
 | 
					    let vms = client
 | 
				
			||||||
 | 
					        .get_full_domains_list()
 | 
				
			||||||
 | 
					        .await?
 | 
				
			||||||
 | 
					        .into_iter()
 | 
				
			||||||
 | 
					        .map(VMInfo::from_domain)
 | 
				
			||||||
 | 
					        .collect::<Result<Vec<_>, _>>()?;
 | 
				
			||||||
 | 
					    let networks = client
 | 
				
			||||||
 | 
					        .get_full_networks_list()
 | 
				
			||||||
 | 
					        .await?
 | 
				
			||||||
 | 
					        .into_iter()
 | 
				
			||||||
 | 
					        .map(NetworkInfo::from_xml)
 | 
				
			||||||
 | 
					        .collect::<Result<Vec<_>, _>>()?;
 | 
				
			||||||
 | 
					    let nw_filters = client
 | 
				
			||||||
 | 
					        .get_full_network_filters_list()
 | 
				
			||||||
 | 
					        .await?
 | 
				
			||||||
 | 
					        .into_iter()
 | 
				
			||||||
 | 
					        .map(NetworkFilter::lib2rest)
 | 
				
			||||||
 | 
					        .collect::<Result<Vec<_>, _>>()?;
 | 
				
			||||||
 | 
					    let tokens = api_tokens::full_list().await?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Create ZIP file
 | 
				
			||||||
 | 
					    let dest_dir = tempfile::tempdir_in(&AppConfig::get().temp_dir)?;
 | 
				
			||||||
 | 
					    let zip_path = dest_dir.path().join("export.zip");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let file = File::create(&zip_path)?;
 | 
				
			||||||
 | 
					    let mut zip = ZipWriter::new(file);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Encode entities to JSON
 | 
				
			||||||
 | 
					    zip_json(&mut zip, "vms", &vms, |v| v.name.to_string())?;
 | 
				
			||||||
 | 
					    zip_json(&mut zip, "networks", &networks, |v| v.name.0.to_string())?;
 | 
				
			||||||
 | 
					    zip_json(
 | 
				
			||||||
 | 
					        &mut zip,
 | 
				
			||||||
 | 
					        "nw_filters",
 | 
				
			||||||
 | 
					        &nw_filters,
 | 
				
			||||||
 | 
					        |v| match constants::BUILTIN_NETWORK_FILTER_RULES.contains(&v.name.0.as_str()) {
 | 
				
			||||||
 | 
					            true => format!("builtin/{}", v.name.0),
 | 
				
			||||||
 | 
					            false => v.name.0.to_string(),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    )?;
 | 
				
			||||||
 | 
					    zip_json(&mut zip, "tokens", &tokens, |v| v.id.0.to_string())?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Finalize ZIP and return response
 | 
				
			||||||
 | 
					    zip.finish()?;
 | 
				
			||||||
 | 
					    let file = File::open(zip_path)?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let file = NamedFile::from_file(
 | 
				
			||||||
 | 
					        file,
 | 
				
			||||||
 | 
					        format!(
 | 
				
			||||||
 | 
					            "export_{}.zip",
 | 
				
			||||||
 | 
					            format_date(time() as i64).unwrap().replace('/', "-")
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    )?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(file.into_response(&req))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,6 +3,27 @@ pub use serve_static_debug::{root_index, serve_static_content};
 | 
				
			|||||||
#[cfg(not(debug_assertions))]
 | 
					#[cfg(not(debug_assertions))]
 | 
				
			||||||
pub use serve_static_release::{root_index, serve_static_content};
 | 
					pub use serve_static_release::{root_index, serve_static_content};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Static API assets hosting
 | 
				
			||||||
 | 
					pub mod serve_assets {
 | 
				
			||||||
 | 
					    use actix_web::{HttpResponse, web};
 | 
				
			||||||
 | 
					    use rust_embed::RustEmbed;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    #[derive(RustEmbed)]
 | 
				
			||||||
 | 
					    #[folder = "assets/"]
 | 
				
			||||||
 | 
					    struct Asset;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Serve API assets
 | 
				
			||||||
 | 
					    pub async fn serve_api_assets(file: web::Path<String>) -> HttpResponse {
 | 
				
			||||||
 | 
					        match Asset::get(&file) {
 | 
				
			||||||
 | 
					            None => HttpResponse::NotFound().body("File not found"),
 | 
				
			||||||
 | 
					            Some(asset) => HttpResponse::Ok()
 | 
				
			||||||
 | 
					                .content_type(asset.metadata.mimetype())
 | 
				
			||||||
 | 
					                .body(asset.data),
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Web asset hosting placeholder in debug mode
 | 
				
			||||||
#[cfg(debug_assertions)]
 | 
					#[cfg(debug_assertions)]
 | 
				
			||||||
mod serve_static_debug {
 | 
					mod serve_static_debug {
 | 
				
			||||||
    use actix_web::{HttpResponse, Responder};
 | 
					    use actix_web::{HttpResponse, Responder};
 | 
				
			||||||
@@ -16,6 +37,7 @@ mod serve_static_debug {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Web asset hosting in release mode
 | 
				
			||||||
#[cfg(not(debug_assertions))]
 | 
					#[cfg(not(debug_assertions))]
 | 
				
			||||||
mod serve_static_release {
 | 
					mod serve_static_release {
 | 
				
			||||||
    use actix_web::{HttpResponse, Responder, web};
 | 
					    use actix_web::{HttpResponse, Responder, web};
 | 
				
			||||||
@@ -23,12 +45,12 @@ mod serve_static_release {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    #[derive(RustEmbed)]
 | 
					    #[derive(RustEmbed)]
 | 
				
			||||||
    #[folder = "static/"]
 | 
					    #[folder = "static/"]
 | 
				
			||||||
    struct Asset;
 | 
					    struct WebAsset;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    fn handle_embedded_file(path: &str, can_fallback: bool) -> HttpResponse {
 | 
					    fn handle_embedded_file(path: &str, can_fallback: bool) -> HttpResponse {
 | 
				
			||||||
        match (Asset::get(path), can_fallback) {
 | 
					        match (WebAsset::get(path), can_fallback) {
 | 
				
			||||||
            (Some(content), _) => HttpResponse::Ok()
 | 
					            (Some(content), _) => HttpResponse::Ok()
 | 
				
			||||||
                .content_type(mime_guess::from_path(path).first_or_octet_stream().as_ref())
 | 
					                .content_type(content.metadata.mimetype())
 | 
				
			||||||
                .body(content.data.into_owned()),
 | 
					                .body(content.data.into_owned()),
 | 
				
			||||||
            (None, false) => HttpResponse::NotFound().body("404 Not Found"),
 | 
					            (None, false) => HttpResponse::NotFound().body("404 Not Found"),
 | 
				
			||||||
            (None, true) => handle_embedded_file("index.html", false),
 | 
					            (None, true) => handle_embedded_file("index.html", false),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -109,6 +109,28 @@ pub async fn get_single_src_def(client: LibVirtReq, id: web::Path<SingleVMUUidRe
 | 
				
			|||||||
        .body(info))
 | 
					        .body(info))
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Get the generated cloud init configuration disk of a vm
 | 
				
			||||||
 | 
					pub async fn get_cloud_init_disk(client: LibVirtReq, id: web::Path<SingleVMUUidReq>) -> HttpResult {
 | 
				
			||||||
 | 
					    let info = match client.get_single_domain(id.uid).await {
 | 
				
			||||||
 | 
					        Ok(i) => i,
 | 
				
			||||||
 | 
					        Err(e) => {
 | 
				
			||||||
 | 
					            log::error!("Failed to get domain information! {e}");
 | 
				
			||||||
 | 
					            return Ok(HttpResponse::InternalServerError().json(e.to_string()));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let vm = VMInfo::from_domain(info)?;
 | 
				
			||||||
 | 
					    let disk = vm.cloud_init.generate_nocloud_disk()?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(HttpResponse::Ok()
 | 
				
			||||||
 | 
					        .content_type("application/x-iso9660-image")
 | 
				
			||||||
 | 
					        .insert_header((
 | 
				
			||||||
 | 
					            "Content-Disposition",
 | 
				
			||||||
 | 
					            format!("attachment; filename=\"cloud_init_{}.iso\"", vm.name),
 | 
				
			||||||
 | 
					        ))
 | 
				
			||||||
 | 
					        .body(disk))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Update a VM information
 | 
					/// Update a VM information
 | 
				
			||||||
pub async fn update(
 | 
					pub async fn update(
 | 
				
			||||||
    client: LibVirtReq,
 | 
					    client: LibVirtReq,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
use crate::libvirt_lib_structures::XMLUuid;
 | 
					use crate::libvirt_lib_structures::XMLUuid;
 | 
				
			||||||
 | 
					use crate::utils::cloud_init_utils::CloudInitConfig;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// VirtWeb specific metadata
 | 
					/// VirtWeb specific metadata
 | 
				
			||||||
#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
 | 
					#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
 | 
				
			||||||
@@ -8,6 +9,8 @@ pub struct DomainMetadataVirtWebXML {
 | 
				
			|||||||
    pub ns: String,
 | 
					    pub ns: String,
 | 
				
			||||||
    #[serde(skip_serializing_if = "Option::is_none")]
 | 
					    #[serde(skip_serializing_if = "Option::is_none")]
 | 
				
			||||||
    pub group: Option<String>,
 | 
					    pub group: Option<String>,
 | 
				
			||||||
 | 
					    #[serde(skip_serializing_if = "Option::is_none")]
 | 
				
			||||||
 | 
					    pub cloud_init: Option<CloudInitConfig>,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Domain metadata
 | 
					/// Domain metadata
 | 
				
			||||||
@@ -22,10 +25,14 @@ pub struct DomainMetadataXML {
 | 
				
			|||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 | 
					#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
#[serde(rename = "os")]
 | 
					#[serde(rename = "os")]
 | 
				
			||||||
pub struct OSXML {
 | 
					pub struct OSXML {
 | 
				
			||||||
    #[serde(rename = "@firmware", default)]
 | 
					    #[serde(rename = "@firmware", default, skip_serializing_if = "Option::is_none")]
 | 
				
			||||||
    pub firmware: String,
 | 
					    pub firmware: Option<String>,
 | 
				
			||||||
    pub r#type: OSTypeXML,
 | 
					    pub r#type: OSTypeXML,
 | 
				
			||||||
 | 
					    #[serde(skip_serializing_if = "Option::is_none")]
 | 
				
			||||||
    pub loader: Option<OSLoaderXML>,
 | 
					    pub loader: Option<OSLoaderXML>,
 | 
				
			||||||
 | 
					    #[serde(skip_serializing_if = "Option::is_none")]
 | 
				
			||||||
 | 
					    pub bootmenu: Option<OSBootMenuXML>,
 | 
				
			||||||
 | 
					    pub smbios: Option<OSSMBiosXML>,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// OS Type information
 | 
					/// OS Type information
 | 
				
			||||||
@@ -48,6 +55,24 @@ pub struct OSLoaderXML {
 | 
				
			|||||||
    pub secure: String,
 | 
					    pub secure: String,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Legacy boot menu information
 | 
				
			||||||
 | 
					#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
 | 
					#[serde(rename = "bootmenu")]
 | 
				
			||||||
 | 
					pub struct OSBootMenuXML {
 | 
				
			||||||
 | 
					    #[serde(rename = "@enable")]
 | 
				
			||||||
 | 
					    pub enable: String,
 | 
				
			||||||
 | 
					    #[serde(rename = "@timeout")]
 | 
				
			||||||
 | 
					    pub timeout: usize,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// SMBIOS System information
 | 
				
			||||||
 | 
					#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
 | 
					#[serde(rename = "smbios")]
 | 
				
			||||||
 | 
					pub struct OSSMBiosXML {
 | 
				
			||||||
 | 
					    #[serde(rename = "@mode")]
 | 
				
			||||||
 | 
					    pub mode: String,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Hypervisor features
 | 
					/// Hypervisor features
 | 
				
			||||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Default, Debug)]
 | 
					#[derive(serde::Serialize, serde::Deserialize, Clone, Default, Debug)]
 | 
				
			||||||
#[serde(rename = "features")]
 | 
					#[serde(rename = "features")]
 | 
				
			||||||
@@ -71,7 +96,9 @@ pub struct NetMacAddress {
 | 
				
			|||||||
#[serde(rename = "source")]
 | 
					#[serde(rename = "source")]
 | 
				
			||||||
pub struct NetIntSourceXML {
 | 
					pub struct NetIntSourceXML {
 | 
				
			||||||
    #[serde(rename = "@network")]
 | 
					    #[serde(rename = "@network")]
 | 
				
			||||||
    pub network: String,
 | 
					    pub network: Option<String>,
 | 
				
			||||||
 | 
					    #[serde(rename = "@bridge")]
 | 
				
			||||||
 | 
					    pub bridge: Option<String>,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 | 
					#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
@@ -305,6 +332,29 @@ pub struct DomainCPUXML {
 | 
				
			|||||||
    pub topology: Option<DomainCPUTopology>,
 | 
					    pub topology: Option<DomainCPUTopology>,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
 | 
					#[serde(rename = "entry")]
 | 
				
			||||||
 | 
					pub struct OEMStringEntryXML {
 | 
				
			||||||
 | 
					    #[serde(rename = "$text", default)]
 | 
				
			||||||
 | 
					    pub content: String,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
 | 
					#[serde(rename = "oemStrings")]
 | 
				
			||||||
 | 
					pub struct OEMStringsXML {
 | 
				
			||||||
 | 
					    #[serde(rename = "entry")]
 | 
				
			||||||
 | 
					    pub entries: Vec<OEMStringEntryXML>,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
 | 
					#[serde(rename = "sysinfo")]
 | 
				
			||||||
 | 
					pub struct SysInfoXML {
 | 
				
			||||||
 | 
					    #[serde(rename = "@type")]
 | 
				
			||||||
 | 
					    pub r#type: String,
 | 
				
			||||||
 | 
					    #[serde(rename = "oemStrings")]
 | 
				
			||||||
 | 
					    pub oem_strings: Option<OEMStringsXML>,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Domain information, see https://libvirt.org/formatdomain.html
 | 
					/// Domain information, see https://libvirt.org/formatdomain.html
 | 
				
			||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 | 
					#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
#[serde(rename = "domain")]
 | 
					#[serde(rename = "domain")]
 | 
				
			||||||
@@ -335,6 +385,10 @@ pub struct DomainXML {
 | 
				
			|||||||
    /// CPU information
 | 
					    /// CPU information
 | 
				
			||||||
    pub cpu: DomainCPUXML,
 | 
					    pub cpu: DomainCPUXML,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// SMBios strings
 | 
				
			||||||
 | 
					    pub sysinfo: Option<SysInfoXML>,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Behavior when guest state change
 | 
				
			||||||
    pub on_poweroff: String,
 | 
					    pub on_poweroff: String,
 | 
				
			||||||
    pub on_reboot: String,
 | 
					    pub on_reboot: String,
 | 
				
			||||||
    pub on_crash: String,
 | 
					    pub on_crash: String,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,4 +13,6 @@ enum LibVirtStructError {
 | 
				
			|||||||
    ParseFilteringChain(String),
 | 
					    ParseFilteringChain(String),
 | 
				
			||||||
    #[error("NetworkFilterExtractionError: {0}")]
 | 
					    #[error("NetworkFilterExtractionError: {0}")]
 | 
				
			||||||
    NetworkFilterExtraction(String),
 | 
					    NetworkFilterExtraction(String),
 | 
				
			||||||
 | 
					    #[error("CloudInitConfigurationError: {0}")]
 | 
				
			||||||
 | 
					    CloudInitConfiguration(String),
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,10 +3,13 @@ use crate::constants;
 | 
				
			|||||||
use crate::libvirt_lib_structures::XMLUuid;
 | 
					use crate::libvirt_lib_structures::XMLUuid;
 | 
				
			||||||
use crate::libvirt_lib_structures::domain::*;
 | 
					use crate::libvirt_lib_structures::domain::*;
 | 
				
			||||||
use crate::libvirt_rest_structures::LibVirtStructError;
 | 
					use crate::libvirt_rest_structures::LibVirtStructError;
 | 
				
			||||||
use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction;
 | 
					use crate::libvirt_rest_structures::LibVirtStructError::{
 | 
				
			||||||
use crate::utils::disks_utils::Disk;
 | 
					    CloudInitConfiguration, StructureExtraction,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					use crate::utils::cloud_init_utils::CloudInitConfig;
 | 
				
			||||||
 | 
					use crate::utils::file_size_utils::FileSize;
 | 
				
			||||||
use crate::utils::files_utils;
 | 
					use crate::utils::files_utils;
 | 
				
			||||||
use crate::utils::files_utils::convert_size_unit_to_mb;
 | 
					use crate::utils::vm_file_disks_utils::{VMDiskBus, VMDiskFormat, VMFileDisk};
 | 
				
			||||||
use lazy_regex::regex;
 | 
					use lazy_regex::regex;
 | 
				
			||||||
use num::Integer;
 | 
					use num::Integer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -17,6 +20,7 @@ pub struct VMGroupId(pub String);
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
#[derive(serde::Serialize, serde::Deserialize)]
 | 
					#[derive(serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
pub enum BootType {
 | 
					pub enum BootType {
 | 
				
			||||||
 | 
					    Legacy,
 | 
				
			||||||
    UEFI,
 | 
					    UEFI,
 | 
				
			||||||
    UEFISecureBoot,
 | 
					    UEFISecureBoot,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -29,6 +33,12 @@ pub enum VMArchitecture {
 | 
				
			|||||||
    X86_64,
 | 
					    X86_64,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
 | 
					pub enum NetworkInterfaceModelType {
 | 
				
			||||||
 | 
					    Virtio,
 | 
				
			||||||
 | 
					    E1000,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[derive(serde::Serialize, serde::Deserialize)]
 | 
					#[derive(serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
pub struct NWFilterParam {
 | 
					pub struct NWFilterParam {
 | 
				
			||||||
    name: String,
 | 
					    name: String,
 | 
				
			||||||
@@ -46,6 +56,7 @@ pub struct Network {
 | 
				
			|||||||
    #[serde(flatten)]
 | 
					    #[serde(flatten)]
 | 
				
			||||||
    r#type: NetworkType,
 | 
					    r#type: NetworkType,
 | 
				
			||||||
    mac: String,
 | 
					    mac: String,
 | 
				
			||||||
 | 
					    model: NetworkInterfaceModelType,
 | 
				
			||||||
    nwfilterref: Option<NWFilterRef>,
 | 
					    nwfilterref: Option<NWFilterRef>,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -53,7 +64,8 @@ pub struct Network {
 | 
				
			|||||||
#[serde(tag = "type")]
 | 
					#[serde(tag = "type")]
 | 
				
			||||||
pub enum NetworkType {
 | 
					pub enum NetworkType {
 | 
				
			||||||
    UserspaceSLIRPStack,
 | 
					    UserspaceSLIRPStack,
 | 
				
			||||||
    DefinedNetwork { network: String }, // TODO : complete network types
 | 
					    DefinedNetwork { network: String },
 | 
				
			||||||
 | 
					    Bridge { bridge: String },
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[derive(serde::Serialize, serde::Deserialize)]
 | 
					#[derive(serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
@@ -69,20 +81,25 @@ pub struct VMInfo {
 | 
				
			|||||||
    pub group: Option<VMGroupId>,
 | 
					    pub group: Option<VMGroupId>,
 | 
				
			||||||
    pub boot_type: BootType,
 | 
					    pub boot_type: BootType,
 | 
				
			||||||
    pub architecture: VMArchitecture,
 | 
					    pub architecture: VMArchitecture,
 | 
				
			||||||
    /// VM allocated memory, in megabytes
 | 
					    /// VM allocated RAM memory
 | 
				
			||||||
    pub memory: usize,
 | 
					    pub memory: FileSize,
 | 
				
			||||||
    /// Number of vCPU for the VM
 | 
					    /// Number of vCPU for the VM
 | 
				
			||||||
    pub number_vcpu: usize,
 | 
					    pub number_vcpu: usize,
 | 
				
			||||||
    /// Enable VNC access through admin console
 | 
					    /// Enable VNC access through admin console
 | 
				
			||||||
    pub vnc_access: bool,
 | 
					    pub vnc_access: bool,
 | 
				
			||||||
    /// Attach ISO file(s)
 | 
					    /// Attach ISO file(s)
 | 
				
			||||||
    pub iso_files: Vec<String>,
 | 
					    pub iso_files: Vec<String>,
 | 
				
			||||||
    /// Storage - https://access.redhat.com/documentation/fr-fr/red_hat_enterprise_linux/6/html/virtualization_administration_guide/sect-virtualization-virtualized_block_devices-adding_storage_devices_to_guests#sect-Virtualization-Adding_storage_devices_to_guests-Adding_file_based_storage_to_a_guest
 | 
					    /// File Storage - https://access.redhat.com/documentation/fr-fr/red_hat_enterprise_linux/6/html/virtualization_administration_guide/sect-virtualization-virtualized_block_devices-adding_storage_devices_to_guests#sect-Virtualization-Adding_storage_devices_to_guests-Adding_file_based_storage_to_a_guest
 | 
				
			||||||
    pub disks: Vec<Disk>,
 | 
					    pub file_disks: Vec<VMFileDisk>,
 | 
				
			||||||
    /// Network cards
 | 
					    /// Network cards
 | 
				
			||||||
    pub networks: Vec<Network>,
 | 
					    pub networks: Vec<Network>,
 | 
				
			||||||
    /// Add a TPM v2.0 module
 | 
					    /// Add a TPM v2.0 module
 | 
				
			||||||
    pub tpm_module: bool,
 | 
					    pub tpm_module: bool,
 | 
				
			||||||
 | 
					    /// Strings injected as OEM Strings in SMBios configuration
 | 
				
			||||||
 | 
					    pub oem_strings: Vec<String>,
 | 
				
			||||||
 | 
					    /// Cloud init configuration
 | 
				
			||||||
 | 
					    #[serde(default)]
 | 
				
			||||||
 | 
					    pub cloud_init: CloudInitConfig,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
impl VMInfo {
 | 
					impl VMInfo {
 | 
				
			||||||
@@ -127,8 +144,26 @@ impl VMInfo {
 | 
				
			|||||||
            return Err(StructureExtraction("Invalid number of vCPU specified!").into());
 | 
					            return Err(StructureExtraction("Invalid number of vCPU specified!").into());
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        let mut disks = vec![];
 | 
					        if let Some(e) = self.cloud_init.check_error() {
 | 
				
			||||||
 | 
					            return Err(CloudInitConfiguration(e).into());
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let mut iso_absolute_files = vec![];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Process cloud init image
 | 
				
			||||||
 | 
					        if self.cloud_init.attach_config {
 | 
				
			||||||
 | 
					            let cloud_init_disk_path = AppConfig::get().cloud_init_disk_path_for_vm(&self.name);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Apply latest cloud init configuration
 | 
				
			||||||
 | 
					            std::fs::write(
 | 
				
			||||||
 | 
					                &cloud_init_disk_path,
 | 
				
			||||||
 | 
					                self.cloud_init.generate_nocloud_disk()?,
 | 
				
			||||||
 | 
					            )?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            iso_absolute_files.push(cloud_init_disk_path);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Process uploaded ISO files
 | 
				
			||||||
        for iso_file in &self.iso_files {
 | 
					        for iso_file in &self.iso_files {
 | 
				
			||||||
            if !files_utils::check_file_name(iso_file) {
 | 
					            if !files_utils::check_file_name(iso_file) {
 | 
				
			||||||
                return Err(StructureExtraction("ISO filename is invalid!").into());
 | 
					                return Err(StructureExtraction("ISO filename is invalid!").into());
 | 
				
			||||||
@@ -140,6 +175,13 @@ impl VMInfo {
 | 
				
			|||||||
                return Err(StructureExtraction("Specified ISO file does not exists!").into());
 | 
					                return Err(StructureExtraction("Specified ISO file does not exists!").into());
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            iso_absolute_files.push(path);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let mut disks = vec![];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Add ISO disk files
 | 
				
			||||||
 | 
					        for iso_path in iso_absolute_files {
 | 
				
			||||||
            disks.push(DiskXML {
 | 
					            disks.push(DiskXML {
 | 
				
			||||||
                r#type: "file".to_string(),
 | 
					                r#type: "file".to_string(),
 | 
				
			||||||
                device: "cdrom".to_string(),
 | 
					                device: "cdrom".to_string(),
 | 
				
			||||||
@@ -149,7 +191,7 @@ impl VMInfo {
 | 
				
			|||||||
                    cache: "none".to_string(),
 | 
					                    cache: "none".to_string(),
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
                source: DiskSourceXML {
 | 
					                source: DiskSourceXML {
 | 
				
			||||||
                    file: path.to_string_lossy().to_string(),
 | 
					                    file: iso_path.to_string_lossy().to_string(),
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
                target: DiskTargetXML {
 | 
					                target: DiskTargetXML {
 | 
				
			||||||
                    dev: format!(
 | 
					                    dev: format!(
 | 
				
			||||||
@@ -166,6 +208,7 @@ impl VMInfo {
 | 
				
			|||||||
            })
 | 
					            })
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Configure VNC access, if requested
 | 
				
			||||||
        let (vnc_graphics, vnc_video) = match self.vnc_access {
 | 
					        let (vnc_graphics, vnc_video) = match self.vnc_access {
 | 
				
			||||||
            true => (
 | 
					            true => (
 | 
				
			||||||
                Some(GraphicsXML {
 | 
					                Some(GraphicsXML {
 | 
				
			||||||
@@ -192,7 +235,11 @@ impl VMInfo {
 | 
				
			|||||||
            };
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            let model = Some(NetIntModelXML {
 | 
					            let model = Some(NetIntModelXML {
 | 
				
			||||||
                r#type: "virtio".to_string(),
 | 
					                r#type: match n.model {
 | 
				
			||||||
 | 
					                    NetworkInterfaceModelType::Virtio => "virtio",
 | 
				
			||||||
 | 
					                    NetworkInterfaceModelType::E1000 => "e1000",
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                .to_string(),
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            let filterref = if let Some(n) = &n.nwfilterref {
 | 
					            let filterref = if let Some(n) = &n.nwfilterref {
 | 
				
			||||||
@@ -237,7 +284,18 @@ impl VMInfo {
 | 
				
			|||||||
                    mac,
 | 
					                    mac,
 | 
				
			||||||
                    r#type: "network".to_string(),
 | 
					                    r#type: "network".to_string(),
 | 
				
			||||||
                    source: Some(NetIntSourceXML {
 | 
					                    source: Some(NetIntSourceXML {
 | 
				
			||||||
                        network: network.to_string(),
 | 
					                        network: Some(network.to_string()),
 | 
				
			||||||
 | 
					                        bridge: None,
 | 
				
			||||||
 | 
					                    }),
 | 
				
			||||||
 | 
					                    model,
 | 
				
			||||||
 | 
					                    filterref,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                NetworkType::Bridge { bridge } => DomainNetInterfaceXML {
 | 
				
			||||||
 | 
					                    r#type: "bridge".to_string(),
 | 
				
			||||||
 | 
					                    mac,
 | 
				
			||||||
 | 
					                    source: Some(NetIntSourceXML {
 | 
				
			||||||
 | 
					                        network: None,
 | 
				
			||||||
 | 
					                        bridge: Some(bridge.to_string()),
 | 
				
			||||||
                    }),
 | 
					                    }),
 | 
				
			||||||
                    model,
 | 
					                    model,
 | 
				
			||||||
                    filterref,
 | 
					                    filterref,
 | 
				
			||||||
@@ -246,15 +304,21 @@ impl VMInfo {
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Check disks name for duplicates
 | 
					        // Check disks name for duplicates
 | 
				
			||||||
        for disk in &self.disks {
 | 
					        for disk in &self.file_disks {
 | 
				
			||||||
            if self.disks.iter().filter(|d| d.name == disk.name).count() > 1 {
 | 
					            if self
 | 
				
			||||||
 | 
					                .file_disks
 | 
				
			||||||
 | 
					                .iter()
 | 
				
			||||||
 | 
					                .filter(|d| d.name == disk.name)
 | 
				
			||||||
 | 
					                .count()
 | 
				
			||||||
 | 
					                > 1
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
                return Err(StructureExtraction("Two different disks have the same name!").into());
 | 
					                return Err(StructureExtraction("Two different disks have the same name!").into());
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Apply disks configuration. Starting from now, the function should ideally never fail due to
 | 
					        // Apply disks configuration. Starting from now, the function should ideally never fail due to
 | 
				
			||||||
        // bad user input
 | 
					        // bad user input
 | 
				
			||||||
        for disk in &self.disks {
 | 
					        for disk in &self.file_disks {
 | 
				
			||||||
            disk.check_config()?;
 | 
					            disk.check_config()?;
 | 
				
			||||||
            disk.apply_config(uuid)?;
 | 
					            disk.apply_config(uuid)?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -267,7 +331,10 @@ impl VMInfo {
 | 
				
			|||||||
                device: "disk".to_string(),
 | 
					                device: "disk".to_string(),
 | 
				
			||||||
                driver: DiskDriverXML {
 | 
					                driver: DiskDriverXML {
 | 
				
			||||||
                    name: "qemu".to_string(),
 | 
					                    name: "qemu".to_string(),
 | 
				
			||||||
                    r#type: "raw".to_string(),
 | 
					                    r#type: match disk.format {
 | 
				
			||||||
 | 
					                        VMDiskFormat::Raw { .. } => "raw".to_string(),
 | 
				
			||||||
 | 
					                        VMDiskFormat::QCow2 => "qcow2".to_string(),
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
                    cache: "none".to_string(),
 | 
					                    cache: "none".to_string(),
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
                source: DiskSourceXML {
 | 
					                source: DiskSourceXML {
 | 
				
			||||||
@@ -278,7 +345,11 @@ impl VMInfo {
 | 
				
			|||||||
                        "vd{}",
 | 
					                        "vd{}",
 | 
				
			||||||
                        ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"][disks.len()]
 | 
					                        ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"][disks.len()]
 | 
				
			||||||
                    ),
 | 
					                    ),
 | 
				
			||||||
                    bus: "virtio".to_string(),
 | 
					                    bus: match disk.bus {
 | 
				
			||||||
 | 
					                        VMDiskBus::Virtio => "virtio",
 | 
				
			||||||
 | 
					                        VMDiskBus::SATA => "sata",
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                    .to_string(),
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
                readonly: None,
 | 
					                readonly: None,
 | 
				
			||||||
                boot: DiskBootXML {
 | 
					                boot: DiskBootXML {
 | 
				
			||||||
@@ -300,6 +371,7 @@ impl VMInfo {
 | 
				
			|||||||
                virtweb: DomainMetadataVirtWebXML {
 | 
					                virtweb: DomainMetadataVirtWebXML {
 | 
				
			||||||
                    ns: "https://virtweb.communiquons.org".to_string(),
 | 
					                    ns: "https://virtweb.communiquons.org".to_string(),
 | 
				
			||||||
                    group: self.group.clone().map(|g| g.0),
 | 
					                    group: self.group.clone().map(|g| g.0),
 | 
				
			||||||
 | 
					                    cloud_init: Some(self.cloud_init.clone()),
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
            }),
 | 
					            }),
 | 
				
			||||||
            os: OSXML {
 | 
					            os: OSXML {
 | 
				
			||||||
@@ -312,12 +384,28 @@ impl VMInfo {
 | 
				
			|||||||
                    machine: "q35".to_string(),
 | 
					                    machine: "q35".to_string(),
 | 
				
			||||||
                    body: "hvm".to_string(),
 | 
					                    body: "hvm".to_string(),
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
                firmware: "efi".to_string(),
 | 
					                firmware: match self.boot_type {
 | 
				
			||||||
                loader: Some(OSLoaderXML {
 | 
					                    BootType::Legacy => None,
 | 
				
			||||||
                    secure: match self.boot_type {
 | 
					                    _ => Some("efi".to_string()),
 | 
				
			||||||
                        BootType::UEFI => "no".to_string(),
 | 
					 | 
				
			||||||
                        BootType::UEFISecureBoot => "yes".to_string(),
 | 
					 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
 | 
					                loader: match self.boot_type {
 | 
				
			||||||
 | 
					                    BootType::Legacy => None,
 | 
				
			||||||
 | 
					                    _ => Some(OSLoaderXML {
 | 
				
			||||||
 | 
					                        secure: match self.boot_type {
 | 
				
			||||||
 | 
					                            BootType::UEFISecureBoot => "yes".to_string(),
 | 
				
			||||||
 | 
					                            _ => "no".to_string(),
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    }),
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                bootmenu: match self.boot_type {
 | 
				
			||||||
 | 
					                    BootType::Legacy => Some(OSBootMenuXML {
 | 
				
			||||||
 | 
					                        enable: "yes".to_string(),
 | 
				
			||||||
 | 
					                        timeout: 3000,
 | 
				
			||||||
 | 
					                    }),
 | 
				
			||||||
 | 
					                    _ => None,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                smbios: Some(OSSMBiosXML {
 | 
				
			||||||
 | 
					                    mode: "sysinfo".to_string(),
 | 
				
			||||||
                }),
 | 
					                }),
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -353,7 +441,7 @@ impl VMInfo {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
            memory: DomainMemoryXML {
 | 
					            memory: DomainMemoryXML {
 | 
				
			||||||
                unit: "MB".to_string(),
 | 
					                unit: "MB".to_string(),
 | 
				
			||||||
                memory: self.memory,
 | 
					                memory: self.memory.as_mb(),
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            vcpu: DomainVCPUXML {
 | 
					            vcpu: DomainVCPUXML {
 | 
				
			||||||
@@ -375,6 +463,17 @@ impl VMInfo {
 | 
				
			|||||||
                }),
 | 
					                }),
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            sysinfo: Some(SysInfoXML {
 | 
				
			||||||
 | 
					                r#type: "smbios".to_string(),
 | 
				
			||||||
 | 
					                oem_strings: Some(OEMStringsXML {
 | 
				
			||||||
 | 
					                    entries: self
 | 
				
			||||||
 | 
					                        .oem_strings
 | 
				
			||||||
 | 
					                        .iter()
 | 
				
			||||||
 | 
					                        .map(|s| OEMStringEntryXML { content: s.clone() })
 | 
				
			||||||
 | 
					                        .collect(),
 | 
				
			||||||
 | 
					                }),
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            on_poweroff: "destroy".to_string(),
 | 
					            on_poweroff: "destroy".to_string(),
 | 
				
			||||||
            on_reboot: "restart".to_string(),
 | 
					            on_reboot: "restart".to_string(),
 | 
				
			||||||
            on_crash: "destroy".to_string(),
 | 
					            on_crash: "destroy".to_string(),
 | 
				
			||||||
@@ -396,9 +495,10 @@ impl VMInfo {
 | 
				
			|||||||
                .virtweb
 | 
					                .virtweb
 | 
				
			||||||
                .group
 | 
					                .group
 | 
				
			||||||
                .map(VMGroupId),
 | 
					                .map(VMGroupId),
 | 
				
			||||||
            boot_type: match domain.os.loader {
 | 
					            boot_type: match (domain.os.loader, domain.os.bootmenu) {
 | 
				
			||||||
                None => BootType::UEFI,
 | 
					                (_, Some(_)) => BootType::Legacy,
 | 
				
			||||||
                Some(l) => match l.secure.as_str() {
 | 
					                (None, _) => BootType::UEFI,
 | 
				
			||||||
 | 
					                (Some(l), _) => match l.secure.as_str() {
 | 
				
			||||||
                    "yes" => BootType::UEFISecureBoot,
 | 
					                    "yes" => BootType::UEFISecureBoot,
 | 
				
			||||||
                    _ => BootType::UEFI,
 | 
					                    _ => BootType::UEFI,
 | 
				
			||||||
                },
 | 
					                },
 | 
				
			||||||
@@ -414,7 +514,7 @@ impl VMInfo {
 | 
				
			|||||||
                }
 | 
					                }
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
            number_vcpu: domain.vcpu.body,
 | 
					            number_vcpu: domain.vcpu.body,
 | 
				
			||||||
            memory: convert_size_unit_to_mb(&domain.memory.unit, domain.memory.memory)?,
 | 
					            memory: FileSize::from_size_unit(&domain.memory.unit, domain.memory.memory)?,
 | 
				
			||||||
            vnc_access: domain.devices.graphics.is_some(),
 | 
					            vnc_access: domain.devices.graphics.is_some(),
 | 
				
			||||||
            iso_files: domain
 | 
					            iso_files: domain
 | 
				
			||||||
                .devices
 | 
					                .devices
 | 
				
			||||||
@@ -422,14 +522,18 @@ impl VMInfo {
 | 
				
			|||||||
                .iter()
 | 
					                .iter()
 | 
				
			||||||
                .filter(|d| d.device == "cdrom")
 | 
					                .filter(|d| d.device == "cdrom")
 | 
				
			||||||
                .map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string())
 | 
					                .map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string())
 | 
				
			||||||
 | 
					                .filter(|d| !d.starts_with(constants::CLOUD_INIT_IMAGE_PREFIX_NAME))
 | 
				
			||||||
                .collect(),
 | 
					                .collect(),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            disks: domain
 | 
					            file_disks: domain
 | 
				
			||||||
                .devices
 | 
					                .devices
 | 
				
			||||||
                .disks
 | 
					                .disks
 | 
				
			||||||
                .iter()
 | 
					                .iter()
 | 
				
			||||||
                .filter(|d| d.device == "disk")
 | 
					                .filter(|d| d.device == "disk")
 | 
				
			||||||
                .map(|d| Disk::load_from_file(&d.source.file).unwrap())
 | 
					                .map(|d| {
 | 
				
			||||||
 | 
					                    VMFileDisk::load_from_file(&d.source.file, &d.target.bus)
 | 
				
			||||||
 | 
					                        .expect("Failed to load file disk information!")
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
                .collect(),
 | 
					                .collect(),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            networks: domain
 | 
					            networks: domain
 | 
				
			||||||
@@ -442,7 +546,34 @@ impl VMInfo {
 | 
				
			|||||||
                        r#type: match d.r#type.as_str() {
 | 
					                        r#type: match d.r#type.as_str() {
 | 
				
			||||||
                            "user" => NetworkType::UserspaceSLIRPStack,
 | 
					                            "user" => NetworkType::UserspaceSLIRPStack,
 | 
				
			||||||
                            "network" => NetworkType::DefinedNetwork {
 | 
					                            "network" => NetworkType::DefinedNetwork {
 | 
				
			||||||
                                network: d.source.as_ref().unwrap().network.to_string(),
 | 
					                                network: d
 | 
				
			||||||
 | 
					                                    .source
 | 
				
			||||||
 | 
					                                    .as_ref()
 | 
				
			||||||
 | 
					                                    .unwrap()
 | 
				
			||||||
 | 
					                                    .network
 | 
				
			||||||
 | 
					                                    .as_deref()
 | 
				
			||||||
 | 
					                                    .ok_or_else(|| {
 | 
				
			||||||
 | 
					                                        LibVirtStructError::DomainExtraction(
 | 
				
			||||||
 | 
					                                            "Missing source network for defined network!"
 | 
				
			||||||
 | 
					                                                .to_string(),
 | 
				
			||||||
 | 
					                                        )
 | 
				
			||||||
 | 
					                                    })?
 | 
				
			||||||
 | 
					                                    .to_string(),
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                            "bridge" => NetworkType::Bridge {
 | 
				
			||||||
 | 
					                                bridge: d
 | 
				
			||||||
 | 
					                                    .source
 | 
				
			||||||
 | 
					                                    .as_ref()
 | 
				
			||||||
 | 
					                                    .unwrap()
 | 
				
			||||||
 | 
					                                    .bridge
 | 
				
			||||||
 | 
					                                    .as_deref()
 | 
				
			||||||
 | 
					                                    .ok_or_else(|| {
 | 
				
			||||||
 | 
					                                        LibVirtStructError::DomainExtraction(
 | 
				
			||||||
 | 
					                                            "Missing bridge name for bridge connection!"
 | 
				
			||||||
 | 
					                                                .to_string(),
 | 
				
			||||||
 | 
					                                        )
 | 
				
			||||||
 | 
					                                    })?
 | 
				
			||||||
 | 
					                                    .to_string(),
 | 
				
			||||||
                            },
 | 
					                            },
 | 
				
			||||||
                            a => {
 | 
					                            a => {
 | 
				
			||||||
                                return Err(LibVirtStructError::DomainExtraction(format!(
 | 
					                                return Err(LibVirtStructError::DomainExtraction(format!(
 | 
				
			||||||
@@ -450,6 +581,18 @@ impl VMInfo {
 | 
				
			|||||||
                                )));
 | 
					                                )));
 | 
				
			||||||
                            }
 | 
					                            }
 | 
				
			||||||
                        },
 | 
					                        },
 | 
				
			||||||
 | 
					                        model: match d.model.as_ref() {
 | 
				
			||||||
 | 
					                            None => NetworkInterfaceModelType::Virtio,
 | 
				
			||||||
 | 
					                            Some(model) => match model.r#type.as_str() {
 | 
				
			||||||
 | 
					                                "virtio" => NetworkInterfaceModelType::Virtio,
 | 
				
			||||||
 | 
					                                "e1000" => NetworkInterfaceModelType::E1000,
 | 
				
			||||||
 | 
					                                model => {
 | 
				
			||||||
 | 
					                                    return Err(LibVirtStructError::DomainExtraction(format!(
 | 
				
			||||||
 | 
					                                        "Unknown network interface model type: {model}! "
 | 
				
			||||||
 | 
					                                    )));
 | 
				
			||||||
 | 
					                                }
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
                        nwfilterref: d.filterref.as_ref().map(|f| NWFilterRef {
 | 
					                        nwfilterref: d.filterref.as_ref().map(|f| NWFilterRef {
 | 
				
			||||||
                            name: f.filter.to_string(),
 | 
					                            name: f.filter.to_string(),
 | 
				
			||||||
                            parameters: f
 | 
					                            parameters: f
 | 
				
			||||||
@@ -466,6 +609,19 @@ impl VMInfo {
 | 
				
			|||||||
                .collect::<Result<Vec<_>, _>>()?,
 | 
					                .collect::<Result<Vec<_>, _>>()?,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            tpm_module: domain.devices.tpm.is_some(),
 | 
					            tpm_module: domain.devices.tpm.is_some(),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            oem_strings: domain
 | 
				
			||||||
 | 
					                .sysinfo
 | 
				
			||||||
 | 
					                .and_then(|s| s.oem_strings)
 | 
				
			||||||
 | 
					                .map(|s| s.entries.iter().map(|o| o.content.to_string()).collect())
 | 
				
			||||||
 | 
					                .unwrap_or_default(),
 | 
				
			||||||
 | 
					            cloud_init: domain
 | 
				
			||||||
 | 
					                .metadata
 | 
				
			||||||
 | 
					                .clone()
 | 
				
			||||||
 | 
					                .unwrap_or_default()
 | 
				
			||||||
 | 
					                .virtweb
 | 
				
			||||||
 | 
					                .cloud_init
 | 
				
			||||||
 | 
					                .unwrap_or_default(),
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,6 +13,7 @@ use actix_web::middleware::Logger;
 | 
				
			|||||||
use actix_web::web::Data;
 | 
					use actix_web::web::Data;
 | 
				
			||||||
use actix_web::{App, HttpServer, web};
 | 
					use actix_web::{App, HttpServer, web};
 | 
				
			||||||
use light_openid::basic_state_manager::BasicStateManager;
 | 
					use light_openid::basic_state_manager::BasicStateManager;
 | 
				
			||||||
 | 
					use std::cmp::max;
 | 
				
			||||||
use std::time::Duration;
 | 
					use std::time::Duration;
 | 
				
			||||||
use virtweb_backend::actors::libvirt_actor::LibVirtActor;
 | 
					use virtweb_backend::actors::libvirt_actor::LibVirtActor;
 | 
				
			||||||
use virtweb_backend::actors::vnc_tokens_actor::VNCTokensManager;
 | 
					use virtweb_backend::actors::vnc_tokens_actor::VNCTokensManager;
 | 
				
			||||||
@@ -22,13 +23,14 @@ use virtweb_backend::constants::{
 | 
				
			|||||||
    MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME,
 | 
					    MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
use virtweb_backend::controllers::{
 | 
					use virtweb_backend::controllers::{
 | 
				
			||||||
    api_tokens_controller, auth_controller, groups_controller, iso_controller, network_controller,
 | 
					    api_tokens_controller, auth_controller, disk_images_controller, groups_controller,
 | 
				
			||||||
    nwfilter_controller, server_controller, static_controller, vm_controller,
 | 
					    iso_controller, network_controller, nwfilter_controller, server_controller, static_controller,
 | 
				
			||||||
 | 
					    vm_controller,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
use virtweb_backend::libvirt_client::LibVirtClient;
 | 
					use virtweb_backend::libvirt_client::LibVirtClient;
 | 
				
			||||||
use virtweb_backend::middlewares::auth_middleware::AuthChecker;
 | 
					use virtweb_backend::middlewares::auth_middleware::AuthChecker;
 | 
				
			||||||
use virtweb_backend::nat::nat_conf_mode;
 | 
					use virtweb_backend::nat::nat_conf_mode;
 | 
				
			||||||
use virtweb_backend::utils::files_utils;
 | 
					use virtweb_backend::utils::{exec_utils, files_utils};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[actix_web::main]
 | 
					#[actix_web::main]
 | 
				
			||||||
async fn main() -> std::io::Result<()> {
 | 
					async fn main() -> std::io::Result<()> {
 | 
				
			||||||
@@ -43,11 +45,29 @@ async fn main() -> std::io::Result<()> {
 | 
				
			|||||||
    // Load additional config from file, if requested
 | 
					    // Load additional config from file, if requested
 | 
				
			||||||
    AppConfig::parse_env_file().unwrap();
 | 
					    AppConfig::parse_env_file().unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    log::debug!("Checking for required programs");
 | 
				
			||||||
 | 
					    exec_utils::check_program(
 | 
				
			||||||
 | 
					        constants::PROGRAM_QEMU_IMAGE,
 | 
				
			||||||
 | 
					        "QEMU disk image utility is required to manipulate QCow2 files!",
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    exec_utils::check_program(
 | 
				
			||||||
 | 
					        constants::PROGRAM_IP,
 | 
				
			||||||
 | 
					        "ip is required to access bridges information!",
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    exec_utils::check_program(
 | 
				
			||||||
 | 
					        constants::PROGRAM_CLOUD_LOCALDS,
 | 
				
			||||||
 | 
					        "cloud-localds from package cloud-image-utils is required to build cloud-init images!",
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    log::debug!("Create required directory, if missing");
 | 
					    log::debug!("Create required directory, if missing");
 | 
				
			||||||
    files_utils::create_directory_if_missing(AppConfig::get().iso_storage_path()).unwrap();
 | 
					    files_utils::create_directory_if_missing(AppConfig::get().iso_storage_path()).unwrap();
 | 
				
			||||||
 | 
					    files_utils::create_directory_if_missing(AppConfig::get().cloud_init_disk_storage_path())
 | 
				
			||||||
 | 
					        .unwrap();
 | 
				
			||||||
 | 
					    files_utils::create_directory_if_missing(AppConfig::get().disk_images_storage_path()).unwrap();
 | 
				
			||||||
    files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap();
 | 
					    files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap();
 | 
				
			||||||
    files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap();
 | 
					    files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap();
 | 
				
			||||||
    files_utils::create_directory_if_missing(AppConfig::get().disks_storage_path()).unwrap();
 | 
					    files_utils::create_directory_if_missing(AppConfig::get().root_vm_disks_storage_path())
 | 
				
			||||||
 | 
					        .unwrap();
 | 
				
			||||||
    files_utils::create_directory_if_missing(AppConfig::get().nat_path()).unwrap();
 | 
					    files_utils::create_directory_if_missing(AppConfig::get().nat_path()).unwrap();
 | 
				
			||||||
    files_utils::create_directory_if_missing(AppConfig::get().definitions_path()).unwrap();
 | 
					    files_utils::create_directory_if_missing(AppConfig::get().definitions_path()).unwrap();
 | 
				
			||||||
    files_utils::create_directory_if_missing(AppConfig::get().api_tokens_path()).unwrap();
 | 
					    files_utils::create_directory_if_missing(AppConfig::get().api_tokens_path()).unwrap();
 | 
				
			||||||
@@ -108,7 +128,9 @@ async fn main() -> std::io::Result<()> {
 | 
				
			|||||||
            }))
 | 
					            }))
 | 
				
			||||||
            .app_data(conn.clone())
 | 
					            .app_data(conn.clone())
 | 
				
			||||||
            // Uploaded files
 | 
					            // Uploaded files
 | 
				
			||||||
            .app_data(MultipartFormConfig::default().total_limit(constants::ISO_MAX_SIZE))
 | 
					            .app_data(MultipartFormConfig::default().total_limit(
 | 
				
			||||||
 | 
					                max(constants::DISK_IMAGE_MAX_SIZE, constants::ISO_MAX_SIZE).as_bytes(),
 | 
				
			||||||
 | 
					            ))
 | 
				
			||||||
            .app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir))
 | 
					            .app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir))
 | 
				
			||||||
            // Server controller
 | 
					            // Server controller
 | 
				
			||||||
            .route(
 | 
					            .route(
 | 
				
			||||||
@@ -131,6 +153,14 @@ async fn main() -> std::io::Result<()> {
 | 
				
			|||||||
                "/api/server/networks",
 | 
					                "/api/server/networks",
 | 
				
			||||||
                web::get().to(server_controller::networks_list),
 | 
					                web::get().to(server_controller::networks_list),
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					            .route(
 | 
				
			||||||
 | 
					                "/api/server/bridges",
 | 
				
			||||||
 | 
					                web::get().to(server_controller::bridges_list),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .route(
 | 
				
			||||||
 | 
					                "/api/server/export_configs",
 | 
				
			||||||
 | 
					                web::get().to(server_controller::export_all_configs),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
            // Auth controller
 | 
					            // Auth controller
 | 
				
			||||||
            .route(
 | 
					            .route(
 | 
				
			||||||
                "/api/auth/local",
 | 
					                "/api/auth/local",
 | 
				
			||||||
@@ -178,6 +208,10 @@ async fn main() -> std::io::Result<()> {
 | 
				
			|||||||
                "/api/vm/{uid}/src",
 | 
					                "/api/vm/{uid}/src",
 | 
				
			||||||
                web::get().to(vm_controller::get_single_src_def),
 | 
					                web::get().to(vm_controller::get_single_src_def),
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					            .route(
 | 
				
			||||||
 | 
					                "/api/vm/{uid}/cloud_init_disk",
 | 
				
			||||||
 | 
					                web::get().to(vm_controller::get_cloud_init_disk),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
            .route(
 | 
					            .route(
 | 
				
			||||||
                "/api/vm/{uid}/autostart",
 | 
					                "/api/vm/{uid}/autostart",
 | 
				
			||||||
                web::get().to(vm_controller::get_autostart),
 | 
					                web::get().to(vm_controller::get_autostart),
 | 
				
			||||||
@@ -315,6 +349,35 @@ async fn main() -> std::io::Result<()> {
 | 
				
			|||||||
                "/api/nwfilter/{uid}",
 | 
					                "/api/nwfilter/{uid}",
 | 
				
			||||||
                web::delete().to(nwfilter_controller::delete),
 | 
					                web::delete().to(nwfilter_controller::delete),
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					            // Disk images library
 | 
				
			||||||
 | 
					            .route(
 | 
				
			||||||
 | 
					                "/api/disk_images/upload",
 | 
				
			||||||
 | 
					                web::post().to(disk_images_controller::upload),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .route(
 | 
				
			||||||
 | 
					                "/api/disk_images/list",
 | 
				
			||||||
 | 
					                web::get().to(disk_images_controller::get_list),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .route(
 | 
				
			||||||
 | 
					                "/api/disk_images/{filename}",
 | 
				
			||||||
 | 
					                web::get().to(disk_images_controller::download),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .route(
 | 
				
			||||||
 | 
					                "/api/disk_images/{filename}/convert",
 | 
				
			||||||
 | 
					                web::post().to(disk_images_controller::convert),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .route(
 | 
				
			||||||
 | 
					                "/api/disk_images/{filename}/rename",
 | 
				
			||||||
 | 
					                web::post().to(disk_images_controller::rename),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .route(
 | 
				
			||||||
 | 
					                "/api/disk_images/{filename}",
 | 
				
			||||||
 | 
					                web::delete().to(disk_images_controller::delete),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .route(
 | 
				
			||||||
 | 
					                "/api/vm/{uid}/disk/{diskid}/backup",
 | 
				
			||||||
 | 
					                web::post().to(disk_images_controller::backup_disk),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
            // API tokens controller
 | 
					            // API tokens controller
 | 
				
			||||||
            .route(
 | 
					            .route(
 | 
				
			||||||
                "/api/token/create",
 | 
					                "/api/token/create",
 | 
				
			||||||
@@ -337,6 +400,11 @@ async fn main() -> std::io::Result<()> {
 | 
				
			|||||||
                web::delete().to(api_tokens_controller::delete),
 | 
					                web::delete().to(api_tokens_controller::delete),
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            // Static assets
 | 
					            // Static assets
 | 
				
			||||||
 | 
					            .route(
 | 
				
			||||||
 | 
					                "/api/assets/{tail:.*}",
 | 
				
			||||||
 | 
					                web::get().to(static_controller::serve_assets::serve_api_assets),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            // Static web frontend
 | 
				
			||||||
            .route("/", web::get().to(static_controller::root_index))
 | 
					            .route("/", web::get().to(static_controller::root_index))
 | 
				
			||||||
            .route(
 | 
					            .route(
 | 
				
			||||||
                "/{tail:.*}",
 | 
					                "/{tail:.*}",
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										117
									
								
								virtweb_backend/src/utils/cloud_init_utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								virtweb_backend/src/utils/cloud_init_utils.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,117 @@
 | 
				
			|||||||
 | 
					use crate::app_config::AppConfig;
 | 
				
			||||||
 | 
					use crate::constants;
 | 
				
			||||||
 | 
					use std::process::Command;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Cloud init DS Mode
 | 
				
			||||||
 | 
					#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
 | 
					pub enum CloudInitDSMode {
 | 
				
			||||||
 | 
					    /// Networking is required
 | 
				
			||||||
 | 
					    Net,
 | 
				
			||||||
 | 
					    /// Does not require networking to be up before user-data actions are run
 | 
				
			||||||
 | 
					    Local,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// VM Cloud Init configuration
 | 
				
			||||||
 | 
					///
 | 
				
			||||||
 | 
					/// RedHat documentation: https://docs.redhat.com/fr/documentation/red_hat_enterprise_linux/9/html/configuring_and_managing_cloud-init_for_rhel_9/configuring-cloud-init_cloud-content
 | 
				
			||||||
 | 
					/// cloud-localds source code: https://github.com/canonical/cloud-utils/blob/main/bin/cloud-localds
 | 
				
			||||||
 | 
					#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)]
 | 
				
			||||||
 | 
					pub struct CloudInitConfig {
 | 
				
			||||||
 | 
					    pub attach_config: bool,
 | 
				
			||||||
 | 
					    /// Main user data
 | 
				
			||||||
 | 
					    pub user_data: String,
 | 
				
			||||||
 | 
					    /// Instance ID, set in metadata file
 | 
				
			||||||
 | 
					    #[serde(skip_serializing_if = "Option::is_none")]
 | 
				
			||||||
 | 
					    pub instance_id: Option<String>,
 | 
				
			||||||
 | 
					    /// Local hostname, set in metadata file
 | 
				
			||||||
 | 
					    #[serde(skip_serializing_if = "Option::is_none")]
 | 
				
			||||||
 | 
					    pub local_hostname: Option<String>,
 | 
				
			||||||
 | 
					    /// Data source mode
 | 
				
			||||||
 | 
					    #[serde(skip_serializing_if = "Option::is_none")]
 | 
				
			||||||
 | 
					    pub dsmode: Option<CloudInitDSMode>,
 | 
				
			||||||
 | 
					    /// Network configuration
 | 
				
			||||||
 | 
					    #[serde(skip_serializing_if = "Option::is_none")]
 | 
				
			||||||
 | 
					    pub network_configuration: Option<String>,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					impl CloudInitConfig {
 | 
				
			||||||
 | 
					    /// Check cloud init configuration
 | 
				
			||||||
 | 
					    pub fn check_error(&self) -> Option<String> {
 | 
				
			||||||
 | 
					        if !self.user_data.is_empty() {
 | 
				
			||||||
 | 
					            // Check YAML content
 | 
				
			||||||
 | 
					            if let Err(e) = serde_yml::from_str::<serde_json::Value>(&self.user_data) {
 | 
				
			||||||
 | 
					                return Some(format!(
 | 
				
			||||||
 | 
					                    "user data is an invalid YAML file! Deserialization error: {e}"
 | 
				
			||||||
 | 
					                ));
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Check first line
 | 
				
			||||||
 | 
					            if !self.user_data.starts_with("#cloud-config\n") {
 | 
				
			||||||
 | 
					                return Some(
 | 
				
			||||||
 | 
					                    "user data file MUST start with '#cloud-config' as first line!".to_string(),
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        None
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Generate disk image for nocloud usage
 | 
				
			||||||
 | 
					    pub fn generate_nocloud_disk(&self) -> anyhow::Result<Vec<u8>> {
 | 
				
			||||||
 | 
					        let temp_path = tempfile::tempdir_in(&AppConfig::get().temp_dir)?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let mut cmd = Command::new(constants::PROGRAM_CLOUD_LOCALDS);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // ISO destination path
 | 
				
			||||||
 | 
					        let temp_iso = temp_path.path().join("disk.iso");
 | 
				
			||||||
 | 
					        cmd.arg(&temp_iso);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Process network configuration
 | 
				
			||||||
 | 
					        if let Some(net_conf) = &self.network_configuration {
 | 
				
			||||||
 | 
					            let net_conf_path = temp_path.path().join("network");
 | 
				
			||||||
 | 
					            std::fs::write(&net_conf_path, net_conf)?;
 | 
				
			||||||
 | 
					            cmd.arg("--network-config").arg(&net_conf_path);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Process user data
 | 
				
			||||||
 | 
					        let user_data_path = temp_path.path().join("user-data");
 | 
				
			||||||
 | 
					        std::fs::write(&user_data_path, &self.user_data)?;
 | 
				
			||||||
 | 
					        cmd.arg(user_data_path);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Process metadata
 | 
				
			||||||
 | 
					        let mut metadatas = vec![];
 | 
				
			||||||
 | 
					        if let Some(inst_id) = &self.instance_id {
 | 
				
			||||||
 | 
					            metadatas.push(format!("instance-id: {}", inst_id));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if let Some(local_hostname) = &self.local_hostname {
 | 
				
			||||||
 | 
					            metadatas.push(format!("local-hostname: {}", local_hostname));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if let Some(dsmode) = &self.dsmode {
 | 
				
			||||||
 | 
					            metadatas.push(format!(
 | 
				
			||||||
 | 
					                "dsmode: {}",
 | 
				
			||||||
 | 
					                match dsmode {
 | 
				
			||||||
 | 
					                    CloudInitDSMode::Net => "net",
 | 
				
			||||||
 | 
					                    CloudInitDSMode::Local => "local",
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            ));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        let meta_data_path = temp_path.path().join("meta-data");
 | 
				
			||||||
 | 
					        std::fs::write(&meta_data_path, metadatas.join("\n"))?;
 | 
				
			||||||
 | 
					        cmd.arg(meta_data_path);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Execute command
 | 
				
			||||||
 | 
					        let output = cmd.output()?;
 | 
				
			||||||
 | 
					        if !output.status.success() {
 | 
				
			||||||
 | 
					            anyhow::bail!(
 | 
				
			||||||
 | 
					                "{} exited with status {}!\nStdout: {}\nStderr: {}",
 | 
				
			||||||
 | 
					                constants::PROGRAM_CLOUD_LOCALDS,
 | 
				
			||||||
 | 
					                output.status,
 | 
				
			||||||
 | 
					                String::from_utf8_lossy(&output.stdout),
 | 
				
			||||||
 | 
					                String::from_utf8_lossy(&output.stderr)
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Read generated ISO file
 | 
				
			||||||
 | 
					        Ok(std::fs::read(temp_iso)?)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,133 +0,0 @@
 | 
				
			|||||||
use crate::app_config::AppConfig;
 | 
					 | 
				
			||||||
use crate::constants;
 | 
					 | 
				
			||||||
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;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[derive(thiserror::Error, Debug)]
 | 
					 | 
				
			||||||
enum DisksError {
 | 
					 | 
				
			||||||
    #[error("DiskParseError: {0}")]
 | 
					 | 
				
			||||||
    Parse(&'static str),
 | 
					 | 
				
			||||||
    #[error("DiskConfigError: {0}")]
 | 
					 | 
				
			||||||
    Config(&'static str),
 | 
					 | 
				
			||||||
    #[error("DiskCreateError")]
 | 
					 | 
				
			||||||
    Create,
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/// Type of disk allocation
 | 
					 | 
				
			||||||
#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)]
 | 
					 | 
				
			||||||
pub enum DiskAllocType {
 | 
					 | 
				
			||||||
    Fixed,
 | 
					 | 
				
			||||||
    Sparse,
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[derive(serde::Serialize, serde::Deserialize)]
 | 
					 | 
				
			||||||
pub struct Disk {
 | 
					 | 
				
			||||||
    /// Disk size, in megabytes
 | 
					 | 
				
			||||||
    pub size: usize,
 | 
					 | 
				
			||||||
    /// Disk name
 | 
					 | 
				
			||||||
    pub name: String,
 | 
					 | 
				
			||||||
    pub alloc_type: DiskAllocType,
 | 
					 | 
				
			||||||
    /// Set this variable to true to delete the disk
 | 
					 | 
				
			||||||
    pub delete: bool,
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
impl Disk {
 | 
					 | 
				
			||||||
    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());
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        let metadata = file.metadata()?;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // Approximate estimation
 | 
					 | 
				
			||||||
        let is_sparse = metadata.len() / 512 >= metadata.st_blocks();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        Ok(Self {
 | 
					 | 
				
			||||||
            size: metadata.len() as usize / (1000 * 1000),
 | 
					 | 
				
			||||||
            name: path.rsplit_once('/').unwrap().1.to_string(),
 | 
					 | 
				
			||||||
            alloc_type: match is_sparse {
 | 
					 | 
				
			||||||
                true => DiskAllocType::Sparse,
 | 
					 | 
				
			||||||
                false => DiskAllocType::Fixed,
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            delete: false,
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    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());
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if self.size < constants::DISK_SIZE_MIN || self.size > constants::DISK_SIZE_MAX {
 | 
					 | 
				
			||||||
            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);
 | 
					 | 
				
			||||||
        domain_dir.join(&self.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(());
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        let mut cmd = Command::new("/usr/bin/dd");
 | 
					 | 
				
			||||||
        cmd.arg("if=/dev/zero")
 | 
					 | 
				
			||||||
            .arg(format!("of={}", file.to_string_lossy()))
 | 
					 | 
				
			||||||
            .arg("bs=1M");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        match self.alloc_type {
 | 
					 | 
				
			||||||
            DiskAllocType::Fixed => cmd.arg(format!("count={}", self.size)),
 | 
					 | 
				
			||||||
            DiskAllocType::Sparse => cmd.arg(format!("seek={}", self.size)).arg("count=0"),
 | 
					 | 
				
			||||||
        };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        let res = cmd.output()?;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if !res.status.success() {
 | 
					 | 
				
			||||||
            log::error!(
 | 
					 | 
				
			||||||
                "Failed to create disk! stderr={} stdout={}",
 | 
					 | 
				
			||||||
                String::from_utf8_lossy(&res.stderr),
 | 
					 | 
				
			||||||
                String::from_utf8_lossy(&res.stdout)
 | 
					 | 
				
			||||||
            );
 | 
					 | 
				
			||||||
            return Err(DisksError::Create.into());
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        Ok(())
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										10
									
								
								virtweb_backend/src/utils/exec_utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								virtweb_backend/src/utils/exec_utils.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					use std::path::Path;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Check the existence of a required program
 | 
				
			||||||
 | 
					pub fn check_program(name: &str, description: &str) {
 | 
				
			||||||
 | 
					    let path = Path::new(name);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if !path.exists() {
 | 
				
			||||||
 | 
					        panic!("{name} does not exist! {description}");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										468
									
								
								virtweb_backend/src/utils/file_disks_utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										468
									
								
								virtweb_backend/src/utils/file_disks_utils.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,468 @@
 | 
				
			|||||||
 | 
					use crate::app_config::AppConfig;
 | 
				
			||||||
 | 
					use crate::constants;
 | 
				
			||||||
 | 
					use crate::utils::file_size_utils::FileSize;
 | 
				
			||||||
 | 
					use std::fs::File;
 | 
				
			||||||
 | 
					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("DiskCreateError")]
 | 
				
			||||||
 | 
					    Create,
 | 
				
			||||||
 | 
					    #[error("DiskConvertError: {0}")]
 | 
				
			||||||
 | 
					    Convert(String),
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(Debug, serde::Serialize, serde::Deserialize, Copy, Clone, PartialEq, Eq)]
 | 
				
			||||||
 | 
					#[serde(tag = "format")]
 | 
				
			||||||
 | 
					pub enum DiskFileFormat {
 | 
				
			||||||
 | 
					    Raw {
 | 
				
			||||||
 | 
					        #[serde(default)]
 | 
				
			||||||
 | 
					        is_sparse: bool,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    QCow2 {
 | 
				
			||||||
 | 
					        #[serde(default)]
 | 
				
			||||||
 | 
					        virtual_size: FileSize,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    GzCompressedRaw,
 | 
				
			||||||
 | 
					    GzCompressedQCow2,
 | 
				
			||||||
 | 
					    XzCompressedRaw,
 | 
				
			||||||
 | 
					    XzCompressedQCow2,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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,
 | 
				
			||||||
 | 
					    pub name: String,
 | 
				
			||||||
 | 
					    pub created: u64,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					impl DiskFileInfo {
 | 
				
			||||||
 | 
					    /// Get disk image file information
 | 
				
			||||||
 | 
					    pub fn load_file(file: &Path) -> anyhow::Result<Self> {
 | 
				
			||||||
 | 
					        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 ext = file.extension().and_then(|s| s.to_str()).unwrap_or("raw");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Determine file format
 | 
				
			||||||
 | 
					        let format = match ext {
 | 
				
			||||||
 | 
					            "qcow2" => DiskFileFormat::QCow2 {
 | 
				
			||||||
 | 
					                virtual_size: qcow_virt_size(file)?,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            "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),
 | 
				
			||||||
 | 
					            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(),
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Create a new empty disk
 | 
				
			||||||
 | 
					    pub fn create(file: &Path, format: DiskFileFormat, size: FileSize) -> anyhow::Result<()> {
 | 
				
			||||||
 | 
					        // Prepare command to create file
 | 
				
			||||||
 | 
					        let res = match format {
 | 
				
			||||||
 | 
					            DiskFileFormat::Raw { is_sparse } => {
 | 
				
			||||||
 | 
					                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"),
 | 
				
			||||||
 | 
					                };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                cmd.output()?
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            DiskFileFormat::QCow2 { virtual_size } => {
 | 
				
			||||||
 | 
					                let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
 | 
				
			||||||
 | 
					                cmd.arg("create")
 | 
				
			||||||
 | 
					                    .arg("-f")
 | 
				
			||||||
 | 
					                    .arg("qcow2")
 | 
				
			||||||
 | 
					                    .arg(file)
 | 
				
			||||||
 | 
					                    .arg(format!("{}M", virtual_size.as_mb()));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                cmd.output()?
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            _ => anyhow::bail!("Cannot create disk file image of this format: {format:?}!"),
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Execute Linux command
 | 
				
			||||||
 | 
					        if !res.status.success() {
 | 
				
			||||||
 | 
					            log::error!(
 | 
				
			||||||
 | 
					                "Failed to create disk! stderr={} stdout={}",
 | 
				
			||||||
 | 
					                String::from_utf8_lossy(&res.stderr),
 | 
				
			||||||
 | 
					                String::from_utf8_lossy(&res.stdout)
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            return Err(DisksError::Create.into());
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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)]
 | 
				
			||||||
 | 
					struct QCowInfoOutput {
 | 
				
			||||||
 | 
					    #[serde(rename = "virtual-size")]
 | 
				
			||||||
 | 
					    virtual_size: usize,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Get QCow2 virtual size
 | 
				
			||||||
 | 
					fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> {
 | 
				
			||||||
 | 
					    // 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 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)
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    let res_json = String::from_utf8(output.stdout)?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Decode JSON
 | 
				
			||||||
 | 
					    let decoded: QCowInfoOutput = serde_json::from_str(&res_json)?;
 | 
				
			||||||
 | 
					    Ok(FileSize::from_bytes(decoded.virtual_size))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										91
									
								
								virtweb_backend/src/utils/file_size_utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								virtweb_backend/src/utils/file_size_utils.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,91 @@
 | 
				
			|||||||
 | 
					use std::ops::Mul;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(thiserror::Error, Debug)]
 | 
				
			||||||
 | 
					enum FilesSizeUtilsError {
 | 
				
			||||||
 | 
					    #[error("UnitConvertError: {0}")]
 | 
				
			||||||
 | 
					    UnitConvert(String),
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Holds a data size, convertible in any form
 | 
				
			||||||
 | 
					#[derive(
 | 
				
			||||||
 | 
					    serde::Serialize,
 | 
				
			||||||
 | 
					    serde::Deserialize,
 | 
				
			||||||
 | 
					    Copy,
 | 
				
			||||||
 | 
					    Clone,
 | 
				
			||||||
 | 
					    Debug,
 | 
				
			||||||
 | 
					    Eq,
 | 
				
			||||||
 | 
					    PartialEq,
 | 
				
			||||||
 | 
					    PartialOrd,
 | 
				
			||||||
 | 
					    Ord,
 | 
				
			||||||
 | 
					    Default,
 | 
				
			||||||
 | 
					)]
 | 
				
			||||||
 | 
					pub struct FileSize(usize);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					impl FileSize {
 | 
				
			||||||
 | 
					    pub const fn from_bytes(size: usize) -> Self {
 | 
				
			||||||
 | 
					        Self(size)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    pub const fn from_mb(mb: usize) -> Self {
 | 
				
			||||||
 | 
					        Self(mb * 1000 * 1000)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    pub const fn from_gb(gb: usize) -> Self {
 | 
				
			||||||
 | 
					        Self(gb * 1000 * 1000 * 1000)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Convert size unit to MB
 | 
				
			||||||
 | 
					    pub fn from_size_unit(unit: &str, value: usize) -> anyhow::Result<Self> {
 | 
				
			||||||
 | 
					        let fact = match unit {
 | 
				
			||||||
 | 
					            "bytes" | "b" => 1f64,
 | 
				
			||||||
 | 
					            "KB" => 1000f64,
 | 
				
			||||||
 | 
					            "MB" => 1000f64 * 1000f64,
 | 
				
			||||||
 | 
					            "GB" => 1000f64 * 1000f64 * 1000f64,
 | 
				
			||||||
 | 
					            "TB" => 1000f64 * 1000f64 * 1000f64 * 1000f64,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            "k" | "KiB" => 1024f64,
 | 
				
			||||||
 | 
					            "M" | "MiB" => 1024f64 * 1024f64,
 | 
				
			||||||
 | 
					            "G" | "GiB" => 1024f64 * 1024f64 * 1024f64,
 | 
				
			||||||
 | 
					            "T" | "TiB" => 1024f64 * 1024f64 * 1024f64 * 1024f64,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            _ => {
 | 
				
			||||||
 | 
					                return Err(
 | 
				
			||||||
 | 
					                    FilesSizeUtilsError::UnitConvert(format!("Unknown size unit: {unit}")).into(),
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Ok(Self((value as f64).mul(fact).ceil() as usize))
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Get file size as bytes
 | 
				
			||||||
 | 
					    pub fn as_bytes(&self) -> usize {
 | 
				
			||||||
 | 
					        self.0
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Get file size as megabytes
 | 
				
			||||||
 | 
					    pub fn as_mb(&self) -> usize {
 | 
				
			||||||
 | 
					        self.0 / (1000 * 1000)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[cfg(test)]
 | 
				
			||||||
 | 
					mod tests {
 | 
				
			||||||
 | 
					    use crate::utils::file_size_utils::FileSize;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    #[test]
 | 
				
			||||||
 | 
					    fn convert_units_mb() {
 | 
				
			||||||
 | 
					        assert_eq!(FileSize::from_size_unit("MB", 1).unwrap().as_mb(), 1);
 | 
				
			||||||
 | 
					        assert_eq!(FileSize::from_size_unit("MB", 1000).unwrap().as_mb(), 1000);
 | 
				
			||||||
 | 
					        assert_eq!(
 | 
				
			||||||
 | 
					            FileSize::from_size_unit("GB", 1000).unwrap().as_mb(),
 | 
				
			||||||
 | 
					            1000 * 1000
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        assert_eq!(FileSize::from_size_unit("GB", 1).unwrap().as_mb(), 1000);
 | 
				
			||||||
 | 
					        assert_eq!(FileSize::from_size_unit("GiB", 3).unwrap().as_mb(), 3221);
 | 
				
			||||||
 | 
					        assert_eq!(
 | 
				
			||||||
 | 
					            FileSize::from_size_unit("KiB", 488281).unwrap().as_mb(),
 | 
				
			||||||
 | 
					            499
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,13 +1,6 @@
 | 
				
			|||||||
use std::ops::{Div, Mul};
 | 
					 | 
				
			||||||
use std::os::unix::fs::PermissionsExt;
 | 
					use std::os::unix::fs::PermissionsExt;
 | 
				
			||||||
use std::path::Path;
 | 
					use std::path::Path;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[derive(thiserror::Error, Debug)]
 | 
					 | 
				
			||||||
enum FilesUtilsError {
 | 
					 | 
				
			||||||
    #[error("UnitConvertError: {0}")]
 | 
					 | 
				
			||||||
    UnitConvert(String),
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const INVALID_CHARS: [&str; 19] = [
 | 
					const INVALID_CHARS: [&str; 19] = [
 | 
				
			||||||
    "@", "\\", "/", ":", ",", "<", ">", "%", "'", "\"", "?", "{", "}", "$", "*", "|", ";", "=",
 | 
					    "@", "\\", "/", ":", ",", "<", ">", "%", "'", "\"", "?", "{", "}", "$", "*", "|", ";", "=",
 | 
				
			||||||
    "\t",
 | 
					    "\t",
 | 
				
			||||||
@@ -35,31 +28,9 @@ pub fn set_file_permission<P: AsRef<Path>>(path: P, mode: u32) -> anyhow::Result
 | 
				
			|||||||
    Ok(())
 | 
					    Ok(())
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Convert size unit to MB
 | 
					 | 
				
			||||||
pub fn convert_size_unit_to_mb(unit: &str, value: usize) -> anyhow::Result<usize> {
 | 
					 | 
				
			||||||
    let fact = match unit {
 | 
					 | 
				
			||||||
        "bytes" | "b" => 1f64,
 | 
					 | 
				
			||||||
        "KB" => 1000f64,
 | 
					 | 
				
			||||||
        "MB" => 1000f64 * 1000f64,
 | 
					 | 
				
			||||||
        "GB" => 1000f64 * 1000f64 * 1000f64,
 | 
					 | 
				
			||||||
        "TB" => 1000f64 * 1000f64 * 1000f64 * 1000f64,
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        "k" | "KiB" => 1024f64,
 | 
					 | 
				
			||||||
        "M" | "MiB" => 1024f64 * 1024f64,
 | 
					 | 
				
			||||||
        "G" | "GiB" => 1024f64 * 1024f64 * 1024f64,
 | 
					 | 
				
			||||||
        "T" | "TiB" => 1024f64 * 1024f64 * 1024f64 * 1024f64,
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        _ => {
 | 
					 | 
				
			||||||
            return Err(FilesUtilsError::UnitConvert(format!("Unknown size unit: {unit}")).into());
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    Ok((value as f64).mul(fact.div((1000 * 1000) as f64)).ceil() as usize)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
#[cfg(test)]
 | 
					#[cfg(test)]
 | 
				
			||||||
mod test {
 | 
					mod test {
 | 
				
			||||||
    use crate::utils::files_utils::{check_file_name, convert_size_unit_to_mb};
 | 
					    use crate::utils::files_utils::check_file_name;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    #[test]
 | 
					    #[test]
 | 
				
			||||||
    fn empty_file_name() {
 | 
					    fn empty_file_name() {
 | 
				
			||||||
@@ -85,14 +56,4 @@ mod test {
 | 
				
			|||||||
    fn valid_file_name() {
 | 
					    fn valid_file_name() {
 | 
				
			||||||
        assert!(check_file_name("test.iso"));
 | 
					        assert!(check_file_name("test.iso"));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					 | 
				
			||||||
    #[test]
 | 
					 | 
				
			||||||
    fn convert_units_mb() {
 | 
					 | 
				
			||||||
        assert_eq!(convert_size_unit_to_mb("MB", 1).unwrap(), 1);
 | 
					 | 
				
			||||||
        assert_eq!(convert_size_unit_to_mb("MB", 1000).unwrap(), 1000);
 | 
					 | 
				
			||||||
        assert_eq!(convert_size_unit_to_mb("GB", 1000).unwrap(), 1000 * 1000);
 | 
					 | 
				
			||||||
        assert_eq!(convert_size_unit_to_mb("GB", 1).unwrap(), 1000);
 | 
					 | 
				
			||||||
        assert_eq!(convert_size_unit_to_mb("GiB", 3).unwrap(), 3222);
 | 
					 | 
				
			||||||
        assert_eq!(convert_size_unit_to_mb("KiB", 488281).unwrap(), 500);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,10 @@
 | 
				
			|||||||
pub mod disks_utils;
 | 
					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 files_utils;
 | 
				
			||||||
pub mod net_utils;
 | 
					pub mod net_utils;
 | 
				
			||||||
pub mod rand_utils;
 | 
					pub mod rand_utils;
 | 
				
			||||||
pub mod time_utils;
 | 
					pub mod time_utils;
 | 
				
			||||||
pub mod url_utils;
 | 
					pub mod url_utils;
 | 
				
			||||||
 | 
					pub mod vm_file_disks_utils;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,8 @@
 | 
				
			|||||||
 | 
					use crate::constants;
 | 
				
			||||||
use nix::sys::socket::{AddressFamily, SockaddrLike};
 | 
					use nix::sys::socket::{AddressFamily, SockaddrLike};
 | 
				
			||||||
use std::collections::HashMap;
 | 
					use std::collections::HashMap;
 | 
				
			||||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
 | 
					use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
 | 
				
			||||||
 | 
					use std::process::Command;
 | 
				
			||||||
use std::str::FromStr;
 | 
					use std::str::FromStr;
 | 
				
			||||||
use sysinfo::Networks;
 | 
					use sysinfo::Networks;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -68,7 +70,7 @@ pub fn net_list() -> Vec<String> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
/// Get the list of available network interfaces associated with their IP address
 | 
					/// Get the list of available network interfaces associated with their IP address
 | 
				
			||||||
pub fn net_list_and_ips() -> anyhow::Result<HashMap<String, Vec<IpAddr>>> {
 | 
					pub fn net_list_and_ips() -> anyhow::Result<HashMap<String, Vec<IpAddr>>> {
 | 
				
			||||||
    let addrs = nix::ifaddrs::getifaddrs().unwrap();
 | 
					    let addrs = nix::ifaddrs::getifaddrs()?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let mut res = HashMap::new();
 | 
					    let mut res = HashMap::new();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -136,6 +138,31 @@ pub fn net_list_and_ips() -> anyhow::Result<HashMap<String, Vec<IpAddr>>> {
 | 
				
			|||||||
    Ok(res)
 | 
					    Ok(res)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(serde::Deserialize)]
 | 
				
			||||||
 | 
					struct IPBridgeInfo {
 | 
				
			||||||
 | 
					    ifname: String,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Get the list of bridge interfaces
 | 
				
			||||||
 | 
					pub fn bridges_list() -> anyhow::Result<Vec<String>> {
 | 
				
			||||||
 | 
					    let mut cmd = Command::new(constants::PROGRAM_IP);
 | 
				
			||||||
 | 
					    cmd.args(["-json", "link", "show", "type", "bridge"]);
 | 
				
			||||||
 | 
					    let output = cmd.output()?;
 | 
				
			||||||
 | 
					    if !output.status.success() {
 | 
				
			||||||
 | 
					        anyhow::bail!(
 | 
				
			||||||
 | 
					            "{} failed, status: {}, stderr: {}",
 | 
				
			||||||
 | 
					            constants::PROGRAM_IP,
 | 
				
			||||||
 | 
					            output.status,
 | 
				
			||||||
 | 
					            String::from_utf8_lossy(&output.stderr)
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Parse JSON result
 | 
				
			||||||
 | 
					    let res: Vec<IPBridgeInfo> = serde_json::from_str(&String::from_utf8_lossy(&output.stdout))?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(res.iter().map(|ip| ip.ifname.clone()).collect())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[cfg(test)]
 | 
					#[cfg(test)]
 | 
				
			||||||
mod tests {
 | 
					mod tests {
 | 
				
			||||||
    use crate::utils::net_utils::{
 | 
					    use crate::utils::net_utils::{
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					use chrono::Datelike;
 | 
				
			||||||
use std::time::{SystemTime, UNIX_EPOCH};
 | 
					use std::time::{SystemTime, UNIX_EPOCH};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/// Get the current time since epoch
 | 
					/// Get the current time since epoch
 | 
				
			||||||
@@ -13,3 +14,15 @@ pub fn time() -> u64 {
 | 
				
			|||||||
        .unwrap()
 | 
					        .unwrap()
 | 
				
			||||||
        .as_secs()
 | 
					        .as_secs()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Format given UNIX time in a simple format
 | 
				
			||||||
 | 
					pub fn format_date(time: i64) -> anyhow::Result<String> {
 | 
				
			||||||
 | 
					    let date = chrono::DateTime::from_timestamp(time, 0).ok_or(anyhow::anyhow!("invalid date"))?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Ok(format!(
 | 
				
			||||||
 | 
					        "{:0>2}/{:0>2}/{}",
 | 
				
			||||||
 | 
					        date.day(),
 | 
				
			||||||
 | 
					        date.month(),
 | 
				
			||||||
 | 
					        date.year()
 | 
				
			||||||
 | 
					    ))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										190
									
								
								virtweb_backend/src/utils/vm_file_disks_utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								virtweb_backend/src/utils/vm_file_disks_utils.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,190 @@
 | 
				
			|||||||
 | 
					use crate::app_config::AppConfig;
 | 
				
			||||||
 | 
					use crate::constants;
 | 
				
			||||||
 | 
					use crate::libvirt_lib_structures::XMLUuid;
 | 
				
			||||||
 | 
					use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo};
 | 
				
			||||||
 | 
					use crate::utils::file_size_utils::FileSize;
 | 
				
			||||||
 | 
					use crate::utils::files_utils;
 | 
				
			||||||
 | 
					use lazy_regex::regex;
 | 
				
			||||||
 | 
					use std::path::{Path, PathBuf};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(thiserror::Error, Debug)]
 | 
				
			||||||
 | 
					enum VMDisksError {
 | 
				
			||||||
 | 
					    #[error("DiskConfigError: {0}")]
 | 
				
			||||||
 | 
					    Config(&'static str),
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
 | 
					pub enum VMDiskBus {
 | 
				
			||||||
 | 
					    Virtio,
 | 
				
			||||||
 | 
					    SATA,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// Disk allocation type
 | 
				
			||||||
 | 
					#[derive(serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
 | 
					#[serde(tag = "format")]
 | 
				
			||||||
 | 
					pub enum VMDiskFormat {
 | 
				
			||||||
 | 
					    Raw {
 | 
				
			||||||
 | 
					        /// Is raw file a sparse file?
 | 
				
			||||||
 | 
					        is_sparse: bool,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    QCow2,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[derive(serde::Serialize, serde::Deserialize)]
 | 
				
			||||||
 | 
					pub struct VMFileDisk {
 | 
				
			||||||
 | 
					    /// Disk name
 | 
				
			||||||
 | 
					    pub name: String,
 | 
				
			||||||
 | 
					    /// Disk size, in bytes
 | 
				
			||||||
 | 
					    pub size: FileSize,
 | 
				
			||||||
 | 
					    /// Disk bus
 | 
				
			||||||
 | 
					    pub bus: VMDiskBus,
 | 
				
			||||||
 | 
					    /// Disk format
 | 
				
			||||||
 | 
					    #[serde(flatten)]
 | 
				
			||||||
 | 
					    pub format: VMDiskFormat,
 | 
				
			||||||
 | 
					    /// When creating a new disk, specify the disk image template to use
 | 
				
			||||||
 | 
					    #[serde(skip_serializing_if = "Option::is_none")]
 | 
				
			||||||
 | 
					    pub from_image: Option<String>,
 | 
				
			||||||
 | 
					    /// Set this variable to true to resize disk image
 | 
				
			||||||
 | 
					    #[serde(skip_serializing_if = "Option::is_none")]
 | 
				
			||||||
 | 
					    pub resize: Option<bool>,
 | 
				
			||||||
 | 
					    /// Set this variable to true to delete the disk
 | 
				
			||||||
 | 
					    pub delete: bool,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					impl VMFileDisk {
 | 
				
			||||||
 | 
					    pub fn load_from_file(path: &str, bus: &str) -> anyhow::Result<Self> {
 | 
				
			||||||
 | 
					        let file = Path::new(path);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let info = DiskFileInfo::load_file(file)?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Ok(Self {
 | 
				
			||||||
 | 
					            name: info.name,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Get only the virtual size of the file
 | 
				
			||||||
 | 
					            size: match info.format {
 | 
				
			||||||
 | 
					                DiskFileFormat::Raw { .. } => info.file_size,
 | 
				
			||||||
 | 
					                DiskFileFormat::QCow2 { virtual_size } => virtual_size,
 | 
				
			||||||
 | 
					                _ => anyhow::bail!("Unsupported image format: {:?}", info.format),
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            format: match info.format {
 | 
				
			||||||
 | 
					                DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw { is_sparse },
 | 
				
			||||||
 | 
					                DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2,
 | 
				
			||||||
 | 
					                _ => anyhow::bail!("Unsupported image format: {:?}", info.format),
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            bus: match bus {
 | 
				
			||||||
 | 
					                "virtio" => VMDiskBus::Virtio,
 | 
				
			||||||
 | 
					                "sata" => VMDiskBus::SATA,
 | 
				
			||||||
 | 
					                _ => anyhow::bail!("Unsupported disk bus type: {bus}"),
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            delete: false,
 | 
				
			||||||
 | 
					            from_image: None,
 | 
				
			||||||
 | 
					            resize: None,
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    pub fn check_config(&self) -> anyhow::Result<()> {
 | 
				
			||||||
 | 
					        if constants::DISK_NAME_MIN_LEN > self.name.len()
 | 
				
			||||||
 | 
					            || constants::DISK_NAME_MAX_LEN < self.name.len()
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            return Err(VMDisksError::Config("Disk name length is invalid").into());
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if !regex!("^[a-zA-Z0-9]+$").is_match(&self.name) {
 | 
				
			||||||
 | 
					            return Err(VMDisksError::Config("Disk name contains invalid characters!").into());
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Check disk size
 | 
				
			||||||
 | 
					        if !(constants::DISK_SIZE_MIN..=constants::DISK_SIZE_MAX).contains(&self.size) {
 | 
				
			||||||
 | 
					            return Err(VMDisksError::Config("Disk size is invalid!").into());
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Check specified disk image template
 | 
				
			||||||
 | 
					        if let Some(disk_image) = &self.from_image {
 | 
				
			||||||
 | 
					            if !files_utils::check_file_name(disk_image) {
 | 
				
			||||||
 | 
					                return Err(VMDisksError::Config("Disk image template name is not valid!").into());
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if !AppConfig::get().disk_images_file_path(disk_image).is_file() {
 | 
				
			||||||
 | 
					                return Err(
 | 
				
			||||||
 | 
					                    VMDisksError::Config("Specified disk image file does not exist!").into(),
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Ok(())
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Get disk path on file system
 | 
				
			||||||
 | 
					    pub fn disk_path(&self, id: XMLUuid) -> PathBuf {
 | 
				
			||||||
 | 
					        let domain_dir = AppConfig::get().vm_storage_path(id);
 | 
				
			||||||
 | 
					        let file_name = match self.format {
 | 
				
			||||||
 | 
					            VMDiskFormat::Raw { .. } => self.name.to_string(),
 | 
				
			||||||
 | 
					            VMDiskFormat::QCow2 => format!("{}.qcow2", self.name),
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        domain_dir.join(&file_name)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /// Apply disk configuration
 | 
				
			||||||
 | 
					    pub fn apply_config(&self, id: XMLUuid) -> anyhow::Result<()> {
 | 
				
			||||||
 | 
					        self.check_config()?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let file = self.disk_path(id);
 | 
				
			||||||
 | 
					        files_utils::create_directory_if_missing(file.parent().unwrap())?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Delete file if requested
 | 
				
			||||||
 | 
					        if self.delete {
 | 
				
			||||||
 | 
					            if !file.exists() {
 | 
				
			||||||
 | 
					                log::debug!("File {file:?} does not exists, so it was not deleted");
 | 
				
			||||||
 | 
					                return Ok(());
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            log::info!("Deleting {file:?}");
 | 
				
			||||||
 | 
					            std::fs::remove_file(file)?;
 | 
				
			||||||
 | 
					            return Ok(());
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if file.exists() {
 | 
				
			||||||
 | 
					            log::debug!("File {file:?} does not exists, so it was not touched");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        // Create disk if required
 | 
				
			||||||
 | 
					        else {
 | 
				
			||||||
 | 
					            // Determine file format
 | 
				
			||||||
 | 
					            let format = match self.format {
 | 
				
			||||||
 | 
					                VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
 | 
				
			||||||
 | 
					                VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
 | 
				
			||||||
 | 
					                    virtual_size: self.size,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Create / Restore disk file
 | 
				
			||||||
 | 
					            match &self.from_image {
 | 
				
			||||||
 | 
					                // Create disk file
 | 
				
			||||||
 | 
					                None => {
 | 
				
			||||||
 | 
					                    DiskFileInfo::create(&file, format, self.size)?;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Restore disk image template
 | 
				
			||||||
 | 
					                Some(disk_img) => {
 | 
				
			||||||
 | 
					                    let src_file =
 | 
				
			||||||
 | 
					                        DiskFileInfo::load_file(&AppConfig::get().disk_images_file_path(disk_img))?;
 | 
				
			||||||
 | 
					                    src_file.convert(&file, format)?;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Resize disk file if requested
 | 
				
			||||||
 | 
					        if self.resize == Some(true) {
 | 
				
			||||||
 | 
					            let disk = DiskFileInfo::load_file(&file)?;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Can only increase disk size
 | 
				
			||||||
 | 
					            if let Err(e) = disk.resize(self.size) {
 | 
				
			||||||
 | 
					                log::error!("Failed to resize disk file {}: {e:?}", self.name);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Ok(())
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										47
									
								
								virtweb_docs/BRIDGE.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								virtweb_docs/BRIDGE.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
				
			|||||||
 | 
					# Bridges
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Bridges can be used to connect virtual machines to networks.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Setup Bridge on Ubuntu
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					1. Install dependencies:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					sudo apt install bridge-utils
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					2. Adapt your netplan configuration to set the following:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```yaml
 | 
				
			||||||
 | 
					network:
 | 
				
			||||||
 | 
					  version: 2
 | 
				
			||||||
 | 
					  renderer: networkd
 | 
				
			||||||
 | 
					  ethernets:
 | 
				
			||||||
 | 
					    enp2s0:
 | 
				
			||||||
 | 
					      dhcp4: no
 | 
				
			||||||
 | 
					  bridges:
 | 
				
			||||||
 | 
					    br0: # Bridge name
 | 
				
			||||||
 | 
					      dhcp4: yes
 | 
				
			||||||
 | 
					      interfaces:
 | 
				
			||||||
 | 
					         - enp2s0 # Set to your interface
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					3. Apply netplan configuration:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					sudo netplan apply
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					4. Get the state and the list of bridges in the system:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					sudo brctl show
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Or
 | 
				
			||||||
 | 
					ip link show type bridge
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Reference
 | 
				
			||||||
 | 
					[How to Configure Network Bridge in Ubuntu](https://www.tecmint.com/create-network-bridge-in-ubuntu/)
 | 
				
			||||||
							
								
								
									
										11
									
								
								virtweb_docs/REFERENCE.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								virtweb_docs/REFERENCE.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
				
			|||||||
 | 
					## References
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### LibVirt XML documentation
 | 
				
			||||||
 | 
					* Online: https://libvirt.org/format.html
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* Offline with Ubuntu:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```bash
 | 
				
			||||||
 | 
					sudo apt install libvirt-doc
 | 
				
			||||||
 | 
					firefox /usr/share/doc/libvirt-doc/html/index.html
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
@@ -5,9 +5,9 @@
 | 
				
			|||||||
sudo apt install libvirt-dev
 | 
					sudo apt install libvirt-dev
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
2. Libvirt must also be installed:
 | 
					2. Libvirt and cloud image utilities must also be installed:
 | 
				
			||||||
```bash
 | 
					```bash
 | 
				
			||||||
sudo apt install qemu-kvm libvirt-daemon-system
 | 
					sudo apt install qemu-kvm libvirt-daemon-system cloud-image-utils
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
3. Allow the current user to manage VMs:
 | 
					3. Allow the current user to manage VMs:
 | 
				
			||||||
@@ -34,7 +34,7 @@ docker compose up
 | 
				
			|||||||
sudo mkdir /var/virtweb
 | 
					sudo mkdir /var/virtweb
 | 
				
			||||||
sudo chown $USER:$USER /var/virtweb
 | 
					sudo chown $USER:$USER /var/virtweb
 | 
				
			||||||
cd virtweb_backend
 | 
					cd virtweb_backend
 | 
				
			||||||
cargo fmt && cargo clippy && cargo run -- -s /var/virtweb --hypervisor-uri "qemu:///system"
 | 
					cargo fmt && cargo clippy && cargo run -- -s /var/virtweb --hypervisor-uri "qemu:///system" --website-origin "http://localhost:5173"
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
7. Run the frontend
 | 
					7. Run the frontend
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,10 +12,10 @@ The release file will be available in `virtweb_backend/target/release/virtweb_ba
 | 
				
			|||||||
This is the only artifact that must be copied to the server. It is recommended to copy it to the `/usr/local/bin` directory.
 | 
					This is the only artifact that must be copied to the server. It is recommended to copy it to the `/usr/local/bin` directory.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Install requirements
 | 
					## Install requirements
 | 
				
			||||||
In order to work properly, VirtWeb relies on `libvirt`, `qemu` and `kvm`:
 | 
					In order to work properly, VirtWeb relies on `libvirt`, `qemu`, `kvm` and `cloud-localds`:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```bash
 | 
					```bash
 | 
				
			||||||
sudo apt install qemu-kvm libvirt-daemon-system libvirt0 libvirt-clients libvirt-daemon bridge-utils
 | 
					sudo apt install qemu-kvm libvirt-daemon-system libvirt0 libvirt-clients libvirt-daemon bridge-utils cloud-image-utils
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Dedicated user
 | 
					## Dedicated user
 | 
				
			||||||
@@ -61,7 +61,7 @@ STORAGE=/home/virtweb/storage
 | 
				
			|||||||
HYPERVISOR_URI=qemu:///system
 | 
					HYPERVISOR_URI=qemu:///system
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
> Note: `HYPERVISOR_URI=qemu:///system` is used to sepcify that we want to use the main hypervisor.
 | 
					> Note: `HYPERVISOR_URI=qemu:///system` is used to specify that we want to use the main hypervisor.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Register Virtweb service
 | 
					## Register Virtweb service
 | 
				
			||||||
Before registering service, check that the configuration works correctly:
 | 
					Before registering service, check that the configuration works correctly:
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										2438
									
								
								virtweb_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2438
									
								
								virtweb_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -12,42 +12,45 @@
 | 
				
			|||||||
  "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.2.96",
 | 
					    "@mdi/js": "^7.4.47",
 | 
				
			||||||
    "@mdi/react": "^1.6.1",
 | 
					    "@mdi/react": "^1.6.1",
 | 
				
			||||||
    "@mui/icons-material": "^7.0.0",
 | 
					    "@monaco-editor/react": "^4.7.0",
 | 
				
			||||||
    "@mui/material": "^7.0.0",
 | 
					    "@mui/icons-material": "^7.1.2",
 | 
				
			||||||
    "@mui/x-charts": "^7.28.0",
 | 
					    "@mui/material": "^7.1.2",
 | 
				
			||||||
    "@mui/x-data-grid": "^7.28.1",
 | 
					    "@mui/x-charts": "^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.29.0",
 | 
					    "humanize-duration": "^3.32.2",
 | 
				
			||||||
    "mui-file-input": "^7.0.0",
 | 
					    "monaco-editor": "^0.52.2",
 | 
				
			||||||
    "react": "^19.0.0",
 | 
					    "monaco-yaml": "^5.4.0",
 | 
				
			||||||
    "react-dom": "^19.0.0",
 | 
					    "react": "^19.1.0",
 | 
				
			||||||
    "react-router-dom": "^7.4.0",
 | 
					    "react-dom": "^19.1.0",
 | 
				
			||||||
 | 
					    "react-router-dom": "^7.6.2",
 | 
				
			||||||
    "react-syntax-highlighter": "^15.6.1",
 | 
					    "react-syntax-highlighter": "^15.6.1",
 | 
				
			||||||
    "react-vnc": "^3.0.7",
 | 
					    "react-vnc": "^3.1.0",
 | 
				
			||||||
    "uuid": "^11.1.0",
 | 
					    "uuid": "^11.1.0",
 | 
				
			||||||
    "xml-formatter": "^3.6.0"
 | 
					    "xml-formatter": "^3.6.6",
 | 
				
			||||||
 | 
					    "yaml": "^2.8.0"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "devDependencies": {
 | 
					  "devDependencies": {
 | 
				
			||||||
    "@eslint/js": "^9.21.0",
 | 
					    "@eslint/js": "^9.29.0",
 | 
				
			||||||
    "@types/humanize-duration": "^3.27.1",
 | 
					    "@types/humanize-duration": "^3.27.4",
 | 
				
			||||||
    "@types/jest": "^29.5.14",
 | 
					    "@types/jest": "^29.5.14",
 | 
				
			||||||
    "@types/react": "^19.0.12",
 | 
					    "@types/react": "^19.1.8",
 | 
				
			||||||
    "@types/react-dom": "^19.0.4",
 | 
					    "@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.3.4",
 | 
					    "@vitejs/plugin-react": "^4.6.0",
 | 
				
			||||||
    "eslint": "^9.21.0",
 | 
					    "eslint": "^9.29.0",
 | 
				
			||||||
    "eslint-plugin-react-dom": "^1.38.3",
 | 
					    "eslint-plugin-react-dom": "^1.52.2",
 | 
				
			||||||
    "eslint-plugin-react-hooks": "^5.1.0",
 | 
					    "eslint-plugin-react-hooks": "^5.2.0",
 | 
				
			||||||
    "eslint-plugin-react-refresh": "^0.4.19",
 | 
					    "eslint-plugin-react-refresh": "^0.4.20",
 | 
				
			||||||
    "eslint-plugin-react-x": "^1.38.3",
 | 
					    "eslint-plugin-react-x": "^1.49.0",
 | 
				
			||||||
    "globals": "^15.15.0",
 | 
					    "globals": "^16.1.0",
 | 
				
			||||||
    "typescript": "^5.8.2",
 | 
					    "typescript": "^5.8.3",
 | 
				
			||||||
    "typescript-eslint": "^8.24.1",
 | 
					    "typescript-eslint": "^8.32.1",
 | 
				
			||||||
    "vite": "^6.2.3"
 | 
					    "vite": "^6.3.5"
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -38,6 +38,7 @@ import { LoginRoute } from "./routes/auth/LoginRoute";
 | 
				
			|||||||
import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
 | 
					import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
 | 
				
			||||||
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
 | 
					import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
 | 
				
			||||||
import { BaseLoginPage } from "./widgets/BaseLoginPage";
 | 
					import { BaseLoginPage } from "./widgets/BaseLoginPage";
 | 
				
			||||||
 | 
					import { DiskImagesRoute } from "./routes/DiskImagesRoute";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface AuthContext {
 | 
					interface AuthContext {
 | 
				
			||||||
  signedIn: boolean;
 | 
					  signedIn: boolean;
 | 
				
			||||||
@@ -63,6 +64,8 @@ export function App() {
 | 
				
			|||||||
        <Route path="*" element={<BaseAuthenticatedPage />}>
 | 
					        <Route path="*" element={<BaseAuthenticatedPage />}>
 | 
				
			||||||
          <Route path="" element={<HomeRoute />} />
 | 
					          <Route path="" element={<HomeRoute />} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <Route path="disk_images" element={<DiskImagesRoute />} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <Route path="iso" element={<IsoFilesRoute />} />
 | 
					          <Route path="iso" element={<IsoFilesRoute />} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <Route path="vms" element={<VMListRoute />} />
 | 
					          <Route path="vms" element={<VMListRoute />} />
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -103,6 +103,7 @@ export class APIClient {
 | 
				
			|||||||
        body: body,
 | 
					        body: body,
 | 
				
			||||||
        headers: headers,
 | 
					        headers: headers,
 | 
				
			||||||
        credentials: "include",
 | 
					        credentials: "include",
 | 
				
			||||||
 | 
					        signal: AbortSignal.timeout(50 * 1000 * 1000),
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Process response
 | 
					      // Process response
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										119
									
								
								virtweb_frontend/src/api/DiskImageApi.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								virtweb_frontend/src/api/DiskImageApi.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,119 @@
 | 
				
			|||||||
 | 
					import { APIClient } from "./ApiClient";
 | 
				
			||||||
 | 
					import { VMFileDisk, VMInfo } from "./VMApi";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type DiskImageFormat =
 | 
				
			||||||
 | 
					  | { format: "Raw"; is_sparse: boolean }
 | 
				
			||||||
 | 
					  | { format: "QCow2"; virtual_size?: number }
 | 
				
			||||||
 | 
					  | { format: "GzCompressedQCow2" }
 | 
				
			||||||
 | 
					  | { format: "GzCompressedRaw" }
 | 
				
			||||||
 | 
					  | { format: "XzCompressedQCow2" }
 | 
				
			||||||
 | 
					  | { format: "XzCompressedRaw" };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type DiskImage = {
 | 
				
			||||||
 | 
					  file_size: number;
 | 
				
			||||||
 | 
					  file_name: string;
 | 
				
			||||||
 | 
					  name: string;
 | 
				
			||||||
 | 
					  created: number;
 | 
				
			||||||
 | 
					} & DiskImageFormat;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class DiskImageApi {
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Upload a new disk image file to the server
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  static async Upload(
 | 
				
			||||||
 | 
					    file: File,
 | 
				
			||||||
 | 
					    progress: (progress: number) => void
 | 
				
			||||||
 | 
					  ): Promise<void> {
 | 
				
			||||||
 | 
					    const fd = new FormData();
 | 
				
			||||||
 | 
					    fd.append("file", file);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await APIClient.exec({
 | 
				
			||||||
 | 
					      method: "POST",
 | 
				
			||||||
 | 
					      uri: "/disk_images/upload",
 | 
				
			||||||
 | 
					      formData: fd,
 | 
				
			||||||
 | 
					      upProgress: progress,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Get the list of disk images
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  static async GetList(): Promise<DiskImage[]> {
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      await APIClient.exec({
 | 
				
			||||||
 | 
					        method: "GET",
 | 
				
			||||||
 | 
					        uri: "/disk_images/list",
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    ).data;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Download disk image file
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  static async Download(
 | 
				
			||||||
 | 
					    file: DiskImage,
 | 
				
			||||||
 | 
					    progress: (p: number) => void
 | 
				
			||||||
 | 
					  ): Promise<Blob> {
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      await APIClient.exec({
 | 
				
			||||||
 | 
					        method: "GET",
 | 
				
			||||||
 | 
					        uri: `/disk_images/${file.file_name}`,
 | 
				
			||||||
 | 
					        downProgress(e) {
 | 
				
			||||||
 | 
					          progress(Math.floor(100 * (e.progress / e.total)));
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    ).data;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Convert disk image file
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  static async Convert(
 | 
				
			||||||
 | 
					    file: DiskImage,
 | 
				
			||||||
 | 
					    dest_file_name: string,
 | 
				
			||||||
 | 
					    dest_format: DiskImageFormat
 | 
				
			||||||
 | 
					  ): Promise<void> {
 | 
				
			||||||
 | 
					    await APIClient.exec({
 | 
				
			||||||
 | 
					      method: "POST",
 | 
				
			||||||
 | 
					      uri: `/disk_images/${file.file_name}/convert`,
 | 
				
			||||||
 | 
					      jsonData: { ...dest_format, dest_file_name },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Backup VM disk into image disks library
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  static async BackupVMDisk(
 | 
				
			||||||
 | 
					    vm: VMInfo,
 | 
				
			||||||
 | 
					    disk: VMFileDisk,
 | 
				
			||||||
 | 
					    dest_file_name: string,
 | 
				
			||||||
 | 
					    format: DiskImageFormat
 | 
				
			||||||
 | 
					  ): Promise<void> {
 | 
				
			||||||
 | 
					    await APIClient.exec({
 | 
				
			||||||
 | 
					      uri: `/vm/${vm.uuid}/disk/${disk.name}/backup`,
 | 
				
			||||||
 | 
					      method: "POST",
 | 
				
			||||||
 | 
					      jsonData: { ...format, dest_file_name },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Rename disk image file
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  static async Rename(file: DiskImage, name: string): Promise<void> {
 | 
				
			||||||
 | 
					    await APIClient.exec({
 | 
				
			||||||
 | 
					      method: "POST",
 | 
				
			||||||
 | 
					      uri: `/disk_images/${file.file_name}/rename`,
 | 
				
			||||||
 | 
					      jsonData: { name },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Delete disk image file
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  static async Delete(file: DiskImage): Promise<void> {
 | 
				
			||||||
 | 
					    await APIClient.exec({
 | 
				
			||||||
 | 
					      method: "DELETE",
 | 
				
			||||||
 | 
					      uri: `/disk_images/${file.file_name}`,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -5,6 +5,15 @@ export interface IsoFile {
 | 
				
			|||||||
  size: number;
 | 
					  size: number;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * ISO catalog entries
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export interface ISOCatalogEntry {
 | 
				
			||||||
 | 
					  name: string;
 | 
				
			||||||
 | 
					  url: string;
 | 
				
			||||||
 | 
					  image: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class IsoFilesApi {
 | 
					export class IsoFilesApi {
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * Upload a new ISO file to the server
 | 
					   * Upload a new ISO file to the server
 | 
				
			||||||
@@ -74,4 +83,23 @@ export class IsoFilesApi {
 | 
				
			|||||||
      uri: `/iso/${file.filename}`,
 | 
					      uri: `/iso/${file.filename}`,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Get iso catalog
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  static async Catalog(): Promise<ISOCatalogEntry[]> {
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      await APIClient.exec({
 | 
				
			||||||
 | 
					        method: "GET",
 | 
				
			||||||
 | 
					        uri: "/assets/iso_catalog.json",
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    ).data;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Get catalog image URL
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  static CatalogImageURL(entry: ISOCatalogEntry): string {
 | 
				
			||||||
 | 
					    return APIClient.backendURL() + entry.image;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,6 +5,7 @@ export interface ServerConfig {
 | 
				
			|||||||
  local_auth_enabled: boolean;
 | 
					  local_auth_enabled: boolean;
 | 
				
			||||||
  oidc_auth_enabled: boolean;
 | 
					  oidc_auth_enabled: boolean;
 | 
				
			||||||
  iso_mimetypes: string[];
 | 
					  iso_mimetypes: string[];
 | 
				
			||||||
 | 
					  disk_images_mimetypes: string[];
 | 
				
			||||||
  net_mac_prefix: string;
 | 
					  net_mac_prefix: string;
 | 
				
			||||||
  builtin_nwfilter_rules: string[];
 | 
					  builtin_nwfilter_rules: string[];
 | 
				
			||||||
  nwfilter_chains: string[];
 | 
					  nwfilter_chains: string[];
 | 
				
			||||||
@@ -13,6 +14,7 @@ export interface ServerConfig {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export interface ServerConstraints {
 | 
					export interface ServerConstraints {
 | 
				
			||||||
  iso_max_size: number;
 | 
					  iso_max_size: number;
 | 
				
			||||||
 | 
					  disk_image_max_size: number;
 | 
				
			||||||
  vnc_token_duration: number;
 | 
					  vnc_token_duration: number;
 | 
				
			||||||
  vm_name_size: LenConstraint;
 | 
					  vm_name_size: LenConstraint;
 | 
				
			||||||
  vm_title_size: LenConstraint;
 | 
					  vm_title_size: LenConstraint;
 | 
				
			||||||
@@ -20,6 +22,7 @@ export interface ServerConstraints {
 | 
				
			|||||||
  memory_size: LenConstraint;
 | 
					  memory_size: LenConstraint;
 | 
				
			||||||
  disk_name_size: LenConstraint;
 | 
					  disk_name_size: LenConstraint;
 | 
				
			||||||
  disk_size: LenConstraint;
 | 
					  disk_size: LenConstraint;
 | 
				
			||||||
 | 
					  disk_image_name_size: LenConstraint;
 | 
				
			||||||
  net_name_size: LenConstraint;
 | 
					  net_name_size: LenConstraint;
 | 
				
			||||||
  net_title_size: LenConstraint;
 | 
					  net_title_size: LenConstraint;
 | 
				
			||||||
  net_nat_comment_size: LenConstraint;
 | 
					  net_nat_comment_size: LenConstraint;
 | 
				
			||||||
@@ -217,4 +220,28 @@ export class ServerApi {
 | 
				
			|||||||
      })
 | 
					      })
 | 
				
			||||||
    ).data;
 | 
					    ).data;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Get host networks bridges list
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  static async GetNetworksBridgesList(): Promise<string[]> {
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      await APIClient.exec({
 | 
				
			||||||
 | 
					        method: "GET",
 | 
				
			||||||
 | 
					        uri: "/server/bridges",
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    ).data;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Export all server configs
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  static async ExportServerConfigs(): Promise<Blob> {
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      await APIClient.exec({
 | 
				
			||||||
 | 
					        method: "GET",
 | 
				
			||||||
 | 
					        uri: "/server/export_configs",
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    ).data;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,19 +17,38 @@ export type VMState =
 | 
				
			|||||||
  | "PowerManagementSuspended"
 | 
					  | "PowerManagementSuspended"
 | 
				
			||||||
  | "Other";
 | 
					  | "Other";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type DiskAllocType = "Sparse" | "Fixed";
 | 
					export type VMFileDisk = BaseFileVMDisk & (RawVMDisk | QCow2Disk);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface VMDisk {
 | 
					export type DiskBusType = "Virtio" | "SATA";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface BaseFileVMDisk {
 | 
				
			||||||
  size: number;
 | 
					  size: number;
 | 
				
			||||||
  name: string;
 | 
					  name: string;
 | 
				
			||||||
  alloc_type: DiskAllocType;
 | 
					  bus: DiskBusType;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  delete: boolean;
 | 
					  delete: boolean;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // application attribute
 | 
					  // For new disk only
 | 
				
			||||||
 | 
					  from_image?: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Resize disk image after clone
 | 
				
			||||||
 | 
					  resize?: boolean;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // application attributes
 | 
				
			||||||
  new?: boolean;
 | 
					  new?: boolean;
 | 
				
			||||||
 | 
					  originalSize?: number;
 | 
				
			||||||
  deleteType?: "keepfile" | "deletefile";
 | 
					  deleteType?: "keepfile" | "deletefile";
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface RawVMDisk {
 | 
				
			||||||
 | 
					  format: "Raw";
 | 
				
			||||||
 | 
					  is_sparse: boolean;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface QCow2Disk {
 | 
				
			||||||
 | 
					  format: "QCow2";
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface VMNetInterfaceFilterParams {
 | 
					export interface VMNetInterfaceFilterParams {
 | 
				
			||||||
  name: string;
 | 
					  name: string;
 | 
				
			||||||
  value: string;
 | 
					  value: string;
 | 
				
			||||||
@@ -40,11 +59,16 @@ export interface VMNetInterfaceFilter {
 | 
				
			|||||||
  parameters: VMNetInterfaceFilterParams[];
 | 
					  parameters: VMNetInterfaceFilterParams[];
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type VMNetInterface = (VMNetUserspaceSLIRPStack | VMNetDefinedNetwork) &
 | 
					export type VMNetInterface = (
 | 
				
			||||||
 | 
					  | VMNetUserspaceSLIRPStack
 | 
				
			||||||
 | 
					  | VMNetDefinedNetwork
 | 
				
			||||||
 | 
					  | VMNetBridge
 | 
				
			||||||
 | 
					) &
 | 
				
			||||||
  VMNetInterfaceBase;
 | 
					  VMNetInterfaceBase;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface VMNetInterfaceBase {
 | 
					export interface VMNetInterfaceBase {
 | 
				
			||||||
  mac: string;
 | 
					  mac: string;
 | 
				
			||||||
 | 
					  model: "Virtio" | "E1000";
 | 
				
			||||||
  nwfilterref?: VMNetInterfaceFilter;
 | 
					  nwfilterref?: VMNetInterfaceFilter;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -57,6 +81,22 @@ export interface VMNetDefinedNetwork {
 | 
				
			|||||||
  network: string;
 | 
					  network: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface VMNetBridge {
 | 
				
			||||||
 | 
					  type: "Bridge";
 | 
				
			||||||
 | 
					  bridge: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface VMCloudInit {
 | 
				
			||||||
 | 
					  attach_config: boolean;
 | 
				
			||||||
 | 
					  user_data: string;
 | 
				
			||||||
 | 
					  instance_id?: string;
 | 
				
			||||||
 | 
					  local_hostname?: string;
 | 
				
			||||||
 | 
					  dsmode?: "Net" | "Local";
 | 
				
			||||||
 | 
					  network_configuration?: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type VMBootType = "UEFI" | "UEFISecureBoot" | "Legacy";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface VMInfoInterface {
 | 
					interface VMInfoInterface {
 | 
				
			||||||
  name: string;
 | 
					  name: string;
 | 
				
			||||||
  uuid?: string;
 | 
					  uuid?: string;
 | 
				
			||||||
@@ -64,15 +104,17 @@ interface VMInfoInterface {
 | 
				
			|||||||
  title?: string;
 | 
					  title?: string;
 | 
				
			||||||
  description?: string;
 | 
					  description?: string;
 | 
				
			||||||
  group?: string;
 | 
					  group?: string;
 | 
				
			||||||
  boot_type: "UEFI" | "UEFISecureBoot";
 | 
					  boot_type: VMBootType;
 | 
				
			||||||
  architecture: "i686" | "x86_64";
 | 
					  architecture: "i686" | "x86_64";
 | 
				
			||||||
  memory: number;
 | 
					  memory: number;
 | 
				
			||||||
  number_vcpu: number;
 | 
					  number_vcpu: number;
 | 
				
			||||||
  vnc_access: boolean;
 | 
					  vnc_access: boolean;
 | 
				
			||||||
  iso_files: string[];
 | 
					  iso_files: string[];
 | 
				
			||||||
  disks: VMDisk[];
 | 
					  file_disks: VMFileDisk[];
 | 
				
			||||||
  networks: VMNetInterface[];
 | 
					  networks: VMNetInterface[];
 | 
				
			||||||
  tpm_module: boolean;
 | 
					  tpm_module: boolean;
 | 
				
			||||||
 | 
					  oem_strings: string[];
 | 
				
			||||||
 | 
					  cloud_init: VMCloudInit;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class VMInfo implements VMInfoInterface {
 | 
					export class VMInfo implements VMInfoInterface {
 | 
				
			||||||
@@ -82,15 +124,17 @@ export class VMInfo implements VMInfoInterface {
 | 
				
			|||||||
  title?: string;
 | 
					  title?: string;
 | 
				
			||||||
  description?: string;
 | 
					  description?: string;
 | 
				
			||||||
  group?: string;
 | 
					  group?: string;
 | 
				
			||||||
  boot_type: "UEFI" | "UEFISecureBoot";
 | 
					  boot_type: VMBootType;
 | 
				
			||||||
  architecture: "i686" | "x86_64";
 | 
					  architecture: "i686" | "x86_64";
 | 
				
			||||||
  number_vcpu: number;
 | 
					  number_vcpu: number;
 | 
				
			||||||
  memory: number;
 | 
					  memory: number;
 | 
				
			||||||
  vnc_access: boolean;
 | 
					  vnc_access: boolean;
 | 
				
			||||||
  iso_files: string[];
 | 
					  iso_files: string[];
 | 
				
			||||||
  disks: VMDisk[];
 | 
					  file_disks: VMFileDisk[];
 | 
				
			||||||
  networks: VMNetInterface[];
 | 
					  networks: VMNetInterface[];
 | 
				
			||||||
  tpm_module: boolean;
 | 
					  tpm_module: boolean;
 | 
				
			||||||
 | 
					  oem_strings: string[];
 | 
				
			||||||
 | 
					  cloud_init: VMCloudInit;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(int: VMInfoInterface) {
 | 
					  constructor(int: VMInfoInterface) {
 | 
				
			||||||
    this.name = int.name;
 | 
					    this.name = int.name;
 | 
				
			||||||
@@ -105,9 +149,11 @@ export class VMInfo implements VMInfoInterface {
 | 
				
			|||||||
    this.memory = int.memory;
 | 
					    this.memory = int.memory;
 | 
				
			||||||
    this.vnc_access = int.vnc_access;
 | 
					    this.vnc_access = int.vnc_access;
 | 
				
			||||||
    this.iso_files = int.iso_files;
 | 
					    this.iso_files = int.iso_files;
 | 
				
			||||||
    this.disks = int.disks;
 | 
					    this.file_disks = int.file_disks;
 | 
				
			||||||
    this.networks = int.networks;
 | 
					    this.networks = int.networks;
 | 
				
			||||||
    this.tpm_module = int.tpm_module;
 | 
					    this.tpm_module = int.tpm_module;
 | 
				
			||||||
 | 
					    this.oem_strings = int.oem_strings;
 | 
				
			||||||
 | 
					    this.cloud_init = int.cloud_init;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static NewEmpty(): VMInfo {
 | 
					  static NewEmpty(): VMInfo {
 | 
				
			||||||
@@ -115,13 +161,15 @@ export class VMInfo implements VMInfoInterface {
 | 
				
			|||||||
      name: "",
 | 
					      name: "",
 | 
				
			||||||
      boot_type: "UEFI",
 | 
					      boot_type: "UEFI",
 | 
				
			||||||
      architecture: "x86_64",
 | 
					      architecture: "x86_64",
 | 
				
			||||||
      memory: 1024,
 | 
					      memory: 1000 * 1000 * 1000,
 | 
				
			||||||
      number_vcpu: 1,
 | 
					      number_vcpu: 1,
 | 
				
			||||||
      vnc_access: true,
 | 
					      vnc_access: true,
 | 
				
			||||||
      iso_files: [],
 | 
					      iso_files: [],
 | 
				
			||||||
      disks: [],
 | 
					      file_disks: [],
 | 
				
			||||||
      networks: [],
 | 
					      networks: [],
 | 
				
			||||||
      tpm_module: true,
 | 
					      tpm_module: true,
 | 
				
			||||||
 | 
					      oem_strings: [],
 | 
				
			||||||
 | 
					      cloud_init: { attach_config: false, user_data: "" },
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -194,8 +242,8 @@ export class VMApi {
 | 
				
			|||||||
   */
 | 
					   */
 | 
				
			||||||
  static async UpdateSingle(vm: VMInfo): Promise<VMInfo> {
 | 
					  static async UpdateSingle(vm: VMInfo): Promise<VMInfo> {
 | 
				
			||||||
    // Process disks list, looking for removal
 | 
					    // Process disks list, looking for removal
 | 
				
			||||||
    vm.disks = vm.disks.filter((d) => d.deleteType !== "keepfile");
 | 
					    vm.file_disks = vm.file_disks.filter((d) => d.deleteType !== "keepfile");
 | 
				
			||||||
    vm.disks.forEach((d) => {
 | 
					    vm.file_disks.forEach((d) => {
 | 
				
			||||||
      if (d.deleteType === "deletefile") d.delete = true;
 | 
					      if (d.deleteType === "deletefile") d.delete = true;
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										148
									
								
								virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,148 @@
 | 
				
			|||||||
 | 
					import {
 | 
				
			||||||
 | 
					  Button,
 | 
				
			||||||
 | 
					  Dialog,
 | 
				
			||||||
 | 
					  DialogActions,
 | 
				
			||||||
 | 
					  DialogContent,
 | 
				
			||||||
 | 
					  DialogContentText,
 | 
				
			||||||
 | 
					  DialogTitle,
 | 
				
			||||||
 | 
					} from "@mui/material";
 | 
				
			||||||
 | 
					import React from "react";
 | 
				
			||||||
 | 
					import { DiskImage, DiskImageApi, DiskImageFormat } from "../api/DiskImageApi";
 | 
				
			||||||
 | 
					import { ServerApi } from "../api/ServerApi";
 | 
				
			||||||
 | 
					import { VMFileDisk, VMInfo } from "../api/VMApi";
 | 
				
			||||||
 | 
					import { useAlert } from "../hooks/providers/AlertDialogProvider";
 | 
				
			||||||
 | 
					import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
 | 
				
			||||||
 | 
					import { FileDiskImageWidget } from "../widgets/FileDiskImageWidget";
 | 
				
			||||||
 | 
					import { CheckboxInput } from "../widgets/forms/CheckboxInput";
 | 
				
			||||||
 | 
					import { SelectInput } from "../widgets/forms/SelectInput";
 | 
				
			||||||
 | 
					import { TextInput } from "../widgets/forms/TextInput";
 | 
				
			||||||
 | 
					import { VMDiskFileWidget } from "../widgets/vms/VMDiskFileWidget";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function ConvertDiskImageDialog(
 | 
				
			||||||
 | 
					  p: {
 | 
				
			||||||
 | 
					    onCancel: () => void;
 | 
				
			||||||
 | 
					    onFinished: () => void;
 | 
				
			||||||
 | 
					  } & (
 | 
				
			||||||
 | 
					    | { backup?: false; image: DiskImage }
 | 
				
			||||||
 | 
					    | { backup: true; disk: VMFileDisk; vm: VMInfo }
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					): React.ReactElement {
 | 
				
			||||||
 | 
					  const alert = useAlert();
 | 
				
			||||||
 | 
					  const loadingMessage = useLoadingMessage();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [format, setFormat] = React.useState<DiskImageFormat>({
 | 
				
			||||||
 | 
					    format: "QCow2",
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const origFilename = p.backup ? p.disk.name : p.image.file_name;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [filename, setFilename] = React.useState(origFilename + ".qcow2");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleFormatChange = (value?: string) => {
 | 
				
			||||||
 | 
					    setFormat({ format: value ?? ("QCow2" as any) });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (value === "QCow2") setFilename(`${origFilename}.qcow2`);
 | 
				
			||||||
 | 
					    if (value === "GzCompressedQCow2") setFilename(`${origFilename}.qcow2.gz`);
 | 
				
			||||||
 | 
					    if (value === "XzCompressedQCow2") setFilename(`${origFilename}.qcow2.xz`);
 | 
				
			||||||
 | 
					    if (value === "Raw") {
 | 
				
			||||||
 | 
					      setFilename(`${origFilename}.raw`);
 | 
				
			||||||
 | 
					      // Check sparse checkbox by default
 | 
				
			||||||
 | 
					      setFormat({ format: "Raw", is_sparse: true });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (value === "GzCompressedRaw") setFilename(`${origFilename}.raw.gz`);
 | 
				
			||||||
 | 
					    if (value === "XzCompressedRaw") setFilename(`${origFilename}.raw.xz`);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleSubmit = async () => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      loadingMessage.show(
 | 
				
			||||||
 | 
					        p.backup ? "Performing backup..." : "Converting image..."
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Perform the conversion / backup operation
 | 
				
			||||||
 | 
					      if (p.backup)
 | 
				
			||||||
 | 
					        await DiskImageApi.BackupVMDisk(p.vm, p.disk, filename, format);
 | 
				
			||||||
 | 
					      else await DiskImageApi.Convert(p.image, filename, format);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      p.onFinished();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      alert(p.backup ? "Backup successful!" : "Conversion successful!");
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      console.error("Failed to perform backup/conversion!", e);
 | 
				
			||||||
 | 
					      alert(
 | 
				
			||||||
 | 
					        p.backup
 | 
				
			||||||
 | 
					          ? `Failed to perform backup! ${e}`
 | 
				
			||||||
 | 
					          : `Failed to convert image! ${e}`
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      loadingMessage.hide();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Dialog open onClose={p.onCancel}>
 | 
				
			||||||
 | 
					      <DialogTitle>
 | 
				
			||||||
 | 
					        {p.backup ? `Backup disk ${p.disk.name}` : "Convert disk image"}
 | 
				
			||||||
 | 
					      </DialogTitle>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <DialogContent>
 | 
				
			||||||
 | 
					        <DialogContentText>
 | 
				
			||||||
 | 
					          Select the destination format for this image:
 | 
				
			||||||
 | 
					        </DialogContentText>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {/* Show details of of the image */}
 | 
				
			||||||
 | 
					        {p.backup ? (
 | 
				
			||||||
 | 
					          <VMDiskFileWidget {...p} />
 | 
				
			||||||
 | 
					        ) : (
 | 
				
			||||||
 | 
					          <FileDiskImageWidget {...p} />
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {/* New image format */}
 | 
				
			||||||
 | 
					        <SelectInput
 | 
				
			||||||
 | 
					          editable
 | 
				
			||||||
 | 
					          label="Target format"
 | 
				
			||||||
 | 
					          value={format.format}
 | 
				
			||||||
 | 
					          onValueChange={handleFormatChange}
 | 
				
			||||||
 | 
					          options={[
 | 
				
			||||||
 | 
					            { value: "QCow2" },
 | 
				
			||||||
 | 
					            { value: "Raw" },
 | 
				
			||||||
 | 
					            { value: "GzCompressedRaw" },
 | 
				
			||||||
 | 
					            { value: "XzCompressedRaw" },
 | 
				
			||||||
 | 
					            { value: "GzCompressedQCow2" },
 | 
				
			||||||
 | 
					            { value: "XzCompressedQCow2" },
 | 
				
			||||||
 | 
					          ]}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {/* Check for sparse file */}
 | 
				
			||||||
 | 
					        {format.format === "Raw" && (
 | 
				
			||||||
 | 
					          <CheckboxInput
 | 
				
			||||||
 | 
					            editable
 | 
				
			||||||
 | 
					            label="Sparse file"
 | 
				
			||||||
 | 
					            checked={format.is_sparse}
 | 
				
			||||||
 | 
					            onValueChange={(c) => {
 | 
				
			||||||
 | 
					              setFormat({ format: "Raw", is_sparse: c });
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {/* New image name */}
 | 
				
			||||||
 | 
					        <TextInput
 | 
				
			||||||
 | 
					          editable
 | 
				
			||||||
 | 
					          label="New image name"
 | 
				
			||||||
 | 
					          value={filename}
 | 
				
			||||||
 | 
					          onValueChange={(s) => {
 | 
				
			||||||
 | 
					            setFilename(s ?? "");
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					          size={ServerApi.Config.constraints.disk_image_name_size}
 | 
				
			||||||
 | 
					          helperText="The image name shall contain the proper file extension for the selected target format"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </DialogContent>
 | 
				
			||||||
 | 
					      <DialogActions>
 | 
				
			||||||
 | 
					        <Button onClick={p.onCancel}>Cancel</Button>
 | 
				
			||||||
 | 
					        <Button onClick={handleSubmit} autoFocus>
 | 
				
			||||||
 | 
					          {p.backup ? "Perform backup" : "Convert image"}
 | 
				
			||||||
 | 
					        </Button>
 | 
				
			||||||
 | 
					      </DialogActions>
 | 
				
			||||||
 | 
					    </Dialog>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										75
									
								
								virtweb_frontend/src/dialogs/IsoCatalogDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								virtweb_frontend/src/dialogs/IsoCatalogDialog.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,75 @@
 | 
				
			|||||||
 | 
					import {
 | 
				
			||||||
 | 
					  Button,
 | 
				
			||||||
 | 
					  Dialog,
 | 
				
			||||||
 | 
					  DialogActions,
 | 
				
			||||||
 | 
					  DialogContent,
 | 
				
			||||||
 | 
					  DialogTitle,
 | 
				
			||||||
 | 
					  List,
 | 
				
			||||||
 | 
					  ListItem,
 | 
				
			||||||
 | 
					  ListItemAvatar,
 | 
				
			||||||
 | 
					  ListItemButton,
 | 
				
			||||||
 | 
					  ListItemText,
 | 
				
			||||||
 | 
					} from "@mui/material";
 | 
				
			||||||
 | 
					import React from "react";
 | 
				
			||||||
 | 
					import { ISOCatalogEntry, IsoFilesApi } from "../api/IsoFilesApi";
 | 
				
			||||||
 | 
					import { AsyncWidget } from "../widgets/AsyncWidget";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function IsoCatalogDialog(p: {
 | 
				
			||||||
 | 
					  open: boolean;
 | 
				
			||||||
 | 
					  onClose: () => void;
 | 
				
			||||||
 | 
					}): React.ReactElement {
 | 
				
			||||||
 | 
					  const [catalog, setCatalog] = React.useState<ISOCatalogEntry[] | undefined>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const load = async () => {
 | 
				
			||||||
 | 
					    setCatalog(await IsoFilesApi.Catalog());
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Dialog open={p.open} onClose={p.onClose}>
 | 
				
			||||||
 | 
					      <DialogTitle>ISO catalog</DialogTitle>
 | 
				
			||||||
 | 
					      <DialogContent>
 | 
				
			||||||
 | 
					        <AsyncWidget
 | 
				
			||||||
 | 
					          loadKey={1}
 | 
				
			||||||
 | 
					          load={load}
 | 
				
			||||||
 | 
					          errMsg="Failed to load catalog"
 | 
				
			||||||
 | 
					          build={() => <IsoCatalogDialogInner catalog={catalog!} />}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </DialogContent>
 | 
				
			||||||
 | 
					      <DialogActions>
 | 
				
			||||||
 | 
					        <Button autoFocus onClick={p.onClose}>
 | 
				
			||||||
 | 
					          Close
 | 
				
			||||||
 | 
					        </Button>
 | 
				
			||||||
 | 
					      </DialogActions>
 | 
				
			||||||
 | 
					    </Dialog>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function IsoCatalogDialogInner(p: {
 | 
				
			||||||
 | 
					  catalog: ISOCatalogEntry[];
 | 
				
			||||||
 | 
					}): React.ReactElement {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <List dense>
 | 
				
			||||||
 | 
					      {p.catalog.map((entry) => (
 | 
				
			||||||
 | 
					        <a
 | 
				
			||||||
 | 
					          key={entry.name}
 | 
				
			||||||
 | 
					          href={entry.url}
 | 
				
			||||||
 | 
					          target="_blank"
 | 
				
			||||||
 | 
					          rel="noopener"
 | 
				
			||||||
 | 
					          style={{ color: "inherit", textDecoration: "none" }}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <ListItem>
 | 
				
			||||||
 | 
					            <ListItemButton>
 | 
				
			||||||
 | 
					              <ListItemAvatar>
 | 
				
			||||||
 | 
					                <img
 | 
				
			||||||
 | 
					                  src={IsoFilesApi.CatalogImageURL(entry)}
 | 
				
			||||||
 | 
					                  style={{ width: "2em" }}
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
 | 
					              </ListItemAvatar>
 | 
				
			||||||
 | 
					              <ListItemText primary={entry.name} />
 | 
				
			||||||
 | 
					            </ListItemButton>
 | 
				
			||||||
 | 
					          </ListItem>
 | 
				
			||||||
 | 
					        </a>
 | 
				
			||||||
 | 
					      ))}
 | 
				
			||||||
 | 
					    </List>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -3,16 +3,44 @@ import "@fontsource/roboto/400.css";
 | 
				
			|||||||
import "@fontsource/roboto/500.css";
 | 
					import "@fontsource/roboto/500.css";
 | 
				
			||||||
import "@fontsource/roboto/700.css";
 | 
					import "@fontsource/roboto/700.css";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { ThemeProvider, createTheme } from "@mui/material";
 | 
				
			||||||
import React from "react";
 | 
					import React from "react";
 | 
				
			||||||
import ReactDOM from "react-dom/client";
 | 
					import ReactDOM from "react-dom/client";
 | 
				
			||||||
import { App } from "./App";
 | 
					import { App } from "./App";
 | 
				
			||||||
 | 
					import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider";
 | 
				
			||||||
 | 
					import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider";
 | 
				
			||||||
 | 
					import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider";
 | 
				
			||||||
 | 
					import { SnackbarProvider } from "./hooks/providers/SnackbarProvider";
 | 
				
			||||||
import "./index.css";
 | 
					import "./index.css";
 | 
				
			||||||
import { LoadServerConfig } from "./widgets/LoadServerConfig";
 | 
					import { LoadServerConfig } from "./widgets/LoadServerConfig";
 | 
				
			||||||
import { ThemeProvider, createTheme } from "@mui/material";
 | 
					
 | 
				
			||||||
import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider";
 | 
					import { loader } from "@monaco-editor/react";
 | 
				
			||||||
import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider";
 | 
					import * as monaco from "monaco-editor";
 | 
				
			||||||
import { SnackbarProvider } from "./hooks/providers/SnackbarProvider";
 | 
					import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
 | 
				
			||||||
import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider";
 | 
					import { configureMonacoYaml } from "monaco-yaml";
 | 
				
			||||||
 | 
					import YamlWorker from "monaco-yaml/yaml.worker?worker";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// This allows to use a self hosted instance of Monaco editor
 | 
				
			||||||
 | 
					loader.config({ monaco });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Add YAML support to Monaco
 | 
				
			||||||
 | 
					configureMonacoYaml(monaco, {
 | 
				
			||||||
 | 
					  enableSchemaRequest: false,
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/// YAML worker
 | 
				
			||||||
 | 
					window.MonacoEnvironment = {
 | 
				
			||||||
 | 
					  getWorker(_moduleId, label) {
 | 
				
			||||||
 | 
					    switch (label) {
 | 
				
			||||||
 | 
					      case "editorWorkerService":
 | 
				
			||||||
 | 
					        return new EditorWorker();
 | 
				
			||||||
 | 
					      case "yaml":
 | 
				
			||||||
 | 
					        return new YamlWorker();
 | 
				
			||||||
 | 
					      default:
 | 
				
			||||||
 | 
					        throw new Error(`Unknown label ${label}`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const darkTheme = createTheme({
 | 
					const darkTheme = createTheme({
 | 
				
			||||||
  palette: {
 | 
					  palette: {
 | 
				
			||||||
@@ -20,9 +48,7 @@ const darkTheme = createTheme({
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const root = ReactDOM.createRoot(
 | 
					const root = ReactDOM.createRoot(document.getElementById("root")!);
 | 
				
			||||||
  document.getElementById("root")!
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
root.render(
 | 
					root.render(
 | 
				
			||||||
  <React.StrictMode>
 | 
					  <React.StrictMode>
 | 
				
			||||||
    <ThemeProvider theme={darkTheme}>
 | 
					    <ThemeProvider theme={darkTheme}>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										417
									
								
								virtweb_frontend/src/routes/DiskImagesRoute.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										417
									
								
								virtweb_frontend/src/routes/DiskImagesRoute.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,417 @@
 | 
				
			|||||||
 | 
					import DeleteIcon from "@mui/icons-material/Delete";
 | 
				
			||||||
 | 
					import DownloadIcon from "@mui/icons-material/Download";
 | 
				
			||||||
 | 
					import LoopIcon from "@mui/icons-material/Loop";
 | 
				
			||||||
 | 
					import MoreVertIcon from "@mui/icons-material/MoreVert";
 | 
				
			||||||
 | 
					import RefreshIcon from "@mui/icons-material/Refresh";
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  Alert,
 | 
				
			||||||
 | 
					  Button,
 | 
				
			||||||
 | 
					  CircularProgress,
 | 
				
			||||||
 | 
					  IconButton,
 | 
				
			||||||
 | 
					  LinearProgress,
 | 
				
			||||||
 | 
					  ListItemIcon,
 | 
				
			||||||
 | 
					  ListItemText,
 | 
				
			||||||
 | 
					  Menu,
 | 
				
			||||||
 | 
					  MenuItem,
 | 
				
			||||||
 | 
					  Tooltip,
 | 
				
			||||||
 | 
					  Typography,
 | 
				
			||||||
 | 
					} from "@mui/material";
 | 
				
			||||||
 | 
					import { DataGrid, GridColDef } from "@mui/x-data-grid";
 | 
				
			||||||
 | 
					import { filesize } from "filesize";
 | 
				
			||||||
 | 
					import React from "react";
 | 
				
			||||||
 | 
					import { DiskImage, DiskImageApi } from "../api/DiskImageApi";
 | 
				
			||||||
 | 
					import { ServerApi } from "../api/ServerApi";
 | 
				
			||||||
 | 
					import { ConvertDiskImageDialog } from "../dialogs/ConvertDiskImageDialog";
 | 
				
			||||||
 | 
					import { useAlert } from "../hooks/providers/AlertDialogProvider";
 | 
				
			||||||
 | 
					import { useConfirm } from "../hooks/providers/ConfirmDialogProvider";
 | 
				
			||||||
 | 
					import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
 | 
				
			||||||
 | 
					import { useSnackbar } from "../hooks/providers/SnackbarProvider";
 | 
				
			||||||
 | 
					import { downloadBlob } from "../utils/FilesUtils";
 | 
				
			||||||
 | 
					import { AsyncWidget } from "../widgets/AsyncWidget";
 | 
				
			||||||
 | 
					import { DateWidget } from "../widgets/DateWidget";
 | 
				
			||||||
 | 
					import { FileInput } from "../widgets/forms/FileInput";
 | 
				
			||||||
 | 
					import { VirtWebPaper } from "../widgets/VirtWebPaper";
 | 
				
			||||||
 | 
					import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DiskImagesRoute(): React.ReactElement {
 | 
				
			||||||
 | 
					  const [list, setList] = React.useState<DiskImage[] | undefined>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const loadKey = React.useRef(1);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const load = async () => {
 | 
				
			||||||
 | 
					    setList(await DiskImageApi.GetList());
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const reload = () => {
 | 
				
			||||||
 | 
					    loadKey.current += 1;
 | 
				
			||||||
 | 
					    setList(undefined);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <VirtWebRouteContainer
 | 
				
			||||||
 | 
					      label="Disk images management"
 | 
				
			||||||
 | 
					      actions={
 | 
				
			||||||
 | 
					        <span>
 | 
				
			||||||
 | 
					          <Tooltip title="Refresh Disk images list">
 | 
				
			||||||
 | 
					            <IconButton onClick={reload}>
 | 
				
			||||||
 | 
					              <RefreshIcon />
 | 
				
			||||||
 | 
					            </IconButton>
 | 
				
			||||||
 | 
					          </Tooltip>
 | 
				
			||||||
 | 
					        </span>
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <AsyncWidget
 | 
				
			||||||
 | 
					        loadKey={loadKey.current}
 | 
				
			||||||
 | 
					        errMsg="Failed to load disk images list!"
 | 
				
			||||||
 | 
					        load={load}
 | 
				
			||||||
 | 
					        ready={list !== undefined}
 | 
				
			||||||
 | 
					        build={() => (
 | 
				
			||||||
 | 
					          <>
 | 
				
			||||||
 | 
					            <UploadDiskImageCard onFileUploaded={reload} />
 | 
				
			||||||
 | 
					            <DiskImageList list={list!} onReload={reload} />
 | 
				
			||||||
 | 
					          </>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </VirtWebRouteContainer>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function UploadDiskImageCard(p: {
 | 
				
			||||||
 | 
					  onFileUploaded: () => void;
 | 
				
			||||||
 | 
					}): React.ReactElement {
 | 
				
			||||||
 | 
					  const alert = useAlert();
 | 
				
			||||||
 | 
					  const snackbar = useSnackbar();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [value, setValue] = React.useState<File | null>(null);
 | 
				
			||||||
 | 
					  const [uploadProgress, setUploadProgress] = React.useState<number | null>(
 | 
				
			||||||
 | 
					    null
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleChange = (newValue: File | null) => {
 | 
				
			||||||
 | 
					    if (
 | 
				
			||||||
 | 
					      newValue &&
 | 
				
			||||||
 | 
					      newValue.size > ServerApi.Config.constraints.disk_image_max_size
 | 
				
			||||||
 | 
					    ) {
 | 
				
			||||||
 | 
					      alert(
 | 
				
			||||||
 | 
					        `The file is too big (max size allowed: ${filesize(
 | 
				
			||||||
 | 
					          ServerApi.Config.constraints.disk_image_max_size
 | 
				
			||||||
 | 
					        )}`
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (
 | 
				
			||||||
 | 
					      newValue &&
 | 
				
			||||||
 | 
					      newValue.type.length > 0 &&
 | 
				
			||||||
 | 
					      !ServerApi.Config.disk_images_mimetypes.includes(newValue.type)
 | 
				
			||||||
 | 
					    ) {
 | 
				
			||||||
 | 
					      alert(`Selected file mimetype is not allowed! (${newValue.type})`);
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setValue(newValue);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const upload = async () => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      setUploadProgress(0);
 | 
				
			||||||
 | 
					      await DiskImageApi.Upload(value!, setUploadProgress);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      setValue(null);
 | 
				
			||||||
 | 
					      snackbar("The file was successfully uploaded!");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      p.onFileUploaded();
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      console.error(e);
 | 
				
			||||||
 | 
					      await alert(`Failed to perform file upload! ${e}`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setUploadProgress(null);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (uploadProgress !== null) {
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <VirtWebPaper label="File upload" noHorizontalMargin>
 | 
				
			||||||
 | 
					        <Typography variant="body1">
 | 
				
			||||||
 | 
					          Upload in progress ({Math.floor(uploadProgress * 100)}%)...
 | 
				
			||||||
 | 
					        </Typography>
 | 
				
			||||||
 | 
					        <LinearProgress variant="determinate" value={uploadProgress * 100} />
 | 
				
			||||||
 | 
					      </VirtWebPaper>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <VirtWebPaper label="Disk image upload" noHorizontalMargin>
 | 
				
			||||||
 | 
					      <div style={{ display: "flex", alignItems: "center" }}>
 | 
				
			||||||
 | 
					        <FileInput
 | 
				
			||||||
 | 
					          value={value}
 | 
				
			||||||
 | 
					          onChange={handleChange}
 | 
				
			||||||
 | 
					          style={{ flex: 1 }}
 | 
				
			||||||
 | 
					          slotProps={{
 | 
				
			||||||
 | 
					            htmlInput: {
 | 
				
			||||||
 | 
					              accept: ServerApi.Config.disk_images_mimetypes.join(","),
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {value && <Button onClick={upload}>Upload</Button>}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </VirtWebPaper>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function DiskImageList(p: {
 | 
				
			||||||
 | 
					  list: DiskImage[];
 | 
				
			||||||
 | 
					  onReload: () => void;
 | 
				
			||||||
 | 
					}): React.ReactElement {
 | 
				
			||||||
 | 
					  const alert = useAlert();
 | 
				
			||||||
 | 
					  const snackbar = useSnackbar();
 | 
				
			||||||
 | 
					  const confirm = useConfirm();
 | 
				
			||||||
 | 
					  const loadingMessage = useLoadingMessage();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [dlProgress, setDlProgress] = React.useState<undefined | number>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [currConversion, setCurrConversion] = React.useState<
 | 
				
			||||||
 | 
					    DiskImage | undefined
 | 
				
			||||||
 | 
					  >();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Download disk image file
 | 
				
			||||||
 | 
					  const downloadDiskImage = async (entry: DiskImage) => {
 | 
				
			||||||
 | 
					    setDlProgress(0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const blob = await DiskImageApi.Download(entry, setDlProgress);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      downloadBlob(blob, entry.file_name);
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      console.error(e);
 | 
				
			||||||
 | 
					      alert(`Failed to download disk image file! ${e}`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setDlProgress(undefined);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Convert disk image file
 | 
				
			||||||
 | 
					  const convertDiskImage = (entry: DiskImage) => {
 | 
				
			||||||
 | 
					    setCurrConversion(entry);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Delete disk image
 | 
				
			||||||
 | 
					  const deleteDiskImage = async (entry: DiskImage) => {
 | 
				
			||||||
 | 
					    if (
 | 
				
			||||||
 | 
					      !(await confirm(
 | 
				
			||||||
 | 
					        `Do you really want to delete this disk image (${entry.file_name}) ?`
 | 
				
			||||||
 | 
					      ))
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    loadingMessage.show("Deleting disk image file...");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await DiskImageApi.Delete(entry);
 | 
				
			||||||
 | 
					      snackbar("The disk image has been successfully deleted!");
 | 
				
			||||||
 | 
					      p.onReload();
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      console.error(e);
 | 
				
			||||||
 | 
					      alert(`Failed to delete disk image!\n${e}`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    loadingMessage.hide();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (p.list.length === 0)
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <Typography variant="body1" style={{ textAlign: "center" }}>
 | 
				
			||||||
 | 
					        No disk image uploaded for now.
 | 
				
			||||||
 | 
					      </Typography>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const columns: GridColDef<(typeof p.list)[number]>[] = [
 | 
				
			||||||
 | 
					    { field: "file_name", headerName: "File name", flex: 3, editable: true },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      field: "format",
 | 
				
			||||||
 | 
					      headerName: "Format",
 | 
				
			||||||
 | 
					      flex: 1,
 | 
				
			||||||
 | 
					      renderCell(params) {
 | 
				
			||||||
 | 
					        let content = params.row.format;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (params.row.format === "Raw") {
 | 
				
			||||||
 | 
					          content += params.row.is_sparse ? " (Sparse)" : " (Fixed)";
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return content;
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      field: "file_size",
 | 
				
			||||||
 | 
					      headerName: "File size",
 | 
				
			||||||
 | 
					      flex: 1,
 | 
				
			||||||
 | 
					      renderCell(params) {
 | 
				
			||||||
 | 
					        let res = filesize(params.row.file_size);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (params.row.format === "QCow2") {
 | 
				
			||||||
 | 
					          res += ` (${filesize(params.row.virtual_size!)})`;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return res;
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      field: "created",
 | 
				
			||||||
 | 
					      headerName: "Created",
 | 
				
			||||||
 | 
					      flex: 1,
 | 
				
			||||||
 | 
					      renderCell(params) {
 | 
				
			||||||
 | 
					        return <DateWidget time={params.row.created} />;
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      field: "actions",
 | 
				
			||||||
 | 
					      type: "actions",
 | 
				
			||||||
 | 
					      headerName: "",
 | 
				
			||||||
 | 
					      width: 55,
 | 
				
			||||||
 | 
					      cellClassName: "actions",
 | 
				
			||||||
 | 
					      editable: false,
 | 
				
			||||||
 | 
					      getActions: (params) => {
 | 
				
			||||||
 | 
					        return [
 | 
				
			||||||
 | 
					          <DiskImageActionMenu
 | 
				
			||||||
 | 
					            key="menu"
 | 
				
			||||||
 | 
					            diskImage={params.row}
 | 
				
			||||||
 | 
					            onDownload={downloadDiskImage}
 | 
				
			||||||
 | 
					            onConvert={convertDiskImage}
 | 
				
			||||||
 | 
					            onDelete={deleteDiskImage}
 | 
				
			||||||
 | 
					          />,
 | 
				
			||||||
 | 
					        ];
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <>
 | 
				
			||||||
 | 
					      {/* Download notification */}
 | 
				
			||||||
 | 
					      {dlProgress !== undefined && (
 | 
				
			||||||
 | 
					        <Alert severity="info">
 | 
				
			||||||
 | 
					          <div
 | 
				
			||||||
 | 
					            style={{
 | 
				
			||||||
 | 
					              display: "flex",
 | 
				
			||||||
 | 
					              flexDirection: "row",
 | 
				
			||||||
 | 
					              alignItems: "center",
 | 
				
			||||||
 | 
					              overflow: "hidden",
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <Typography variant="body1">
 | 
				
			||||||
 | 
					              Downloading... {dlProgress}%
 | 
				
			||||||
 | 
					            </Typography>
 | 
				
			||||||
 | 
					            <CircularProgress
 | 
				
			||||||
 | 
					              variant="determinate"
 | 
				
			||||||
 | 
					              size={"1.5rem"}
 | 
				
			||||||
 | 
					              style={{ marginLeft: "10px" }}
 | 
				
			||||||
 | 
					              value={dlProgress}
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </Alert>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Disk image conversion dialog */}
 | 
				
			||||||
 | 
					      {currConversion && (
 | 
				
			||||||
 | 
					        <ConvertDiskImageDialog
 | 
				
			||||||
 | 
					          image={currConversion}
 | 
				
			||||||
 | 
					          onCancel={() => {
 | 
				
			||||||
 | 
					            setCurrConversion(undefined);
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					          onFinished={() => {
 | 
				
			||||||
 | 
					            setCurrConversion(undefined);
 | 
				
			||||||
 | 
					            p.onReload();
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* The table itself */}
 | 
				
			||||||
 | 
					      <DataGrid<DiskImage>
 | 
				
			||||||
 | 
					        getRowId={(c) => c.file_name}
 | 
				
			||||||
 | 
					        rows={p.list}
 | 
				
			||||||
 | 
					        columns={columns}
 | 
				
			||||||
 | 
					        processRowUpdate={async (n, o) => {
 | 
				
			||||||
 | 
					          try {
 | 
				
			||||||
 | 
					            await DiskImageApi.Rename(o, n.file_name);
 | 
				
			||||||
 | 
					            return n;
 | 
				
			||||||
 | 
					          } catch (e) {
 | 
				
			||||||
 | 
					            console.error("Failed to rename disk image!", e);
 | 
				
			||||||
 | 
					            alert(`Failed to rename disk image! ${e}`);
 | 
				
			||||||
 | 
					            throw e;
 | 
				
			||||||
 | 
					          } finally {
 | 
				
			||||||
 | 
					            p.onReload();
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function DiskImageActionMenu(p: {
 | 
				
			||||||
 | 
					  diskImage: DiskImage;
 | 
				
			||||||
 | 
					  onDownload: (d: DiskImage) => void;
 | 
				
			||||||
 | 
					  onConvert: (d: DiskImage) => void;
 | 
				
			||||||
 | 
					  onDelete: (d: DiskImage) => void;
 | 
				
			||||||
 | 
					}): React.ReactElement {
 | 
				
			||||||
 | 
					  const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
 | 
				
			||||||
 | 
					  const open = Boolean(anchorEl);
 | 
				
			||||||
 | 
					  const handleClick = (event: React.MouseEvent<HTMLElement>) => {
 | 
				
			||||||
 | 
					    setAnchorEl(event.currentTarget);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  const handleClose = () => {
 | 
				
			||||||
 | 
					    setAnchorEl(null);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <>
 | 
				
			||||||
 | 
					      <IconButton
 | 
				
			||||||
 | 
					        aria-label="Actions"
 | 
				
			||||||
 | 
					        aria-haspopup="true"
 | 
				
			||||||
 | 
					        onClick={handleClick}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <MoreVertIcon />
 | 
				
			||||||
 | 
					      </IconButton>
 | 
				
			||||||
 | 
					      <Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
 | 
				
			||||||
 | 
					        {/* Download disk image */}
 | 
				
			||||||
 | 
					        <MenuItem
 | 
				
			||||||
 | 
					          onClick={() => {
 | 
				
			||||||
 | 
					            handleClose();
 | 
				
			||||||
 | 
					            p.onDownload(p.diskImage);
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <ListItemIcon>
 | 
				
			||||||
 | 
					            <DownloadIcon />
 | 
				
			||||||
 | 
					          </ListItemIcon>
 | 
				
			||||||
 | 
					          <ListItemText secondary={"Download disk image"}>
 | 
				
			||||||
 | 
					            Download
 | 
				
			||||||
 | 
					          </ListItemText>
 | 
				
			||||||
 | 
					        </MenuItem>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {/* Convert disk image */}
 | 
				
			||||||
 | 
					        <MenuItem
 | 
				
			||||||
 | 
					          onClick={() => {
 | 
				
			||||||
 | 
					            handleClose();
 | 
				
			||||||
 | 
					            p.onConvert(p.diskImage);
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <ListItemIcon>
 | 
				
			||||||
 | 
					            <LoopIcon />
 | 
				
			||||||
 | 
					          </ListItemIcon>
 | 
				
			||||||
 | 
					          <ListItemText secondary={"Convert disk image"}>Convert</ListItemText>
 | 
				
			||||||
 | 
					        </MenuItem>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {/* Delete disk image */}
 | 
				
			||||||
 | 
					        <MenuItem
 | 
				
			||||||
 | 
					          onClick={() => {
 | 
				
			||||||
 | 
					            handleClose();
 | 
				
			||||||
 | 
					            p.onDelete(p.diskImage);
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <ListItemIcon>
 | 
				
			||||||
 | 
					            <DeleteIcon color="error" />
 | 
				
			||||||
 | 
					          </ListItemIcon>
 | 
				
			||||||
 | 
					          <ListItemText secondary={"Delete disk image"}>Delete</ListItemText>
 | 
				
			||||||
 | 
					        </MenuItem>
 | 
				
			||||||
 | 
					      </Menu>
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,4 +1,7 @@
 | 
				
			|||||||
import DeleteIcon from "@mui/icons-material/Delete";
 | 
					import DeleteIcon from "@mui/icons-material/Delete";
 | 
				
			||||||
 | 
					import DownloadIcon from "@mui/icons-material/Download";
 | 
				
			||||||
 | 
					import MenuBookIcon from "@mui/icons-material/MenuBook";
 | 
				
			||||||
 | 
					import RefreshIcon from "@mui/icons-material/Refresh";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Alert,
 | 
					  Alert,
 | 
				
			||||||
  Button,
 | 
					  Button,
 | 
				
			||||||
@@ -9,24 +12,25 @@ import {
 | 
				
			|||||||
  Tooltip,
 | 
					  Tooltip,
 | 
				
			||||||
  Typography,
 | 
					  Typography,
 | 
				
			||||||
} from "@mui/material";
 | 
					} from "@mui/material";
 | 
				
			||||||
import DownloadIcon from "@mui/icons-material/Download";
 | 
					 | 
				
			||||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
 | 
					import { DataGrid, GridColDef } from "@mui/x-data-grid";
 | 
				
			||||||
import { filesize } from "filesize";
 | 
					import { filesize } from "filesize";
 | 
				
			||||||
import { MuiFileInput } from "mui-file-input";
 | 
					 | 
				
			||||||
import React from "react";
 | 
					import React from "react";
 | 
				
			||||||
import { IsoFile, IsoFilesApi } from "../api/IsoFilesApi";
 | 
					import { IsoFile, IsoFilesApi } from "../api/IsoFilesApi";
 | 
				
			||||||
import { ServerApi } from "../api/ServerApi";
 | 
					import { ServerApi } from "../api/ServerApi";
 | 
				
			||||||
 | 
					import { IsoCatalogDialog } from "../dialogs/IsoCatalogDialog";
 | 
				
			||||||
import { useAlert } from "../hooks/providers/AlertDialogProvider";
 | 
					import { useAlert } from "../hooks/providers/AlertDialogProvider";
 | 
				
			||||||
 | 
					import { useConfirm } from "../hooks/providers/ConfirmDialogProvider";
 | 
				
			||||||
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
 | 
					import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
 | 
				
			||||||
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
 | 
					import { useSnackbar } from "../hooks/providers/SnackbarProvider";
 | 
				
			||||||
 | 
					import { downloadBlob } from "../utils/FilesUtils";
 | 
				
			||||||
import { AsyncWidget } from "../widgets/AsyncWidget";
 | 
					import { AsyncWidget } from "../widgets/AsyncWidget";
 | 
				
			||||||
 | 
					import { FileInput } from "../widgets/forms/FileInput";
 | 
				
			||||||
import { VirtWebPaper } from "../widgets/VirtWebPaper";
 | 
					import { VirtWebPaper } from "../widgets/VirtWebPaper";
 | 
				
			||||||
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
 | 
					import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
 | 
				
			||||||
import { useConfirm } from "../hooks/providers/ConfirmDialogProvider";
 | 
					 | 
				
			||||||
import { downloadBlob } from "../utils/FilesUtils";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function IsoFilesRoute(): React.ReactElement {
 | 
					export function IsoFilesRoute(): React.ReactElement {
 | 
				
			||||||
  const [list, setList] = React.useState<IsoFile[] | undefined>();
 | 
					  const [list, setList] = React.useState<IsoFile[] | undefined>();
 | 
				
			||||||
 | 
					  const [isoCatalog, setIsoCatalog] = React.useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const loadKey = React.useRef(1);
 | 
					  const loadKey = React.useRef(1);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -40,19 +44,41 @@ export function IsoFilesRoute(): React.ReactElement {
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
 | 
					    <>
 | 
				
			||||||
      <AsyncWidget
 | 
					      <AsyncWidget
 | 
				
			||||||
        loadKey={loadKey.current}
 | 
					        loadKey={loadKey.current}
 | 
				
			||||||
        errMsg="Failed to load ISO files list!"
 | 
					        errMsg="Failed to load ISO files list!"
 | 
				
			||||||
        load={load}
 | 
					        load={load}
 | 
				
			||||||
        ready={list !== undefined}
 | 
					        ready={list !== undefined}
 | 
				
			||||||
        build={() => (
 | 
					        build={() => (
 | 
				
			||||||
        <VirtWebRouteContainer label="ISO files management">
 | 
					          <VirtWebRouteContainer
 | 
				
			||||||
 | 
					            label="ISO files management"
 | 
				
			||||||
 | 
					            actions={
 | 
				
			||||||
 | 
					              <span>
 | 
				
			||||||
 | 
					                <Tooltip title="Open the ISO catalog">
 | 
				
			||||||
 | 
					                  <IconButton onClick={() => { setIsoCatalog(true); }}>
 | 
				
			||||||
 | 
					                    <MenuBookIcon />
 | 
				
			||||||
 | 
					                  </IconButton>
 | 
				
			||||||
 | 
					                </Tooltip>
 | 
				
			||||||
 | 
					                <Tooltip title="Refresh ISO list">
 | 
				
			||||||
 | 
					                  <IconButton onClick={reload}>
 | 
				
			||||||
 | 
					                    <RefreshIcon />
 | 
				
			||||||
 | 
					                  </IconButton>
 | 
				
			||||||
 | 
					                </Tooltip>
 | 
				
			||||||
 | 
					              </span>
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
            <UploadIsoFileCard onFileUploaded={reload} />
 | 
					            <UploadIsoFileCard onFileUploaded={reload} />
 | 
				
			||||||
            <UploadIsoFileFromUrlCard onFileUploaded={reload} />
 | 
					            <UploadIsoFileFromUrlCard onFileUploaded={reload} />
 | 
				
			||||||
            <IsoFilesList list={list!} onReload={reload} />
 | 
					            <IsoFilesList list={list!} onReload={reload} />
 | 
				
			||||||
          </VirtWebRouteContainer>
 | 
					          </VirtWebRouteContainer>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
 | 
					      <IsoCatalogDialog
 | 
				
			||||||
 | 
					        open={isoCatalog}
 | 
				
			||||||
 | 
					        onClose={() => { setIsoCatalog(false); }}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -104,7 +130,7 @@ function UploadIsoFileCard(p: {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  if (uploadProgress !== null) {
 | 
					  if (uploadProgress !== null) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <VirtWebPaper label="File upload">
 | 
					      <VirtWebPaper label="File upload" noHorizontalMargin>
 | 
				
			||||||
        <Typography variant="body1">
 | 
					        <Typography variant="body1">
 | 
				
			||||||
          Upload in progress ({Math.floor(uploadProgress * 100)}%)...
 | 
					          Upload in progress ({Math.floor(uploadProgress * 100)}%)...
 | 
				
			||||||
        </Typography>
 | 
					        </Typography>
 | 
				
			||||||
@@ -114,9 +140,9 @@ function UploadIsoFileCard(p: {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <VirtWebPaper label="File upload">
 | 
					    <VirtWebPaper label="File upload" noHorizontalMargin>
 | 
				
			||||||
      <div style={{ display: "flex", alignItems: "center" }}>
 | 
					      <div style={{ display: "flex", alignItems: "center" }}>
 | 
				
			||||||
        <MuiFileInput
 | 
					        <FileInput
 | 
				
			||||||
          value={value}
 | 
					          value={value}
 | 
				
			||||||
          onChange={handleChange}
 | 
					          onChange={handleChange}
 | 
				
			||||||
          style={{ flex: 1 }}
 | 
					          style={{ flex: 1 }}
 | 
				
			||||||
@@ -162,7 +188,7 @@ function UploadIsoFileFromUrlCard(p: {
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <VirtWebPaper label="File upload from URL">
 | 
					    <VirtWebPaper label="File upload from URL" noHorizontalMargin>
 | 
				
			||||||
      <div style={{ display: "flex", alignItems: "center" }}>
 | 
					      <div style={{ display: "flex", alignItems: "center" }}>
 | 
				
			||||||
        <TextField
 | 
					        <TextField
 | 
				
			||||||
          label="URL"
 | 
					          label="URL"
 | 
				
			||||||
@@ -279,7 +305,6 @@ function IsoFilesList(p: {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <>
 | 
					    <>
 | 
				
			||||||
      <VirtWebPaper label="Files list">
 | 
					 | 
				
			||||||
      {/* Download notification */}
 | 
					      {/* Download notification */}
 | 
				
			||||||
      {dlProgress !== undefined && (
 | 
					      {dlProgress !== undefined && (
 | 
				
			||||||
        <Alert severity="info">
 | 
					        <Alert severity="info">
 | 
				
			||||||
@@ -303,14 +328,8 @@ function IsoFilesList(p: {
 | 
				
			|||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </Alert>
 | 
					        </Alert>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
					      {/* ISO files list table */}
 | 
				
			||||||
        {/* Files list table */}
 | 
					      <DataGrid getRowId={(c) => c.filename} rows={p.list} columns={columns} />
 | 
				
			||||||
        <DataGrid
 | 
					 | 
				
			||||||
          getRowId={(c) => c.filename}
 | 
					 | 
				
			||||||
          rows={p.list}
 | 
					 | 
				
			||||||
          columns={columns}
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      </VirtWebPaper>
 | 
					 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,7 +3,15 @@ import { RouterLink } from "../widgets/RouterLink";
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export function NotFoundRoute(): React.ReactElement {
 | 
					export function NotFoundRoute(): React.ReactElement {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div style={{ textAlign: "center" }}>
 | 
					    <div
 | 
				
			||||||
 | 
					      style={{
 | 
				
			||||||
 | 
					        textAlign: "center",
 | 
				
			||||||
 | 
					        flex: 1,
 | 
				
			||||||
 | 
					        justifyContent: "center",
 | 
				
			||||||
 | 
					        display: "flex",
 | 
				
			||||||
 | 
					        flexDirection: "column",
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      <h1>404 Not found</h1>
 | 
					      <h1>404 Not found</h1>
 | 
				
			||||||
      <p>The page you requested was not found!</p>
 | 
					      <p>The page you requested was not found!</p>
 | 
				
			||||||
      <RouterLink to="/">
 | 
					      <RouterLink to="/">
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,18 +9,21 @@ import {
 | 
				
			|||||||
import Icon from "@mdi/react";
 | 
					import Icon from "@mdi/react";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Box,
 | 
					  Box,
 | 
				
			||||||
 | 
					  IconButton,
 | 
				
			||||||
  LinearProgress,
 | 
					  LinearProgress,
 | 
				
			||||||
  Table,
 | 
					  Table,
 | 
				
			||||||
  TableBody,
 | 
					  TableBody,
 | 
				
			||||||
  TableCell,
 | 
					  TableCell,
 | 
				
			||||||
  TableHead,
 | 
					  TableHead,
 | 
				
			||||||
  TableRow,
 | 
					  TableRow,
 | 
				
			||||||
 | 
					  Tooltip,
 | 
				
			||||||
  Typography,
 | 
					  Typography,
 | 
				
			||||||
} from "@mui/material";
 | 
					} from "@mui/material";
 | 
				
			||||||
import Grid from "@mui/material/Grid";
 | 
					import Grid from "@mui/material/Grid";
 | 
				
			||||||
import { PieChart } from "@mui/x-charts";
 | 
					import { PieChart } from "@mui/x-charts";
 | 
				
			||||||
import { filesize } from "filesize";
 | 
					import { filesize } from "filesize";
 | 
				
			||||||
import humanizeDuration from "humanize-duration";
 | 
					import humanizeDuration from "humanize-duration";
 | 
				
			||||||
 | 
					import IosShareIcon from "@mui/icons-material/IosShare";
 | 
				
			||||||
import React from "react";
 | 
					import React from "react";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  DiskInfo,
 | 
					  DiskInfo,
 | 
				
			||||||
@@ -31,6 +34,8 @@ import {
 | 
				
			|||||||
import { AsyncWidget } from "../widgets/AsyncWidget";
 | 
					import { AsyncWidget } from "../widgets/AsyncWidget";
 | 
				
			||||||
import { VirtWebPaper } from "../widgets/VirtWebPaper";
 | 
					import { VirtWebPaper } from "../widgets/VirtWebPaper";
 | 
				
			||||||
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
 | 
					import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
 | 
				
			||||||
 | 
					import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
 | 
				
			||||||
 | 
					import { useAlert } from "../hooks/providers/AlertDialogProvider";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function SysInfoRoute(): React.ReactElement {
 | 
					export function SysInfoRoute(): React.ReactElement {
 | 
				
			||||||
  const [info, setInfo] = React.useState<ServerSystemInfo>();
 | 
					  const [info, setInfo] = React.useState<ServerSystemInfo>();
 | 
				
			||||||
@@ -52,6 +57,23 @@ export function SysInfoRoute(): React.ReactElement {
 | 
				
			|||||||
export function SysInfoRouteInner(p: {
 | 
					export function SysInfoRouteInner(p: {
 | 
				
			||||||
  info: ServerSystemInfo;
 | 
					  info: ServerSystemInfo;
 | 
				
			||||||
}): React.ReactElement {
 | 
					}): React.ReactElement {
 | 
				
			||||||
 | 
					  const alert = useAlert();
 | 
				
			||||||
 | 
					  const loadingMessage = useLoadingMessage();
 | 
				
			||||||
 | 
					  const downloadAllConfig = async () => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      loadingMessage.show("Downloading server config...");
 | 
				
			||||||
 | 
					      const res = await ServerApi.ExportServerConfigs();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const url = URL.createObjectURL(res);
 | 
				
			||||||
 | 
					      window.location.href = url;
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      console.error("Failed to download server config!", e);
 | 
				
			||||||
 | 
					      alert(`Failed to download server config! ${e}`);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      loadingMessage.hide();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const sumDiskUsage = p.info.disks.reduce(
 | 
					  const sumDiskUsage = p.info.disks.reduce(
 | 
				
			||||||
    (prev, disk) => {
 | 
					    (prev, disk) => {
 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
@@ -63,7 +85,16 @@ export function SysInfoRouteInner(p: {
 | 
				
			|||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <VirtWebRouteContainer label="Sysinfo">
 | 
					    <VirtWebRouteContainer
 | 
				
			||||||
 | 
					      label="Sysinfo"
 | 
				
			||||||
 | 
					      actions={
 | 
				
			||||||
 | 
					        <Tooltip title="Export all server configs">
 | 
				
			||||||
 | 
					          <IconButton onClick={downloadAllConfig}>
 | 
				
			||||||
 | 
					            <IosShareIcon />
 | 
				
			||||||
 | 
					          </IconButton>
 | 
				
			||||||
 | 
					        </Tooltip>
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      <Grid container spacing={2}>
 | 
					      <Grid container spacing={2}>
 | 
				
			||||||
        {/* Memory */}
 | 
					        {/* Memory */}
 | 
				
			||||||
        <Grid size={{ xs: 4 }}>
 | 
					        <Grid size={{ xs: 4 }}>
 | 
				
			||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,6 +10,7 @@ import {
 | 
				
			|||||||
  TableContainer,
 | 
					  TableContainer,
 | 
				
			||||||
  TableHead,
 | 
					  TableHead,
 | 
				
			||||||
  TableRow,
 | 
					  TableRow,
 | 
				
			||||||
 | 
					  Typography,
 | 
				
			||||||
} from "@mui/material";
 | 
					} from "@mui/material";
 | 
				
			||||||
import React from "react";
 | 
					import React from "react";
 | 
				
			||||||
import { useNavigate } from "react-router-dom";
 | 
					import { useNavigate } from "react-router-dom";
 | 
				
			||||||
@@ -58,6 +59,7 @@ export function TokensListRouteInner(p: {
 | 
				
			|||||||
        </RouterLink>
 | 
					        </RouterLink>
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
 | 
					      {p.list.length > 0 && (
 | 
				
			||||||
        <TableContainer component={Paper}>
 | 
					        <TableContainer component={Paper}>
 | 
				
			||||||
          <Table>
 | 
					          <Table>
 | 
				
			||||||
            <TableHead>
 | 
					            <TableHead>
 | 
				
			||||||
@@ -122,6 +124,13 @@ export function TokensListRouteInner(p: {
 | 
				
			|||||||
            </TableBody>
 | 
					            </TableBody>
 | 
				
			||||||
          </Table>
 | 
					          </Table>
 | 
				
			||||||
        </TableContainer>
 | 
					        </TableContainer>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {p.list.length === 0 && (
 | 
				
			||||||
 | 
					        <Typography style={{ textAlign: "center" }}>
 | 
				
			||||||
 | 
					          No API token created yet.
 | 
				
			||||||
 | 
					        </Typography>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
    </VirtWebRouteContainer>
 | 
					    </VirtWebRouteContainer>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -154,7 +154,7 @@ function VMListWidget(p: {
 | 
				
			|||||||
                        {row.name}
 | 
					                        {row.name}
 | 
				
			||||||
                      </TableCell>
 | 
					                      </TableCell>
 | 
				
			||||||
                      <TableCell>{row.description ?? ""}</TableCell>
 | 
					                      <TableCell>{row.description ?? ""}</TableCell>
 | 
				
			||||||
                      <TableCell>{vmMemoryToHuman(row.memory)}</TableCell>
 | 
					                      <TableCell>{filesize(row.memory)}</TableCell>
 | 
				
			||||||
                      <TableCell>{row.number_vcpu}</TableCell>
 | 
					                      <TableCell>{row.number_vcpu}</TableCell>
 | 
				
			||||||
                      <TableCell>
 | 
					                      <TableCell>
 | 
				
			||||||
                        <VMStatusWidget
 | 
					                        <VMStatusWidget
 | 
				
			||||||
@@ -183,13 +183,13 @@ function VMListWidget(p: {
 | 
				
			|||||||
            <TableCell></TableCell>
 | 
					            <TableCell></TableCell>
 | 
				
			||||||
            <TableCell></TableCell>
 | 
					            <TableCell></TableCell>
 | 
				
			||||||
            <TableCell>
 | 
					            <TableCell>
 | 
				
			||||||
              {vmMemoryToHuman(
 | 
					              {filesize(
 | 
				
			||||||
                p.list
 | 
					                p.list
 | 
				
			||||||
                  .filter((v) => runningVMs.has(v.name))
 | 
					                  .filter((v) => runningVMs.has(v.name))
 | 
				
			||||||
                  .reduce((s, v) => s + v.memory, 0)
 | 
					                  .reduce((s, v) => s + v.memory, 0)
 | 
				
			||||||
              )}
 | 
					              )}
 | 
				
			||||||
              {" / "}
 | 
					              {" / "}
 | 
				
			||||||
              {vmMemoryToHuman(p.list.reduce((s, v) => s + v.memory, 0))}
 | 
					              {filesize(p.list.reduce((s, v) => s + v.memory, 0))}
 | 
				
			||||||
            </TableCell>
 | 
					            </TableCell>
 | 
				
			||||||
            <TableCell>
 | 
					            <TableCell>
 | 
				
			||||||
              {p.list
 | 
					              {p.list
 | 
				
			||||||
@@ -206,7 +206,3 @@ function VMListWidget(p: {
 | 
				
			|||||||
    </TableContainer>
 | 
					    </TableContainer>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
function vmMemoryToHuman(size: number): string {
 | 
					 | 
				
			||||||
  return filesize(size * 1000 * 1000);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -59,6 +59,7 @@ function VMRouteBody(p: { vm: VMInfo }): React.ReactElement {
 | 
				
			|||||||
      <VMDetails
 | 
					      <VMDetails
 | 
				
			||||||
        vm={p.vm}
 | 
					        vm={p.vm}
 | 
				
			||||||
        editable={false}
 | 
					        editable={false}
 | 
				
			||||||
 | 
					        state={state}
 | 
				
			||||||
        screenshot={p.vm.vnc_access && state === "Running"}
 | 
					        screenshot={p.vm.vnc_access && state === "Running"}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    </VirtWebRouteContainer>
 | 
					    </VirtWebRouteContainer>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
 | 
					import VisibilityIcon from "@mui/icons-material/Visibility";
 | 
				
			||||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
 | 
					import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Alert,
 | 
					  Alert,
 | 
				
			||||||
  CircularProgress,
 | 
					  CircularProgress,
 | 
				
			||||||
@@ -36,7 +36,9 @@ export function LoginRoute(): React.ReactElement {
 | 
				
			|||||||
  const canSubmit = username.length > 0 && password.length > 0;
 | 
					  const canSubmit = username.length > 0 && password.length > 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [showPassword, setShowPassword] = React.useState(false);
 | 
					  const [showPassword, setShowPassword] = React.useState(false);
 | 
				
			||||||
  const handleClickShowPassword = () => { setShowPassword((show) => !show); };
 | 
					  const handleClickShowPassword = () => {
 | 
				
			||||||
 | 
					    setShowPassword((show) => !show);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleMouseDownPassword = (
 | 
					  const handleMouseDownPassword = (
 | 
				
			||||||
    event: React.MouseEvent<HTMLButtonElement>
 | 
					    event: React.MouseEvent<HTMLButtonElement>
 | 
				
			||||||
@@ -105,12 +107,14 @@ export function LoginRoute(): React.ReactElement {
 | 
				
			|||||||
              label="Username"
 | 
					              label="Username"
 | 
				
			||||||
              name="username"
 | 
					              name="username"
 | 
				
			||||||
              value={username}
 | 
					              value={username}
 | 
				
			||||||
              onChange={(e) => { setUsername(e.target.value); }}
 | 
					              onChange={(e) => {
 | 
				
			||||||
 | 
					                setUsername(e.target.value);
 | 
				
			||||||
 | 
					              }}
 | 
				
			||||||
              autoComplete="username"
 | 
					              autoComplete="username"
 | 
				
			||||||
              autoFocus
 | 
					              autoFocus
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <FormControl fullWidth variant="outlined">
 | 
					            <FormControl required fullWidth variant="outlined">
 | 
				
			||||||
              <InputLabel htmlFor="password">Password</InputLabel>
 | 
					              <InputLabel htmlFor="password">Password</InputLabel>
 | 
				
			||||||
              <OutlinedInput
 | 
					              <OutlinedInput
 | 
				
			||||||
                required
 | 
					                required
 | 
				
			||||||
@@ -120,7 +124,9 @@ export function LoginRoute(): React.ReactElement {
 | 
				
			|||||||
                type={showPassword ? "text" : "password"}
 | 
					                type={showPassword ? "text" : "password"}
 | 
				
			||||||
                id="password"
 | 
					                id="password"
 | 
				
			||||||
                value={password}
 | 
					                value={password}
 | 
				
			||||||
                onChange={(e) => { setPassword(e.target.value); }}
 | 
					                onChange={(e) => {
 | 
				
			||||||
 | 
					                  setPassword(e.target.value);
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
                autoComplete="current-password"
 | 
					                autoComplete="current-password"
 | 
				
			||||||
                endAdornment={
 | 
					                endAdornment={
 | 
				
			||||||
                  <InputAdornment position="end">
 | 
					                  <InputAdornment position="end">
 | 
				
			||||||
@@ -131,7 +137,11 @@ export function LoginRoute(): React.ReactElement {
 | 
				
			|||||||
                        onMouseDown={handleMouseDownPassword}
 | 
					                        onMouseDown={handleMouseDownPassword}
 | 
				
			||||||
                        edge="end"
 | 
					                        edge="end"
 | 
				
			||||||
                      >
 | 
					                      >
 | 
				
			||||||
                        {showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
 | 
					                        {showPassword ? (
 | 
				
			||||||
 | 
					                          <VisibilityOffIcon />
 | 
				
			||||||
 | 
					                        ) : (
 | 
				
			||||||
 | 
					                          <VisibilityIcon />
 | 
				
			||||||
 | 
					                        )}
 | 
				
			||||||
                      </IconButton>
 | 
					                      </IconButton>
 | 
				
			||||||
                    </Tooltip>
 | 
					                    </Tooltip>
 | 
				
			||||||
                  </InputAdornment>
 | 
					                  </InputAdornment>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,6 +2,7 @@ import {
 | 
				
			|||||||
  mdiApi,
 | 
					  mdiApi,
 | 
				
			||||||
  mdiBoxShadow,
 | 
					  mdiBoxShadow,
 | 
				
			||||||
  mdiDisc,
 | 
					  mdiDisc,
 | 
				
			||||||
 | 
					  mdiHarddisk,
 | 
				
			||||||
  mdiHome,
 | 
					  mdiHome,
 | 
				
			||||||
  mdiInformation,
 | 
					  mdiInformation,
 | 
				
			||||||
  mdiLan,
 | 
					  mdiLan,
 | 
				
			||||||
@@ -13,7 +14,7 @@ import {
 | 
				
			|||||||
  List,
 | 
					  List,
 | 
				
			||||||
  ListItemButton,
 | 
					  ListItemButton,
 | 
				
			||||||
  ListItemIcon,
 | 
					  ListItemIcon,
 | 
				
			||||||
  ListItemText
 | 
					  ListItemText,
 | 
				
			||||||
} from "@mui/material";
 | 
					} from "@mui/material";
 | 
				
			||||||
import { Outlet, useLocation } from "react-router-dom";
 | 
					import { Outlet, useLocation } from "react-router-dom";
 | 
				
			||||||
import { RouterLink } from "./RouterLink";
 | 
					import { RouterLink } from "./RouterLink";
 | 
				
			||||||
@@ -66,6 +67,11 @@ export function BaseAuthenticatedPage(): React.ReactElement {
 | 
				
			|||||||
            uri="/nwfilter"
 | 
					            uri="/nwfilter"
 | 
				
			||||||
            icon={<Icon path={mdiSecurityNetwork} size={1} />}
 | 
					            icon={<Icon path={mdiSecurityNetwork} size={1} />}
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
 | 
					          <NavLink
 | 
				
			||||||
 | 
					            label="Disk images"
 | 
				
			||||||
 | 
					            uri="/disk_images"
 | 
				
			||||||
 | 
					            icon={<Icon path={mdiHarddisk} size={1} />}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
          <NavLink
 | 
					          <NavLink
 | 
				
			||||||
            label="ISO files"
 | 
					            label="ISO files"
 | 
				
			||||||
            uri="/iso"
 | 
					            uri="/iso"
 | 
				
			||||||
@@ -82,7 +88,15 @@ export function BaseAuthenticatedPage(): React.ReactElement {
 | 
				
			|||||||
            icon={<Icon path={mdiInformation} size={1} />}
 | 
					            icon={<Icon path={mdiInformation} size={1} />}
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
        </List>
 | 
					        </List>
 | 
				
			||||||
        <div style={{ flex: 1 }}>
 | 
					        <div
 | 
				
			||||||
 | 
					          style={{
 | 
				
			||||||
 | 
					            flexGrow: 1,
 | 
				
			||||||
 | 
					            flexShrink: 0,
 | 
				
			||||||
 | 
					            flexBasis: 0,
 | 
				
			||||||
 | 
					            minWidth: 0,
 | 
				
			||||||
 | 
					            display: "flex",
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
          <Outlet />
 | 
					          <Outlet />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </Box>
 | 
					      </Box>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										13
									
								
								virtweb_frontend/src/widgets/DateWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								virtweb_frontend/src/widgets/DateWidget.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
				
			|||||||
 | 
					export function DateWidget(p: { time: number }): React.ReactElement {
 | 
				
			||||||
 | 
					  const date = new Date(p.time * 1000);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <>
 | 
				
			||||||
 | 
					      {pad(date.getDate())}/{pad(date.getMonth() + 1)}/{date.getFullYear()}
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function pad(num: number): string {
 | 
				
			||||||
 | 
					  return num.toString().padStart(2, "0");
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										23
									
								
								virtweb_frontend/src/widgets/FileDiskImageWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								virtweb_frontend/src/widgets/FileDiskImageWidget.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
				
			|||||||
 | 
					import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material";
 | 
				
			||||||
 | 
					import { DiskImage } from "../api/DiskImageApi";
 | 
				
			||||||
 | 
					import { mdiHarddisk } from "@mdi/js";
 | 
				
			||||||
 | 
					import { filesize } from "filesize";
 | 
				
			||||||
 | 
					import Icon from "@mdi/react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function FileDiskImageWidget(p: {
 | 
				
			||||||
 | 
					  image: DiskImage;
 | 
				
			||||||
 | 
					}): React.ReactElement {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <ListItem>
 | 
				
			||||||
 | 
					      <ListItemAvatar>
 | 
				
			||||||
 | 
					        <Avatar>
 | 
				
			||||||
 | 
					          <Icon path={mdiHarddisk} />
 | 
				
			||||||
 | 
					        </Avatar>
 | 
				
			||||||
 | 
					      </ListItemAvatar>
 | 
				
			||||||
 | 
					      <ListItemText
 | 
				
			||||||
 | 
					        primary={p.image.file_name}
 | 
				
			||||||
 | 
					        secondary={`${p.image.format} - ${filesize(p.image.file_size)}`}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </ListItem>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -2,10 +2,19 @@ import { Paper, Typography } from "@mui/material";
 | 
				
			|||||||
import React, { PropsWithChildren } from "react";
 | 
					import React, { PropsWithChildren } from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function VirtWebPaper(
 | 
					export function VirtWebPaper(
 | 
				
			||||||
  p: { label: string | React.ReactElement } & PropsWithChildren
 | 
					  p: {
 | 
				
			||||||
 | 
					    label: string | React.ReactElement;
 | 
				
			||||||
 | 
					    noHorizontalMargin?: boolean;
 | 
				
			||||||
 | 
					  } & PropsWithChildren
 | 
				
			||||||
): React.ReactElement {
 | 
					): React.ReactElement {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Paper elevation={2} style={{ padding: "10px", margin: "20px" }}>
 | 
					    <Paper
 | 
				
			||||||
 | 
					      elevation={2}
 | 
				
			||||||
 | 
					      style={{
 | 
				
			||||||
 | 
					        padding: "10px",
 | 
				
			||||||
 | 
					        margin: p.noHorizontalMargin ? "20px 0px" : "20px",
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      <Typography
 | 
					      <Typography
 | 
				
			||||||
        variant="subtitle1"
 | 
					        variant="subtitle1"
 | 
				
			||||||
        style={{ marginBottom: "10px", fontWeight: "bold" }}
 | 
					        style={{ marginBottom: "10px", fontWeight: "bold" }}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,7 +8,18 @@ export function VirtWebRouteContainer(
 | 
				
			|||||||
  } & PropsWithChildren
 | 
					  } & PropsWithChildren
 | 
				
			||||||
): React.ReactElement {
 | 
					): React.ReactElement {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div style={{ margin: "50px" }}>
 | 
					    <div
 | 
				
			||||||
 | 
					      style={{
 | 
				
			||||||
 | 
					        margin: "50px",
 | 
				
			||||||
 | 
					        flex: "1",
 | 
				
			||||||
 | 
					        flexGrow: 1,
 | 
				
			||||||
 | 
					        flexShrink: 0,
 | 
				
			||||||
 | 
					        flexBasis: 0,
 | 
				
			||||||
 | 
					        minWidth: 0,
 | 
				
			||||||
 | 
					        display: "flex",
 | 
				
			||||||
 | 
					        flexDirection: "column",
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      <div
 | 
					      <div
 | 
				
			||||||
        style={{
 | 
					        style={{
 | 
				
			||||||
          display: "flex",
 | 
					          display: "flex",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,7 +17,9 @@ export function CheckboxInput(p: {
 | 
				
			|||||||
        <Checkbox
 | 
					        <Checkbox
 | 
				
			||||||
          disabled={!p.editable}
 | 
					          disabled={!p.editable}
 | 
				
			||||||
          checked={p.checked}
 | 
					          checked={p.checked}
 | 
				
			||||||
          onChange={(e) => { p.onValueChange(e.target.checked); }}
 | 
					          onChange={(e) => {
 | 
				
			||||||
 | 
					            p.onValueChange(e.target.checked);
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      label={p.label}
 | 
					      label={p.label}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										341
									
								
								virtweb_frontend/src/widgets/forms/CloudInitEditor.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										341
									
								
								virtweb_frontend/src/widgets/forms/CloudInitEditor.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,341 @@
 | 
				
			|||||||
 | 
					/* eslint-disable @typescript-eslint/no-base-to-string */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import Editor from "@monaco-editor/react";
 | 
				
			||||||
 | 
					import BookIcon from "@mui/icons-material/Book";
 | 
				
			||||||
 | 
					import RefreshIcon from "@mui/icons-material/Refresh";
 | 
				
			||||||
 | 
					import { Grid, IconButton, InputAdornment, Tooltip } from "@mui/material";
 | 
				
			||||||
 | 
					import React from "react";
 | 
				
			||||||
 | 
					import { v4 as uuidv4 } from "uuid";
 | 
				
			||||||
 | 
					import YAML from "yaml";
 | 
				
			||||||
 | 
					import { VMInfo } from "../../api/VMApi";
 | 
				
			||||||
 | 
					import { RouterLink } from "../RouterLink";
 | 
				
			||||||
 | 
					import { CheckboxInput } from "./CheckboxInput";
 | 
				
			||||||
 | 
					import { EditSection } from "./EditSection";
 | 
				
			||||||
 | 
					import { SelectInput } from "./SelectInput";
 | 
				
			||||||
 | 
					import { TextInput } from "./TextInput";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface CloudInitProps {
 | 
				
			||||||
 | 
					  vm: VMInfo;
 | 
				
			||||||
 | 
					  onChange?: () => void;
 | 
				
			||||||
 | 
					  editable: boolean;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function CloudInitEditor(p: CloudInitProps): React.ReactElement {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <>
 | 
				
			||||||
 | 
					      <EditSection>
 | 
				
			||||||
 | 
					        {/* Attach cloud init disk */}
 | 
				
			||||||
 | 
					        <CheckboxInput
 | 
				
			||||||
 | 
					          {...p}
 | 
				
			||||||
 | 
					          label="Attach Cloud Init disk"
 | 
				
			||||||
 | 
					          checked={p.vm.cloud_init.attach_config}
 | 
				
			||||||
 | 
					          onValueChange={(v) => {
 | 
				
			||||||
 | 
					            p.vm.cloud_init.attach_config = v;
 | 
				
			||||||
 | 
					            p.onChange?.();
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </EditSection>
 | 
				
			||||||
 | 
					      <Grid container spacing={2}>
 | 
				
			||||||
 | 
					        <CloudInitMetadata
 | 
				
			||||||
 | 
					          {...p}
 | 
				
			||||||
 | 
					          editable={p.editable && p.vm.cloud_init.attach_config}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					        <CloudInitRawUserData
 | 
				
			||||||
 | 
					          {...p}
 | 
				
			||||||
 | 
					          editable={p.editable && p.vm.cloud_init.attach_config}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					        <CloudInitNetworkConfig
 | 
				
			||||||
 | 
					          {...p}
 | 
				
			||||||
 | 
					          editable={p.editable && p.vm.cloud_init.attach_config}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					        <CloudInitUserDataAssistant
 | 
				
			||||||
 | 
					          {...p}
 | 
				
			||||||
 | 
					          editable={p.editable && p.vm.cloud_init.attach_config}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </Grid>
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function CloudInitMetadata(p: CloudInitProps): React.ReactElement {
 | 
				
			||||||
 | 
					  // Regenerate instance id
 | 
				
			||||||
 | 
					  const reGenerateInstanceId = () => {
 | 
				
			||||||
 | 
					    p.vm.cloud_init.instance_id = uuidv4();
 | 
				
			||||||
 | 
					    p.onChange?.();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <EditSection title="Metadata">
 | 
				
			||||||
 | 
					      {/* Instance ID */}
 | 
				
			||||||
 | 
					      <TextInput
 | 
				
			||||||
 | 
					        {...p}
 | 
				
			||||||
 | 
					        label="Instance ID"
 | 
				
			||||||
 | 
					        value={p.vm.cloud_init.instance_id}
 | 
				
			||||||
 | 
					        onValueChange={(v) => {
 | 
				
			||||||
 | 
					          p.vm.cloud_init.instance_id = v;
 | 
				
			||||||
 | 
					          p.onChange?.();
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					        endAdornment={
 | 
				
			||||||
 | 
					          p.editable ? (
 | 
				
			||||||
 | 
					            <InputAdornment position="end">
 | 
				
			||||||
 | 
					              <Tooltip title="Generate a new instance ID">
 | 
				
			||||||
 | 
					                <IconButton onClick={reGenerateInstanceId}>
 | 
				
			||||||
 | 
					                  <RefreshIcon />
 | 
				
			||||||
 | 
					                </IconButton>
 | 
				
			||||||
 | 
					              </Tooltip>
 | 
				
			||||||
 | 
					            </InputAdornment>
 | 
				
			||||||
 | 
					          ) : (
 | 
				
			||||||
 | 
					            <></>
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Instance hostname */}
 | 
				
			||||||
 | 
					      <TextInput
 | 
				
			||||||
 | 
					        {...p}
 | 
				
			||||||
 | 
					        label="Local hostname"
 | 
				
			||||||
 | 
					        value={p.vm.cloud_init.local_hostname}
 | 
				
			||||||
 | 
					        onValueChange={(v) => {
 | 
				
			||||||
 | 
					          p.vm.cloud_init.local_hostname = v;
 | 
				
			||||||
 | 
					          p.onChange?.();
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Data source mode */}
 | 
				
			||||||
 | 
					      <SelectInput
 | 
				
			||||||
 | 
					        {...p}
 | 
				
			||||||
 | 
					        label="Data source mode"
 | 
				
			||||||
 | 
					        value={p.vm.cloud_init.dsmode}
 | 
				
			||||||
 | 
					        onValueChange={(v) => {
 | 
				
			||||||
 | 
					          p.vm.cloud_init.dsmode = v as any;
 | 
				
			||||||
 | 
					          p.onChange?.();
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					        options={[
 | 
				
			||||||
 | 
					          { label: "None", value: undefined },
 | 
				
			||||||
 | 
					          { value: "Net" },
 | 
				
			||||||
 | 
					          { value: "Local" },
 | 
				
			||||||
 | 
					        ]}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </EditSection>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function CloudInitRawUserData(p: CloudInitProps): React.ReactElement {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <EditSection
 | 
				
			||||||
 | 
					      title="User data"
 | 
				
			||||||
 | 
					      actions={
 | 
				
			||||||
 | 
					        <RouterLink
 | 
				
			||||||
 | 
					          target="_blank"
 | 
				
			||||||
 | 
					          to="https://cloudinit.readthedocs.io/en/latest/reference/index.html"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <Tooltip title="Official reference">
 | 
				
			||||||
 | 
					            <IconButton size="small">
 | 
				
			||||||
 | 
					              <BookIcon />
 | 
				
			||||||
 | 
					            </IconButton>
 | 
				
			||||||
 | 
					          </Tooltip>
 | 
				
			||||||
 | 
					        </RouterLink>
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <Editor
 | 
				
			||||||
 | 
					        theme="vs-dark"
 | 
				
			||||||
 | 
					        options={{
 | 
				
			||||||
 | 
					          readOnly: !p.editable,
 | 
				
			||||||
 | 
					          quickSuggestions: { other: true, comments: true, strings: true },
 | 
				
			||||||
 | 
					          wordWrap: "on",
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					        language="yaml"
 | 
				
			||||||
 | 
					        height={"30vh"}
 | 
				
			||||||
 | 
					        value={p.vm.cloud_init.user_data}
 | 
				
			||||||
 | 
					        onChange={(v) => {
 | 
				
			||||||
 | 
					          p.vm.cloud_init.user_data = v ?? "";
 | 
				
			||||||
 | 
					          p.onChange?.();
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </EditSection>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function CloudInitNetworkConfig(p: CloudInitProps): React.ReactElement {
 | 
				
			||||||
 | 
					  if (!p.editable && !p.vm.cloud_init.network_configuration) return <></>;
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <EditSection
 | 
				
			||||||
 | 
					      title="Network configuration"
 | 
				
			||||||
 | 
					      actions={
 | 
				
			||||||
 | 
					        <RouterLink
 | 
				
			||||||
 | 
					          target="_blank"
 | 
				
			||||||
 | 
					          to="https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v2.html"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <Tooltip title="Official network configuration reference">
 | 
				
			||||||
 | 
					            <IconButton size="small">
 | 
				
			||||||
 | 
					              <BookIcon />
 | 
				
			||||||
 | 
					            </IconButton>
 | 
				
			||||||
 | 
					          </Tooltip>
 | 
				
			||||||
 | 
					        </RouterLink>
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <Editor
 | 
				
			||||||
 | 
					        theme="vs-dark"
 | 
				
			||||||
 | 
					        options={{
 | 
				
			||||||
 | 
					          readOnly: !p.editable,
 | 
				
			||||||
 | 
					          quickSuggestions: { other: true, comments: true, strings: true },
 | 
				
			||||||
 | 
					          wordWrap: "on",
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					        language="yaml"
 | 
				
			||||||
 | 
					        height={"30vh"}
 | 
				
			||||||
 | 
					        value={p.vm.cloud_init.network_configuration ?? ""}
 | 
				
			||||||
 | 
					        onChange={(v) => {
 | 
				
			||||||
 | 
					          if (v && v !== "") p.vm.cloud_init.network_configuration = v;
 | 
				
			||||||
 | 
					          else p.vm.cloud_init.network_configuration = undefined;
 | 
				
			||||||
 | 
					          p.onChange?.();
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </EditSection>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function CloudInitUserDataAssistant(p: CloudInitProps): React.ReactElement {
 | 
				
			||||||
 | 
					  const user_data = React.useMemo(() => {
 | 
				
			||||||
 | 
					    return YAML.parseDocument(p.vm.cloud_init.user_data);
 | 
				
			||||||
 | 
					  }, [p.vm.cloud_init.user_data]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onChange = () => {
 | 
				
			||||||
 | 
					    p.vm.cloud_init.user_data = user_data.toString();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!p.vm.cloud_init.user_data.startsWith("#cloud-config"))
 | 
				
			||||||
 | 
					      p.vm.cloud_init.user_data = `#cloud-config\n${p.vm.cloud_init.user_data}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    p.onChange?.();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const SYSTEMD_NOT_SERIAL = `/bin/sh -c "rm -f /etc/default/grub.d/50-cloudimg-settings.cfg && sed -i 's/quiet splash//g' /etc/default/grub && update-grub"`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <EditSection title="User data assistant">
 | 
				
			||||||
 | 
					      <CloudInitTextInput
 | 
				
			||||||
 | 
					        editable={p.editable}
 | 
				
			||||||
 | 
					        name="Default user name"
 | 
				
			||||||
 | 
					        refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
 | 
				
			||||||
 | 
					        attrPath={["user", "name"]}
 | 
				
			||||||
 | 
					        onChange={onChange}
 | 
				
			||||||
 | 
					        yaml={user_data}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      <CloudInitTextInput
 | 
				
			||||||
 | 
					        editable={p.editable}
 | 
				
			||||||
 | 
					        name="Default user password"
 | 
				
			||||||
 | 
					        refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
 | 
				
			||||||
 | 
					        attrPath={["password"]}
 | 
				
			||||||
 | 
					        onChange={onChange}
 | 
				
			||||||
 | 
					        yaml={user_data}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      <CloudInitBooleanInput
 | 
				
			||||||
 | 
					        editable={p.editable}
 | 
				
			||||||
 | 
					        name="Expire password to require new password on next login"
 | 
				
			||||||
 | 
					        yaml={user_data}
 | 
				
			||||||
 | 
					        attrPath={["chpasswd", "expire"]}
 | 
				
			||||||
 | 
					        onChange={onChange}
 | 
				
			||||||
 | 
					        refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      <br />
 | 
				
			||||||
 | 
					      <CloudInitBooleanInput
 | 
				
			||||||
 | 
					        editable={p.editable}
 | 
				
			||||||
 | 
					        name="Enable SSH password auth"
 | 
				
			||||||
 | 
					        yaml={user_data}
 | 
				
			||||||
 | 
					        attrPath={["ssh_pwauth"]}
 | 
				
			||||||
 | 
					        onChange={onChange}
 | 
				
			||||||
 | 
					        refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      <CloudInitTextInput
 | 
				
			||||||
 | 
					        editable={p.editable}
 | 
				
			||||||
 | 
					        name="Keyboard layout"
 | 
				
			||||||
 | 
					        refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#keyboard"
 | 
				
			||||||
 | 
					        attrPath={["keyboard", "layout"]}
 | 
				
			||||||
 | 
					        onChange={onChange}
 | 
				
			||||||
 | 
					        yaml={user_data}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      <CloudInitTextInput
 | 
				
			||||||
 | 
					        editable={p.editable}
 | 
				
			||||||
 | 
					        name="Final message"
 | 
				
			||||||
 | 
					        refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#final-message"
 | 
				
			||||||
 | 
					        attrPath={["final_message"]}
 | 
				
			||||||
 | 
					        onChange={onChange}
 | 
				
			||||||
 | 
					        yaml={user_data}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      {/* /bin/sh -c "rm -f /etc/default/grub.d/50-cloudimg-settings.cfg && update-grub" */}
 | 
				
			||||||
 | 
					      <CheckboxInput
 | 
				
			||||||
 | 
					        editable={p.editable}
 | 
				
			||||||
 | 
					        label="Show all startup messages on tty1, not serial"
 | 
				
			||||||
 | 
					        checked={
 | 
				
			||||||
 | 
					          !!(user_data.get("runcmd") as any)?.items.find(
 | 
				
			||||||
 | 
					            (a: any) => a.value === SYSTEMD_NOT_SERIAL
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        onValueChange={(c) => {
 | 
				
			||||||
 | 
					          if (!user_data.getIn(["runcmd"])) user_data.addIn(["runcmd"], []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          const runcmd = user_data.getIn(["runcmd"]) as any;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          if (c) {
 | 
				
			||||||
 | 
					            runcmd.addIn([], SYSTEMD_NOT_SERIAL);
 | 
				
			||||||
 | 
					          } else {
 | 
				
			||||||
 | 
					            const idx = runcmd.items.findIndex(
 | 
				
			||||||
 | 
					              (o: any) => o.value === SYSTEMD_NOT_SERIAL
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            runcmd.items.splice(idx, 1);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          onChange();
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </EditSection>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function CloudInitTextInput(p: {
 | 
				
			||||||
 | 
					  editable: boolean;
 | 
				
			||||||
 | 
					  name: string;
 | 
				
			||||||
 | 
					  refUrl: string;
 | 
				
			||||||
 | 
					  attrPath: Iterable<unknown>;
 | 
				
			||||||
 | 
					  yaml: YAML.Document;
 | 
				
			||||||
 | 
					  onChange?: () => void;
 | 
				
			||||||
 | 
					}): React.ReactElement {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <TextInput
 | 
				
			||||||
 | 
					      editable={p.editable}
 | 
				
			||||||
 | 
					      label={p.name}
 | 
				
			||||||
 | 
					      value={String(p.yaml.getIn(p.attrPath) ?? "")}
 | 
				
			||||||
 | 
					      onValueChange={(v) => {
 | 
				
			||||||
 | 
					        if (v !== undefined) p.yaml.setIn(p.attrPath, v);
 | 
				
			||||||
 | 
					        else p.yaml.deleteIn(p.attrPath);
 | 
				
			||||||
 | 
					        p.onChange?.();
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					      endAdornment={
 | 
				
			||||||
 | 
					        <RouterLink to={p.refUrl} target="_blank">
 | 
				
			||||||
 | 
					          <IconButton size="small">
 | 
				
			||||||
 | 
					            <BookIcon />
 | 
				
			||||||
 | 
					          </IconButton>
 | 
				
			||||||
 | 
					        </RouterLink>
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function CloudInitBooleanInput(p: {
 | 
				
			||||||
 | 
					  editable: boolean;
 | 
				
			||||||
 | 
					  name: string;
 | 
				
			||||||
 | 
					  refUrl: string;
 | 
				
			||||||
 | 
					  attrPath: Iterable<unknown>;
 | 
				
			||||||
 | 
					  yaml: YAML.Document;
 | 
				
			||||||
 | 
					  onChange?: () => void;
 | 
				
			||||||
 | 
					}): React.ReactElement {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <CheckboxInput
 | 
				
			||||||
 | 
					      editable={p.editable}
 | 
				
			||||||
 | 
					      label={p.name}
 | 
				
			||||||
 | 
					      checked={p.yaml.getIn(p.attrPath) === true}
 | 
				
			||||||
 | 
					      onValueChange={(v) => {
 | 
				
			||||||
 | 
					        p.yaml.setIn(p.attrPath, v);
 | 
				
			||||||
 | 
					        p.onChange?.();
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										24
									
								
								virtweb_frontend/src/widgets/forms/DiskBusSelect.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								virtweb_frontend/src/widgets/forms/DiskBusSelect.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
				
			|||||||
 | 
					import { DiskBusType } from "../../api/VMApi";
 | 
				
			||||||
 | 
					import { SelectInput } from "./SelectInput";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DiskBusSelect(p: {
 | 
				
			||||||
 | 
					  editable: boolean;
 | 
				
			||||||
 | 
					  value: DiskBusType;
 | 
				
			||||||
 | 
					  label?: string;
 | 
				
			||||||
 | 
					  onValueChange: (value: DiskBusType) => void;
 | 
				
			||||||
 | 
					  size?: "medium" | "small";
 | 
				
			||||||
 | 
					  disableUnderline?: boolean;
 | 
				
			||||||
 | 
					  disableBottomMargin?: boolean;
 | 
				
			||||||
 | 
					}): React.ReactElement {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <SelectInput
 | 
				
			||||||
 | 
					      {...p}
 | 
				
			||||||
 | 
					      label={p.label ?? "Disk bus type"}
 | 
				
			||||||
 | 
					      options={[
 | 
				
			||||||
 | 
					        { label: "virtio", value: "Virtio" },
 | 
				
			||||||
 | 
					        { label: "sata", value: "SATA" },
 | 
				
			||||||
 | 
					      ]}
 | 
				
			||||||
 | 
					      onValueChange={(v) => { p.onValueChange(v as any); }}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										40
									
								
								virtweb_frontend/src/widgets/forms/DiskImageSelect.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								virtweb_frontend/src/widgets/forms/DiskImageSelect.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
				
			|||||||
 | 
					import {
 | 
				
			||||||
 | 
					  FormControl,
 | 
				
			||||||
 | 
					  InputLabel,
 | 
				
			||||||
 | 
					  MenuItem,
 | 
				
			||||||
 | 
					  Select,
 | 
				
			||||||
 | 
					  SelectChangeEvent,
 | 
				
			||||||
 | 
					} from "@mui/material";
 | 
				
			||||||
 | 
					import React from "react";
 | 
				
			||||||
 | 
					import { DiskImage } from "../../api/DiskImageApi";
 | 
				
			||||||
 | 
					import { FileDiskImageWidget } from "../FileDiskImageWidget";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Select a disk image
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export function DiskImageSelect(p: {
 | 
				
			||||||
 | 
					  label: string;
 | 
				
			||||||
 | 
					  value?: string;
 | 
				
			||||||
 | 
					  onValueChange: (image: string | undefined) => void;
 | 
				
			||||||
 | 
					  list: DiskImage[];
 | 
				
			||||||
 | 
					}): React.ReactElement {
 | 
				
			||||||
 | 
					  const handleChange = (event: SelectChangeEvent) => {
 | 
				
			||||||
 | 
					    p.onValueChange(event.target.value);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <FormControl fullWidth variant="standard">
 | 
				
			||||||
 | 
					      <InputLabel>{p.label}</InputLabel>
 | 
				
			||||||
 | 
					      <Select value={p.value} label={p.label} onChange={handleChange}>
 | 
				
			||||||
 | 
					        <MenuItem value={undefined}>
 | 
				
			||||||
 | 
					          <i>None</i>
 | 
				
			||||||
 | 
					        </MenuItem>
 | 
				
			||||||
 | 
					        {p.list.map((d) => (
 | 
				
			||||||
 | 
					          <MenuItem key={d.file_name} value={d.file_name}>
 | 
				
			||||||
 | 
					            <FileDiskImageWidget image={d} />
 | 
				
			||||||
 | 
					          </MenuItem>
 | 
				
			||||||
 | 
					        ))}
 | 
				
			||||||
 | 
					      </Select>
 | 
				
			||||||
 | 
					    </FormControl>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										25
									
								
								virtweb_frontend/src/widgets/forms/DiskSizeInput.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								virtweb_frontend/src/widgets/forms/DiskSizeInput.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
				
			|||||||
 | 
					import { ServerApi } from "../../api/ServerApi";
 | 
				
			||||||
 | 
					import { TextInput } from "./TextInput";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function DiskSizeInput(p: {
 | 
				
			||||||
 | 
					  editable: boolean;
 | 
				
			||||||
 | 
					  label?: string;
 | 
				
			||||||
 | 
					  value: number;
 | 
				
			||||||
 | 
					  onChange?: (size: number) => void;
 | 
				
			||||||
 | 
					}): React.ReactElement {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <TextInput
 | 
				
			||||||
 | 
					      editable={p.editable}
 | 
				
			||||||
 | 
					      label={p.label ?? "Disk size (GB)"}
 | 
				
			||||||
 | 
					      size={{
 | 
				
			||||||
 | 
					        min: ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000),
 | 
				
			||||||
 | 
					        max: ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000),
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					      value={(p.value / (1000 * 1000 * 1000)).toString()}
 | 
				
			||||||
 | 
					      onValueChange={(v) => {
 | 
				
			||||||
 | 
					        p.onChange?.(Number(v ?? "0") * 1000 * 1000 * 1000);
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					      type="number"
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -19,13 +19,10 @@ export function EditSection(
 | 
				
			|||||||
              display: "flex",
 | 
					              display: "flex",
 | 
				
			||||||
              justifyContent: "space-between",
 | 
					              justifyContent: "space-between",
 | 
				
			||||||
              alignItems: "center",
 | 
					              alignItems: "center",
 | 
				
			||||||
 | 
					              marginBottom: "15px",
 | 
				
			||||||
            }}
 | 
					            }}
 | 
				
			||||||
          >
 | 
					          >
 | 
				
			||||||
            {p.title && (
 | 
					            {p.title && <Typography variant="h5">{p.title}</Typography>}
 | 
				
			||||||
              <Typography variant="h5" style={{ marginBottom: "15px" }}>
 | 
					 | 
				
			||||||
                {p.title}
 | 
					 | 
				
			||||||
              </Typography>
 | 
					 | 
				
			||||||
            )}
 | 
					 | 
				
			||||||
            {p.actions}
 | 
					            {p.actions}
 | 
				
			||||||
          </span>
 | 
					          </span>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										77
									
								
								virtweb_frontend/src/widgets/forms/FileInput.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								virtweb_frontend/src/widgets/forms/FileInput.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,77 @@
 | 
				
			|||||||
 | 
					import AttachFileIcon from "@mui/icons-material/AttachFile";
 | 
				
			||||||
 | 
					import ClearIcon from "@mui/icons-material/Clear";
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  IconButton,
 | 
				
			||||||
 | 
					  InputAdornment,
 | 
				
			||||||
 | 
					  OutlinedInputProps,
 | 
				
			||||||
 | 
					  TextField,
 | 
				
			||||||
 | 
					  TextFieldSlotsAndSlotProps,
 | 
				
			||||||
 | 
					  Tooltip,
 | 
				
			||||||
 | 
					} from "@mui/material";
 | 
				
			||||||
 | 
					import { filesize } from "filesize";
 | 
				
			||||||
 | 
					import React from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function FileInput(
 | 
				
			||||||
 | 
					  p: {
 | 
				
			||||||
 | 
					    label?: string;
 | 
				
			||||||
 | 
					    value: File | null;
 | 
				
			||||||
 | 
					    onChange: (file: File | null) => void;
 | 
				
			||||||
 | 
					    style?: React.CSSProperties;
 | 
				
			||||||
 | 
					  } & TextFieldSlotsAndSlotProps<OutlinedInputProps>
 | 
				
			||||||
 | 
					): React.ReactElement {
 | 
				
			||||||
 | 
					  const fileInput = React.useRef<HTMLInputElement>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <TextField
 | 
				
			||||||
 | 
					      onClick={() => fileInput.current?.click()}
 | 
				
			||||||
 | 
					      {...p}
 | 
				
			||||||
 | 
					      value={""}
 | 
				
			||||||
 | 
					      onChange={console.log}
 | 
				
			||||||
 | 
					      type="file"
 | 
				
			||||||
 | 
					      slotProps={{
 | 
				
			||||||
 | 
					        input: {
 | 
				
			||||||
 | 
					          startAdornment: (
 | 
				
			||||||
 | 
					            <>
 | 
				
			||||||
 | 
					              <InputAdornment position="start">
 | 
				
			||||||
 | 
					                <AttachFileIcon />
 | 
				
			||||||
 | 
					                  
 | 
				
			||||||
 | 
					                {p.value ? p.value.name : "Select a file"}
 | 
				
			||||||
 | 
					              </InputAdornment>
 | 
				
			||||||
 | 
					            </>
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          endAdornment: p.value ? (
 | 
				
			||||||
 | 
					            <InputAdornment position="end">
 | 
				
			||||||
 | 
					              {filesize(p.value.size)}
 | 
				
			||||||
 | 
					              <Tooltip
 | 
				
			||||||
 | 
					                title="Remove attached file"
 | 
				
			||||||
 | 
					                onClick={(e) => {
 | 
				
			||||||
 | 
					                  e.stopPropagation();
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					                <IconButton
 | 
				
			||||||
 | 
					                  onClick={(e) => {
 | 
				
			||||||
 | 
					                    e.stopPropagation();
 | 
				
			||||||
 | 
					                    p.onChange(null);
 | 
				
			||||||
 | 
					                  }}
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                  <ClearIcon />
 | 
				
			||||||
 | 
					                </IconButton>
 | 
				
			||||||
 | 
					              </Tooltip>
 | 
				
			||||||
 | 
					            </InputAdornment>
 | 
				
			||||||
 | 
					          ) : undefined,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          onChange: (e) => {
 | 
				
			||||||
 | 
					            const files = (e.target as any).files;
 | 
				
			||||||
 | 
					            p.onChange(files.length > 0 ? files[0] : null);
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        htmlInput: {
 | 
				
			||||||
 | 
					          ref: fileInput,
 | 
				
			||||||
 | 
					          style: { opacity: 0 },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					      placeholder={"Insert a file"}
 | 
				
			||||||
 | 
					      variant="outlined"
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										91
									
								
								virtweb_frontend/src/widgets/forms/OEMStringFormWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								virtweb_frontend/src/widgets/forms/OEMStringFormWidget.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,91 @@
 | 
				
			|||||||
 | 
					/* eslint-disable react-x/no-array-index-key */
 | 
				
			||||||
 | 
					import AddIcon from "@mui/icons-material/Add";
 | 
				
			||||||
 | 
					import ClearIcon from "@mui/icons-material/Clear";
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  Alert,
 | 
				
			||||||
 | 
					  IconButton,
 | 
				
			||||||
 | 
					  InputAdornment,
 | 
				
			||||||
 | 
					  TextField,
 | 
				
			||||||
 | 
					  Tooltip,
 | 
				
			||||||
 | 
					} from "@mui/material";
 | 
				
			||||||
 | 
					import { VMInfo } from "../../api/VMApi";
 | 
				
			||||||
 | 
					import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
 | 
				
			||||||
 | 
					import { EditSection } from "./EditSection";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function OEMStringFormWidget(p: {
 | 
				
			||||||
 | 
					  vm: VMInfo;
 | 
				
			||||||
 | 
					  editable: boolean;
 | 
				
			||||||
 | 
					  onChange?: () => void;
 | 
				
			||||||
 | 
					}): React.ReactElement {
 | 
				
			||||||
 | 
					  const confirm = useConfirm();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleDeleteOEMString = async (num: number) => {
 | 
				
			||||||
 | 
					    if (!(await confirm("Do you really want to delete this entry?"))) return;
 | 
				
			||||||
 | 
					    p.vm.oem_strings.splice(num, 1);
 | 
				
			||||||
 | 
					    p.onChange?.();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!p.editable && p.vm.oem_strings.length === 0) return <></>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <EditSection
 | 
				
			||||||
 | 
					      title="SMBIOS OEM Strings"
 | 
				
			||||||
 | 
					      actions={
 | 
				
			||||||
 | 
					        p.editable ? (
 | 
				
			||||||
 | 
					          <Tooltip title="Add a new string entry">
 | 
				
			||||||
 | 
					            <IconButton
 | 
				
			||||||
 | 
					              onClick={() => {
 | 
				
			||||||
 | 
					                p.vm.oem_strings.push("");
 | 
				
			||||||
 | 
					                p.onChange?.();
 | 
				
			||||||
 | 
					              }}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <AddIcon />
 | 
				
			||||||
 | 
					            </IconButton>
 | 
				
			||||||
 | 
					          </Tooltip>
 | 
				
			||||||
 | 
					        ) : (
 | 
				
			||||||
 | 
					          <></>
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <Alert severity="info">
 | 
				
			||||||
 | 
					        You can use the{" "}
 | 
				
			||||||
 | 
					        <a
 | 
				
			||||||
 | 
					          href="https://www.nongnu.org/dmidecode/"
 | 
				
			||||||
 | 
					          target="_blank"
 | 
				
			||||||
 | 
					          rel="noreferrer noopener"
 | 
				
			||||||
 | 
					          style={{ color: "inherit" }}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <i>dmidecode</i>
 | 
				
			||||||
 | 
					        </a>{" "}
 | 
				
			||||||
 | 
					        tool on Linux to extract these strings on the guest.
 | 
				
			||||||
 | 
					      </Alert>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {p.vm.oem_strings.map((s, num) => (
 | 
				
			||||||
 | 
					        <TextField
 | 
				
			||||||
 | 
					          key={num}
 | 
				
			||||||
 | 
					          fullWidth
 | 
				
			||||||
 | 
					          disabled={!p.editable}
 | 
				
			||||||
 | 
					          value={s}
 | 
				
			||||||
 | 
					          onChange={(e) => {
 | 
				
			||||||
 | 
					            p.vm.oem_strings[num] = e.target.value;
 | 
				
			||||||
 | 
					            p.onChange?.();
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					          style={{ marginTop: "5px" }}
 | 
				
			||||||
 | 
					          slotProps={{
 | 
				
			||||||
 | 
					            input: {
 | 
				
			||||||
 | 
					              endAdornment: p.editable ? (
 | 
				
			||||||
 | 
					                <InputAdornment position="end">
 | 
				
			||||||
 | 
					                  <Tooltip title="Remove entry">
 | 
				
			||||||
 | 
					                    <IconButton onClick={() => handleDeleteOEMString(num)}>
 | 
				
			||||||
 | 
					                      <ClearIcon />
 | 
				
			||||||
 | 
					                    </IconButton>
 | 
				
			||||||
 | 
					                  </Tooltip>
 | 
				
			||||||
 | 
					                </InputAdornment>
 | 
				
			||||||
 | 
					              ) : undefined,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      ))}
 | 
				
			||||||
 | 
					    </EditSection>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -17,8 +17,11 @@ export function SelectInput(p: {
 | 
				
			|||||||
  value?: string;
 | 
					  value?: string;
 | 
				
			||||||
  editable: boolean;
 | 
					  editable: boolean;
 | 
				
			||||||
  label?: string;
 | 
					  label?: string;
 | 
				
			||||||
 | 
					  size?: "medium" | "small";
 | 
				
			||||||
  options: SelectOption[];
 | 
					  options: SelectOption[];
 | 
				
			||||||
  onValueChange: (o?: string) => void;
 | 
					  onValueChange: (o?: string) => void;
 | 
				
			||||||
 | 
					  disableUnderline?: boolean;
 | 
				
			||||||
 | 
					  disableBottomMargin?: boolean;
 | 
				
			||||||
}): React.ReactElement {
 | 
					}): React.ReactElement {
 | 
				
			||||||
  if (!p.editable && !p.value) return <></>;
 | 
					  if (!p.editable && !p.value) return <></>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -28,12 +31,18 @@ export function SelectInput(p: {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <FormControl fullWidth variant="standard" style={{ marginBottom: "15px" }}>
 | 
					    <FormControl
 | 
				
			||||||
 | 
					      fullWidth
 | 
				
			||||||
 | 
					      variant="standard"
 | 
				
			||||||
 | 
					      style={{ marginBottom: p.disableBottomMargin ? "0px" : "15px" }}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      {p.label && <InputLabel>{p.label}</InputLabel>}
 | 
					      {p.label && <InputLabel>{p.label}</InputLabel>}
 | 
				
			||||||
      <Select
 | 
					      <Select
 | 
				
			||||||
 | 
					        {...p}
 | 
				
			||||||
        value={p.value ?? ""}
 | 
					        value={p.value ?? ""}
 | 
				
			||||||
        label={p.label}
 | 
					        onChange={(e) => {
 | 
				
			||||||
        onChange={(e) => { p.onValueChange(e.target.value); }}
 | 
					          p.onValueChange(e.target.value);
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        {p.options.map((e) => (
 | 
					        {p.options.map((e) => (
 | 
				
			||||||
          <MenuItem
 | 
					          <MenuItem
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,6 +17,8 @@ export function TextInput(p: {
 | 
				
			|||||||
  type?: React.HTMLInputTypeAttribute;
 | 
					  type?: React.HTMLInputTypeAttribute;
 | 
				
			||||||
  style?: React.CSSProperties;
 | 
					  style?: React.CSSProperties;
 | 
				
			||||||
  helperText?: string;
 | 
					  helperText?: string;
 | 
				
			||||||
 | 
					  disabled?: boolean;
 | 
				
			||||||
 | 
					  endAdornment?: React.ReactNode;
 | 
				
			||||||
}): React.ReactElement {
 | 
					}): React.ReactElement {
 | 
				
			||||||
  if (!p.editable && (p.value ?? "") === "") return <></>;
 | 
					  if (!p.editable && (p.value ?? "") === "") return <></>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -35,6 +37,7 @@ export function TextInput(p: {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <TextField
 | 
					    <TextField
 | 
				
			||||||
 | 
					      disabled={p.disabled}
 | 
				
			||||||
      label={p.label}
 | 
					      label={p.label}
 | 
				
			||||||
      value={p.value ?? ""}
 | 
					      value={p.value ?? ""}
 | 
				
			||||||
      onChange={(e) =>
 | 
					      onChange={(e) =>
 | 
				
			||||||
@@ -49,6 +52,7 @@ export function TextInput(p: {
 | 
				
			|||||||
        input: {
 | 
					        input: {
 | 
				
			||||||
          readOnly: !p.editable,
 | 
					          readOnly: !p.editable,
 | 
				
			||||||
          type: p.type,
 | 
					          type: p.type,
 | 
				
			||||||
 | 
					          endAdornment: p.endAdornment,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
      }}
 | 
					      }}
 | 
				
			||||||
      variant={"standard"}
 | 
					      variant={"standard"}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,69 +1,119 @@
 | 
				
			|||||||
import { mdiHarddisk } from "@mdi/js";
 | 
					import { mdiHarddiskPlus } from "@mdi/js";
 | 
				
			||||||
import Icon from "@mdi/react";
 | 
					import Icon from "@mdi/react";
 | 
				
			||||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
 | 
					import CheckCircleIcon from "@mui/icons-material/CheckCircle";
 | 
				
			||||||
import DeleteIcon from "@mui/icons-material/Delete";
 | 
					import DeleteIcon from "@mui/icons-material/Delete";
 | 
				
			||||||
import {
 | 
					import ExpandIcon from "@mui/icons-material/Expand";
 | 
				
			||||||
  Avatar,
 | 
					import { Button, IconButton, Paper, Tooltip, Typography } from "@mui/material";
 | 
				
			||||||
  Button,
 | 
					import React from "react";
 | 
				
			||||||
  IconButton,
 | 
					import { DiskImage } from "../../api/DiskImageApi";
 | 
				
			||||||
  ListItem,
 | 
					 | 
				
			||||||
  ListItemAvatar,
 | 
					 | 
				
			||||||
  ListItemText,
 | 
					 | 
				
			||||||
  Paper,
 | 
					 | 
				
			||||||
  Tooltip,
 | 
					 | 
				
			||||||
} from "@mui/material";
 | 
					 | 
				
			||||||
import { filesize } from "filesize";
 | 
					 | 
				
			||||||
import { ServerApi } from "../../api/ServerApi";
 | 
					import { ServerApi } from "../../api/ServerApi";
 | 
				
			||||||
import { VMDisk, VMInfo } from "../../api/VMApi";
 | 
					import { VMFileDisk, VMInfo, VMState } from "../../api/VMApi";
 | 
				
			||||||
 | 
					import { ConvertDiskImageDialog } from "../../dialogs/ConvertDiskImageDialog";
 | 
				
			||||||
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
 | 
					import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
 | 
				
			||||||
 | 
					import { VMDiskFileWidget } from "../vms/VMDiskFileWidget";
 | 
				
			||||||
 | 
					import { CheckboxInput } from "./CheckboxInput";
 | 
				
			||||||
 | 
					import { DiskBusSelect } from "./DiskBusSelect";
 | 
				
			||||||
 | 
					import { DiskImageSelect } from "./DiskImageSelect";
 | 
				
			||||||
 | 
					import { DiskSizeInput } from "./DiskSizeInput";
 | 
				
			||||||
import { SelectInput } from "./SelectInput";
 | 
					import { SelectInput } from "./SelectInput";
 | 
				
			||||||
import { TextInput } from "./TextInput";
 | 
					import { TextInput } from "./TextInput";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function VMDisksList(p: {
 | 
					export function VMDisksList(p: {
 | 
				
			||||||
  vm: VMInfo;
 | 
					  vm: VMInfo;
 | 
				
			||||||
 | 
					  state?: VMState;
 | 
				
			||||||
  onChange?: () => void;
 | 
					  onChange?: () => void;
 | 
				
			||||||
  editable: boolean;
 | 
					  editable: boolean;
 | 
				
			||||||
 | 
					  diskImagesList: DiskImage[];
 | 
				
			||||||
}): React.ReactElement {
 | 
					}): React.ReactElement {
 | 
				
			||||||
 | 
					  const [currBackupRequest, setCurrBackupRequest] = React.useState<
 | 
				
			||||||
 | 
					    VMFileDisk | undefined
 | 
				
			||||||
 | 
					  >();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const addNewDisk = () => {
 | 
					  const addNewDisk = () => {
 | 
				
			||||||
    p.vm.disks.push({
 | 
					    p.vm.file_disks.push({
 | 
				
			||||||
      alloc_type: "Sparse",
 | 
					      format: "QCow2",
 | 
				
			||||||
      size: 10000,
 | 
					      size: 10000 * 1000 * 1000,
 | 
				
			||||||
 | 
					      bus: "Virtio",
 | 
				
			||||||
      delete: false,
 | 
					      delete: false,
 | 
				
			||||||
      name: `disk${p.vm.disks.length}`,
 | 
					      name: `disk${p.vm.file_disks.length}`,
 | 
				
			||||||
      new: true,
 | 
					      new: true,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    p.onChange?.();
 | 
					    p.onChange?.();
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleBackupRequest = (disk: VMFileDisk) => {
 | 
				
			||||||
 | 
					    setCurrBackupRequest(disk);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleFinishBackup = () => {
 | 
				
			||||||
 | 
					    setCurrBackupRequest(undefined);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <>
 | 
					    <>
 | 
				
			||||||
      {/* disks list */}
 | 
					      {/* disks list */}
 | 
				
			||||||
      {p.vm.disks.map((d, num) => (
 | 
					      {p.vm.file_disks.map((d, num) => (
 | 
				
			||||||
        <DiskInfo
 | 
					        <DiskInfo
 | 
				
			||||||
          // eslint-disable-next-line react-x/no-array-index-key
 | 
					          // eslint-disable-next-line react-x/no-array-index-key
 | 
				
			||||||
          key={num}
 | 
					          key={num}
 | 
				
			||||||
          editable={p.editable}
 | 
					          editable={p.editable}
 | 
				
			||||||
 | 
					          canBackup={!p.editable && !d.new && p.state !== "Running"}
 | 
				
			||||||
          disk={d}
 | 
					          disk={d}
 | 
				
			||||||
          onChange={p.onChange}
 | 
					          onChange={p.onChange}
 | 
				
			||||||
          removeFromList={() => {
 | 
					          removeFromList={() => {
 | 
				
			||||||
            p.vm.disks.splice(num, 1);
 | 
					            p.vm.file_disks.splice(num, 1);
 | 
				
			||||||
            p.onChange?.();
 | 
					            p.onChange?.();
 | 
				
			||||||
          }}
 | 
					          }}
 | 
				
			||||||
 | 
					          onRequestBackup={handleBackupRequest}
 | 
				
			||||||
 | 
					          diskImagesList={p.diskImagesList}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      ))}
 | 
					      ))}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {p.vm.file_disks.length === 0 && (
 | 
				
			||||||
 | 
					        <Typography style={{ textAlign: "center", paddingTop: "25px" }}>
 | 
				
			||||||
 | 
					          No disk file yet!
 | 
				
			||||||
 | 
					        </Typography>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {p.editable && <Button onClick={addNewDisk}>Add new disk</Button>}
 | 
					      {p.editable && <Button onClick={addNewDisk}>Add new disk</Button>}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Disk backup */}
 | 
				
			||||||
 | 
					      {currBackupRequest && (
 | 
				
			||||||
 | 
					        <ConvertDiskImageDialog
 | 
				
			||||||
 | 
					          backup
 | 
				
			||||||
 | 
					          onCancel={handleFinishBackup}
 | 
				
			||||||
 | 
					          onFinished={handleFinishBackup}
 | 
				
			||||||
 | 
					          vm={p.vm}
 | 
				
			||||||
 | 
					          disk={currBackupRequest}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function DiskInfo(p: {
 | 
					function DiskInfo(p: {
 | 
				
			||||||
  editable: boolean;
 | 
					  editable: boolean;
 | 
				
			||||||
  disk: VMDisk;
 | 
					  canBackup: boolean;
 | 
				
			||||||
 | 
					  disk: VMFileDisk;
 | 
				
			||||||
  onChange?: () => void;
 | 
					  onChange?: () => void;
 | 
				
			||||||
  removeFromList: () => void;
 | 
					  removeFromList: () => void;
 | 
				
			||||||
 | 
					  onRequestBackup: (disk: VMFileDisk) => void;
 | 
				
			||||||
 | 
					  diskImagesList: DiskImage[];
 | 
				
			||||||
}): React.ReactElement {
 | 
					}): React.ReactElement {
 | 
				
			||||||
  const confirm = useConfirm();
 | 
					  const confirm = useConfirm();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const expandDisk = () => {
 | 
				
			||||||
 | 
					    if (p.disk.resize === true) {
 | 
				
			||||||
 | 
					      p.disk.resize = false;
 | 
				
			||||||
 | 
					      p.disk.size = p.disk.originalSize!;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      p.disk.resize = true;
 | 
				
			||||||
 | 
					      p.disk.originalSize = p.disk.size!;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    p.onChange?.();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const deleteDisk = async () => {
 | 
					  const deleteDisk = async () => {
 | 
				
			||||||
    if (p.disk.deleteType) {
 | 
					    if (p.disk.deleteType) {
 | 
				
			||||||
      p.disk.deleteType = undefined;
 | 
					      p.disk.deleteType = undefined;
 | 
				
			||||||
@@ -86,9 +136,30 @@ function DiskInfo(p: {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  if (!p.editable || !p.disk.new)
 | 
					  if (!p.editable || !p.disk.new)
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <ListItem
 | 
					      <>
 | 
				
			||||||
 | 
					        <VMDiskFileWidget
 | 
				
			||||||
 | 
					          {...p}
 | 
				
			||||||
          secondaryAction={
 | 
					          secondaryAction={
 | 
				
			||||||
          p.editable && (
 | 
					            <>
 | 
				
			||||||
 | 
					              {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
 | 
					                <IconButton
 | 
				
			||||||
                  edge="end"
 | 
					                  edge="end"
 | 
				
			||||||
                  aria-label="delete disk"
 | 
					                  aria-label="delete disk"
 | 
				
			||||||
@@ -104,36 +175,41 @@ function DiskInfo(p: {
 | 
				
			|||||||
                    </Tooltip>
 | 
					                    </Tooltip>
 | 
				
			||||||
                  )}
 | 
					                  )}
 | 
				
			||||||
                </IconButton>
 | 
					                </IconButton>
 | 
				
			||||||
          )
 | 
					              )}
 | 
				
			||||||
        }
 | 
					
 | 
				
			||||||
 | 
					              {p.canBackup && (
 | 
				
			||||||
 | 
					                <Tooltip title="Backup this disk">
 | 
				
			||||||
 | 
					                  <IconButton
 | 
				
			||||||
 | 
					                    onClick={() => {
 | 
				
			||||||
 | 
					                      p.onRequestBackup(p.disk);
 | 
				
			||||||
 | 
					                    }}
 | 
				
			||||||
                  >
 | 
					                  >
 | 
				
			||||||
        <ListItemAvatar>
 | 
					                    <Icon path={mdiHarddiskPlus} size={1} />
 | 
				
			||||||
          <Avatar>
 | 
					                  </IconButton>
 | 
				
			||||||
            <Icon path={mdiHarddisk} />
 | 
					                </Tooltip>
 | 
				
			||||||
          </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.alloc_type
 | 
					 | 
				
			||||||
          }`}
 | 
					 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      </ListItem>
 | 
					
 | 
				
			||||||
 | 
					        {/* New disk size*/}
 | 
				
			||||||
 | 
					        {p.disk.resize && !p.disk.deleteType && (
 | 
				
			||||||
 | 
					          <DiskSizeInput
 | 
				
			||||||
 | 
					            editable
 | 
				
			||||||
 | 
					            label="New disk size (GB)"
 | 
				
			||||||
 | 
					            value={p.disk.size}
 | 
				
			||||||
 | 
					            onChange={(v) => {
 | 
				
			||||||
 | 
					              p.disk.size = v;
 | 
				
			||||||
 | 
					              p.onChange?.();
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      </>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Paper elevation={3} style={{ margin: "10px", padding: "10px" }}>
 | 
					    <Paper elevation={3} style={{ margin: "10px", padding: "10px" }}>
 | 
				
			||||||
 | 
					      <div style={{ display: "flex", justifyContent: "space-between" }}>
 | 
				
			||||||
        <TextInput
 | 
					        <TextInput
 | 
				
			||||||
          editable={true}
 | 
					          editable={true}
 | 
				
			||||||
          label="Disk name"
 | 
					          label="Disk name"
 | 
				
			||||||
@@ -145,38 +221,86 @@ function DiskInfo(p: {
 | 
				
			|||||||
            p.onChange?.();
 | 
					            p.onChange?.();
 | 
				
			||||||
          }}
 | 
					          }}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
 | 
					 | 
				
			||||||
      <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"
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <div style={{ display: "flex", justifyContent: "space-between" }}>
 | 
					 | 
				
			||||||
        <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) => {
 | 
					 | 
				
			||||||
            p.disk.alloc_type = v as any;
 | 
					 | 
				
			||||||
            p.onChange?.();
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <IconButton onClick={p.removeFromList}>
 | 
					        <IconButton onClick={p.removeFromList}>
 | 
				
			||||||
          <DeleteIcon />
 | 
					          <DeleteIcon />
 | 
				
			||||||
        </IconButton>
 | 
					        </IconButton>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <SelectInput
 | 
				
			||||||
 | 
					        editable={true}
 | 
				
			||||||
 | 
					        label="Disk format"
 | 
				
			||||||
 | 
					        options={[
 | 
				
			||||||
 | 
					          { label: "Raw file", value: "Raw" },
 | 
				
			||||||
 | 
					          { label: "QCow2", value: "QCow2" },
 | 
				
			||||||
 | 
					        ]}
 | 
				
			||||||
 | 
					        value={p.disk.format}
 | 
				
			||||||
 | 
					        onValueChange={(v) => {
 | 
				
			||||||
 | 
					          p.disk.format = v as any;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          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}
 | 
				
			||||||
 | 
					          onValueChange={(v) => {
 | 
				
			||||||
 | 
					            if (p.disk.format === "Raw") p.disk.is_sparse = v;
 | 
				
			||||||
 | 
					            p.onChange?.();
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Resize disk image */}
 | 
				
			||||||
 | 
					      {!!p.disk.from_image && (
 | 
				
			||||||
 | 
					        <CheckboxInput
 | 
				
			||||||
 | 
					          editable
 | 
				
			||||||
 | 
					          checked={p.disk.resize}
 | 
				
			||||||
 | 
					          label="Resize disk file"
 | 
				
			||||||
 | 
					          onValueChange={(v) => {
 | 
				
			||||||
 | 
					            p.disk.resize = v;
 | 
				
			||||||
 | 
					            p.onChange?.();
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Disk size */}
 | 
				
			||||||
 | 
					      {(!p.disk.from_image || p.disk.resize === true) && (
 | 
				
			||||||
 | 
					        <DiskSizeInput
 | 
				
			||||||
 | 
					          editable
 | 
				
			||||||
 | 
					          value={p.disk.size}
 | 
				
			||||||
 | 
					          onChange={(v) => {
 | 
				
			||||||
 | 
					            p.disk.size = v;
 | 
				
			||||||
 | 
					            p.onChange?.();
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Disk image selection */}
 | 
				
			||||||
 | 
					      <DiskImageSelect
 | 
				
			||||||
 | 
					        label="Use disk image as template"
 | 
				
			||||||
 | 
					        list={p.diskImagesList}
 | 
				
			||||||
 | 
					        value={p.disk.from_image}
 | 
				
			||||||
 | 
					        onValueChange={(v) => {
 | 
				
			||||||
 | 
					          p.disk.from_image = v;
 | 
				
			||||||
 | 
					          p.onChange?.();
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
    </Paper>
 | 
					    </Paper>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,6 +10,7 @@ import {
 | 
				
			|||||||
  ListItemAvatar,
 | 
					  ListItemAvatar,
 | 
				
			||||||
  ListItemText,
 | 
					  ListItemText,
 | 
				
			||||||
  Tooltip,
 | 
					  Tooltip,
 | 
				
			||||||
 | 
					  Typography,
 | 
				
			||||||
} from "@mui/material";
 | 
					} from "@mui/material";
 | 
				
			||||||
import Grid from "@mui/material/Grid";
 | 
					import Grid from "@mui/material/Grid";
 | 
				
			||||||
import { NWFilter } from "../../api/NWFilterApi";
 | 
					import { NWFilter } from "../../api/NWFilterApi";
 | 
				
			||||||
@@ -29,11 +30,13 @@ export function VMNetworksList(p: {
 | 
				
			|||||||
  onChange?: () => void;
 | 
					  onChange?: () => void;
 | 
				
			||||||
  editable: boolean;
 | 
					  editable: boolean;
 | 
				
			||||||
  networksList: NetworkInfo[];
 | 
					  networksList: NetworkInfo[];
 | 
				
			||||||
 | 
					  bridgesList: string[];
 | 
				
			||||||
  networkFiltersList: NWFilter[];
 | 
					  networkFiltersList: NWFilter[];
 | 
				
			||||||
}): React.ReactElement {
 | 
					}): React.ReactElement {
 | 
				
			||||||
  const addNew = () => {
 | 
					  const addNew = () => {
 | 
				
			||||||
    p.vm.networks.push({
 | 
					    p.vm.networks.push({
 | 
				
			||||||
      type: "UserspaceSLIRPStack",
 | 
					      type: "UserspaceSLIRPStack",
 | 
				
			||||||
 | 
					      model: "Virtio",
 | 
				
			||||||
      mac: randomMacAddress(ServerApi.Config.net_mac_prefix),
 | 
					      mac: randomMacAddress(ServerApi.Config.net_mac_prefix),
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    p.onChange?.();
 | 
					    p.onChange?.();
 | 
				
			||||||
@@ -47,6 +50,12 @@ export function VMNetworksList(p: {
 | 
				
			|||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {p.vm.networks.length === 0 && (
 | 
				
			||||||
 | 
					        <Typography style={{ textAlign: "center", paddingTop: "25px" }}>
 | 
				
			||||||
 | 
					          No network interface defined yet!
 | 
				
			||||||
 | 
					        </Typography>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <Grid container spacing={2}>
 | 
					      <Grid container spacing={2}>
 | 
				
			||||||
        {/* networks list */}
 | 
					        {/* networks list */}
 | 
				
			||||||
        {p.vm.networks.map((n, num) => (
 | 
					        {p.vm.networks.map((n, num) => (
 | 
				
			||||||
@@ -72,6 +81,7 @@ function NetworkInfoWidget(p: {
 | 
				
			|||||||
  onChange?: () => void;
 | 
					  onChange?: () => void;
 | 
				
			||||||
  removeFromList: () => void;
 | 
					  removeFromList: () => void;
 | 
				
			||||||
  networksList: NetworkInfo[];
 | 
					  networksList: NetworkInfo[];
 | 
				
			||||||
 | 
					  bridgesList: string[];
 | 
				
			||||||
  networkFiltersList: NWFilter[];
 | 
					  networkFiltersList: NWFilter[];
 | 
				
			||||||
}): React.ReactElement {
 | 
					}): React.ReactElement {
 | 
				
			||||||
  const confirm = useConfirm();
 | 
					  const confirm = useConfirm();
 | 
				
			||||||
@@ -130,6 +140,11 @@ function NetworkInfoWidget(p: {
 | 
				
			|||||||
                    value: "DefinedNetwork",
 | 
					                    value: "DefinedNetwork",
 | 
				
			||||||
                    description: "Attach to a defined network",
 | 
					                    description: "Attach to a defined network",
 | 
				
			||||||
                  },
 | 
					                  },
 | 
				
			||||||
 | 
					                  {
 | 
				
			||||||
 | 
					                    label: "Host bridge",
 | 
				
			||||||
 | 
					                    value: "Bridge",
 | 
				
			||||||
 | 
					                    description: "Attach to an host's bridge",
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
                ]}
 | 
					                ]}
 | 
				
			||||||
              />
 | 
					              />
 | 
				
			||||||
            ) : (
 | 
					            ) : (
 | 
				
			||||||
@@ -139,6 +154,7 @@ function NetworkInfoWidget(p: {
 | 
				
			|||||||
        />
 | 
					        />
 | 
				
			||||||
      </ListItem>
 | 
					      </ListItem>
 | 
				
			||||||
      <div style={{ marginLeft: "70px" }}>
 | 
					      <div style={{ marginLeft: "70px" }}>
 | 
				
			||||||
 | 
					        {/* MAC address input */}
 | 
				
			||||||
        <MACInput
 | 
					        <MACInput
 | 
				
			||||||
          editable={p.editable}
 | 
					          editable={p.editable}
 | 
				
			||||||
          label="MAC Address"
 | 
					          label="MAC Address"
 | 
				
			||||||
@@ -149,8 +165,28 @@ function NetworkInfoWidget(p: {
 | 
				
			|||||||
          }}
 | 
					          }}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {/* NIC model */}
 | 
				
			||||||
 | 
					        <SelectInput
 | 
				
			||||||
 | 
					          editable={p.editable}
 | 
				
			||||||
 | 
					          label="NIC Model"
 | 
				
			||||||
 | 
					          value={p.network.model}
 | 
				
			||||||
 | 
					          onValueChange={(v) => {
 | 
				
			||||||
 | 
					            p.network.model = v as any;
 | 
				
			||||||
 | 
					            p.onChange?.();
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					          options={[
 | 
				
			||||||
 | 
					            { label: "e1000", value: "E1000" },
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					              label: "virtio",
 | 
				
			||||||
 | 
					              value: "Virtio",
 | 
				
			||||||
 | 
					              description:
 | 
				
			||||||
 | 
					                "Recommended model, but will require specific drivers on OS that do not support it.",
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          ]}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {/* Defined network selection */}
 | 
				
			||||||
        {p.network.type === "DefinedNetwork" && (
 | 
					        {p.network.type === "DefinedNetwork" && (
 | 
				
			||||||
          <>
 | 
					 | 
				
			||||||
          <SelectInput
 | 
					          <SelectInput
 | 
				
			||||||
            editable={p.editable}
 | 
					            editable={p.editable}
 | 
				
			||||||
            label="Defined network"
 | 
					            label="Defined network"
 | 
				
			||||||
@@ -173,7 +209,29 @@ function NetworkInfoWidget(p: {
 | 
				
			|||||||
              p.onChange?.();
 | 
					              p.onChange?.();
 | 
				
			||||||
            }}
 | 
					            }}
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {/* Bridge selection */}
 | 
				
			||||||
 | 
					        {p.network.type === "Bridge" && (
 | 
				
			||||||
 | 
					          <SelectInput
 | 
				
			||||||
 | 
					            editable={p.editable}
 | 
				
			||||||
 | 
					            label="Host bridge"
 | 
				
			||||||
 | 
					            options={p.bridgesList.map((n) => {
 | 
				
			||||||
 | 
					              return {
 | 
				
			||||||
 | 
					                label: n,
 | 
				
			||||||
 | 
					                value: n,
 | 
				
			||||||
 | 
					              };
 | 
				
			||||||
 | 
					            })}
 | 
				
			||||||
 | 
					            value={p.network.bridge}
 | 
				
			||||||
 | 
					            onValueChange={(v) => {
 | 
				
			||||||
 | 
					              if (p.network.type === "Bridge") p.network.bridge = v as any;
 | 
				
			||||||
 | 
					              p.onChange?.();
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {p.network.type !== "UserspaceSLIRPStack" && (
 | 
				
			||||||
 | 
					          <>
 | 
				
			||||||
            {/* Network Filter */}
 | 
					            {/* Network Filter */}
 | 
				
			||||||
            <NWFilterSelectInput
 | 
					            <NWFilterSelectInput
 | 
				
			||||||
              editable={p.editable}
 | 
					              editable={p.editable}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,7 @@
 | 
				
			|||||||
import {
 | 
					import {
 | 
				
			||||||
  Checkbox,
 | 
					  Checkbox,
 | 
				
			||||||
  FormControlLabel,
 | 
					  FormControlLabel,
 | 
				
			||||||
 | 
					  Grid,
 | 
				
			||||||
  Paper,
 | 
					  Paper,
 | 
				
			||||||
  Table,
 | 
					  Table,
 | 
				
			||||||
  TableBody,
 | 
					  TableBody,
 | 
				
			||||||
@@ -59,6 +60,8 @@ export function TokenRightsEditor(p: {
 | 
				
			|||||||
              <TableCell align="center">Get XML definition</TableCell>
 | 
					              <TableCell align="center">Get XML definition</TableCell>
 | 
				
			||||||
              <TableCell align="center">Get autostart</TableCell>
 | 
					              <TableCell align="center">Get autostart</TableCell>
 | 
				
			||||||
              <TableCell align="center">Set autostart</TableCell>
 | 
					              <TableCell align="center">Set autostart</TableCell>
 | 
				
			||||||
 | 
					              <TableCell align="center">Get CloudInit disk</TableCell>
 | 
				
			||||||
 | 
					              <TableCell align="center">Backup disk</TableCell>
 | 
				
			||||||
            </TableRow>
 | 
					            </TableRow>
 | 
				
			||||||
          </TableHead>
 | 
					          </TableHead>
 | 
				
			||||||
          <TableBody>
 | 
					          <TableBody>
 | 
				
			||||||
@@ -82,6 +85,17 @@ export function TokenRightsEditor(p: {
 | 
				
			|||||||
                {...p}
 | 
					                {...p}
 | 
				
			||||||
                right={{ verb: "PUT", path: "/api/vm/*/autostart" }}
 | 
					                right={{ verb: "PUT", path: "/api/vm/*/autostart" }}
 | 
				
			||||||
              />
 | 
					              />
 | 
				
			||||||
 | 
					              <CellRight
 | 
				
			||||||
 | 
					                {...p}
 | 
				
			||||||
 | 
					                right={{
 | 
				
			||||||
 | 
					                  verb: "GET",
 | 
				
			||||||
 | 
					                  path: "/api/vm/*/cloud_init_disk",
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					              <CellRight
 | 
				
			||||||
 | 
					                {...p}
 | 
				
			||||||
 | 
					                right={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }}
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
            </TableRow>
 | 
					            </TableRow>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            {/* Per VM operations */}
 | 
					            {/* Per VM operations */}
 | 
				
			||||||
@@ -118,6 +132,22 @@ export function TokenRightsEditor(p: {
 | 
				
			|||||||
                  right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }}
 | 
					                  right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }}
 | 
				
			||||||
                  parent={{ verb: "PUT", path: "/api/vm/*/autostart" }}
 | 
					                  parent={{ verb: "PUT", path: "/api/vm/*/autostart" }}
 | 
				
			||||||
                />
 | 
					                />
 | 
				
			||||||
 | 
					                <CellRight
 | 
				
			||||||
 | 
					                  {...p}
 | 
				
			||||||
 | 
					                  right={{
 | 
				
			||||||
 | 
					                    verb: "GET",
 | 
				
			||||||
 | 
					                    path: `/api/vm/${v.uuid}/cloud_init_disk`,
 | 
				
			||||||
 | 
					                  }}
 | 
				
			||||||
 | 
					                  parent={{ verb: "GET", path: "/api/vm/*/cloud_init_disk" }}
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
 | 
					                <CellRight
 | 
				
			||||||
 | 
					                  {...p}
 | 
				
			||||||
 | 
					                  right={{
 | 
				
			||||||
 | 
					                    verb: "POST",
 | 
				
			||||||
 | 
					                    path: `/api/vm/${v.uuid}/disk/*/backup`,
 | 
				
			||||||
 | 
					                  }}
 | 
				
			||||||
 | 
					                  parent={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }}
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
              </TableRow>
 | 
					              </TableRow>
 | 
				
			||||||
            ))}
 | 
					            ))}
 | 
				
			||||||
          </TableBody>
 | 
					          </TableBody>
 | 
				
			||||||
@@ -669,6 +699,43 @@ export function TokenRightsEditor(p: {
 | 
				
			|||||||
        </Table>
 | 
					        </Table>
 | 
				
			||||||
      </RightsSection>
 | 
					      </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 */}
 | 
					          {/* ISO files */}
 | 
				
			||||||
          <RightsSection label="ISO files">
 | 
					          <RightsSection label="ISO files">
 | 
				
			||||||
            <RouteRight
 | 
					            <RouteRight
 | 
				
			||||||
@@ -697,6 +764,8 @@ export function TokenRightsEditor(p: {
 | 
				
			|||||||
              label="Delete ISO files"
 | 
					              label="Delete ISO files"
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </RightsSection>
 | 
					          </RightsSection>
 | 
				
			||||||
 | 
					        </Grid>
 | 
				
			||||||
 | 
					      </Grid>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {/* Server general information */}
 | 
					      {/* Server general information */}
 | 
				
			||||||
      <RightsSection label="Server">
 | 
					      <RightsSection label="Server">
 | 
				
			||||||
@@ -725,6 +794,16 @@ export function TokenRightsEditor(p: {
 | 
				
			|||||||
          right={{ verb: "GET", path: "/api/server/networks" }}
 | 
					          right={{ verb: "GET", path: "/api/server/networks" }}
 | 
				
			||||||
          label="Get list of network cards"
 | 
					          label="Get list of network cards"
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
 | 
					        <RouteRight
 | 
				
			||||||
 | 
					          {...p}
 | 
				
			||||||
 | 
					          right={{ verb: "GET", path: "/api/server/bridges" }}
 | 
				
			||||||
 | 
					          label="Get list of network bridges"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					        <RouteRight
 | 
				
			||||||
 | 
					          {...p}
 | 
				
			||||||
 | 
					          right={{ verb: "GET", path: "/api/server/export_configs" }}
 | 
				
			||||||
 | 
					          label="Export all configurations"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
      </RightsSection>
 | 
					      </RightsSection>
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,12 +5,13 @@ import Grid from "@mui/material/Grid";
 | 
				
			|||||||
import React from "react";
 | 
					import React from "react";
 | 
				
			||||||
import { useNavigate } from "react-router-dom";
 | 
					import { useNavigate } from "react-router-dom";
 | 
				
			||||||
import { validate as validateUUID } from "uuid";
 | 
					import { validate as validateUUID } from "uuid";
 | 
				
			||||||
 | 
					import { DiskImage, DiskImageApi } from "../../api/DiskImageApi";
 | 
				
			||||||
import { GroupApi } from "../../api/GroupApi";
 | 
					import { GroupApi } from "../../api/GroupApi";
 | 
				
			||||||
import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi";
 | 
					import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi";
 | 
				
			||||||
import { NWFilter, NWFilterApi } from "../../api/NWFilterApi";
 | 
					import { NWFilter, NWFilterApi } from "../../api/NWFilterApi";
 | 
				
			||||||
import { NetworkApi, NetworkInfo } from "../../api/NetworksApi";
 | 
					import { NetworkApi, NetworkInfo } from "../../api/NetworksApi";
 | 
				
			||||||
import { ServerApi } from "../../api/ServerApi";
 | 
					import { ServerApi } from "../../api/ServerApi";
 | 
				
			||||||
import { VMApi, VMInfo } from "../../api/VMApi";
 | 
					import { VMApi, VMInfo, VMState } from "../../api/VMApi";
 | 
				
			||||||
import { useAlert } from "../../hooks/providers/AlertDialogProvider";
 | 
					import { useAlert } from "../../hooks/providers/AlertDialogProvider";
 | 
				
			||||||
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
 | 
					import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
 | 
				
			||||||
import { useSnackbar } from "../../hooks/providers/SnackbarProvider";
 | 
					import { useSnackbar } from "../../hooks/providers/SnackbarProvider";
 | 
				
			||||||
@@ -18,7 +19,9 @@ 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 { ResAutostartInput } from "../forms/ResAutostartInput";
 | 
					import { ResAutostartInput } from "../forms/ResAutostartInput";
 | 
				
			||||||
import { SelectInput } from "../forms/SelectInput";
 | 
					import { SelectInput } from "../forms/SelectInput";
 | 
				
			||||||
import { TextInput } from "../forms/TextInput";
 | 
					import { TextInput } from "../forms/TextInput";
 | 
				
			||||||
@@ -32,11 +35,16 @@ interface DetailsProps {
 | 
				
			|||||||
  editable: boolean;
 | 
					  editable: boolean;
 | 
				
			||||||
  onChange?: () => void;
 | 
					  onChange?: () => void;
 | 
				
			||||||
  screenshot?: boolean;
 | 
					  screenshot?: boolean;
 | 
				
			||||||
 | 
					  state?: VMState | undefined;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function VMDetails(p: DetailsProps): React.ReactElement {
 | 
					export function VMDetails(p: DetailsProps): React.ReactElement {
 | 
				
			||||||
  const [groupsList, setGroupsList] = React.useState<string[] | undefined>();
 | 
					  const [groupsList, setGroupsList] = React.useState<string[] | undefined>();
 | 
				
			||||||
 | 
					  const [diskImagesList, setDiskImagesList] = React.useState<
 | 
				
			||||||
 | 
					    DiskImage[] | undefined
 | 
				
			||||||
 | 
					  >();
 | 
				
			||||||
  const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>();
 | 
					  const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>();
 | 
				
			||||||
 | 
					  const [bridgesList, setBridgesList] = React.useState<string[] | undefined>();
 | 
				
			||||||
  const [vcpuCombinations, setVCPUCombinations] = React.useState<
 | 
					  const [vcpuCombinations, setVCPUCombinations] = React.useState<
 | 
				
			||||||
    number[] | undefined
 | 
					    number[] | undefined
 | 
				
			||||||
  >();
 | 
					  >();
 | 
				
			||||||
@@ -49,7 +57,9 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const load = async () => {
 | 
					  const load = async () => {
 | 
				
			||||||
    setGroupsList(await GroupApi.GetList());
 | 
					    setGroupsList(await GroupApi.GetList());
 | 
				
			||||||
 | 
					    setDiskImagesList(await DiskImageApi.GetList());
 | 
				
			||||||
    setIsoList(await IsoFilesApi.GetList());
 | 
					    setIsoList(await IsoFilesApi.GetList());
 | 
				
			||||||
 | 
					    setBridgesList(await ServerApi.GetNetworksBridgesList());
 | 
				
			||||||
    setVCPUCombinations(await ServerApi.NumberVCPUs());
 | 
					    setVCPUCombinations(await ServerApi.NumberVCPUs());
 | 
				
			||||||
    setNetworksList(await NetworkApi.GetList());
 | 
					    setNetworksList(await NetworkApi.GetList());
 | 
				
			||||||
    setNetworkFiltersList(await NWFilterApi.GetList());
 | 
					    setNetworkFiltersList(await NWFilterApi.GetList());
 | 
				
			||||||
@@ -63,7 +73,9 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
 | 
				
			|||||||
      build={() => (
 | 
					      build={() => (
 | 
				
			||||||
        <VMDetailsInner
 | 
					        <VMDetailsInner
 | 
				
			||||||
          groupsList={groupsList!}
 | 
					          groupsList={groupsList!}
 | 
				
			||||||
 | 
					          diskImagesList={diskImagesList!}
 | 
				
			||||||
          isoList={isoList!}
 | 
					          isoList={isoList!}
 | 
				
			||||||
 | 
					          bridgesList={bridgesList!}
 | 
				
			||||||
          vcpuCombinations={vcpuCombinations!}
 | 
					          vcpuCombinations={vcpuCombinations!}
 | 
				
			||||||
          networksList={networksList!}
 | 
					          networksList={networksList!}
 | 
				
			||||||
          networkFiltersList={networkFiltersList!}
 | 
					          networkFiltersList={networkFiltersList!}
 | 
				
			||||||
@@ -78,13 +90,17 @@ enum VMTab {
 | 
				
			|||||||
  General = 0,
 | 
					  General = 0,
 | 
				
			||||||
  Storage,
 | 
					  Storage,
 | 
				
			||||||
  Network,
 | 
					  Network,
 | 
				
			||||||
 | 
					  CloudInit,
 | 
				
			||||||
 | 
					  Advanced,
 | 
				
			||||||
  XML,
 | 
					  XML,
 | 
				
			||||||
  Danger,
 | 
					  Danger,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type DetailsInnerProps = DetailsProps & {
 | 
					type DetailsInnerProps = DetailsProps & {
 | 
				
			||||||
  groupsList: string[];
 | 
					  groupsList: string[];
 | 
				
			||||||
 | 
					  diskImagesList: DiskImage[];
 | 
				
			||||||
  isoList: IsoFile[];
 | 
					  isoList: IsoFile[];
 | 
				
			||||||
 | 
					  bridgesList: string[];
 | 
				
			||||||
  vcpuCombinations: number[];
 | 
					  vcpuCombinations: number[];
 | 
				
			||||||
  networksList: NetworkInfo[];
 | 
					  networksList: NetworkInfo[];
 | 
				
			||||||
  networkFiltersList: NWFilter[];
 | 
					  networkFiltersList: NWFilter[];
 | 
				
			||||||
@@ -102,6 +118,13 @@ 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: "XML",
 | 
					            label: "XML",
 | 
				
			||||||
            value: VMTab.XML,
 | 
					            value: VMTab.XML,
 | 
				
			||||||
@@ -119,6 +142,8 @@ 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.XML && <VMDetailsTabXML {...p} />}
 | 
					      {currTab === VMTab.XML && <VMDetailsTabXML {...p} />}
 | 
				
			||||||
      {currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />}
 | 
					      {currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />}
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
@@ -263,6 +288,7 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement {
 | 
				
			|||||||
          options={[
 | 
					          options={[
 | 
				
			||||||
            { label: "UEFI with Secure Boot", value: "UEFISecureBoot" },
 | 
					            { label: "UEFI with Secure Boot", value: "UEFISecureBoot" },
 | 
				
			||||||
            { label: "UEFI", value: "UEFI" },
 | 
					            { label: "UEFI", value: "UEFI" },
 | 
				
			||||||
 | 
					            { label: "Legacy", value: "Legacy" },
 | 
				
			||||||
          ]}
 | 
					          ]}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -270,14 +296,16 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement {
 | 
				
			|||||||
          label="Memory (MB)"
 | 
					          label="Memory (MB)"
 | 
				
			||||||
          editable={p.editable}
 | 
					          editable={p.editable}
 | 
				
			||||||
          type="number"
 | 
					          type="number"
 | 
				
			||||||
          value={p.vm.memory.toString()}
 | 
					          value={Math.floor(p.vm.memory / (1000 * 1000)).toString()}
 | 
				
			||||||
          onValueChange={(v) => {
 | 
					          onValueChange={(v) => {
 | 
				
			||||||
            p.vm.memory = Number(v ?? "0");
 | 
					            p.vm.memory = Number(v ?? "0") * 1000 * 1000;
 | 
				
			||||||
            p.onChange?.();
 | 
					            p.onChange?.();
 | 
				
			||||||
          }}
 | 
					          }}
 | 
				
			||||||
          checkValue={(v) =>
 | 
					          checkValue={(v) =>
 | 
				
			||||||
            Number(v) > ServerApi.Config.constraints.memory_size.min &&
 | 
					            Number(v) >
 | 
				
			||||||
            Number(v) < ServerApi.Config.constraints.memory_size.max
 | 
					              ServerApi.Config.constraints.memory_size.min / (1000 * 1000) &&
 | 
				
			||||||
 | 
					            Number(v) <
 | 
				
			||||||
 | 
					              ServerApi.Config.constraints.memory_size.max / (1000 * 1000)
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -334,8 +362,8 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement {
 | 
				
			|||||||
function VMDetailsTabStorage(p: DetailsInnerProps): React.ReactElement {
 | 
					function VMDetailsTabStorage(p: DetailsInnerProps): React.ReactElement {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <Grid container spacing={2}>
 | 
					    <Grid container spacing={2}>
 | 
				
			||||||
      {(p.editable || p.vm.disks.length > 0) && (
 | 
					      {(p.editable || p.vm.file_disks.length > 0) && (
 | 
				
			||||||
        <EditSection title="Disks storage">
 | 
					        <EditSection title="File disks storage">
 | 
				
			||||||
          <VMDisksList {...p} />
 | 
					          <VMDisksList {...p} />
 | 
				
			||||||
        </EditSection>
 | 
					        </EditSection>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
@@ -361,6 +389,19 @@ 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 {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Grid container spacing={2}>
 | 
				
			||||||
 | 
					      {/* OEM strings */}
 | 
				
			||||||
 | 
					      <OEMStringFormWidget {...p} />
 | 
				
			||||||
 | 
					    </Grid>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function VMDetailsTabXML(p: DetailsInnerProps): React.ReactElement {
 | 
					function VMDetailsTabXML(p: DetailsInnerProps): React.ReactElement {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <XMLAsyncWidget
 | 
					    <XMLAsyncWidget
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										72
									
								
								virtweb_frontend/src/widgets/vms/VMDiskFileWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								virtweb_frontend/src/widgets/vms/VMDiskFileWidget.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,72 @@
 | 
				
			|||||||
 | 
					import { mdiHarddisk } from "@mdi/js";
 | 
				
			||||||
 | 
					import { Icon } from "@mdi/react";
 | 
				
			||||||
 | 
					import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material";
 | 
				
			||||||
 | 
					import { filesize } from "filesize";
 | 
				
			||||||
 | 
					import { VMFileDisk } from "../../api/VMApi";
 | 
				
			||||||
 | 
					import { DiskBusSelect } from "../forms/DiskBusSelect";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function VMDiskFileWidget(p: {
 | 
				
			||||||
 | 
					  editable?: boolean;
 | 
				
			||||||
 | 
					  disk: VMFileDisk;
 | 
				
			||||||
 | 
					  secondaryAction?: React.ReactElement;
 | 
				
			||||||
 | 
					  onChange?: () => void;
 | 
				
			||||||
 | 
					}): React.ReactElement {
 | 
				
			||||||
 | 
					  const info = [filesize(p.disk.size), p.disk.format];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (p.disk.format === "Raw") info.push(p.disk.is_sparse ? "Sparse" : "Fixed");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!p.editable) info.push(p.disk.bus);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <ListItem secondaryAction={p.secondaryAction}>
 | 
				
			||||||
 | 
					      <ListItemAvatar>
 | 
				
			||||||
 | 
					        <Avatar>
 | 
				
			||||||
 | 
					          <Icon path={mdiHarddisk} />
 | 
				
			||||||
 | 
					        </Avatar>
 | 
				
			||||||
 | 
					      </ListItemAvatar>
 | 
				
			||||||
 | 
					      <ListItemText
 | 
				
			||||||
 | 
					        primary={
 | 
				
			||||||
 | 
					          <>
 | 
				
			||||||
 | 
					            {p.disk.name}{" "}
 | 
				
			||||||
 | 
					            {p.disk.deleteType && (
 | 
				
			||||||
 | 
					              <span style={{ color: "red" }}>
 | 
				
			||||||
 | 
					                {p.disk.deleteType === "deletefile"
 | 
				
			||||||
 | 
					                  ? "Remove, DELETING block file"
 | 
				
			||||||
 | 
					                  : "Remove, keeping block file"}
 | 
				
			||||||
 | 
					              </span>
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					          </>
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        secondary={
 | 
				
			||||||
 | 
					          <div style={{ display: "flex", alignItems: "center" }}>
 | 
				
			||||||
 | 
					            {p.editable ? (
 | 
				
			||||||
 | 
					              <div
 | 
				
			||||||
 | 
					                style={{
 | 
				
			||||||
 | 
					                  maxWidth: "80px",
 | 
				
			||||||
 | 
					                  display: "inline-block",
 | 
				
			||||||
 | 
					                  marginRight: "10px",
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					                <DiskBusSelect
 | 
				
			||||||
 | 
					                  onValueChange={(v) => {
 | 
				
			||||||
 | 
					                    p.disk.bus = v;
 | 
				
			||||||
 | 
					                    p.onChange?.();
 | 
				
			||||||
 | 
					                  }}
 | 
				
			||||||
 | 
					                  label=""
 | 
				
			||||||
 | 
					                  editable
 | 
				
			||||||
 | 
					                  value={p.disk.bus}
 | 
				
			||||||
 | 
					                  size="small"
 | 
				
			||||||
 | 
					                  disableUnderline
 | 
				
			||||||
 | 
					                  disableBottomMargin
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            ) : (
 | 
				
			||||||
 | 
					              ""
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					            <div style={{ height: "100%" }}>{info.join(" - ")}</div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </ListItem>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Reference in New Issue
	
	Block a user