Compare commits
	
		
			62 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | ||||||
|   | |||||||
							
								
								
									
										573
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										573
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -7,12 +7,12 @@ 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.38", 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" | ||||||
| @@ -22,27 +22,25 @@ actix-ws = "0.3.0" | |||||||
| actix-http = "3.10.0" | actix-http = "3.10.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"] } | 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.15", 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.1", 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" | ||||||
|   | |||||||
							
								
								
									
										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" | ||||||
|  |   } | ||||||
|  | ] | ||||||
| @@ -245,11 +245,21 @@ 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 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 +270,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,31 @@ 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; 3] = [ | ||||||
|  |     "application/x-qemu-disk", | ||||||
|  |     "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 +51,11 @@ 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); | ||||||
|  |  | ||||||
| /// 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 +120,21 @@ 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 QEMU_IMAGE_PROGRAM: &str = "/usr/bin/qemu-img"; | ||||||
|  |  | ||||||
|  | /// IP program path | ||||||
|  | pub const IP_PROGRAM: &str = "/usr/sbin/ip"; | ||||||
|  |  | ||||||
|  | /// Copy program path | ||||||
|  | pub const COPY_PROGRAM: &str = "/bin/cp"; | ||||||
|  |  | ||||||
|  | /// Gzip program path | ||||||
|  | pub const GZIP_PROGRAM: &str = "/usr/bin/gzip"; | ||||||
|  |  | ||||||
|  | /// Bash program | ||||||
|  | pub const BASH_PROGRAM: &str = "/usr/bin/bash"; | ||||||
|  |  | ||||||
|  | /// DD program | ||||||
|  | pub const DD_PROGRAM: &str = "/usr/bin/dd"; | ||||||
|   | |||||||
							
								
								
									
										247
									
								
								virtweb_backend/src/controllers/disk_images_controller.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										247
									
								
								virtweb_backend/src/controllers/disk_images_controller.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,247 @@ | |||||||
|  | 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)?; | ||||||
|  |  | ||||||
|  |     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,10 @@ 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; |  | ||||||
|  |  | ||||||
| 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 +62,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 +98,7 @@ 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()) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -16,6 +16,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 +38,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 +46,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 +66,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 +80,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 +195,7 @@ 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()?)) | ||||||
|  | } | ||||||
|   | |||||||
| @@ -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), | ||||||
|   | |||||||
| @@ -22,10 +22,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 +52,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 +93,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 +329,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 +382,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, | ||||||
|   | |||||||
| @@ -4,9 +4,9 @@ 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::StructureExtraction; | ||||||
| use crate::utils::disks_utils::Disk; | 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 +17,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 +30,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 +53,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 +61,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 +78,22 @@ 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>, | ||||||
| } | } | ||||||
|  |  | ||||||
| impl VMInfo { | impl VMInfo { | ||||||
| @@ -129,6 +140,7 @@ impl VMInfo { | |||||||
|  |  | ||||||
|         let mut disks = vec![]; |         let mut disks = vec![]; | ||||||
|  |  | ||||||
|  |         // Add 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()); | ||||||
| @@ -192,7 +204,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 +253,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 +273,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 +300,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 +314,11 @@ impl VMInfo { | |||||||
|                         "vd{}", |                         "vd{}", | ||||||
|                         ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"][disks.len()] |                         ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"][disks.len()] | ||||||
|                     ), |                     ), | ||||||
|                     bus: "virtio".to_string(), |                     bus: match disk.bus { | ||||||
|  |                         VMDiskBus::Virtio => "virtio", | ||||||
|  |                         VMDiskBus::SATA => "sata", | ||||||
|  |                     } | ||||||
|  |                     .to_string(), | ||||||
|                 }, |                 }, | ||||||
|                 readonly: None, |                 readonly: None, | ||||||
|                 boot: DiskBootXML { |                 boot: DiskBootXML { | ||||||
| @@ -312,12 +352,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 +409,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 +431,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 +463,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 +482,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 | ||||||
| @@ -424,12 +492,15 @@ impl VMInfo { | |||||||
|                 .map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string()) |                 .map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string()) | ||||||
|                 .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 +513,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 +548,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 +576,12 @@ 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(), | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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,23 @@ 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::QEMU_IMAGE_PROGRAM, | ||||||
|  |         "QEMU disk image utility is required to manipulate QCow2 files!", | ||||||
|  |     ); | ||||||
|  |     exec_utils::check_program( | ||||||
|  |         constants::IP_PROGRAM, | ||||||
|  |         "ip is required to access bridges information!", | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     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().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 +122,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 +147,10 @@ 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), | ||||||
|  |             ) | ||||||
|             // Auth controller |             // Auth controller | ||||||
|             .route( |             .route( | ||||||
|                 "/api/auth/local", |                 "/api/auth/local", | ||||||
| @@ -315,6 +335,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 +386,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:.*}", | ||||||
|   | |||||||
| @@ -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}"); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										352
									
								
								virtweb_backend/src/utils/file_disks_utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										352
									
								
								virtweb_backend/src/utils/file_disks_utils.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,352 @@ | |||||||
|  | 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, | ||||||
|  |     }, | ||||||
|  |     CompressedRaw, | ||||||
|  |     CompressedQCow2, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl DiskFileFormat { | ||||||
|  |     pub fn ext(&self) -> &'static [&'static str] { | ||||||
|  |         match self { | ||||||
|  |             DiskFileFormat::Raw { .. } => &["raw", ""], | ||||||
|  |             DiskFileFormat::QCow2 { .. } => &["qcow2"], | ||||||
|  |             DiskFileFormat::CompressedRaw => &["raw.gz"], | ||||||
|  |             DiskFileFormat::CompressedQCow2 => &["qcow2.gz"], | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 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::CompressedQCow2 | ||||||
|  |             } | ||||||
|  |             "gz" => DiskFileFormat::CompressedRaw, | ||||||
|  |             _ => 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::QEMU_IMAGE_PROGRAM); | ||||||
|  |                 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 | ||||||
|  |             (DiskFileFormat::CompressedQCow2, DiskFileFormat::QCow2 { .. }) => { | ||||||
|  |                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||||
|  |                 cmd.arg("--keep") | ||||||
|  |                     .arg("--decompress") | ||||||
|  |                     .arg("--to-stdout") | ||||||
|  |                     .arg(&self.file_path) | ||||||
|  |                     .stdout(File::create(&temp_file)?); | ||||||
|  |                 cmd | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Compress QCow2 | ||||||
|  |             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::CompressedQCow2) => { | ||||||
|  |                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||||
|  |                 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::QEMU_IMAGE_PROGRAM); | ||||||
|  |                 cmd.arg("convert").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::QEMU_IMAGE_PROGRAM); | ||||||
|  |                 cmd.arg("convert") | ||||||
|  |                     .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::QEMU_IMAGE_PROGRAM); | ||||||
|  |                 cmd.arg("convert").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::COPY_PROGRAM); | ||||||
|  |                 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::DD_PROGRAM); | ||||||
|  |                 cmd.arg("conv=sparse") | ||||||
|  |                     .arg(format!("if={}", self.file_path.display())) | ||||||
|  |                     .arg(format!("of={}", temp_file.display())); | ||||||
|  |                 cmd | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Compress Raw | ||||||
|  |             (DiskFileFormat::Raw { .. }, DiskFileFormat::CompressedRaw) => { | ||||||
|  |                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||||
|  |                 cmd.arg("--keep") | ||||||
|  |                     .arg("--to-stdout") | ||||||
|  |                     .arg(&self.file_path) | ||||||
|  |                     .stdout(File::create(&temp_file)?); | ||||||
|  |                 cmd | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Decompress Raw to not sparse file | ||||||
|  |             (DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => { | ||||||
|  |                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||||
|  |                 cmd.arg("--keep") | ||||||
|  |                     .arg("--decompress") | ||||||
|  |                     .arg("--to-stdout") | ||||||
|  |                     .arg(&self.file_path) | ||||||
|  |                     .stdout(File::create(&temp_file)?); | ||||||
|  |                 cmd | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Decompress Raw to sparse file | ||||||
|  |             // https://benou.fr/www/ben/decompressing-sparse-files.html | ||||||
|  |             (DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => { | ||||||
|  |                 let mut cmd = Command::new(constants::BASH_PROGRAM); | ||||||
|  |                 cmd.arg("-c").arg(format!( | ||||||
|  |                     "{} -d -c {} | {} conv=sparse of={}", | ||||||
|  |                     constants::GZIP_PROGRAM, | ||||||
|  |                     self.file_path.display(), | ||||||
|  |                     constants::DD_PROGRAM, | ||||||
|  |                     temp_file.display() | ||||||
|  |                 )); | ||||||
|  |                 cmd | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Dumb copy of file | ||||||
|  |             (a, b) if a == b => { | ||||||
|  |                 let mut cmd = Command::new(constants::COPY_PROGRAM); | ||||||
|  |                 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(()) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[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::QEMU_IMAGE_PROGRAM); | ||||||
|  |     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::QEMU_IMAGE_PROGRAM, | ||||||
|  |             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,9 @@ | |||||||
| pub mod disks_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::IP_PROGRAM); | ||||||
|  |     cmd.args(["-json", "link", "show", "type", "bridge"]); | ||||||
|  |     let output = cmd.output()?; | ||||||
|  |     if !output.status.success() { | ||||||
|  |         anyhow::bail!( | ||||||
|  |             "{} failed, status: {}, stderr: {}", | ||||||
|  |             constants::IP_PROGRAM, | ||||||
|  |             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::{ | ||||||
|   | |||||||
							
								
								
									
										174
									
								
								virtweb_backend/src/utils/vm_file_disks_utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								virtweb_backend/src/utils/vm_file_disks_utils.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,174 @@ | |||||||
|  | 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 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, | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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"); | ||||||
|  |             return Ok(()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         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)?; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         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 | ||||||
|  | ``` | ||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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: | ||||||
|   | |||||||
							
								
								
									
										1657
									
								
								virtweb_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1657
									
								
								virtweb_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -13,41 +13,40 @@ | |||||||
|     "@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.5", | ||||||
|     "@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", |     "@mui/icons-material": "^7.1.0", | ||||||
|     "@mui/material": "^7.0.0", |     "@mui/material": "^7.1.0", | ||||||
|     "@mui/x-charts": "^7.28.0", |     "@mui/x-charts": "^8.3.1", | ||||||
|     "@mui/x-data-grid": "^7.28.1", |     "@mui/x-data-grid": "^8.3.1", | ||||||
|     "date-and-time": "^3.6.0", |     "date-and-time": "^3.6.0", | ||||||
|     "filesize": "^10.1.6", |     "filesize": "^10.1.6", | ||||||
|     "humanize-duration": "^3.29.0", |     "humanize-duration": "^3.32.2", | ||||||
|     "mui-file-input": "^7.0.0", |     "react": "^19.1.0", | ||||||
|     "react": "^19.0.0", |     "react-dom": "^19.1.0", | ||||||
|     "react-dom": "^19.0.0", |     "react-router-dom": "^7.6.0", | ||||||
|     "react-router-dom": "^7.4.0", |  | ||||||
|     "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" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@eslint/js": "^9.21.0", |     "@eslint/js": "^9.27.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.5", | ||||||
|     "@types/react-dom": "^19.0.4", |     "@types/react-dom": "^19.1.5", | ||||||
|     "@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.4.1", | ||||||
|     "eslint": "^9.21.0", |     "eslint": "^9.27.0", | ||||||
|     "eslint-plugin-react-dom": "^1.38.3", |     "eslint-plugin-react-dom": "^1.49.0", | ||||||
|     "eslint-plugin-react-hooks": "^5.1.0", |     "eslint-plugin-react-hooks": "^5.1.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 | ||||||
|   | |||||||
							
								
								
									
										117
									
								
								virtweb_frontend/src/api/DiskImageApi.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								virtweb_frontend/src/api/DiskImageApi.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,117 @@ | |||||||
|  | import { APIClient } from "./ApiClient"; | ||||||
|  | import { VMFileDisk, VMInfo } from "./VMApi"; | ||||||
|  |  | ||||||
|  | export type DiskImageFormat = | ||||||
|  |   | { format: "Raw"; is_sparse: boolean } | ||||||
|  |   | { format: "QCow2"; virtual_size?: number } | ||||||
|  |   | { format: "CompressedQCow2" } | ||||||
|  |   | { format: "CompressedRaw" }; | ||||||
|  |  | ||||||
|  | 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,16 @@ 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; | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -17,19 +17,34 @@ 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; | ||||||
|  |  | ||||||
|  |   // application attributes | ||||||
|   new?: boolean; |   new?: boolean; | ||||||
|   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 +55,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 +77,13 @@ export interface VMNetDefinedNetwork { | |||||||
|   network: string; |   network: string; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export interface VMNetBridge { | ||||||
|  |   type: "Bridge"; | ||||||
|  |   bridge: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export type VMBootType = "UEFI" | "UEFISecureBoot" | "Legacy"; | ||||||
|  |  | ||||||
| interface VMInfoInterface { | interface VMInfoInterface { | ||||||
|   name: string; |   name: string; | ||||||
|   uuid?: string; |   uuid?: string; | ||||||
| @@ -64,15 +91,16 @@ 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[]; | ||||||
| } | } | ||||||
|  |  | ||||||
| export class VMInfo implements VMInfoInterface { | export class VMInfo implements VMInfoInterface { | ||||||
| @@ -82,15 +110,16 @@ 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[]; | ||||||
|  |  | ||||||
|   constructor(int: VMInfoInterface) { |   constructor(int: VMInfoInterface) { | ||||||
|     this.name = int.name; |     this.name = int.name; | ||||||
| @@ -105,9 +134,10 @@ 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; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   static NewEmpty(): VMInfo { |   static NewEmpty(): VMInfo { | ||||||
| @@ -115,13 +145,14 @@ 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: [], | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -194,8 +225,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; | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										144
									
								
								virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,144 @@ | |||||||
|  | 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 === "CompressedQCow2") setFilename(`${origFilename}.qcow2.gz`); | ||||||
|  |     if (value === "Raw") { | ||||||
|  |       setFilename(`${origFilename}.raw`); | ||||||
|  |       // Check sparse checkbox by default | ||||||
|  |       setFormat({ format: "Raw", is_sparse: true }); | ||||||
|  |     } | ||||||
|  |     if (value === "CompressedRaw") setFilename(`${origFilename}.raw.gz`); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   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: "CompressedRaw" }, | ||||||
|  |             { value: "CompressedQCow2" }, | ||||||
|  |           ]} | ||||||
|  |         /> | ||||||
|  |  | ||||||
|  |         {/* 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> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										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="/"> | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -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", | ||||||
|   | |||||||
							
								
								
									
										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> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										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" | ||||||
|  |     /> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										89
									
								
								virtweb_frontend/src/widgets/forms/OEMStringFormWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								virtweb_frontend/src/widgets/forms/OEMStringFormWidget.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | |||||||
|  | /* 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?.(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   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,7 @@ export function TextInput(p: { | |||||||
|   type?: React.HTMLInputTypeAttribute; |   type?: React.HTMLInputTypeAttribute; | ||||||
|   style?: React.CSSProperties; |   style?: React.CSSProperties; | ||||||
|   helperText?: string; |   helperText?: string; | ||||||
|  |   disabled?: boolean; | ||||||
| }): React.ReactElement { | }): React.ReactElement { | ||||||
|   if (!p.editable && (p.value ?? "") === "") return <></>; |   if (!p.editable && (p.value ?? "") === "") return <></>; | ||||||
|  |  | ||||||
| @@ -35,6 +36,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) => | ||||||
|   | |||||||
| @@ -1,67 +1,96 @@ | |||||||
| 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 { Button, IconButton, Paper, Tooltip } from "@mui/material"; | ||||||
|   Avatar, | import React from "react"; | ||||||
|   Button, | import { DiskImage } from "../../api/DiskImageApi"; | ||||||
|   IconButton, |  | ||||||
|   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 { 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.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 deleteDisk = async () => { |   const deleteDisk = async () => { | ||||||
| @@ -86,9 +115,11 @@ 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 && ( | ||||||
|               <IconButton |               <IconButton | ||||||
|                 edge="end" |                 edge="end" | ||||||
|                 aria-label="delete disk" |                 aria-label="delete disk" | ||||||
| @@ -104,36 +135,27 @@ 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> |  | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|   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 +167,78 @@ 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?.(); | ||||||
|  |           }} | ||||||
|  |         /> | ||||||
|  |       )} | ||||||
|  |  | ||||||
|  |       <TextInput | ||||||
|  |         editable={true} | ||||||
|  |         label="Disk size (GB)" | ||||||
|  |         size={{ | ||||||
|  |           min: | ||||||
|  |             ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000), | ||||||
|  |           max: | ||||||
|  |             ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000), | ||||||
|  |         }} | ||||||
|  |         value={(p.disk.size / (1000 * 1000 * 1000)).toString()} | ||||||
|  |         onValueChange={(v) => { | ||||||
|  |           p.disk.size = Number(v ?? "0") * 1000 * 1000 * 1000; | ||||||
|  |           p.onChange?.(); | ||||||
|  |         }} | ||||||
|  |         type="number" | ||||||
|  |         disabled={!!p.disk.from_image} | ||||||
|  |       /> | ||||||
|  |  | ||||||
|  |       <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> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -29,11 +29,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?.(); | ||||||
| @@ -72,6 +74,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 +133,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 +147,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 +158,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 +202,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,7 @@ 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">Backup disk</TableCell> | ||||||
|             </TableRow> |             </TableRow> | ||||||
|           </TableHead> |           </TableHead> | ||||||
|           <TableBody> |           <TableBody> | ||||||
| @@ -82,6 +84,10 @@ export function TokenRightsEditor(p: { | |||||||
|                 {...p} |                 {...p} | ||||||
|                 right={{ verb: "PUT", path: "/api/vm/*/autostart" }} |                 right={{ verb: "PUT", path: "/api/vm/*/autostart" }} | ||||||
|               /> |               /> | ||||||
|  |               <CellRight | ||||||
|  |                 {...p} | ||||||
|  |                 right={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }} | ||||||
|  |               /> | ||||||
|             </TableRow> |             </TableRow> | ||||||
|  |  | ||||||
|             {/* Per VM operations */} |             {/* Per VM operations */} | ||||||
| @@ -117,6 +123,14 @@ export function TokenRightsEditor(p: { | |||||||
|                   {...p} |                   {...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: "POST", | ||||||
|  |                     path: `/api/vm/${v.uuid}/disk/*/backup`, | ||||||
|  |                   }} | ||||||
|  |                   parent={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }} | ||||||
|                 /> |                 /> | ||||||
|               </TableRow> |               </TableRow> | ||||||
|             ))} |             ))} | ||||||
| @@ -669,6 +683,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 +748,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 +778,11 @@ 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" | ||||||
|  |         /> | ||||||
|       </RightsSection> |       </RightsSection> | ||||||
|     </> |     </> | ||||||
|   ); |   ); | ||||||
|   | |||||||
| @@ -10,7 +10,7 @@ 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"; | ||||||
| @@ -19,6 +19,7 @@ import { TabsWidget } from "../TabsWidget"; | |||||||
| import { XMLAsyncWidget } from "../XMLWidget"; | import { XMLAsyncWidget } from "../XMLWidget"; | ||||||
| import { CheckboxInput } from "../forms/CheckboxInput"; | import { CheckboxInput } from "../forms/CheckboxInput"; | ||||||
| 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"; | ||||||
| @@ -26,17 +27,23 @@ import { VMDisksList } from "../forms/VMDisksList"; | |||||||
| import { VMNetworksList } from "../forms/VMNetworksList"; | import { VMNetworksList } from "../forms/VMNetworksList"; | ||||||
| import { VMSelectIsoInput } from "../forms/VMSelectIsoInput"; | import { VMSelectIsoInput } from "../forms/VMSelectIsoInput"; | ||||||
| import { VMScreenshot } from "./VMScreenshot"; | import { VMScreenshot } from "./VMScreenshot"; | ||||||
|  | import { DiskImage, DiskImageApi } from "../../api/DiskImageApi"; | ||||||
|  |  | ||||||
| interface DetailsProps { | interface DetailsProps { | ||||||
|   vm: VMInfo; |   vm: VMInfo; | ||||||
|   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 +56,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 +72,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 +89,16 @@ enum VMTab { | |||||||
|   General = 0, |   General = 0, | ||||||
|   Storage, |   Storage, | ||||||
|   Network, |   Network, | ||||||
|  |   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 +116,8 @@ 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: "Avanced", value: VMTab.Advanced, visible: true }, | ||||||
|  |  | ||||||
|           { |           { | ||||||
|             label: "XML", |             label: "XML", | ||||||
|             value: VMTab.XML, |             value: VMTab.XML, | ||||||
| @@ -119,6 +135,7 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement { | |||||||
|       {currTab === VMTab.General && <VMDetailsTabGeneral {...p} />} |       {currTab === VMTab.General && <VMDetailsTabGeneral {...p} />} | ||||||
|       {currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />} |       {currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />} | ||||||
|       {currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />} |       {currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />} | ||||||
|  |       {currTab === VMTab.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 +280,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 +288,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 +354,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 +381,15 @@ function VMDetailsTabNetwork(p: DetailsInnerProps): React.ReactElement { | |||||||
|   return <VMNetworksList {...p} />; |   return <VMNetworksList {...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