Compare commits
	
		
			42 Commits
		
	
	
		
			20250409
			...
			9334b984ae
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 9334b984ae | |||
| 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 | 
| @@ -11,7 +11,7 @@ steps: | ||||
|       path: /tmp/web_build | ||||
|   commands: | ||||
|   - cd virtweb_frontend | ||||
|   - npm install --legacy-peer-deps # TODO : remove when mui-file-input is updated | ||||
|   - npm install | ||||
|   - npm run lint | ||||
|   - npm run 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] | ||||
| log = "0.4.27" | ||||
| env_logger = "0.11.7" | ||||
| clap = { version = "4.5.34", features = ["derive", "env"] } | ||||
| env_logger = "0.11.8" | ||||
| clap = { version = "4.5.38", features = ["derive", "env"] } | ||||
| light-openid = { version = "1.0.4", features = ["crypto-wrapper"] } | ||||
| lazy_static = "1.5.0" | ||||
| actix = "0.13.5" | ||||
| actix-web = "4.10.2" | ||||
| actix-web = "4.11.0" | ||||
| actix-remote-ip = "0.1.0" | ||||
| actix-session = { version = "0.10.1", features = ["cookie-session"] } | ||||
| actix-identity = "0.8.0" | ||||
| @@ -22,27 +22,25 @@ actix-ws = "0.3.0" | ||||
| actix-http = "3.10.0" | ||||
| serde = { version = "1.0.219", features = ["derive"] } | ||||
| 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" | ||||
| anyhow = "1.0.97" | ||||
| anyhow = "1.0.98" | ||||
| actix-multipart = "0.7.2" | ||||
| tempfile = "3.19.1" | ||||
| tempfile = "3.20.0" | ||||
| reqwest = { version = "0.12.15", features = ["stream"] } | ||||
| url = "2.5.4" | ||||
| 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"] } | ||||
| lazy-regex = "3.4.1" | ||||
| thiserror = "2.0.12" | ||||
| image = "0.25.6" | ||||
| rand = "0.9.0" | ||||
| bytes = "1.10.1" | ||||
| tokio = { version = "1.44.1", features = ["rt", "time", "macros"] } | ||||
| rand = "0.9.1" | ||||
| tokio = { version = "1.45.1", features = ["rt", "time", "macros"] } | ||||
| futures = "0.3.31" | ||||
| ipnetwork = { version = "0.21.1", features = ["serde"] } | ||||
| num = "0.4.3" | ||||
| rust-embed = { version = "8.6.0" } | ||||
| mime_guess = "2.0.5" | ||||
| rust-embed = { version = "8.7.2", features = ["mime-guess"] } | ||||
| dotenvy = "0.15.7" | ||||
| nix = { version = "0.29.0", features = ["net"] } | ||||
| nix = { version = "0.30.1", features = ["net"] } | ||||
| 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,16 @@ impl AppConfig { | ||||
|         storage_path.canonicalize().unwrap() | ||||
|     } | ||||
|  | ||||
|     /// Get iso storage directory | ||||
|     /// Get iso files storage directory | ||||
|     pub fn iso_storage_path(&self) -> PathBuf { | ||||
|         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 VM vnc sockets directory | ||||
|     pub fn vnc_sockets_path(&self) -> PathBuf { | ||||
|         self.storage_path().join("vnc") | ||||
| @@ -260,15 +265,17 @@ impl AppConfig { | ||||
|         self.vnc_sockets_path().join(format!("vnc-{}", name)) | ||||
|     } | ||||
|  | ||||
|     /// Get VM vnc sockets directory | ||||
|     pub fn disks_storage_path(&self) -> PathBuf { | ||||
|     /// Get VM root disks storage directory | ||||
|     pub fn root_vm_disks_storage_path(&self) -> PathBuf { | ||||
|         self.storage_path().join("disks") | ||||
|     } | ||||
|  | ||||
|     /// Get specific VM disk storage directory | ||||
|     pub fn vm_storage_path(&self, id: XMLUuid) -> PathBuf { | ||||
|         self.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 { | ||||
|         self.storage_path().join("definitions") | ||||
|     } | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| use crate::utils::file_size_utils::FileSize; | ||||
|  | ||||
| /// Name of the cookie that contains session information | ||||
| pub const SESSION_COOKIE_NAME: &str = "X-auth-token"; | ||||
|  | ||||
| @@ -27,6 +29,13 @@ pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [ | ||||
| /// ISO max size | ||||
| pub const ISO_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000; | ||||
|  | ||||
| /// Allowed uploaded disk images formats | ||||
| pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 2] = | ||||
|     ["application/x-qemu-disk", "application/gzip"]; | ||||
|  | ||||
| /// Disk image max size | ||||
| pub const DISK_IMAGE_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000 * 1000; | ||||
|  | ||||
| /// Min VM memory size (MB) | ||||
| pub const MIN_VM_MEMORY: usize = 100; | ||||
|  | ||||
| @@ -39,11 +48,11 @@ pub const DISK_NAME_MIN_LEN: usize = 2; | ||||
| /// Disk name max length | ||||
| pub const DISK_NAME_MAX_LEN: usize = 10; | ||||
|  | ||||
| /// Disk size min (MB) | ||||
| pub const DISK_SIZE_MIN: usize = 100; | ||||
| /// Disk size min (B) | ||||
| pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50); | ||||
|  | ||||
| /// Disk size max (MB) | ||||
| pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 2; | ||||
| /// Disk size max (B) | ||||
| pub const DISK_SIZE_MAX: FileSize = FileSize::from_gb(20000); | ||||
|  | ||||
| /// Net nat entry comment max size | ||||
| pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250; | ||||
| @@ -108,3 +117,21 @@ pub const API_TOKEN_DESCRIPTION_MAX_LENGTH: usize = 30; | ||||
|  | ||||
| /// API token right path max length | ||||
| 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"; | ||||
|   | ||||
							
								
								
									
										164
									
								
								virtweb_backend/src/controllers/disk_images_controller.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								virtweb_backend/src/controllers/disk_images_controller.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,164 @@ | ||||
| use crate::app_config::AppConfig; | ||||
| use crate::constants; | ||||
| use crate::controllers::HttpResult; | ||||
| 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 { | ||||
|         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_storage_path().join(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_storage_path() | ||||
|         .join(&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!")); | ||||
|     } | ||||
|  | ||||
|     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 src_file_path = AppConfig::get() | ||||
|         .disk_images_storage_path() | ||||
|         .join(&p.filename); | ||||
|  | ||||
|     let src = DiskFileInfo::load_file(&src_file_path)?; | ||||
|  | ||||
|     let dst_file_path = AppConfig::get() | ||||
|         .disk_images_storage_path() | ||||
|         .join(&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::Ok().json(src)) | ||||
| } | ||||
|  | ||||
| /// 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_storage_path() | ||||
|         .join(&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()) | ||||
| } | ||||
| @@ -132,12 +132,12 @@ pub async fn get_list() -> HttpResult { | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct DownloadFilePath { | ||||
| pub struct IsoFilePath { | ||||
|     filename: String, | ||||
| } | ||||
|  | ||||
| /// 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) { | ||||
|         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 | ||||
| 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) { | ||||
|         return Ok(HttpResponse::BadRequest().json("Invalid file name!")); | ||||
|     } | ||||
|   | ||||
| @@ -4,10 +4,10 @@ use actix_web::body::BoxBody; | ||||
| use actix_web::{HttpResponse, web}; | ||||
| use std::error::Error; | ||||
| use std::fmt::{Display, Formatter}; | ||||
| use std::io::ErrorKind; | ||||
|  | ||||
| pub mod api_tokens_controller; | ||||
| pub mod auth_controller; | ||||
| pub mod disk_images_controller; | ||||
| pub mod groups_controller; | ||||
| pub mod iso_controller; | ||||
| pub mod network_controller; | ||||
| @@ -62,7 +62,7 @@ impl From<serde_json::Error> for HttpErr { | ||||
|  | ||||
| impl From<Box<dyn Error>> for HttpErr { | ||||
|     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 { | ||||
|     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, | ||||
|     oidc_auth_enabled: bool, | ||||
|     iso_mimetypes: &'static [&'static str], | ||||
|     disk_images_mimetypes: &'static [&'static str], | ||||
|     net_mac_prefix: &'static str, | ||||
|     builtin_nwfilter_rules: &'static [&'static str], | ||||
|     nwfilter_chains: &'static [&'static str], | ||||
| @@ -37,6 +38,7 @@ struct SLenConstraints { | ||||
| #[derive(serde::Serialize)] | ||||
| struct ServerConstraints { | ||||
|     iso_max_size: usize, | ||||
|     disk_image_max_size: usize, | ||||
|     vnc_token_duration: u64, | ||||
|     vm_name_size: LenConstraints, | ||||
|     vm_title_size: LenConstraints, | ||||
| @@ -44,6 +46,7 @@ struct ServerConstraints { | ||||
|     memory_size: LenConstraints, | ||||
|     disk_name_size: LenConstraints, | ||||
|     disk_size: LenConstraints, | ||||
|     disk_image_name_size: LenConstraints, | ||||
|     net_name_size: LenConstraints, | ||||
|     net_title_size: LenConstraints, | ||||
|     net_nat_comment_size: LenConstraints, | ||||
| @@ -63,11 +66,13 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { | ||||
|         local_auth_enabled: *local_auth, | ||||
|         oidc_auth_enabled: !AppConfig::get().disable_oidc, | ||||
|         iso_mimetypes: &constants::ALLOWED_ISO_MIME_TYPES, | ||||
|         disk_images_mimetypes: &constants::ALLOWED_DISK_IMAGES_MIME_TYPES, | ||||
|         net_mac_prefix: constants::NET_MAC_ADDR_PREFIX, | ||||
|         builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES, | ||||
|         nwfilter_chains: &constants::NETWORK_CHAINS, | ||||
|         constraints: ServerConstraints { | ||||
|             iso_max_size: constants::ISO_MAX_SIZE, | ||||
|             disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE, | ||||
|  | ||||
|             vnc_token_duration: VNC_TOKEN_LIFETIME, | ||||
|  | ||||
| @@ -83,10 +88,12 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder { | ||||
|                 max: DISK_NAME_MAX_LEN, | ||||
|             }, | ||||
|             disk_size: LenConstraints { | ||||
|                 min: DISK_SIZE_MIN, | ||||
|                 max: DISK_SIZE_MAX, | ||||
|                 min: DISK_SIZE_MIN.as_bytes(), | ||||
|                 max: DISK_SIZE_MAX.as_bytes(), | ||||
|             }, | ||||
|  | ||||
|             disk_image_name_size: LenConstraints { min: 5, max: 220 }, | ||||
|  | ||||
|             net_name_size: LenConstraints { min: 2, max: 50 }, | ||||
|             net_title_size: LenConstraints { min: 0, max: 50 }, | ||||
|             net_nat_comment_size: LenConstraints { | ||||
| @@ -188,3 +195,7 @@ pub async fn number_vcpus() -> HttpResult { | ||||
| pub async fn networks_list() -> HttpResult { | ||||
|     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))] | ||||
| 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)] | ||||
| mod serve_static_debug { | ||||
|     use actix_web::{HttpResponse, Responder}; | ||||
| @@ -16,6 +37,7 @@ mod serve_static_debug { | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Web asset hosting in release mode | ||||
| #[cfg(not(debug_assertions))] | ||||
| mod serve_static_release { | ||||
|     use actix_web::{HttpResponse, Responder, web}; | ||||
| @@ -23,12 +45,12 @@ mod serve_static_release { | ||||
|  | ||||
|     #[derive(RustEmbed)] | ||||
|     #[folder = "static/"] | ||||
|     struct Asset; | ||||
|     struct WebAsset; | ||||
|  | ||||
|     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() | ||||
|                 .content_type(mime_guess::from_path(path).first_or_octet_stream().as_ref()) | ||||
|                 .content_type(content.metadata.mimetype()) | ||||
|                 .body(content.data.into_owned()), | ||||
|             (None, false) => HttpResponse::NotFound().body("404 Not Found"), | ||||
|             (None, true) => handle_embedded_file("index.html", false), | ||||
|   | ||||
| @@ -26,6 +26,7 @@ pub struct OSXML { | ||||
|     pub firmware: String, | ||||
|     pub r#type: OSTypeXML, | ||||
|     pub loader: Option<OSLoaderXML>, | ||||
|     pub smbios: Option<OSSMBiosXML>, | ||||
| } | ||||
|  | ||||
| /// OS Type information | ||||
| @@ -48,6 +49,14 @@ pub struct OSLoaderXML { | ||||
|     pub secure: String, | ||||
| } | ||||
|  | ||||
| /// SMBIOS System information | ||||
| #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | ||||
| #[serde(rename = "smbios")] | ||||
| pub struct OSSMBiosXML { | ||||
|     #[serde(rename = "@mode")] | ||||
|     pub mode: String, | ||||
| } | ||||
|  | ||||
| /// Hypervisor features | ||||
| #[derive(serde::Serialize, serde::Deserialize, Clone, Default, Debug)] | ||||
| #[serde(rename = "features")] | ||||
| @@ -71,7 +80,9 @@ pub struct NetMacAddress { | ||||
| #[serde(rename = "source")] | ||||
| pub struct NetIntSourceXML { | ||||
|     #[serde(rename = "@network")] | ||||
|     pub network: String, | ||||
|     pub network: Option<String>, | ||||
|     #[serde(rename = "@bridge")] | ||||
|     pub bridge: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | ||||
| @@ -305,6 +316,29 @@ pub struct DomainCPUXML { | ||||
|     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 | ||||
| #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | ||||
| #[serde(rename = "domain")] | ||||
| @@ -335,6 +369,10 @@ pub struct DomainXML { | ||||
|     /// CPU information | ||||
|     pub cpu: DomainCPUXML, | ||||
|  | ||||
|     /// SMBios strings | ||||
|     pub sysinfo: Option<SysInfoXML>, | ||||
|  | ||||
|     /// Behavior when guest state change | ||||
|     pub on_poweroff: String, | ||||
|     pub on_reboot: String, | ||||
|     pub on_crash: String, | ||||
|   | ||||
| @@ -4,9 +4,9 @@ use crate::libvirt_lib_structures::XMLUuid; | ||||
| use crate::libvirt_lib_structures::domain::*; | ||||
| use crate::libvirt_rest_structures::LibVirtStructError; | ||||
| use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction; | ||||
| use crate::utils::disks_utils::Disk; | ||||
| use crate::utils::files_utils; | ||||
| use crate::utils::files_utils::convert_size_unit_to_mb; | ||||
| use crate::utils::vm_file_disks_utils::{VMDiskFormat, VMFileDisk}; | ||||
| use lazy_regex::regex; | ||||
| use num::Integer; | ||||
|  | ||||
| @@ -53,7 +53,8 @@ pub struct Network { | ||||
| #[serde(tag = "type")] | ||||
| pub enum NetworkType { | ||||
|     UserspaceSLIRPStack, | ||||
|     DefinedNetwork { network: String }, // TODO : complete network types | ||||
|     DefinedNetwork { network: String }, | ||||
|     Bridge { bridge: String }, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| @@ -77,12 +78,14 @@ pub struct VMInfo { | ||||
|     pub vnc_access: bool, | ||||
|     /// Attach ISO file(s) | ||||
|     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 | ||||
|     pub disks: Vec<Disk>, | ||||
|     /// File Storage - https://access.redhat.com/documentation/fr-fr/red_hat_enterprise_linux/6/html/virtualization_administration_guide/sect-virtualization-virtualized_block_devices-adding_storage_devices_to_guests#sect-Virtualization-Adding_storage_devices_to_guests-Adding_file_based_storage_to_a_guest | ||||
|     pub file_disks: Vec<VMFileDisk>, | ||||
|     /// Network cards | ||||
|     pub networks: Vec<Network>, | ||||
|     /// Add a TPM v2.0 module | ||||
|     pub tpm_module: bool, | ||||
|     /// Strings injected as OEM Strings in SMBios configuration | ||||
|     pub oem_strings: Vec<String>, | ||||
| } | ||||
|  | ||||
| impl VMInfo { | ||||
| @@ -129,6 +132,7 @@ impl VMInfo { | ||||
|  | ||||
|         let mut disks = vec![]; | ||||
|  | ||||
|         // Add ISO files | ||||
|         for iso_file in &self.iso_files { | ||||
|             if !files_utils::check_file_name(iso_file) { | ||||
|                 return Err(StructureExtraction("ISO filename is invalid!").into()); | ||||
| @@ -237,7 +241,18 @@ impl VMInfo { | ||||
|                     mac, | ||||
|                     r#type: "network".to_string(), | ||||
|                     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, | ||||
|                     filterref, | ||||
| @@ -246,15 +261,21 @@ impl VMInfo { | ||||
|         } | ||||
|  | ||||
|         // Check disks name for duplicates | ||||
|         for disk in &self.disks { | ||||
|             if self.disks.iter().filter(|d| d.name == disk.name).count() > 1 { | ||||
|         for disk in &self.file_disks { | ||||
|             if self | ||||
|                 .file_disks | ||||
|                 .iter() | ||||
|                 .filter(|d| d.name == disk.name) | ||||
|                 .count() | ||||
|                 > 1 | ||||
|             { | ||||
|                 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 | ||||
|         // bad user input | ||||
|         for disk in &self.disks { | ||||
|         for disk in &self.file_disks { | ||||
|             disk.check_config()?; | ||||
|             disk.apply_config(uuid)?; | ||||
|  | ||||
| @@ -267,7 +288,10 @@ impl VMInfo { | ||||
|                 device: "disk".to_string(), | ||||
|                 driver: DiskDriverXML { | ||||
|                     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(), | ||||
|                 }, | ||||
|                 source: DiskSourceXML { | ||||
| @@ -319,6 +343,9 @@ impl VMInfo { | ||||
|                         BootType::UEFISecureBoot => "yes".to_string(), | ||||
|                     }, | ||||
|                 }), | ||||
|                 smbios: Some(OSSMBiosXML { | ||||
|                     mode: "sysinfo".to_string(), | ||||
|                 }), | ||||
|             }, | ||||
|  | ||||
|             features: FeaturesXML { acpi: ACPIXML {} }, | ||||
| @@ -375,6 +402,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_reboot: "restart".to_string(), | ||||
|             on_crash: "destroy".to_string(), | ||||
| @@ -424,12 +462,12 @@ impl VMInfo { | ||||
|                 .map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string()) | ||||
|                 .collect(), | ||||
|  | ||||
|             disks: domain | ||||
|             file_disks: domain | ||||
|                 .devices | ||||
|                 .disks | ||||
|                 .iter() | ||||
|                 .filter(|d| d.device == "disk") | ||||
|                 .map(|d| Disk::load_from_file(&d.source.file).unwrap()) | ||||
|                 .map(|d| VMFileDisk::load_from_file(&d.source.file).unwrap()) | ||||
|                 .collect(), | ||||
|  | ||||
|             networks: domain | ||||
| @@ -442,7 +480,34 @@ impl VMInfo { | ||||
|                         r#type: match d.r#type.as_str() { | ||||
|                             "user" => NetworkType::UserspaceSLIRPStack, | ||||
|                             "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 => { | ||||
|                                 return Err(LibVirtStructError::DomainExtraction(format!( | ||||
| @@ -466,6 +531,12 @@ impl VMInfo { | ||||
|                 .collect::<Result<Vec<_>, _>>()?, | ||||
|  | ||||
|             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::{App, HttpServer, web}; | ||||
| use light_openid::basic_state_manager::BasicStateManager; | ||||
| use std::cmp::max; | ||||
| use std::time::Duration; | ||||
| use virtweb_backend::actors::libvirt_actor::LibVirtActor; | ||||
| use virtweb_backend::actors::vnc_tokens_actor::VNCTokensManager; | ||||
| @@ -22,13 +23,14 @@ use virtweb_backend::constants::{ | ||||
|     MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME, | ||||
| }; | ||||
| use virtweb_backend::controllers::{ | ||||
|     api_tokens_controller, auth_controller, groups_controller, iso_controller, network_controller, | ||||
|     nwfilter_controller, server_controller, static_controller, vm_controller, | ||||
|     api_tokens_controller, auth_controller, disk_images_controller, groups_controller, | ||||
|     iso_controller, network_controller, nwfilter_controller, server_controller, static_controller, | ||||
|     vm_controller, | ||||
| }; | ||||
| use virtweb_backend::libvirt_client::LibVirtClient; | ||||
| use virtweb_backend::middlewares::auth_middleware::AuthChecker; | ||||
| use virtweb_backend::nat::nat_conf_mode; | ||||
| use virtweb_backend::utils::files_utils; | ||||
| use virtweb_backend::utils::{exec_utils, files_utils}; | ||||
|  | ||||
| #[actix_web::main] | ||||
| async fn main() -> std::io::Result<()> { | ||||
| @@ -43,11 +45,23 @@ async fn main() -> std::io::Result<()> { | ||||
|     // Load additional config from file, if requested | ||||
|     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"); | ||||
|     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::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().definitions_path()).unwrap(); | ||||
|     files_utils::create_directory_if_missing(AppConfig::get().api_tokens_path()).unwrap(); | ||||
| @@ -108,7 +122,10 @@ async fn main() -> std::io::Result<()> { | ||||
|             })) | ||||
|             .app_data(conn.clone()) | ||||
|             // 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)), | ||||
|             ) | ||||
|             .app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir)) | ||||
|             // Server controller | ||||
|             .route( | ||||
| @@ -131,6 +148,10 @@ async fn main() -> std::io::Result<()> { | ||||
|                 "/api/server/networks", | ||||
|                 web::get().to(server_controller::networks_list), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/server/bridges", | ||||
|                 web::get().to(server_controller::bridges_list), | ||||
|             ) | ||||
|             // Auth controller | ||||
|             .route( | ||||
|                 "/api/auth/local", | ||||
| @@ -315,6 +336,27 @@ async fn main() -> std::io::Result<()> { | ||||
|                 "/api/nwfilter/{uid}", | ||||
|                 web::delete().to(nwfilter_controller::delete), | ||||
|             ) | ||||
|             // Disk images library | ||||
|             .route( | ||||
|                 "/api/disk_images/upload", | ||||
|                 web::post().to(disk_images_controller::upload), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/disk_images/list", | ||||
|                 web::get().to(disk_images_controller::get_list), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/disk_images/{filename}", | ||||
|                 web::get().to(disk_images_controller::download), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/disk_images/{filename}/convert", | ||||
|                 web::post().to(disk_images_controller::convert), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/disk_images/{filename}", | ||||
|                 web::delete().to(disk_images_controller::delete), | ||||
|             ) | ||||
|             // API tokens controller | ||||
|             .route( | ||||
|                 "/api/token/create", | ||||
| @@ -337,6 +379,11 @@ async fn main() -> std::io::Result<()> { | ||||
|                 web::delete().to(api_tokens_controller::delete), | ||||
|             ) | ||||
|             // 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( | ||||
|                 "/{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)) | ||||
| } | ||||
							
								
								
									
										37
									
								
								virtweb_backend/src/utils/file_size_utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								virtweb_backend/src/utils/file_size_utils.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| #[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) | ||||
|     } | ||||
|  | ||||
|     /// 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) | ||||
|     } | ||||
| } | ||||
| @@ -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 net_utils; | ||||
| pub mod rand_utils; | ||||
| pub mod time_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 std::collections::HashMap; | ||||
| use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; | ||||
| use std::process::Command; | ||||
| use std::str::FromStr; | ||||
| 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 | ||||
| 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(); | ||||
|  | ||||
| @@ -136,6 +138,31 @@ pub fn net_list_and_ips() -> anyhow::Result<HashMap<String, Vec<IpAddr>>> { | ||||
|     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)] | ||||
| mod tests { | ||||
|     use crate::utils::net_utils::{ | ||||
|   | ||||
							
								
								
									
										146
									
								
								virtweb_backend/src/utils/vm_file_disks_utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								virtweb_backend/src/utils/vm_file_disks_utils.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,146 @@ | ||||
| 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), | ||||
| } | ||||
|  | ||||
| /// Type of disk allocation | ||||
| #[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq)] | ||||
| pub enum VMDiskAllocType { | ||||
|     Fixed, | ||||
|     Sparse, | ||||
| } | ||||
|  | ||||
| /// Disk allocation type | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| #[serde(tag = "format")] | ||||
| pub enum VMDiskFormat { | ||||
|     Raw { | ||||
|         /// Type of disk allocation | ||||
|         alloc_type: VMDiskAllocType, | ||||
|     }, | ||||
|     QCow2, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Serialize, serde::Deserialize)] | ||||
| pub struct VMFileDisk { | ||||
|     /// Disk name | ||||
|     pub name: String, | ||||
|     /// Disk size, in bytes | ||||
|     pub size: FileSize, | ||||
|     /// Disk format | ||||
|     #[serde(flatten)] | ||||
|     pub format: VMDiskFormat, | ||||
|     /// Set this variable to true to delete the disk | ||||
|     pub delete: bool, | ||||
| } | ||||
|  | ||||
| impl VMFileDisk { | ||||
|     pub fn load_from_file(path: &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 { | ||||
|                     alloc_type: match is_sparse { | ||||
|                         true => VMDiskAllocType::Sparse, | ||||
|                         false => VMDiskAllocType::Fixed, | ||||
|                     }, | ||||
|                 }, | ||||
|                 DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2, | ||||
|                 _ => anyhow::bail!("Unsupported image format: {:?}", info.format), | ||||
|             }, | ||||
|             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(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()); | ||||
|         } | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Get disk path | ||||
|     pub fn disk_path(&self, id: XMLUuid) -> PathBuf { | ||||
|         let domain_dir = AppConfig::get().vm_storage_path(id); | ||||
|         let file_name = match self.format { | ||||
|             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(()); | ||||
|         } | ||||
|  | ||||
|         // Create disk file | ||||
|         DiskFileInfo::create( | ||||
|             &file, | ||||
|             match self.format { | ||||
|                 VMDiskFormat::Raw { alloc_type } => DiskFileFormat::Raw { | ||||
|                     is_sparse: alloc_type == VMDiskAllocType::Sparse, | ||||
|                 }, | ||||
|                 VMDiskFormat::QCow2 => DiskFileFormat::QCow2 { | ||||
|                     virtual_size: self.size, | ||||
|                 }, | ||||
|             }, | ||||
|             self.size, | ||||
|         )?; | ||||
|  | ||||
|         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 chown $USER:$USER /var/virtweb | ||||
| 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 | ||||
|   | ||||
							
								
								
									
										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/styled": "^11.14.0", | ||||
|     "@fontsource/roboto": "^5.2.5", | ||||
|     "@mdi/js": "^7.2.96", | ||||
|     "@mdi/js": "^7.4.47", | ||||
|     "@mdi/react": "^1.6.1", | ||||
|     "@mui/icons-material": "^7.0.0", | ||||
|     "@mui/material": "^7.0.0", | ||||
|     "@mui/x-charts": "^7.28.0", | ||||
|     "@mui/x-data-grid": "^7.28.1", | ||||
|     "@mui/icons-material": "^7.1.0", | ||||
|     "@mui/material": "^7.1.0", | ||||
|     "@mui/x-charts": "^8.3.1", | ||||
|     "@mui/x-data-grid": "^8.3.1", | ||||
|     "date-and-time": "^3.6.0", | ||||
|     "filesize": "^10.1.6", | ||||
|     "humanize-duration": "^3.29.0", | ||||
|     "mui-file-input": "^7.0.0", | ||||
|     "react": "^19.0.0", | ||||
|     "react-dom": "^19.0.0", | ||||
|     "react-router-dom": "^7.4.0", | ||||
|     "humanize-duration": "^3.32.2", | ||||
|     "react": "^19.1.0", | ||||
|     "react-dom": "^19.1.0", | ||||
|     "react-router-dom": "^7.6.0", | ||||
|     "react-syntax-highlighter": "^15.6.1", | ||||
|     "react-vnc": "^3.0.7", | ||||
|     "react-vnc": "^3.1.0", | ||||
|     "uuid": "^11.1.0", | ||||
|     "xml-formatter": "^3.6.0" | ||||
|     "xml-formatter": "^3.6.6" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@eslint/js": "^9.21.0", | ||||
|     "@types/humanize-duration": "^3.27.1", | ||||
|     "@eslint/js": "^9.27.0", | ||||
|     "@types/humanize-duration": "^3.27.4", | ||||
|     "@types/jest": "^29.5.14", | ||||
|     "@types/react": "^19.0.12", | ||||
|     "@types/react-dom": "^19.0.4", | ||||
|     "@types/react": "^19.1.5", | ||||
|     "@types/react-dom": "^19.1.5", | ||||
|     "@types/react-syntax-highlighter": "^15.5.13", | ||||
|     "@types/uuid": "^10.0.0", | ||||
|     "@vitejs/plugin-react": "^4.3.4", | ||||
|     "eslint": "^9.21.0", | ||||
|     "eslint-plugin-react-dom": "^1.38.3", | ||||
|     "@vitejs/plugin-react": "^4.4.1", | ||||
|     "eslint": "^9.27.0", | ||||
|     "eslint-plugin-react-dom": "^1.49.0", | ||||
|     "eslint-plugin-react-hooks": "^5.1.0", | ||||
|     "eslint-plugin-react-refresh": "^0.4.19", | ||||
|     "eslint-plugin-react-x": "^1.38.3", | ||||
|     "globals": "^15.15.0", | ||||
|     "typescript": "^5.8.2", | ||||
|     "typescript-eslint": "^8.24.1", | ||||
|     "vite": "^6.2.3" | ||||
|     "eslint-plugin-react-refresh": "^0.4.20", | ||||
|     "eslint-plugin-react-x": "^1.49.0", | ||||
|     "globals": "^16.1.0", | ||||
|     "typescript": "^5.8.3", | ||||
|     "typescript-eslint": "^8.32.1", | ||||
|     "vite": "^6.3.5" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -38,6 +38,7 @@ import { LoginRoute } from "./routes/auth/LoginRoute"; | ||||
| import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute"; | ||||
| import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; | ||||
| import { BaseLoginPage } from "./widgets/BaseLoginPage"; | ||||
| import { DiskImagesRoute } from "./routes/DiskImagesRoute"; | ||||
|  | ||||
| interface AuthContext { | ||||
|   signedIn: boolean; | ||||
| @@ -63,6 +64,8 @@ export function App() { | ||||
|         <Route path="*" element={<BaseAuthenticatedPage />}> | ||||
|           <Route path="" element={<HomeRoute />} /> | ||||
|  | ||||
|           <Route path="disk_images" element={<DiskImagesRoute />} /> | ||||
|  | ||||
|           <Route path="iso" element={<IsoFilesRoute />} /> | ||||
|  | ||||
|           <Route path="vms" element={<VMListRoute />} /> | ||||
|   | ||||
							
								
								
									
										89
									
								
								virtweb_frontend/src/api/DiskImageApi.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								virtweb_frontend/src/api/DiskImageApi.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | ||||
| import { APIClient } from "./ApiClient"; | ||||
|  | ||||
| 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 }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * 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; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * ISO catalog entries | ||||
|  */ | ||||
| export interface ISOCatalogEntry { | ||||
|   name: string; | ||||
|   url: string; | ||||
|   image: string; | ||||
| } | ||||
|  | ||||
| export class IsoFilesApi { | ||||
|   /** | ||||
|    * Upload a new ISO file to the server | ||||
| @@ -74,4 +83,23 @@ export class IsoFilesApi { | ||||
|       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; | ||||
|   oidc_auth_enabled: boolean; | ||||
|   iso_mimetypes: string[]; | ||||
|   disk_images_mimetypes: string[]; | ||||
|   net_mac_prefix: string; | ||||
|   builtin_nwfilter_rules: string[]; | ||||
|   nwfilter_chains: string[]; | ||||
| @@ -13,6 +14,7 @@ export interface ServerConfig { | ||||
|  | ||||
| export interface ServerConstraints { | ||||
|   iso_max_size: number; | ||||
|   disk_image_max_size: number; | ||||
|   vnc_token_duration: number; | ||||
|   vm_name_size: LenConstraint; | ||||
|   vm_title_size: LenConstraint; | ||||
| @@ -20,6 +22,7 @@ export interface ServerConstraints { | ||||
|   memory_size: LenConstraint; | ||||
|   disk_name_size: LenConstraint; | ||||
|   disk_size: LenConstraint; | ||||
|   disk_image_name_size: LenConstraint; | ||||
|   net_name_size: LenConstraint; | ||||
|   net_title_size: LenConstraint; | ||||
|   net_nat_comment_size: LenConstraint; | ||||
| @@ -217,4 +220,16 @@ export class ServerApi { | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get host networks bridges list | ||||
|    */ | ||||
|   static async GetNetworksBridgesList(): Promise<string[]> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: "/server/bridges", | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -17,12 +17,11 @@ export type VMState = | ||||
|   | "PowerManagementSuspended" | ||||
|   | "Other"; | ||||
|  | ||||
| export type DiskAllocType = "Sparse" | "Fixed"; | ||||
| export type VMFileDisk = BaseFileVMDisk & (RawVMDisk | QCow2Disk); | ||||
|  | ||||
| export interface VMDisk { | ||||
| export interface BaseFileVMDisk { | ||||
|   size: number; | ||||
|   name: string; | ||||
|   alloc_type: DiskAllocType; | ||||
|   delete: boolean; | ||||
|  | ||||
|   // application attribute | ||||
| @@ -30,6 +29,17 @@ export interface VMDisk { | ||||
|   deleteType?: "keepfile" | "deletefile"; | ||||
| } | ||||
|  | ||||
| export type DiskAllocType = "Sparse" | "Fixed"; | ||||
|  | ||||
| interface RawVMDisk { | ||||
|   format: "Raw"; | ||||
|   alloc_type: DiskAllocType; | ||||
| } | ||||
|  | ||||
| interface QCow2Disk { | ||||
|   format: "QCow2"; | ||||
| } | ||||
|  | ||||
| export interface VMNetInterfaceFilterParams { | ||||
|   name: string; | ||||
|   value: string; | ||||
| @@ -40,7 +50,11 @@ export interface VMNetInterfaceFilter { | ||||
|   parameters: VMNetInterfaceFilterParams[]; | ||||
| } | ||||
|  | ||||
| export type VMNetInterface = (VMNetUserspaceSLIRPStack | VMNetDefinedNetwork) & | ||||
| export type VMNetInterface = ( | ||||
|   | VMNetUserspaceSLIRPStack | ||||
|   | VMNetDefinedNetwork | ||||
|   | VMNetBridge | ||||
| ) & | ||||
|   VMNetInterfaceBase; | ||||
|  | ||||
| export interface VMNetInterfaceBase { | ||||
| @@ -57,6 +71,11 @@ export interface VMNetDefinedNetwork { | ||||
|   network: string; | ||||
| } | ||||
|  | ||||
| export interface VMNetBridge { | ||||
|   type: "Bridge"; | ||||
|   bridge: string; | ||||
| } | ||||
|  | ||||
| interface VMInfoInterface { | ||||
|   name: string; | ||||
|   uuid?: string; | ||||
| @@ -70,9 +89,10 @@ interface VMInfoInterface { | ||||
|   number_vcpu: number; | ||||
|   vnc_access: boolean; | ||||
|   iso_files: string[]; | ||||
|   disks: VMDisk[]; | ||||
|   file_disks: VMFileDisk[]; | ||||
|   networks: VMNetInterface[]; | ||||
|   tpm_module: boolean; | ||||
|   oem_strings: string[]; | ||||
| } | ||||
|  | ||||
| export class VMInfo implements VMInfoInterface { | ||||
| @@ -88,9 +108,10 @@ export class VMInfo implements VMInfoInterface { | ||||
|   memory: number; | ||||
|   vnc_access: boolean; | ||||
|   iso_files: string[]; | ||||
|   disks: VMDisk[]; | ||||
|   file_disks: VMFileDisk[]; | ||||
|   networks: VMNetInterface[]; | ||||
|   tpm_module: boolean; | ||||
|   oem_strings: string[]; | ||||
|  | ||||
|   constructor(int: VMInfoInterface) { | ||||
|     this.name = int.name; | ||||
| @@ -105,9 +126,10 @@ export class VMInfo implements VMInfoInterface { | ||||
|     this.memory = int.memory; | ||||
|     this.vnc_access = int.vnc_access; | ||||
|     this.iso_files = int.iso_files; | ||||
|     this.disks = int.disks; | ||||
|     this.file_disks = int.file_disks; | ||||
|     this.networks = int.networks; | ||||
|     this.tpm_module = int.tpm_module; | ||||
|     this.oem_strings = int.oem_strings; | ||||
|   } | ||||
|  | ||||
|   static NewEmpty(): VMInfo { | ||||
| @@ -119,9 +141,10 @@ export class VMInfo implements VMInfoInterface { | ||||
|       number_vcpu: 1, | ||||
|       vnc_access: true, | ||||
|       iso_files: [], | ||||
|       disks: [], | ||||
|       file_disks: [], | ||||
|       networks: [], | ||||
|       tpm_module: true, | ||||
|       oem_strings: [], | ||||
|     }); | ||||
|   } | ||||
|  | ||||
| @@ -194,8 +217,8 @@ export class VMApi { | ||||
|    */ | ||||
|   static async UpdateSingle(vm: VMInfo): Promise<VMInfo> { | ||||
|     // Process disks list, looking for removal | ||||
|     vm.disks = vm.disks.filter((d) => d.deleteType !== "keepfile"); | ||||
|     vm.disks.forEach((d) => { | ||||
|     vm.file_disks = vm.file_disks.filter((d) => d.deleteType !== "keepfile"); | ||||
|     vm.file_disks.forEach((d) => { | ||||
|       if (d.deleteType === "deletefile") d.delete = true; | ||||
|     }); | ||||
|  | ||||
|   | ||||
							
								
								
									
										123
									
								
								virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								virtweb_frontend/src/dialogs/ConvertDiskImageDialog.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,123 @@ | ||||
| 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 { useAlert } from "../hooks/providers/AlertDialogProvider"; | ||||
| import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider"; | ||||
| import { useSnackbar } from "../hooks/providers/SnackbarProvider"; | ||||
| import { FileDiskImageWidget } from "../widgets/FileDiskImageWidget"; | ||||
| import { CheckboxInput } from "../widgets/forms/CheckboxInput"; | ||||
| import { SelectInput } from "../widgets/forms/SelectInput"; | ||||
| import { TextInput } from "../widgets/forms/TextInput"; | ||||
|  | ||||
| export function ConvertDiskImageDialog(p: { | ||||
|   image: DiskImage; | ||||
|   onCancel: () => void; | ||||
|   onFinished: () => void; | ||||
| }): React.ReactElement { | ||||
|   const alert = useAlert(); | ||||
|   const snackbar = useSnackbar(); | ||||
|   const loadingMessage = useLoadingMessage(); | ||||
|  | ||||
|   const [format, setFormat] = React.useState<DiskImageFormat>({ | ||||
|     format: "QCow2", | ||||
|   }); | ||||
|  | ||||
|   const [filename, setFilename] = React.useState(p.image.file_name + ".qcow2"); | ||||
|  | ||||
|   const handleFormatChange = (value?: string) => { | ||||
|     setFormat({ format: value ?? ("QCow2" as any) }); | ||||
|  | ||||
|     if (value === "QCow2") setFilename(`${p.image.file_name}.qcow2`); | ||||
|     if (value === "CompressedQCow2") | ||||
|       setFilename(`${p.image.file_name}.qcow2.gz`); | ||||
|     if (value === "Raw") { | ||||
|       setFilename(`${p.image.file_name}.raw`); | ||||
|       // Check sparse checkbox by default | ||||
|       setFormat({ format: "Raw", is_sparse: true }); | ||||
|     } | ||||
|     if (value === "CompressedRaw") setFilename(`${p.image.file_name}.raw.gz`); | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = async () => { | ||||
|     try { | ||||
|       loadingMessage.show("Converting image..."); | ||||
|  | ||||
|       // Perform the conversion | ||||
|       await DiskImageApi.Convert(p.image, filename, format); | ||||
|  | ||||
|       p.onFinished(); | ||||
|  | ||||
|       snackbar("Conversion successful!"); | ||||
|     } catch (e) { | ||||
|       console.error("Failed to convert image!", e); | ||||
|       alert(`Failed to convert image! ${e}`); | ||||
|     } finally { | ||||
|       loadingMessage.hide(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Dialog open onClose={p.onCancel}> | ||||
|       <DialogTitle>Convert disk image</DialogTitle> | ||||
|  | ||||
|       <DialogContent> | ||||
|         <DialogContentText> | ||||
|           Select the destination format for this image: | ||||
|         </DialogContentText> | ||||
|         <FileDiskImageWidget image={p.image} /> | ||||
|  | ||||
|         {/* 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" | ||||
|         /> | ||||
|       </DialogContent> | ||||
|       <DialogActions> | ||||
|         <Button onClick={p.onCancel}>Cancel</Button> | ||||
|         <Button onClick={handleSubmit} autoFocus> | ||||
|           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> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										333
									
								
								virtweb_frontend/src/routes/DiskImagesRoute.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										333
									
								
								virtweb_frontend/src/routes/DiskImagesRoute.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,333 @@ | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import DownloadIcon from "@mui/icons-material/Download"; | ||||
| import LoopIcon from "@mui/icons-material/Loop"; | ||||
| import RefreshIcon from "@mui/icons-material/Refresh"; | ||||
| import { | ||||
|   Alert, | ||||
|   Button, | ||||
|   CircularProgress, | ||||
|   IconButton, | ||||
|   LinearProgress, | ||||
|   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 [currConversion, setCurrConversion] = React.useState< | ||||
|     DiskImage | undefined | ||||
|   >(); | ||||
|   const [dlProgress, setDlProgress] = React.useState<undefined | number>(); | ||||
|  | ||||
|   // Convert disk image file | ||||
|   const convertDiskImage = (entry: DiskImage) => { | ||||
|     setCurrConversion(entry); | ||||
|   }; | ||||
|  | ||||
|   // 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); | ||||
|   }; | ||||
|  | ||||
|   // 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 }, | ||||
|     { | ||||
|       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", | ||||
|       headerName: "", | ||||
|       width: 140, | ||||
|       renderCell(params) { | ||||
|         return ( | ||||
|           <> | ||||
|             <Tooltip title="Convert disk image"> | ||||
|               <IconButton onClick={() => { convertDiskImage(params.row); }}> | ||||
|                 <LoopIcon /> | ||||
|               </IconButton> | ||||
|             </Tooltip> | ||||
|             <Tooltip title="Download disk image"> | ||||
|               <IconButton onClick={() => downloadDiskImage(params.row)}> | ||||
|                 <DownloadIcon /> | ||||
|               </IconButton> | ||||
|             </Tooltip> | ||||
|             <Tooltip title="Delete disk image"> | ||||
|               <IconButton onClick={() => deleteDiskImage(params.row)}> | ||||
|                 <DeleteIcon /> | ||||
|               </IconButton> | ||||
|             </Tooltip> | ||||
|           </> | ||||
|         ); | ||||
|       }, | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   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 getRowId={(c) => c.file_name} rows={p.list} columns={columns} /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -1,4 +1,7 @@ | ||||
| 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 { | ||||
|   Alert, | ||||
|   Button, | ||||
| @@ -9,24 +12,25 @@ import { | ||||
|   Tooltip, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| import DownloadIcon from "@mui/icons-material/Download"; | ||||
| import { DataGrid, GridColDef } from "@mui/x-data-grid"; | ||||
| import { filesize } from "filesize"; | ||||
| import { MuiFileInput } from "mui-file-input"; | ||||
| import React from "react"; | ||||
| import { IsoFile, IsoFilesApi } from "../api/IsoFilesApi"; | ||||
| import { ServerApi } from "../api/ServerApi"; | ||||
| import { IsoCatalogDialog } from "../dialogs/IsoCatalogDialog"; | ||||
| 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 { FileInput } from "../widgets/forms/FileInput"; | ||||
| import { VirtWebPaper } from "../widgets/VirtWebPaper"; | ||||
| import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; | ||||
| import { useConfirm } from "../hooks/providers/ConfirmDialogProvider"; | ||||
| import { downloadBlob } from "../utils/FilesUtils"; | ||||
|  | ||||
| export function IsoFilesRoute(): React.ReactElement { | ||||
|   const [list, setList] = React.useState<IsoFile[] | undefined>(); | ||||
|   const [isoCatalog, setIsoCatalog] = React.useState(false); | ||||
|  | ||||
|   const loadKey = React.useRef(1); | ||||
|  | ||||
| @@ -40,19 +44,41 @@ export function IsoFilesRoute(): React.ReactElement { | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <AsyncWidget | ||||
|       loadKey={loadKey.current} | ||||
|       errMsg="Failed to load ISO files list!" | ||||
|       load={load} | ||||
|       ready={list !== undefined} | ||||
|       build={() => ( | ||||
|         <VirtWebRouteContainer label="ISO files management"> | ||||
|           <UploadIsoFileCard onFileUploaded={reload} /> | ||||
|           <UploadIsoFileFromUrlCard onFileUploaded={reload} /> | ||||
|           <IsoFilesList list={list!} onReload={reload} /> | ||||
|         </VirtWebRouteContainer> | ||||
|       )} | ||||
|     /> | ||||
|     <> | ||||
|       <AsyncWidget | ||||
|         loadKey={loadKey.current} | ||||
|         errMsg="Failed to load ISO files list!" | ||||
|         load={load} | ||||
|         ready={list !== undefined} | ||||
|         build={() => ( | ||||
|           <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} /> | ||||
|             <UploadIsoFileFromUrlCard onFileUploaded={reload} /> | ||||
|             <IsoFilesList list={list!} onReload={reload} /> | ||||
|           </VirtWebRouteContainer> | ||||
|         )} | ||||
|       /> | ||||
|       <IsoCatalogDialog | ||||
|         open={isoCatalog} | ||||
|         onClose={() => { setIsoCatalog(false); }} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| @@ -104,7 +130,7 @@ function UploadIsoFileCard(p: { | ||||
|  | ||||
|   if (uploadProgress !== null) { | ||||
|     return ( | ||||
|       <VirtWebPaper label="File upload"> | ||||
|       <VirtWebPaper label="File upload" noHorizontalMargin> | ||||
|         <Typography variant="body1"> | ||||
|           Upload in progress ({Math.floor(uploadProgress * 100)}%)... | ||||
|         </Typography> | ||||
| @@ -114,9 +140,9 @@ function UploadIsoFileCard(p: { | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <VirtWebPaper label="File upload"> | ||||
|     <VirtWebPaper label="File upload" noHorizontalMargin> | ||||
|       <div style={{ display: "flex", alignItems: "center" }}> | ||||
|         <MuiFileInput | ||||
|         <FileInput | ||||
|           value={value} | ||||
|           onChange={handleChange} | ||||
|           style={{ flex: 1 }} | ||||
| @@ -162,7 +188,7 @@ function UploadIsoFileFromUrlCard(p: { | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <VirtWebPaper label="File upload from URL"> | ||||
|     <VirtWebPaper label="File upload from URL" noHorizontalMargin> | ||||
|       <div style={{ display: "flex", alignItems: "center" }}> | ||||
|         <TextField | ||||
|           label="URL" | ||||
| @@ -279,38 +305,31 @@ function IsoFilesList(p: { | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <VirtWebPaper label="Files list"> | ||||
|         {/* 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> | ||||
|         )} | ||||
|  | ||||
|         {/* Files list table */} | ||||
|         <DataGrid | ||||
|           getRowId={(c) => c.filename} | ||||
|           rows={p.list} | ||||
|           columns={columns} | ||||
|         /> | ||||
|       </VirtWebPaper> | ||||
|       {/* 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> | ||||
|       )} | ||||
|       {/* ISO files list table */} | ||||
|       <DataGrid getRowId={(c) => c.filename} rows={p.list} columns={columns} /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -3,7 +3,15 @@ import { RouterLink } from "../widgets/RouterLink"; | ||||
|  | ||||
| export function NotFoundRoute(): React.ReactElement { | ||||
|   return ( | ||||
|     <div style={{ textAlign: "center" }}> | ||||
|     <div | ||||
|       style={{ | ||||
|         textAlign: "center", | ||||
|         flex: 1, | ||||
|         justifyContent: "center", | ||||
|         display: "flex", | ||||
|         flexDirection: "column", | ||||
|       }} | ||||
|     > | ||||
|       <h1>404 Not found</h1> | ||||
|       <p>The page you requested was not found!</p> | ||||
|       <RouterLink to="/"> | ||||
|   | ||||
| @@ -2,6 +2,7 @@ import { | ||||
|   mdiApi, | ||||
|   mdiBoxShadow, | ||||
|   mdiDisc, | ||||
|   mdiHarddisk, | ||||
|   mdiHome, | ||||
|   mdiInformation, | ||||
|   mdiLan, | ||||
| @@ -13,7 +14,7 @@ import { | ||||
|   List, | ||||
|   ListItemButton, | ||||
|   ListItemIcon, | ||||
|   ListItemText | ||||
|   ListItemText, | ||||
| } from "@mui/material"; | ||||
| import { Outlet, useLocation } from "react-router-dom"; | ||||
| import { RouterLink } from "./RouterLink"; | ||||
| @@ -66,6 +67,11 @@ export function BaseAuthenticatedPage(): React.ReactElement { | ||||
|             uri="/nwfilter" | ||||
|             icon={<Icon path={mdiSecurityNetwork} size={1} />} | ||||
|           /> | ||||
|           <NavLink | ||||
|             label="Disk images" | ||||
|             uri="/disk_images" | ||||
|             icon={<Icon path={mdiHarddisk} size={1} />} | ||||
|           /> | ||||
|           <NavLink | ||||
|             label="ISO files" | ||||
|             uri="/iso" | ||||
| @@ -82,7 +88,15 @@ export function BaseAuthenticatedPage(): React.ReactElement { | ||||
|             icon={<Icon path={mdiInformation} size={1} />} | ||||
|           /> | ||||
|         </List> | ||||
|         <div style={{ flex: 1 }}> | ||||
|         <div | ||||
|           style={{ | ||||
|             flexGrow: 1, | ||||
|             flexShrink: 0, | ||||
|             flexBasis: 0, | ||||
|             minWidth: 0, | ||||
|             display: "flex", | ||||
|           }} | ||||
|         > | ||||
|           <Outlet /> | ||||
|         </div> | ||||
|       </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"; | ||||
|  | ||||
| export function VirtWebPaper( | ||||
|   p: { label: string | React.ReactElement } & PropsWithChildren | ||||
|   p: { | ||||
|     label: string | React.ReactElement; | ||||
|     noHorizontalMargin?: boolean; | ||||
|   } & PropsWithChildren | ||||
| ): React.ReactElement { | ||||
|   return ( | ||||
|     <Paper elevation={2} style={{ padding: "10px", margin: "20px" }}> | ||||
|     <Paper | ||||
|       elevation={2} | ||||
|       style={{ | ||||
|         padding: "10px", | ||||
|         margin: p.noHorizontalMargin ? "20px 0px" : "20px", | ||||
|       }} | ||||
|     > | ||||
|       <Typography | ||||
|         variant="subtitle1" | ||||
|         style={{ marginBottom: "10px", fontWeight: "bold" }} | ||||
|   | ||||
| @@ -8,7 +8,18 @@ export function VirtWebRouteContainer( | ||||
|   } & PropsWithChildren | ||||
| ): React.ReactElement { | ||||
|   return ( | ||||
|     <div style={{ margin: "50px" }}> | ||||
|     <div | ||||
|       style={{ | ||||
|         margin: "50px", | ||||
|         flex: "1", | ||||
|         flexGrow: 1, | ||||
|         flexShrink: 0, | ||||
|         flexBasis: 0, | ||||
|         minWidth: 0, | ||||
|         display: "flex", | ||||
|         flexDirection: "column", | ||||
|       }} | ||||
|     > | ||||
|       <div | ||||
|         style={{ | ||||
|           display: "flex", | ||||
|   | ||||
							
								
								
									
										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> | ||||
|   ); | ||||
| } | ||||
| @@ -14,7 +14,7 @@ import { | ||||
| } from "@mui/material"; | ||||
| import { filesize } from "filesize"; | ||||
| import { ServerApi } from "../../api/ServerApi"; | ||||
| import { VMDisk, VMInfo } from "../../api/VMApi"; | ||||
| import { VMFileDisk, VMInfo } from "../../api/VMApi"; | ||||
| import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider"; | ||||
| import { SelectInput } from "./SelectInput"; | ||||
| import { TextInput } from "./TextInput"; | ||||
| @@ -25,11 +25,11 @@ export function VMDisksList(p: { | ||||
|   editable: boolean; | ||||
| }): React.ReactElement { | ||||
|   const addNewDisk = () => { | ||||
|     p.vm.disks.push({ | ||||
|       alloc_type: "Sparse", | ||||
|       size: 10000, | ||||
|     p.vm.file_disks.push({ | ||||
|       format: "QCow2", | ||||
|       size: 10000 * 1000 * 1000, | ||||
|       delete: false, | ||||
|       name: `disk${p.vm.disks.length}`, | ||||
|       name: `disk${p.vm.file_disks.length}`, | ||||
|       new: true, | ||||
|     }); | ||||
|     p.onChange?.(); | ||||
| @@ -38,7 +38,7 @@ export function VMDisksList(p: { | ||||
|   return ( | ||||
|     <> | ||||
|       {/* disks list */} | ||||
|       {p.vm.disks.map((d, num) => ( | ||||
|       {p.vm.file_disks.map((d, num) => ( | ||||
|         <DiskInfo | ||||
|           // eslint-disable-next-line react-x/no-array-index-key | ||||
|           key={num} | ||||
| @@ -46,7 +46,7 @@ export function VMDisksList(p: { | ||||
|           disk={d} | ||||
|           onChange={p.onChange} | ||||
|           removeFromList={() => { | ||||
|             p.vm.disks.splice(num, 1); | ||||
|             p.vm.file_disks.splice(num, 1); | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|         /> | ||||
| @@ -59,7 +59,7 @@ export function VMDisksList(p: { | ||||
|  | ||||
| function DiskInfo(p: { | ||||
|   editable: boolean; | ||||
|   disk: VMDisk; | ||||
|   disk: VMFileDisk; | ||||
|   onChange?: () => void; | ||||
|   removeFromList: () => void; | ||||
| }): React.ReactElement { | ||||
| @@ -125,8 +125,8 @@ function DiskInfo(p: { | ||||
|               )} | ||||
|             </> | ||||
|           } | ||||
|           secondary={`${filesize(p.disk.size * 1000 * 1000)} - ${ | ||||
|             p.disk.alloc_type | ||||
|           secondary={`${filesize(p.disk.size)} - ${p.disk.format}${ | ||||
|             p.disk.format == "Raw" ? " - " + p.disk.alloc_type : "" | ||||
|           }`} | ||||
|         /> | ||||
|       </ListItem> | ||||
| @@ -134,31 +134,55 @@ function DiskInfo(p: { | ||||
|  | ||||
|   return ( | ||||
|     <Paper elevation={3} style={{ margin: "10px", padding: "10px" }}> | ||||
|       <TextInput | ||||
|         editable={true} | ||||
|         label="Disk name" | ||||
|         size={ServerApi.Config.constraints.disk_name_size} | ||||
|         checkValue={(v) => /^[a-zA-Z0-9]+$/.test(v)} | ||||
|         value={p.disk.name} | ||||
|         onValueChange={(v) => { | ||||
|           p.disk.name = v ?? ""; | ||||
|           p.onChange?.(); | ||||
|         }} | ||||
|       /> | ||||
|       <div style={{ display: "flex", justifyContent: "space-between" }}> | ||||
|         <TextInput | ||||
|           editable={true} | ||||
|           label="Disk name" | ||||
|           size={ServerApi.Config.constraints.disk_name_size} | ||||
|           checkValue={(v) => /^[a-zA-Z0-9]+$/.test(v)} | ||||
|           value={p.disk.name} | ||||
|           onValueChange={(v) => { | ||||
|             p.disk.name = v ?? ""; | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|         /> | ||||
|         <IconButton onClick={p.removeFromList}> | ||||
|           <DeleteIcon /> | ||||
|         </IconButton> | ||||
|       </div> | ||||
|  | ||||
|       <TextInput | ||||
|         editable={true} | ||||
|         label="Disk size (MB)" | ||||
|         size={ServerApi.Config.constraints.disk_size} | ||||
|         value={p.disk.size.toString()} | ||||
|         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"); | ||||
|           p.disk.size = Number(v ?? "0") * 1000 * 1000 * 1000; | ||||
|           p.onChange?.(); | ||||
|         }} | ||||
|         type="number" | ||||
|       /> | ||||
|  | ||||
|       <div style={{ display: "flex", justifyContent: "space-between" }}> | ||||
|       <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; | ||||
|           p.onChange?.(); | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       {p.disk.format === "Raw" && ( | ||||
|         <SelectInput | ||||
|           editable={true} | ||||
|           label="File allocation type" | ||||
| @@ -168,15 +192,11 @@ function DiskInfo(p: { | ||||
|           ]} | ||||
|           value={p.disk.alloc_type} | ||||
|           onValueChange={(v) => { | ||||
|             p.disk.alloc_type = v as any; | ||||
|             if (p.disk.format === "Raw") p.disk.alloc_type = v as any; | ||||
|             p.onChange?.(); | ||||
|           }} | ||||
|         /> | ||||
|  | ||||
|         <IconButton onClick={p.removeFromList}> | ||||
|           <DeleteIcon /> | ||||
|         </IconButton> | ||||
|       </div> | ||||
|       )} | ||||
|     </Paper> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -29,6 +29,7 @@ export function VMNetworksList(p: { | ||||
|   onChange?: () => void; | ||||
|   editable: boolean; | ||||
|   networksList: NetworkInfo[]; | ||||
|   bridgesList: string[]; | ||||
|   networkFiltersList: NWFilter[]; | ||||
| }): React.ReactElement { | ||||
|   const addNew = () => { | ||||
| @@ -72,6 +73,7 @@ function NetworkInfoWidget(p: { | ||||
|   onChange?: () => void; | ||||
|   removeFromList: () => void; | ||||
|   networksList: NetworkInfo[]; | ||||
|   bridgesList: string[]; | ||||
|   networkFiltersList: NWFilter[]; | ||||
| }): React.ReactElement { | ||||
|   const confirm = useConfirm(); | ||||
| @@ -130,6 +132,11 @@ function NetworkInfoWidget(p: { | ||||
|                     value: "DefinedNetwork", | ||||
|                     description: "Attach to a defined network", | ||||
|                   }, | ||||
|                   { | ||||
|                     label: "Host bridge", | ||||
|                     value: "Bridge", | ||||
|                     description: "Attach to an host's bridge", | ||||
|                   }, | ||||
|                 ]} | ||||
|               /> | ||||
|             ) : ( | ||||
| @@ -149,31 +156,53 @@ function NetworkInfoWidget(p: { | ||||
|           }} | ||||
|         /> | ||||
|  | ||||
|         {/* Defined network selection */} | ||||
|         {p.network.type === "DefinedNetwork" && ( | ||||
|           <SelectInput | ||||
|             editable={p.editable} | ||||
|             label="Defined network" | ||||
|             options={p.networksList.map((n) => { | ||||
|               const chars = [n.forward_mode.toString()]; | ||||
|               if (n.ip_v4) chars.push("IPv4"); | ||||
|               if (n.ip_v6) chars.push("IPv6"); | ||||
|               if (n.description) chars.push(n.description); | ||||
|  | ||||
|               return { | ||||
|                 label: n.name, | ||||
|                 value: n.name, | ||||
|                 description: chars.join(" - "), | ||||
|               }; | ||||
|             })} | ||||
|             value={p.network.network} | ||||
|             onValueChange={(v) => { | ||||
|               if (p.network.type === "DefinedNetwork") | ||||
|                 p.network.network = v as any; | ||||
|               p.onChange?.(); | ||||
|             }} | ||||
|           /> | ||||
|         )} | ||||
|  | ||||
|         {/* Bridge selection */} | ||||
|         {p.network.type === "Bridge" && ( | ||||
|           <SelectInput | ||||
|             editable={p.editable} | ||||
|             label="Host bridge" | ||||
|             options={p.bridgesList.map((n) => { | ||||
|               return { | ||||
|                 label: n, | ||||
|                 value: n, | ||||
|               }; | ||||
|             })} | ||||
|             value={p.network.bridge} | ||||
|             onValueChange={(v) => { | ||||
|               if (p.network.type === "Bridge") p.network.bridge = v as any; | ||||
|               p.onChange?.(); | ||||
|             }} | ||||
|           /> | ||||
|         )} | ||||
|  | ||||
|         {p.network.type !== "UserspaceSLIRPStack" && ( | ||||
|           <> | ||||
|             <SelectInput | ||||
|               editable={p.editable} | ||||
|               label="Defined network" | ||||
|               options={p.networksList.map((n) => { | ||||
|                 const chars = [n.forward_mode.toString()]; | ||||
|                 if (n.ip_v4) chars.push("IPv4"); | ||||
|                 if (n.ip_v6) chars.push("IPv6"); | ||||
|                 if (n.description) chars.push(n.description); | ||||
|  | ||||
|                 return { | ||||
|                   label: n.name, | ||||
|                   value: n.name, | ||||
|                   description: chars.join(" - "), | ||||
|                 }; | ||||
|               })} | ||||
|               value={p.network.network} | ||||
|               onValueChange={(v) => { | ||||
|                 if (p.network.type === "DefinedNetwork") | ||||
|                   p.network.network = v as any; | ||||
|                 p.onChange?.(); | ||||
|               }} | ||||
|             /> | ||||
|  | ||||
|             {/* Network Filter */} | ||||
|             <NWFilterSelectInput | ||||
|               editable={p.editable} | ||||
|   | ||||
| @@ -725,6 +725,11 @@ export function TokenRightsEditor(p: { | ||||
|           right={{ verb: "GET", path: "/api/server/networks" }} | ||||
|           label="Get list of network cards" | ||||
|         /> | ||||
|         <RouteRight | ||||
|           {...p} | ||||
|           right={{ verb: "GET", path: "/api/server/bridges" }} | ||||
|           label="Get list of network bridges" | ||||
|         /> | ||||
|       </RightsSection> | ||||
|     </> | ||||
|   ); | ||||
|   | ||||
| @@ -19,6 +19,7 @@ import { TabsWidget } from "../TabsWidget"; | ||||
| import { XMLAsyncWidget } from "../XMLWidget"; | ||||
| import { CheckboxInput } from "../forms/CheckboxInput"; | ||||
| import { EditSection } from "../forms/EditSection"; | ||||
| import { OEMStringFormWidget } from "../forms/OEMStringFormWidget"; | ||||
| import { ResAutostartInput } from "../forms/ResAutostartInput"; | ||||
| import { SelectInput } from "../forms/SelectInput"; | ||||
| import { TextInput } from "../forms/TextInput"; | ||||
| @@ -37,6 +38,7 @@ interface DetailsProps { | ||||
| export function VMDetails(p: DetailsProps): React.ReactElement { | ||||
|   const [groupsList, setGroupsList] = React.useState<string[] | undefined>(); | ||||
|   const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>(); | ||||
|   const [bridgesList, setBridgesList] = React.useState<string[] | undefined>(); | ||||
|   const [vcpuCombinations, setVCPUCombinations] = React.useState< | ||||
|     number[] | undefined | ||||
|   >(); | ||||
| @@ -50,6 +52,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement { | ||||
|   const load = async () => { | ||||
|     setGroupsList(await GroupApi.GetList()); | ||||
|     setIsoList(await IsoFilesApi.GetList()); | ||||
|     setBridgesList(await ServerApi.GetNetworksBridgesList()); | ||||
|     setVCPUCombinations(await ServerApi.NumberVCPUs()); | ||||
|     setNetworksList(await NetworkApi.GetList()); | ||||
|     setNetworkFiltersList(await NWFilterApi.GetList()); | ||||
| @@ -64,6 +67,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement { | ||||
|         <VMDetailsInner | ||||
|           groupsList={groupsList!} | ||||
|           isoList={isoList!} | ||||
|           bridgesList={bridgesList!} | ||||
|           vcpuCombinations={vcpuCombinations!} | ||||
|           networksList={networksList!} | ||||
|           networkFiltersList={networkFiltersList!} | ||||
| @@ -78,6 +82,7 @@ enum VMTab { | ||||
|   General = 0, | ||||
|   Storage, | ||||
|   Network, | ||||
|   Advanced, | ||||
|   XML, | ||||
|   Danger, | ||||
| } | ||||
| @@ -85,6 +90,7 @@ enum VMTab { | ||||
| type DetailsInnerProps = DetailsProps & { | ||||
|   groupsList: string[]; | ||||
|   isoList: IsoFile[]; | ||||
|   bridgesList: string[]; | ||||
|   vcpuCombinations: number[]; | ||||
|   networksList: NetworkInfo[]; | ||||
|   networkFiltersList: NWFilter[]; | ||||
| @@ -102,6 +108,8 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement { | ||||
|           { label: "General", value: VMTab.General, visible: true }, | ||||
|           { label: "Storage", value: VMTab.Storage, visible: true }, | ||||
|           { label: "Network", value: VMTab.Network, visible: true }, | ||||
|           { label: "Avanced", value: VMTab.Advanced, visible: true }, | ||||
|  | ||||
|           { | ||||
|             label: "XML", | ||||
|             value: VMTab.XML, | ||||
| @@ -119,6 +127,7 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement { | ||||
|       {currTab === VMTab.General && <VMDetailsTabGeneral {...p} />} | ||||
|       {currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />} | ||||
|       {currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />} | ||||
|       {currTab === VMTab.Advanced && <VMDetailsTabAdvanced {...p} />} | ||||
|       {currTab === VMTab.XML && <VMDetailsTabXML {...p} />} | ||||
|       {currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />} | ||||
|     </> | ||||
| @@ -334,8 +343,8 @@ function VMDetailsTabGeneral(p: DetailsInnerProps): React.ReactElement { | ||||
| function VMDetailsTabStorage(p: DetailsInnerProps): React.ReactElement { | ||||
|   return ( | ||||
|     <Grid container spacing={2}> | ||||
|       {(p.editable || p.vm.disks.length > 0) && ( | ||||
|         <EditSection title="Disks storage"> | ||||
|       {(p.editable || p.vm.file_disks.length > 0) && ( | ||||
|         <EditSection title="File disks storage"> | ||||
|           <VMDisksList {...p} /> | ||||
|         </EditSection> | ||||
|       )} | ||||
| @@ -361,6 +370,15 @@ function VMDetailsTabNetwork(p: DetailsInnerProps): React.ReactElement { | ||||
|   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 { | ||||
|   return ( | ||||
|     <XMLAsyncWidget | ||||
|   | ||||
		Reference in New Issue
	
	Block a user