Compare commits
	
		
			1 Commits
		
	
	
		
			master
			...
			1e0cd6ea8c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1e0cd6ea8c | 
| @@ -5,7 +5,7 @@ name: default | ||||
|  | ||||
| steps: | ||||
| - name: frontend_build | ||||
|   image: node:24 | ||||
|   image: node:22 | ||||
|   volumes: | ||||
|     - name: frontend_app | ||||
|       path: /tmp/frontend_build | ||||
|   | ||||
							
								
								
									
										11
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,11 +1,4 @@ | ||||
| # VirtWeb Remote | ||||
| Web UI that allows to start and stop VMs managed by VirtWEB without having to expose the VirtWEB directly on the Internet. | ||||
| WIP project | ||||
|  | ||||
| VirtWebRemote rely on OpenID to authenticate users. | ||||
|  | ||||
| VirtWebRemote authenticates against VirtWEB API using an API token. Both the token ID and private key are required to be able to authenticate against the VirtWEB API. | ||||
|  | ||||
| ## Docker image options | ||||
| ```bash | ||||
| docker run --rm -it pierre42100/virtweb_remote --help | ||||
| ``` | ||||
| This project aims to use the VirtWeb API to start and stop VM without directly exposing the VirtWEB API to the Internet. | ||||
|   | ||||
							
								
								
									
										1641
									
								
								remote_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1641
									
								
								remote_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,28 +1,28 @@ | ||||
| [package] | ||||
| name = "remote_backend" | ||||
| version = "0.1.0" | ||||
| edition = "2024" | ||||
| edition = "2021" | ||||
|  | ||||
| # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||||
|  | ||||
| [dependencies] | ||||
| log = "0.4.28" | ||||
| env_logger = "0.11.8" | ||||
| clap = { version = "4.5.51", features = ["derive", "env"] } | ||||
| serde = { version = "1.0.228", features = ["derive"] } | ||||
| light-openid = { version = "1.0.4", features = ["crypto-wrapper"] } | ||||
| basic-jwt = "0.3.0" | ||||
| actix-web = "4.11.0" | ||||
| log = "0.4.21" | ||||
| env_logger = "0.11.3" | ||||
| clap = { version = "4.5.4", features = ["derive", "env"] } | ||||
| serde = { version = "1.0.200", features = ["derive"] } | ||||
| light-openid = { version = "1.0.2", features = ["crypto-wrapper"] } | ||||
| basic-jwt = "0.2.0" | ||||
| actix-web = "4.5.1" | ||||
| actix-remote-ip = "0.1.0" | ||||
| actix-session = { version = "0.10.1", features = ["cookie-session"] } | ||||
| actix-identity = "0.9.0" | ||||
| actix-cors = "0.7.1" | ||||
| lazy_static = "1.5.0" | ||||
| anyhow = "1.0.100" | ||||
| reqwest = { version = "0.12.24", features = ["json"] } | ||||
| thiserror = "2.0.17" | ||||
| uuid = { version = "1.18.1", features = ["v4", "serde"] } | ||||
| futures-util = "0.3.31" | ||||
| lazy-regex = "3.4.1" | ||||
| mime_guess = "2.0.5" | ||||
| rust-embed = { version = "8.7.2" } | ||||
| actix-session = { version = "0.9.0", features = ["cookie-session"] } | ||||
| actix-identity = "0.7.1" | ||||
| actix-cors = "0.7.0" | ||||
| lazy_static = "1.4.0" | ||||
| anyhow = "1.0.83" | ||||
| reqwest = { version = "0.12.4", features = ["json"] } | ||||
| thiserror = "1.0.59" | ||||
| uuid = { version = "1.8.0", features = ["v4", "serde"] } | ||||
| futures-util = "0.3.30" | ||||
| lazy-regex = "3.1.0" | ||||
| mime_guess = "2.0.4" | ||||
| rust-embed = { version = "8.3.0" } | ||||
| @@ -6,4 +6,4 @@ RUN apt-get update \ | ||||
|  | ||||
| COPY remote_backend /usr/local/bin/remote_backend | ||||
|  | ||||
| ENTRYPOINT ["/usr/local/bin/remote_backend"] | ||||
| ENTRYPOINT /usr/local/bin/remote_backend | ||||
|   | ||||
| @@ -29,7 +29,7 @@ pub struct AppConfig { | ||||
|     #[arg( | ||||
|         long, | ||||
|         env, | ||||
|         default_value = "http://localhost:9001/dex/.well-known/openid-configuration" | ||||
|         default_value = "http://localhost:9001/.well-known/openid-configuration" | ||||
|     )] | ||||
|     pub oidc_configuration_url: String, | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| use actix_remote_ip::RemoteIP; | ||||
| use actix_web::web::Data; | ||||
| use actix_web::{HttpResponse, Responder, web}; | ||||
| use actix_web::{web, HttpResponse, Responder}; | ||||
| use light_openid::basic_state_manager::BasicStateManager; | ||||
|  | ||||
| use crate::app_config::AppConfig; | ||||
|   | ||||
| @@ -1,79 +0,0 @@ | ||||
| use crate::controllers::HttpResult; | ||||
| use crate::virtweb_client; | ||||
| use crate::virtweb_client::{GroupID, VMUuid}; | ||||
| use actix_web::{HttpResponse, web}; | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct GroupIDInPath { | ||||
|     gid: GroupID, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct VMIDInQuery { | ||||
|     vm_id: Option<VMUuid>, | ||||
| } | ||||
|  | ||||
| /// Get the state of one or all VM | ||||
| pub async fn vm_state( | ||||
|     path: web::Path<GroupIDInPath>, | ||||
|     query: web::Query<VMIDInQuery>, | ||||
| ) -> HttpResult { | ||||
|     Ok(HttpResponse::Ok().json(virtweb_client::group_vm_state(&path.gid, query.vm_id).await?)) | ||||
| } | ||||
|  | ||||
| /// Start one or all VM | ||||
| pub async fn vm_start( | ||||
|     path: web::Path<GroupIDInPath>, | ||||
|     query: web::Query<VMIDInQuery>, | ||||
| ) -> HttpResult { | ||||
|     Ok(HttpResponse::Ok().json(virtweb_client::group_vm_start(&path.gid, query.vm_id).await?)) | ||||
| } | ||||
|  | ||||
| /// Shutdown one or all VM | ||||
| pub async fn vm_shutdown( | ||||
|     path: web::Path<GroupIDInPath>, | ||||
|     query: web::Query<VMIDInQuery>, | ||||
| ) -> HttpResult { | ||||
|     Ok(HttpResponse::Ok().json(virtweb_client::group_vm_shutdown(&path.gid, query.vm_id).await?)) | ||||
| } | ||||
|  | ||||
| /// Kill one or all VM | ||||
| pub async fn vm_kill(path: web::Path<GroupIDInPath>, query: web::Query<VMIDInQuery>) -> HttpResult { | ||||
|     Ok(HttpResponse::Ok().json(virtweb_client::group_vm_kill(&path.gid, query.vm_id).await?)) | ||||
| } | ||||
|  | ||||
| /// Reset one or all VM | ||||
| pub async fn vm_reset( | ||||
|     path: web::Path<GroupIDInPath>, | ||||
|     query: web::Query<VMIDInQuery>, | ||||
| ) -> HttpResult { | ||||
|     Ok(HttpResponse::Ok().json(virtweb_client::group_vm_reset(&path.gid, query.vm_id).await?)) | ||||
| } | ||||
|  | ||||
| /// Suspend one or all VM | ||||
| pub async fn vm_suspend( | ||||
|     path: web::Path<GroupIDInPath>, | ||||
|     query: web::Query<VMIDInQuery>, | ||||
| ) -> HttpResult { | ||||
|     Ok(HttpResponse::Ok().json(virtweb_client::group_vm_suspend(&path.gid, query.vm_id).await?)) | ||||
| } | ||||
|  | ||||
| /// Resume one or all VM | ||||
| pub async fn vm_resume( | ||||
|     path: web::Path<GroupIDInPath>, | ||||
|     query: web::Query<VMIDInQuery>, | ||||
| ) -> HttpResult { | ||||
|     Ok(HttpResponse::Ok().json(virtweb_client::group_vm_resume(&path.gid, query.vm_id).await?)) | ||||
| } | ||||
|  | ||||
| /// Screenshot one or all VM | ||||
| pub async fn vm_screenshot( | ||||
|     path: web::Path<GroupIDInPath>, | ||||
|     query: web::Query<VMIDInQuery>, | ||||
| ) -> HttpResult { | ||||
|     let screenshot = virtweb_client::group_vm_screenshot(&path.gid, query.vm_id).await?; | ||||
|  | ||||
|     Ok(HttpResponse::Ok() | ||||
|         .insert_header(("content-type", "image/png")) | ||||
|         .body(screenshot)) | ||||
| } | ||||
| @@ -1,11 +1,11 @@ | ||||
| use actix_web::HttpResponse; | ||||
| use actix_web::body::BoxBody; | ||||
| use actix_web::http::StatusCode; | ||||
| use actix_web::HttpResponse; | ||||
| use std::error::Error; | ||||
| use std::fmt::{Display, Formatter}; | ||||
| use std::io::ErrorKind; | ||||
|  | ||||
| pub mod auth_controller; | ||||
| pub mod group_controller; | ||||
| pub mod server_controller; | ||||
| pub mod static_controller; | ||||
| pub mod sys_info_controller; | ||||
| @@ -37,7 +37,7 @@ impl actix_web::error::ResponseError for HttpErr { | ||||
|         } | ||||
|     } | ||||
|     fn error_response(&self) -> HttpResponse<BoxBody> { | ||||
|         log::error!("Error while processing request! {self}"); | ||||
|         log::error!("Error while processing request! {}", self); | ||||
|  | ||||
|         HttpResponse::InternalServerError().body("Failed to execute request!") | ||||
|     } | ||||
| @@ -51,7 +51,7 @@ impl From<anyhow::Error> for HttpErr { | ||||
|  | ||||
| impl From<Box<dyn Error>> for HttpErr { | ||||
|     fn from(value: Box<dyn Error>) -> Self { | ||||
|         HttpErr::Err(std::io::Error::other(value.to_string()).into()) | ||||
|         HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -81,7 +81,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::other(value.to_string()).into()) | ||||
|         HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into()) | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,6 @@ | ||||
| use crate::app_config::AppConfig; | ||||
| use crate::controllers::HttpResult; | ||||
| use crate::extractors::auth_extractor::AuthExtractor; | ||||
| use crate::virtweb_client; | ||||
| use crate::virtweb_client::{GroupID, VMCaps, VMInfo}; | ||||
| use actix_web::HttpResponse; | ||||
|  | ||||
| #[derive(serde::Serialize)] | ||||
| @@ -17,75 +15,3 @@ pub async fn config(auth: AuthExtractor) -> HttpResult { | ||||
|         disable_auth: AppConfig::get().unsecure_disable_login, | ||||
|     })) | ||||
| } | ||||
|  | ||||
| #[derive(Default, Debug, serde::Serialize)] | ||||
| pub struct Rights { | ||||
|     groups: Vec<GroupInfo>, | ||||
|     vms: Vec<VMInfoAndCaps>, | ||||
|     sys_info: bool, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Serialize)] | ||||
| pub struct GroupInfo { | ||||
|     id: GroupID, | ||||
|     vms: Vec<VMInfo>, | ||||
|     #[serde(flatten)] | ||||
|     caps: VMCaps, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Serialize)] | ||||
| pub struct VMInfoAndCaps { | ||||
|     #[serde(flatten)] | ||||
|     info: VMInfo, | ||||
|     #[serde(flatten)] | ||||
|     caps: VMCaps, | ||||
| } | ||||
|  | ||||
| pub async fn rights() -> HttpResult { | ||||
|     let rights = virtweb_client::get_token_info().await?; | ||||
|  | ||||
|     let mut res = Rights { | ||||
|         groups: vec![], | ||||
|         vms: vec![], | ||||
|         sys_info: rights.can_retrieve_system_info(), | ||||
|     }; | ||||
|  | ||||
|     for g in rights.list_groups() { | ||||
|         let group_vms = virtweb_client::group_vm_info(&g).await?; | ||||
|  | ||||
|         res.groups.push(GroupInfo { | ||||
|             id: g.clone(), | ||||
|             vms: group_vms, | ||||
|             caps: VMCaps { | ||||
|                 can_get_state: rights.is_route_allowed("GET", &g.route_vm_state(None)), | ||||
|                 can_start: rights.is_route_allowed("GET", &g.route_vm_start(None)), | ||||
|                 can_shutdown: rights.is_route_allowed("GET", &g.route_vm_shutdown(None)), | ||||
|                 can_kill: rights.is_route_allowed("GET", &g.route_vm_kill(None)), | ||||
|                 can_reset: rights.is_route_allowed("GET", &g.route_vm_reset(None)), | ||||
|                 can_suspend: rights.is_route_allowed("GET", &g.route_vm_suspend(None)), | ||||
|                 can_resume: rights.is_route_allowed("GET", &g.route_vm_resume(None)), | ||||
|                 can_screenshot: rights.is_route_allowed("GET", &g.route_vm_screenshot(None)), | ||||
|             }, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     for v in rights.list_vm() { | ||||
|         let vm_info = virtweb_client::vm_info(v).await?; | ||||
|  | ||||
|         res.vms.push(VMInfoAndCaps { | ||||
|             info: vm_info, | ||||
|             caps: VMCaps { | ||||
|                 can_get_state: rights.is_route_allowed("GET", &v.route_state()), | ||||
|                 can_start: rights.is_route_allowed("GET", &v.route_start()), | ||||
|                 can_shutdown: rights.is_route_allowed("GET", &v.route_shutdown()), | ||||
|                 can_kill: rights.is_route_allowed("GET", &v.route_kill()), | ||||
|                 can_reset: rights.is_route_allowed("GET", &v.route_reset()), | ||||
|                 can_suspend: rights.is_route_allowed("GET", &v.route_suspend()), | ||||
|                 can_resume: rights.is_route_allowed("GET", &v.route_resume()), | ||||
|                 can_screenshot: rights.is_route_allowed("GET", &v.route_screenshot()), | ||||
|             }, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     Ok(HttpResponse::Ok().json(res)) | ||||
| } | ||||
|   | ||||
| @@ -18,7 +18,7 @@ mod serve_static_debug { | ||||
|  | ||||
| #[cfg(not(debug_assertions))] | ||||
| mod serve_static_release { | ||||
|     use actix_web::{HttpResponse, Responder, web}; | ||||
|     use actix_web::{web, HttpResponse, Responder}; | ||||
|     use rust_embed::RustEmbed; | ||||
|  | ||||
|     #[derive(RustEmbed)] | ||||
|   | ||||
| @@ -2,6 +2,20 @@ use crate::controllers::HttpResult; | ||||
| use crate::virtweb_client; | ||||
| use actix_web::HttpResponse; | ||||
|  | ||||
| #[derive(serde::Serialize)] | ||||
| struct SysInfoStatus { | ||||
|     allowed: bool, | ||||
| } | ||||
|  | ||||
| /// Check if system info can be retrieved | ||||
| pub async fn config() -> HttpResult { | ||||
|     let info = virtweb_client::get_token_info().await?; | ||||
|  | ||||
|     Ok(HttpResponse::Ok().json(SysInfoStatus { | ||||
|         allowed: info.can_retrieve_system_info(), | ||||
|     })) | ||||
| } | ||||
|  | ||||
| /// Get current system status | ||||
| pub async fn status() -> HttpResult { | ||||
|     Ok(HttpResponse::Ok().json(virtweb_client::get_server_info().await?)) | ||||
|   | ||||
| @@ -3,7 +3,55 @@ | ||||
| use crate::controllers::HttpResult; | ||||
| use crate::virtweb_client; | ||||
| use crate::virtweb_client::VMUuid; | ||||
| use actix_web::{HttpResponse, web}; | ||||
| use actix_web::{web, HttpResponse}; | ||||
|  | ||||
| #[derive(Debug, serde::Serialize)] | ||||
| pub struct VMInfoAndCaps { | ||||
|     uiid: VMUuid, | ||||
|     name: String, | ||||
|     description: Option<String>, | ||||
|     architecture: String, | ||||
|     memory: usize, | ||||
|     number_vcpu: usize, | ||||
|     can_get_state: bool, | ||||
|     can_start: bool, | ||||
|     can_shutdown: bool, | ||||
|     can_kill: bool, | ||||
|     can_reset: bool, | ||||
|     can_suspend: bool, | ||||
|     can_resume: bool, | ||||
|     can_screenshot: bool, | ||||
| } | ||||
|  | ||||
| /// Get the list of VMs that can be controlled by VirtWeb remote | ||||
| pub async fn list() -> HttpResult { | ||||
|     let rights = virtweb_client::get_token_info().await?; | ||||
|  | ||||
|     let mut res = vec![]; | ||||
|  | ||||
|     for v in rights.list_vm() { | ||||
|         let vm_info = virtweb_client::vm_info(v).await?; | ||||
|  | ||||
|         res.push(VMInfoAndCaps { | ||||
|             uiid: vm_info.uuid, | ||||
|             name: vm_info.name, | ||||
|             description: vm_info.description.clone(), | ||||
|             architecture: vm_info.architecture.to_string(), | ||||
|             memory: vm_info.memory, | ||||
|             number_vcpu: vm_info.number_vcpu, | ||||
|             can_get_state: rights.is_route_allowed("GET", &v.route_state()), | ||||
|             can_start: rights.is_route_allowed("GET", &v.route_start()), | ||||
|             can_shutdown: rights.is_route_allowed("GET", &v.route_shutdown()), | ||||
|             can_kill: rights.is_route_allowed("GET", &v.route_kill()), | ||||
|             can_reset: rights.is_route_allowed("GET", &v.route_reset()), | ||||
|             can_suspend: rights.is_route_allowed("GET", &v.route_suspend()), | ||||
|             can_resume: rights.is_route_allowed("GET", &v.route_resume()), | ||||
|             can_screenshot: rights.is_route_allowed("GET", &v.route_screenshot()), | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     Ok(HttpResponse::Ok().json(res)) | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| pub struct ReqPath { | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| use actix_identity::Identity; | ||||
| use actix_web::dev::Payload; | ||||
| use actix_web::{Error, FromRequest, HttpMessage, HttpRequest}; | ||||
| use futures_util::future::{Ready, ready}; | ||||
| use futures_util::future::{ready, Ready}; | ||||
| use std::fmt::Display; | ||||
|  | ||||
| pub struct AuthExtractor { | ||||
|   | ||||
| @@ -1,19 +1,18 @@ | ||||
| use actix_cors::Cors; | ||||
| use actix_identity::config::LogoutBehaviour; | ||||
| use actix_identity::IdentityMiddleware; | ||||
| use actix_identity::config::LogoutBehavior; | ||||
| use actix_remote_ip::RemoteIPConfig; | ||||
| use actix_session::SessionMiddleware; | ||||
| use actix_session::storage::CookieSessionStore; | ||||
| use actix_session::SessionMiddleware; | ||||
| use actix_web::cookie::{Key, SameSite}; | ||||
| use actix_web::middleware::Logger; | ||||
| use actix_web::web::Data; | ||||
| use actix_web::{App, HttpServer, web}; | ||||
| use actix_web::{web, App, HttpServer}; | ||||
| use light_openid::basic_state_manager::BasicStateManager; | ||||
| use remote_backend::app_config::AppConfig; | ||||
| use remote_backend::constants; | ||||
| use remote_backend::controllers::{ | ||||
|     auth_controller, group_controller, server_controller, static_controller, sys_info_controller, | ||||
|     vm_controller, | ||||
|     auth_controller, server_controller, static_controller, sys_info_controller, vm_controller, | ||||
| }; | ||||
| use remote_backend::middlewares::auth_middleware::AuthChecker; | ||||
| use std::time::Duration; | ||||
| @@ -37,7 +36,7 @@ async fn main() -> std::io::Result<()> { | ||||
|         .build(); | ||||
|  | ||||
|         let identity_middleware = IdentityMiddleware::builder() | ||||
|             .logout_behavior(LogoutBehavior::PurgeSession) | ||||
|             .logout_behaviour(LogoutBehaviour::PurgeSession) | ||||
|             .visit_deadline(Some(Duration::from_secs( | ||||
|                 constants::MAX_INACTIVITY_DURATION, | ||||
|             ))) | ||||
| @@ -83,44 +82,8 @@ async fn main() -> std::io::Result<()> { | ||||
|                 "/api/auth/sign_out", | ||||
|                 web::get().to(auth_controller::sign_out), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/server/rights", | ||||
|                 web::get().to(server_controller::rights), | ||||
|             ) | ||||
|             // Groups routes | ||||
|             .route( | ||||
|                 "/api/group/{gid}/vm/state", | ||||
|                 web::get().to(group_controller::vm_state), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/group/{gid}/vm/start", | ||||
|                 web::get().to(group_controller::vm_start), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/group/{gid}/vm/shutdown", | ||||
|                 web::get().to(group_controller::vm_shutdown), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/group/{gid}/vm/kill", | ||||
|                 web::get().to(group_controller::vm_kill), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/group/{gid}/vm/reset", | ||||
|                 web::get().to(group_controller::vm_reset), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/group/{gid}/vm/suspend", | ||||
|                 web::get().to(group_controller::vm_suspend), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/group/{gid}/vm/resume", | ||||
|                 web::get().to(group_controller::vm_resume), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/group/{gid}/vm/screenshot", | ||||
|                 web::get().to(group_controller::vm_screenshot), | ||||
|             ) | ||||
|             // VM routes | ||||
|             .route("/api/vm/list", web::get().to(vm_controller::list)) | ||||
|             .route("/api/vm/{uid}/state", web::get().to(vm_controller::state)) | ||||
|             .route("/api/vm/{uid}/start", web::get().to(vm_controller::start)) | ||||
|             .route( | ||||
| @@ -139,6 +102,10 @@ async fn main() -> std::io::Result<()> { | ||||
|                 web::get().to(vm_controller::screenshot), | ||||
|             ) | ||||
|             // Sys info routes | ||||
|             .route( | ||||
|                 "/api/sysinfo/config", | ||||
|                 web::get().to(sys_info_controller::config), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/api/sysinfo/status", | ||||
|                 web::get().to(sys_info_controller::status), | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| use std::future::{Ready, ready}; | ||||
| use std::future::{ready, Ready}; | ||||
| use std::rc::Rc; | ||||
|  | ||||
| use crate::app_config::AppConfig; | ||||
| @@ -7,8 +7,8 @@ use crate::extractors::auth_extractor::AuthExtractor; | ||||
| use actix_web::body::EitherBody; | ||||
| use actix_web::dev::Payload; | ||||
| use actix_web::{ | ||||
|     dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, | ||||
|     Error, FromRequest, HttpResponse, | ||||
|     dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready}, | ||||
| }; | ||||
| use futures_util::future::LocalBoxFuture; | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| use crate::app_config::AppConfig; | ||||
| use crate::utils::time; | ||||
| use lazy_regex::regex; | ||||
| use std::collections::HashMap; | ||||
| use std::fmt::Display; | ||||
| use std::str::FromStr; | ||||
| use thiserror::Error; | ||||
| @@ -13,105 +12,9 @@ pub enum VirtWebClientError { | ||||
|     InvalidStatusCode(u16), | ||||
| } | ||||
|  | ||||
| #[derive(Eq, PartialEq, Debug, Clone, serde::Serialize, serde::Deserialize)] | ||||
| pub struct GroupID(String); | ||||
|  | ||||
| impl GroupID { | ||||
|     pub fn route_vm_info(&self) -> String { | ||||
|         format!("/api/group/{}/vm/info", self.0) | ||||
|     } | ||||
|  | ||||
|     pub fn route_vm_state(&self, vm: Option<VMUuid>) -> String { | ||||
|         format!( | ||||
|             "/api/group/{}/vm/state{}", | ||||
|             self.0, | ||||
|             match vm { | ||||
|                 None => "".to_string(), | ||||
|                 Some(id) => format!("?vm_id={}", id.0), | ||||
|             } | ||||
|         ) | ||||
|     } | ||||
|     pub fn route_vm_start(&self, vm: Option<VMUuid>) -> String { | ||||
|         format!( | ||||
|             "/api/group/{}/vm/start{}", | ||||
|             self.0, | ||||
|             match vm { | ||||
|                 None => "".to_string(), | ||||
|                 Some(id) => format!("?vm_id={}", id.0), | ||||
|             } | ||||
|         ) | ||||
|     } | ||||
|     pub fn route_vm_shutdown(&self, vm: Option<VMUuid>) -> String { | ||||
|         format!( | ||||
|             "/api/group/{}/vm/shutdown{}", | ||||
|             self.0, | ||||
|             match vm { | ||||
|                 None => "".to_string(), | ||||
|                 Some(id) => format!("?vm_id={}", id.0), | ||||
|             } | ||||
|         ) | ||||
|     } | ||||
|     pub fn route_vm_suspend(&self, vm: Option<VMUuid>) -> String { | ||||
|         format!( | ||||
|             "/api/group/{}/vm/suspend{}", | ||||
|             self.0, | ||||
|             match vm { | ||||
|                 None => "".to_string(), | ||||
|                 Some(id) => format!("?vm_id={}", id.0), | ||||
|             } | ||||
|         ) | ||||
|     } | ||||
|     pub fn route_vm_resume(&self, vm: Option<VMUuid>) -> String { | ||||
|         format!( | ||||
|             "/api/group/{}/vm/resume{}", | ||||
|             self.0, | ||||
|             match vm { | ||||
|                 None => "".to_string(), | ||||
|                 Some(id) => format!("?vm_id={}", id.0), | ||||
|             } | ||||
|         ) | ||||
|     } | ||||
|     pub fn route_vm_kill(&self, vm: Option<VMUuid>) -> String { | ||||
|         format!( | ||||
|             "/api/group/{}/vm/kill{}", | ||||
|             self.0, | ||||
|             match vm { | ||||
|                 None => "".to_string(), | ||||
|                 Some(id) => format!("?vm_id={}", id.0), | ||||
|             } | ||||
|         ) | ||||
|     } | ||||
|     pub fn route_vm_reset(&self, vm: Option<VMUuid>) -> String { | ||||
|         format!( | ||||
|             "/api/group/{}/vm/reset{}", | ||||
|             self.0, | ||||
|             match vm { | ||||
|                 None => "".to_string(), | ||||
|                 Some(id) => format!("?vm_id={}", id.0), | ||||
|             } | ||||
|         ) | ||||
|     } | ||||
|     pub fn route_vm_screenshot(&self, vm: Option<VMUuid>) -> String { | ||||
|         format!( | ||||
|             "/api/group/{}/vm/screenshot{}", | ||||
|             self.0, | ||||
|             match vm { | ||||
|                 None => "".to_string(), | ||||
|                 Some(id) => format!("?vm_id={}", id.0), | ||||
|             } | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Eq, PartialEq, Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Hash)] | ||||
| #[derive(Eq, PartialEq, Debug, Copy, Clone, serde::Serialize, serde::Deserialize)] | ||||
| pub struct VMUuid(Uuid); | ||||
|  | ||||
| #[derive(Default, serde::Deserialize, serde::Serialize)] | ||||
| pub struct TreatmentResult { | ||||
|     ok: usize, | ||||
|     failed: usize, | ||||
| } | ||||
|  | ||||
| impl VMUuid { | ||||
|     pub fn route_info(&self) -> String { | ||||
|         format!("/api/vm/{}", self.0) | ||||
| @@ -166,7 +69,7 @@ pub struct TokenClaims { | ||||
|     pub nonce: String, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize, serde::Serialize, Debug)] | ||||
| #[derive(serde::Deserialize, Debug)] | ||||
| pub struct VMInfo { | ||||
|     pub uuid: VMUuid, | ||||
|     pub name: String, | ||||
| @@ -176,18 +79,6 @@ pub struct VMInfo { | ||||
|     pub number_vcpu: usize, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize, serde::Serialize, Debug)] | ||||
| pub struct VMCaps { | ||||
|     pub can_get_state: bool, | ||||
|     pub can_start: bool, | ||||
|     pub can_shutdown: bool, | ||||
|     pub can_kill: bool, | ||||
|     pub can_reset: bool, | ||||
|     pub can_suspend: bool, | ||||
|     pub can_resume: bool, | ||||
|     pub can_screenshot: bool, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize, serde::Serialize, Debug)] | ||||
| pub struct VMState { | ||||
|     pub state: String, | ||||
| @@ -256,16 +147,6 @@ impl TokenInfo { | ||||
|         false | ||||
|     } | ||||
|  | ||||
|     /// List the groups with access | ||||
|     pub fn list_groups(&self) -> Vec<GroupID> { | ||||
|         self.rights | ||||
|             .iter() | ||||
|             .filter(|r| r.verb == "GET") | ||||
|             .filter(|r| regex!("^/api/group/[^/]+/vm/info$").is_match(&r.path)) | ||||
|             .map(|r| GroupID(r.path.split("/").nth(3).unwrap().to_string())) | ||||
|             .collect::<Vec<_>>() | ||||
|     } | ||||
|  | ||||
|     /// List the virtual machines with access | ||||
|     pub fn list_vm(&self) -> Vec<VMUuid> { | ||||
|         self.rights | ||||
| @@ -287,13 +168,12 @@ async fn request<D: Display>(uri: D) -> anyhow::Result<reqwest::Response> { | ||||
|     let url = format!("{}{}", AppConfig::get().virtweb_base_url, uri); | ||||
|     log::debug!("Will query {uri}..."); | ||||
|  | ||||
|     let uri = uri.to_string(); | ||||
|     let jwt = TokenClaims { | ||||
|         sub: AppConfig::get().virtweb_token_id.to_string(), | ||||
|         iat: time() - 60 * 2, | ||||
|         exp: time() + 60 * 3, | ||||
|         verb: "GET".to_string(), | ||||
|         path: uri.split_once('?').map(|s| s.0).unwrap_or(&uri).to_string(), | ||||
|         path: uri.to_string(), | ||||
|         nonce: Uuid::new_v4().to_string(), | ||||
|     }; | ||||
|     let jwt = AppConfig::get().token_private_key().sign_jwt(&jwt)?; | ||||
| @@ -380,73 +260,6 @@ pub async fn vm_screenshot(id: VMUuid) -> anyhow::Result<Vec<u8>> { | ||||
|         .to_vec()) | ||||
| } | ||||
|  | ||||
| /// Get the VM of a group | ||||
| pub async fn group_vm_info(id: &GroupID) -> anyhow::Result<Vec<VMInfo>> { | ||||
|     json_request(id.route_vm_info()).await | ||||
| } | ||||
|  | ||||
| /// Get the state of one or all VMs of a group | ||||
| pub async fn group_vm_state( | ||||
|     id: &GroupID, | ||||
|     vm_id: Option<VMUuid>, | ||||
| ) -> anyhow::Result<HashMap<VMUuid, String>> { | ||||
|     json_request(id.route_vm_state(vm_id)).await | ||||
| } | ||||
|  | ||||
| /// Start one or all VMs of a group | ||||
| pub async fn group_vm_start( | ||||
|     id: &GroupID, | ||||
|     vm_id: Option<VMUuid>, | ||||
| ) -> anyhow::Result<TreatmentResult> { | ||||
|     json_request(id.route_vm_start(vm_id)).await | ||||
| } | ||||
|  | ||||
| /// Shutdown one or all VMs of a group | ||||
| pub async fn group_vm_shutdown( | ||||
|     id: &GroupID, | ||||
|     vm_id: Option<VMUuid>, | ||||
| ) -> anyhow::Result<TreatmentResult> { | ||||
|     json_request(id.route_vm_shutdown(vm_id)).await | ||||
| } | ||||
|  | ||||
| /// Kill one or all VMs of a group | ||||
| pub async fn group_vm_kill(id: &GroupID, vm_id: Option<VMUuid>) -> anyhow::Result<TreatmentResult> { | ||||
|     json_request(id.route_vm_kill(vm_id)).await | ||||
| } | ||||
|  | ||||
| /// Reset one or all VMs of a group | ||||
| pub async fn group_vm_reset( | ||||
|     id: &GroupID, | ||||
|     vm_id: Option<VMUuid>, | ||||
| ) -> anyhow::Result<TreatmentResult> { | ||||
|     json_request(id.route_vm_reset(vm_id)).await | ||||
| } | ||||
|  | ||||
| /// Suspend one or all VMs of a group | ||||
| pub async fn group_vm_suspend( | ||||
|     id: &GroupID, | ||||
|     vm_id: Option<VMUuid>, | ||||
| ) -> anyhow::Result<TreatmentResult> { | ||||
|     json_request(id.route_vm_suspend(vm_id)).await | ||||
| } | ||||
|  | ||||
| /// Resume one or all VMs of a group | ||||
| pub async fn group_vm_resume( | ||||
|     id: &GroupID, | ||||
|     vm_id: Option<VMUuid>, | ||||
| ) -> anyhow::Result<TreatmentResult> { | ||||
|     json_request(id.route_vm_resume(vm_id)).await | ||||
| } | ||||
|  | ||||
| /// Get the screenshot of one or all VMs of a group | ||||
| pub async fn group_vm_screenshot(id: &GroupID, vm_id: Option<VMUuid>) -> anyhow::Result<Vec<u8>> { | ||||
|     Ok(request(id.route_vm_screenshot(vm_id)) | ||||
|         .await? | ||||
|         .bytes() | ||||
|         .await? | ||||
|         .to_vec()) | ||||
| } | ||||
|  | ||||
| /// Get current server information | ||||
| pub async fn get_server_info() -> anyhow::Result<SystemInfo> { | ||||
|     json_request("/api/server/info").await | ||||
|   | ||||
							
								
								
									
										18
									
								
								remote_frontend/.eslintrc.cjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								remote_frontend/.eslintrc.cjs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| module.exports = { | ||||
|   root: true, | ||||
|   env: { browser: true, es2020: true }, | ||||
|   extends: [ | ||||
|     'eslint:recommended', | ||||
|     'plugin:@typescript-eslint/recommended', | ||||
|     'plugin:react-hooks/recommended', | ||||
|   ], | ||||
|   ignorePatterns: ['dist', '.eslintrc.cjs'], | ||||
|   parser: '@typescript-eslint/parser', | ||||
|   plugins: ['react-refresh'], | ||||
|   rules: { | ||||
|     'react-refresh/only-export-components': [ | ||||
|       'warn', | ||||
|       { allowConstantExport: true }, | ||||
|     ], | ||||
|   }, | ||||
| } | ||||
| @@ -1,28 +0,0 @@ | ||||
| import js from '@eslint/js' | ||||
| import globals from 'globals' | ||||
| import reactHooks from 'eslint-plugin-react-hooks' | ||||
| import reactRefresh from 'eslint-plugin-react-refresh' | ||||
| import tseslint from 'typescript-eslint' | ||||
|  | ||||
| export default tseslint.config( | ||||
|   { ignores: ['dist'] }, | ||||
|   { | ||||
|     extends: [js.configs.recommended, ...tseslint.configs.recommended], | ||||
|     files: ['**/*.{ts,tsx}'], | ||||
|     languageOptions: { | ||||
|       ecmaVersion: 2020, | ||||
|       globals: globals.browser, | ||||
|     }, | ||||
|     plugins: { | ||||
|       'react-hooks': reactHooks, | ||||
|       'react-refresh': reactRefresh, | ||||
|     }, | ||||
|     rules: { | ||||
|       ...reactHooks.configs.recommended.rules, | ||||
|       'react-refresh/only-export-components': [ | ||||
|         'warn', | ||||
|         { allowConstantExport: true }, | ||||
|       ], | ||||
|     }, | ||||
|   }, | ||||
| ) | ||||
							
								
								
									
										5169
									
								
								remote_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5169
									
								
								remote_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -10,25 +10,22 @@ | ||||
|     "preview": "vite preview" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@fluentui/react-components": "^9.72.3", | ||||
|     "@fluentui/react-icons": "^2.0.313", | ||||
|     "filesize": "^11.0.13", | ||||
|     "react": "^18.3.1", | ||||
|     "react-dom": "^18.3.1" | ||||
|     "@fluentui/react-components": "^9.49.2", | ||||
|     "@fluentui/react-icons": "^2.0.239", | ||||
|     "filesize": "^10.1.1", | ||||
|     "react": "^18.2.0", | ||||
|     "react-dom": "^18.2.0" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@eslint/js": "^9.38.0", | ||||
|     "@types/react": "^18.3.26", | ||||
|     "@types/react-dom": "^18.3.7", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.46.2", | ||||
|     "@typescript-eslint/parser": "^8.46.2", | ||||
|     "@vitejs/plugin-react": "^5.1.0", | ||||
|     "eslint": "^9.38.0", | ||||
|     "eslint-plugin-react-hooks": "^5.2.0", | ||||
|     "eslint-plugin-react-refresh": "^0.4.24", | ||||
|     "globals": "^16.4.0", | ||||
|     "typescript": "^5.9.3", | ||||
|     "typescript-eslint": "^8.43.0", | ||||
|     "vite": "^7.1.12" | ||||
|     "@types/react": "^18.2.66", | ||||
|     "@types/react-dom": "^18.2.22", | ||||
|     "@typescript-eslint/eslint-plugin": "^7.2.0", | ||||
|     "@typescript-eslint/parser": "^7.2.0", | ||||
|     "@vitejs/plugin-react": "^4.2.1", | ||||
|     "eslint": "^8.57.0", | ||||
|     "eslint-plugin-react-hooks": "^4.6.0", | ||||
|     "eslint-plugin-react-refresh": "^0.4.6", | ||||
|     "typescript": "^5.2.2", | ||||
|     "vite": "^5.2.11" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -5,8 +5,6 @@ import { | ||||
|   typographyStyles, | ||||
| } from "@fluentui/react-components"; | ||||
| import { | ||||
|   AppsListDetailFilled, | ||||
|   AppsListDetailRegular, | ||||
|   DesktopFilled, | ||||
|   DesktopRegular, | ||||
|   InfoFilled, | ||||
| @@ -14,13 +12,12 @@ import { | ||||
|   bundleIcon, | ||||
| } from "@fluentui/react-icons"; | ||||
| import React from "react"; | ||||
| import { Rights, ServerApi } from "./api/ServerApi"; | ||||
| import { ServerApi } from "./api/ServerApi"; | ||||
| import { AuthRouteWidget } from "./routes/AuthRouteWidget"; | ||||
| import { AsyncWidget } from "./widgets/AsyncWidget"; | ||||
| import { MainMenu } from "./widgets/MainMenu"; | ||||
| import { SystemInfoWidget } from "./widgets/SystemInfoWidget"; | ||||
| import { VirtualMachinesWidget } from "./widgets/VirtualMachinesWidget"; | ||||
| import { GroupsWidget } from "./widgets/GroupsWidget"; | ||||
|  | ||||
| const useStyles = makeStyles({ | ||||
|   title: typographyStyles.title2, | ||||
| @@ -30,8 +27,6 @@ const InfoIcon = bundleIcon(InfoFilled, InfoRegular); | ||||
|  | ||||
| const DesktopIcon = bundleIcon(DesktopFilled, DesktopRegular); | ||||
|  | ||||
| const AppListIcon = bundleIcon(AppsListDetailFilled, AppsListDetailRegular); | ||||
|  | ||||
| export function App() { | ||||
|   return ( | ||||
|     <AsyncWidget | ||||
| @@ -45,79 +40,45 @@ export function App() { | ||||
| } | ||||
|  | ||||
| function AppInner(): React.ReactElement { | ||||
|   const styles = useStyles(); | ||||
|   const [tab, setTab] = React.useState<"vm" | "info">("vm"); | ||||
|  | ||||
|   if (!ServerApi.Config.authenticated && !ServerApi.Config.disable_auth) | ||||
|     return <AuthRouteWidget />; | ||||
|  | ||||
|   return <AuthenticatedApp />; | ||||
| } | ||||
|  | ||||
| function AuthenticatedApp(): React.ReactElement { | ||||
|   const styles = useStyles(); | ||||
|   const [tab, setTab] = React.useState<"group" | "vm" | "info">("group"); | ||||
|  | ||||
|   const [rights, setRights] = React.useState<Rights | undefined>(); | ||||
|  | ||||
|   const load = async () => { | ||||
|     const rights = await ServerApi.GetRights(); | ||||
|     setRights(rights); | ||||
|  | ||||
|     if (rights!.groups.length > 0) setTab("group"); | ||||
|     else if (rights!.vms.length > 0) setTab("vm"); | ||||
|     else setTab("info"); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <AsyncWidget | ||||
|       loadKey={1} | ||||
|       load={load} | ||||
|       errMsg="Failed to retrieve application rights!" | ||||
|       build={() => { | ||||
|         return ( | ||||
|           <div | ||||
|             style={{ | ||||
|               width: "95%", | ||||
|               maxWidth: "1000px", | ||||
|               margin: "50px auto", | ||||
|             }} | ||||
|           > | ||||
|             <span className={styles.title}>VirtWebRemote</span> | ||||
|             <div | ||||
|               style={{ | ||||
|                 display: "flex", | ||||
|                 justifyContent: "space-between", | ||||
|                 marginTop: "30px", | ||||
|               }} | ||||
|             > | ||||
|               <TabList | ||||
|                 selectedValue={tab} | ||||
|                 onTabSelect={(_, d) => setTab(d.value as any)} | ||||
|               > | ||||
|                 {rights!.groups.length > 0 && ( | ||||
|                   <Tab value="group" icon={<AppListIcon />}> | ||||
|                     Groups | ||||
|                   </Tab> | ||||
|                 )} | ||||
|                 {rights!.vms.length > 0 && ( | ||||
|                   <Tab value="vm" icon={<DesktopIcon />}> | ||||
|                     Virtual machines | ||||
|                   </Tab> | ||||
|                 )} | ||||
|                 {rights!.sys_info && ( | ||||
|                   <Tab value="info" icon={<InfoIcon />}> | ||||
|                     System info | ||||
|                   </Tab> | ||||
|                 )} | ||||
|               </TabList> | ||||
|               <div> | ||||
|                 <MainMenu /> | ||||
|               </div> | ||||
|             </div> | ||||
|             {tab === "group" && <GroupsWidget rights={rights!} />} | ||||
|             {tab === "vm" && <VirtualMachinesWidget rights={rights!} />} | ||||
|             {tab === "info" && <SystemInfoWidget />} | ||||
|           </div> | ||||
|         ); | ||||
|     <div | ||||
|       style={{ | ||||
|         width: "95%", | ||||
|         maxWidth: "1000px", | ||||
|         margin: "50px auto", | ||||
|       }} | ||||
|     /> | ||||
|     > | ||||
|       <span className={styles.title}>VirtWebRemote</span> | ||||
|       <div | ||||
|         style={{ | ||||
|           display: "flex", | ||||
|           justifyContent: "space-between", | ||||
|           marginTop: "30px", | ||||
|         }} | ||||
|       > | ||||
|         <TabList | ||||
|           selectedValue={tab} | ||||
|           onTabSelect={(_, d) => setTab(d.value as any)} | ||||
|         > | ||||
|           <Tab value="vm" icon={<DesktopIcon />}> | ||||
|             Virtual machines | ||||
|           </Tab> | ||||
|           <Tab value="info" icon={<InfoIcon />}> | ||||
|             System info | ||||
|           </Tab> | ||||
|         </TabList> | ||||
|         <div> | ||||
|           <MainMenu /> | ||||
|         </div> | ||||
|       </div> | ||||
|       {tab === "vm" && <VirtualMachinesWidget />} | ||||
|       {tab === "info" && <SystemInfoWidget />} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -1,107 +0,0 @@ | ||||
| import { APIClient } from "./ApiClient"; | ||||
| import { VMGroup } from "./ServerApi"; | ||||
| import { VMInfo, VMState } from "./VMApi"; | ||||
|  | ||||
| export interface GroupVMState { | ||||
|   [key: string]: VMState; | ||||
| } | ||||
|  | ||||
| export interface TreatmentResult { | ||||
|   ok: number; | ||||
|   failed: number; | ||||
| } | ||||
|  | ||||
| export class GroupApi { | ||||
|   /** | ||||
|    * Get the state of the VMs of a group | ||||
|    */ | ||||
|   static async State(g: VMGroup): Promise<GroupVMState> { | ||||
|     return ( | ||||
|       await APIClient.exec({ method: "GET", uri: `/group/${g.id}/vm/state` }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request to start the VM of a group | ||||
|    */ | ||||
|   static async StartVM(g: VMGroup, vm?: VMInfo): Promise<TreatmentResult> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: `/group/${g.id}/vm/start` + (vm ? `?vm_id=${vm.uuid}` : ""), | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request to suspend the VM of a group | ||||
|    */ | ||||
|   static async SuspendVM(g: VMGroup, vm?: VMInfo): Promise<TreatmentResult> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: `/group/${g.id}/vm/suspend` + (vm ? `?vm_id=${vm.uuid}` : ""), | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request to resume the VM of a group | ||||
|    */ | ||||
|   static async ResumeVM(g: VMGroup, vm?: VMInfo): Promise<TreatmentResult> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: `/group/${g.id}/vm/resume` + (vm ? `?vm_id=${vm.uuid}` : ""), | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request to shutdown the VM of a group | ||||
|    */ | ||||
|   static async ShutdownVM(g: VMGroup, vm?: VMInfo): Promise<TreatmentResult> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: `/group/${g.id}/vm/shutdown` + (vm ? `?vm_id=${vm.uuid}` : ""), | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request to kill the VM of a group | ||||
|    */ | ||||
|   static async KillVM(g: VMGroup, vm?: VMInfo): Promise<TreatmentResult> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: `/group/${g.id}/vm/kill` + (vm ? `?vm_id=${vm.uuid}` : ""), | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request to reset the VM of a group | ||||
|    */ | ||||
|   static async ResetVM(g: VMGroup, vm?: VMInfo): Promise<TreatmentResult> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: `/group/${g.id}/vm/reset` + (vm ? `?vm_id=${vm.uuid}` : ""), | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request a screenshot of the VM of group | ||||
|    */ | ||||
|   static async ScreenshotVM(g: VMGroup, vm?: VMInfo): Promise<Blob> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         method: "GET", | ||||
|         uri: `/group/${g.id}/vm/screenshot` + (vm ? `?vm_id=${vm.uuid}` : ""), | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
| } | ||||
| @@ -1,24 +1,10 @@ | ||||
| import { APIClient } from "./ApiClient"; | ||||
| import { VMCaps, VMInfo, VMInfoAndCaps } from "./VMApi"; | ||||
|  | ||||
| export interface ServerConfig { | ||||
|   authenticated: boolean; | ||||
|   disable_auth: boolean; | ||||
| } | ||||
|  | ||||
| export interface Rights { | ||||
|   groups: VMGroup[]; | ||||
|   vms: VMInfoAndCaps[]; | ||||
|   sys_info: boolean; | ||||
| } | ||||
|  | ||||
| export type VMGroup = VMGroupInfo & VMCaps; | ||||
|  | ||||
| export interface VMGroupInfo { | ||||
|   id: string; | ||||
|   vms: VMInfo[]; | ||||
| } | ||||
|  | ||||
| let config: ServerConfig | null = null; | ||||
|  | ||||
| export class ServerApi { | ||||
| @@ -41,16 +27,4 @@ export class ServerApi { | ||||
|     if (config === null) throw new Error("Missing configuration!"); | ||||
|     return config; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get application rights | ||||
|    */ | ||||
|   static async GetRights(): Promise<Rights> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         uri: "/server/rights", | ||||
|         method: "GET", | ||||
|       }) | ||||
|     ).data; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,9 @@ | ||||
| import { APIClient } from "./ApiClient"; | ||||
|  | ||||
| export interface SysInfoConfig { | ||||
|   allowed: boolean; | ||||
| } | ||||
|  | ||||
| export interface LoadAverage { | ||||
|   one: number; | ||||
|   five: number; | ||||
| @@ -20,6 +24,14 @@ export interface SysInfoStatus { | ||||
| } | ||||
|  | ||||
| export class SysInfoApi { | ||||
|   /** | ||||
|    * Get system info configuration (ie. check if it allowed) | ||||
|    */ | ||||
|   static async GetConfig(): Promise<SysInfoConfig> { | ||||
|     return (await APIClient.exec({ method: "GET", uri: "/sysinfo/config" })) | ||||
|       .data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get system status | ||||
|    */ | ||||
|   | ||||
| @@ -1,15 +1,12 @@ | ||||
| import { APIClient } from "./ApiClient"; | ||||
|  | ||||
| export interface VMInfo { | ||||
|   uuid: string; | ||||
|   uiid: string; | ||||
|   name: string; | ||||
|   description?: string; | ||||
|   architecture: string; | ||||
|   memory: number; | ||||
|   number_vcpu: number; | ||||
| } | ||||
|  | ||||
| export interface VMCaps { | ||||
|   can_get_state: boolean; | ||||
|   can_start: boolean; | ||||
|   can_shutdown: boolean; | ||||
| @@ -20,8 +17,6 @@ export interface VMCaps { | ||||
|   can_screenshot: boolean; | ||||
| } | ||||
|  | ||||
| export type VMInfoAndCaps = VMInfo & VMCaps; | ||||
|  | ||||
| export type VMState = | ||||
|   | "NoState" | ||||
|   | "Running" | ||||
| @@ -34,12 +29,19 @@ export type VMState = | ||||
|   | "Other"; | ||||
|  | ||||
| export class VMApi { | ||||
|   /** | ||||
|    * Get the list of VM that can be managed by this console | ||||
|    */ | ||||
|   static async GetList(): Promise<VMInfo[]> { | ||||
|     return (await APIClient.exec({ method: "GET", uri: "/vm/list" })).data; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get the state of a VM | ||||
|    */ | ||||
|   static async State(vm: VMInfo): Promise<VMState> { | ||||
|     return ( | ||||
|       await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/state` }) | ||||
|       await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/state` }) | ||||
|     ).data.state; | ||||
|   } | ||||
|  | ||||
| @@ -47,42 +49,42 @@ export class VMApi { | ||||
|    * Request to start VM | ||||
|    */ | ||||
|   static async StartVM(vm: VMInfo): Promise<void> { | ||||
|     await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/start` }); | ||||
|     await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/start` }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request to suspend VM | ||||
|    */ | ||||
|   static async SuspendVM(vm: VMInfo): Promise<void> { | ||||
|     await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/suspend` }); | ||||
|     await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/suspend` }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request to resume VM | ||||
|    */ | ||||
|   static async ResumeVM(vm: VMInfo): Promise<void> { | ||||
|     await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/resume` }); | ||||
|     await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/resume` }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request to shutdown VM | ||||
|    */ | ||||
|   static async ShutdownVM(vm: VMInfo): Promise<void> { | ||||
|     await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/shutdown` }); | ||||
|     await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/shutdown` }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request to kill VM | ||||
|    */ | ||||
|   static async KillVM(vm: VMInfo): Promise<void> { | ||||
|     await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/kill` }); | ||||
|     await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/kill` }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request to reset VM | ||||
|    */ | ||||
|   static async ResetVM(vm: VMInfo): Promise<void> { | ||||
|     await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/reset` }); | ||||
|     await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/reset` }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
| @@ -91,7 +93,7 @@ export class VMApi { | ||||
|   static async Screenshot(vm: VMInfo): Promise<Blob> { | ||||
|     return ( | ||||
|       await APIClient.exec({ | ||||
|         uri: `/vm/${vm.uuid}/screenshot`, | ||||
|         uri: `/vm/${vm.uiid}/screenshot`, | ||||
|         method: "GET", | ||||
|       }) | ||||
|     ).data; | ||||
|   | ||||
| @@ -20,7 +20,7 @@ type ThemeContext = { theme: Theme; set: (theme: Theme) => void }; | ||||
| const ThemeContextK = React.createContext<ThemeContext | null>(null); | ||||
|  | ||||
| export function ThemeProvider(p: React.PropsWithChildren): React.ReactElement { | ||||
|   const [theme, setTheme] = React.useState<Theme>("teamsdark"); | ||||
|   const [theme, setTheme] = React.useState<Theme>("highcontrast"); | ||||
|  | ||||
|   let fluentTheme = teamsHighContrastTheme; | ||||
|   switch (theme) { | ||||
|   | ||||
| @@ -1,177 +0,0 @@ | ||||
| import { Button, Spinner, Toolbar, Tooltip } from "@fluentui/react-components"; | ||||
| import { | ||||
|   ArrowResetRegular, | ||||
|   PauseRegular, | ||||
|   PlayCircleRegular, | ||||
|   PlayFilled, | ||||
|   PowerRegular, | ||||
|   StopRegular, | ||||
| } from "@fluentui/react-icons"; | ||||
| import React from "react"; | ||||
| import { GroupApi, TreatmentResult } from "../api/GroupApi"; | ||||
| import { VMGroup } from "../api/ServerApi"; | ||||
| import { VMInfo, VMState } from "../api/VMApi"; | ||||
| import { useAlert } from "../hooks/providers/AlertDialogProvider"; | ||||
| import { useConfirm } from "../hooks/providers/ConfirmDialogProvider"; | ||||
| import { useToast } from "../hooks/providers/ToastProvider"; | ||||
|  | ||||
| export function GroupVMAction(p: { | ||||
|   group: VMGroup; | ||||
|   state?: VMState; | ||||
|   vm?: VMInfo; | ||||
| }): React.ReactElement { | ||||
|   return ( | ||||
|     <Toolbar> | ||||
|       <GroupVMButton | ||||
|         enabled={p.group.can_start} | ||||
|         icon={<PlayFilled />} | ||||
|         tooltip="Start" | ||||
|         group={p.group} | ||||
|         vm={p.vm} | ||||
|         allowedStates={["Shutdown", "Shutoff", "Crashed"]} | ||||
|         currState={p.state} | ||||
|         needConfirm={false} | ||||
|         action={GroupApi.StartVM} | ||||
|       /> | ||||
|       <GroupVMButton | ||||
|         enabled={p.group.can_suspend} | ||||
|         icon={<PauseRegular />} | ||||
|         tooltip="Suspend" | ||||
|         group={p.group} | ||||
|         vm={p.vm} | ||||
|         allowedStates={["Running"]} | ||||
|         currState={p.state} | ||||
|         needConfirm={true} | ||||
|         action={GroupApi.SuspendVM} | ||||
|       /> | ||||
|       <GroupVMButton | ||||
|         enabled={p.group.can_resume} | ||||
|         icon={<PlayCircleRegular />} | ||||
|         tooltip="Resume" | ||||
|         group={p.group} | ||||
|         vm={p.vm} | ||||
|         allowedStates={["Paused", "PowerManagementSuspended"]} | ||||
|         currState={p.state} | ||||
|         needConfirm={false} | ||||
|         action={GroupApi.ResumeVM} | ||||
|       /> | ||||
|       <GroupVMButton | ||||
|         enabled={p.group.can_shutdown} | ||||
|         icon={<PowerRegular />} | ||||
|         tooltip="Shutdown" | ||||
|         group={p.group} | ||||
|         vm={p.vm} | ||||
|         allowedStates={["Running"]} | ||||
|         currState={p.state} | ||||
|         needConfirm={true} | ||||
|         action={GroupApi.ShutdownVM} | ||||
|       /> | ||||
|       <GroupVMButton | ||||
|         enabled={p.group.can_kill} | ||||
|         icon={<StopRegular />} | ||||
|         tooltip="Kill" | ||||
|         group={p.group} | ||||
|         vm={p.vm} | ||||
|         allowedStates={[ | ||||
|           "Running", | ||||
|           "Paused", | ||||
|           "PowerManagementSuspended", | ||||
|           "Blocked", | ||||
|         ]} | ||||
|         currState={p.state} | ||||
|         needConfirm={true} | ||||
|         action={GroupApi.KillVM} | ||||
|       /> | ||||
|       <GroupVMButton | ||||
|         enabled={p.group.can_reset} | ||||
|         icon={<ArrowResetRegular />} | ||||
|         tooltip="Reset" | ||||
|         group={p.group} | ||||
|         vm={p.vm} | ||||
|         allowedStates={[ | ||||
|           "Running", | ||||
|           "Paused", | ||||
|           "PowerManagementSuspended", | ||||
|           "Blocked", | ||||
|         ]} | ||||
|         currState={p.state} | ||||
|         needConfirm={true} | ||||
|         action={GroupApi.ResetVM} | ||||
|       /> | ||||
|     </Toolbar> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function GroupVMButton(p: { | ||||
|   enabled: boolean; | ||||
|   icon: React.ReactElement; | ||||
|   action: (group: VMGroup, vm?: VMInfo) => Promise<TreatmentResult>; | ||||
|   tooltip: string; | ||||
|   currState?: VMState; | ||||
|   allowedStates: VMState[]; | ||||
|   group: VMGroup; | ||||
|   vm?: VMInfo; | ||||
|   needConfirm: boolean; | ||||
| }): React.ReactElement { | ||||
|   const toast = useToast(); | ||||
|   const confirm = useConfirm(); | ||||
|   const alert = useAlert(); | ||||
|  | ||||
|   const [running, setRunning] = React.useState(false); | ||||
|  | ||||
|   const target = p.vm | ||||
|     ? `the VM ${p.vm.name}` | ||||
|     : `all the VM of the group ${p.group.id}`; | ||||
|  | ||||
|   const allowed = | ||||
|     !p.vm || (p.currState && p.allowedStates.includes(p.currState)); | ||||
|  | ||||
|   const perform = async () => { | ||||
|     if (running || !allowed) return; | ||||
|     try { | ||||
|       if ( | ||||
|         (!p.vm || p.needConfirm) && | ||||
|         !(await confirm( | ||||
|           `Do you want to perform ${p.tooltip} action on ${target}?`, | ||||
|           `Confirmation`, | ||||
|           p.tooltip | ||||
|         )) | ||||
|       ) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       setRunning(true); | ||||
|  | ||||
|       const result = await p.action(p.group, p.vm); | ||||
|  | ||||
|       toast( | ||||
|         p.tooltip, | ||||
|         `${p.tooltip} action on ${target}: ${result.ok} OK / ${result.failed} Failed`, | ||||
|         "success" | ||||
|       ); | ||||
|     } catch (e) { | ||||
|       console.error("Failed to perform group action!", e); | ||||
|  | ||||
|       alert(`Failed to perform ${p.tooltip} action on ${target}: ${e}`); | ||||
|     } finally { | ||||
|       setRunning(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   if (!p.enabled) return <></>; | ||||
|  | ||||
|   return ( | ||||
|     <Tooltip | ||||
|       content={`${p.tooltip} ${target}`} | ||||
|       relationship="description" | ||||
|       withArrow | ||||
|     > | ||||
|       <Button | ||||
|         icon={running ? <Spinner size="tiny" /> : p.icon} | ||||
|         onClick={allowed ? perform : undefined} | ||||
|         disabled={!allowed} | ||||
|         appearance="subtle" | ||||
|       /> | ||||
|     </Tooltip> | ||||
|   ); | ||||
| } | ||||
| @@ -1,171 +0,0 @@ | ||||
| import { | ||||
|   Button, | ||||
|   Card, | ||||
|   Dialog, | ||||
|   DialogActions, | ||||
|   DialogBody, | ||||
|   DialogContent, | ||||
|   DialogSurface, | ||||
|   DialogTitle, | ||||
|   DialogTrigger, | ||||
|   Table, | ||||
|   TableBody, | ||||
|   TableCell, | ||||
|   TableCellActions, | ||||
|   TableCellLayout, | ||||
|   TableHeader, | ||||
|   TableHeaderCell, | ||||
|   TableRow, | ||||
|   Title3, | ||||
|   Tooltip, | ||||
| } from "@fluentui/react-components"; | ||||
| import { Desktop24Regular, ScreenshotRegular } from "@fluentui/react-icons"; | ||||
| import { filesize } from "filesize"; | ||||
| import React from "react"; | ||||
| import { GroupApi, GroupVMState } from "../api/GroupApi"; | ||||
| import { Rights, VMGroup } from "../api/ServerApi"; | ||||
| import { VMInfo } from "../api/VMApi"; | ||||
| import { useToast } from "../hooks/providers/ToastProvider"; | ||||
| import { GroupVMAction } from "./GroupVMAction"; | ||||
| import { VMLiveScreenshot } from "./VMLiveScreenshot"; | ||||
|  | ||||
| export function GroupsWidget(p: { rights: Rights }): React.ReactElement { | ||||
|   return ( | ||||
|     <> | ||||
|       {p.rights.groups.map((g) => ( | ||||
|         <GroupInfo group={g} /> | ||||
|       ))} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function GroupInfo(p: { group: VMGroup }): React.ReactElement { | ||||
|   const toast = useToast(); | ||||
|  | ||||
|   const [state, setState] = React.useState<GroupVMState | undefined>(); | ||||
|   const [screenshotVM, setScreenshotVM] = React.useState<VMInfo | undefined>(); | ||||
|  | ||||
|   const load = async () => { | ||||
|     const newState = await GroupApi.State(p.group); | ||||
|     if (state !== newState) setState(newState); | ||||
|   }; | ||||
|  | ||||
|   const screenshot = (vm: VMInfo) => { | ||||
|     setScreenshotVM(vm); | ||||
|   }; | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     const interval = setInterval(async () => { | ||||
|       try { | ||||
|         if (p.group.can_get_state) await load(); | ||||
|       } catch (e) { | ||||
|         console.error(e); | ||||
|         toast( | ||||
|           "Error", | ||||
|           `Failed to refresh group ${p.group.id} VMs status!`, | ||||
|           "error" | ||||
|         ); | ||||
|       } | ||||
|     }, 1000); | ||||
|     return () => clearInterval(interval); | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Card | ||||
|         style={{ | ||||
|           margin: "50px 10px", | ||||
|           display: "flex", | ||||
|           flexDirection: "column", | ||||
|         }} | ||||
|       > | ||||
|         <div style={{ display: "flex", justifyContent: "space-between" }}> | ||||
|           <Title3 style={{ marginLeft: "10px" }}>{p.group.id}</Title3> | ||||
|           <GroupVMAction group={p.group} /> | ||||
|         </div> | ||||
|         <Table sortable> | ||||
|           <TableHeader> | ||||
|             <TableRow> | ||||
|               <TableHeaderCell>VM</TableHeaderCell> | ||||
|               <TableHeaderCell>Resources</TableHeaderCell> | ||||
|               <TableHeaderCell>State</TableHeaderCell> | ||||
|               <TableHeaderCell>Actions</TableHeaderCell> | ||||
|             </TableRow> | ||||
|           </TableHeader> | ||||
|           <TableBody> | ||||
|             {p.group.vms.map((item) => ( | ||||
|               <TableRow key={item.uuid}> | ||||
|                 <TableCell> | ||||
|                   <TableCellLayout | ||||
|                     media={<Desktop24Regular />} | ||||
|                     appearance="primary" | ||||
|                     description={item.description} | ||||
|                   > | ||||
|                     {item.name} | ||||
|                   </TableCellLayout> | ||||
|                   <TableCellActions> | ||||
|                     {state?.[item.uuid] === "Running" && ( | ||||
|                       <Tooltip | ||||
|                         relationship="description" | ||||
|                         content={"Take a screenshot of the VM screen"} | ||||
|                         withArrow | ||||
|                       > | ||||
|                         <Button | ||||
|                           icon={<ScreenshotRegular />} | ||||
|                           appearance="subtle" | ||||
|                           aria-label="Edit" | ||||
|                           disabled={!p.group.can_screenshot} | ||||
|                           onClick={() => screenshot(item)} | ||||
|                         /> | ||||
|                       </Tooltip> | ||||
|                     )} | ||||
|                   </TableCellActions> | ||||
|                 </TableCell> | ||||
|                 <TableCell> | ||||
|                   {item.architecture} • RAM :{" "} | ||||
|                   {filesize(item.memory)} •{" "} | ||||
|                   {item.number_vcpu} vCPU | ||||
|                 </TableCell> | ||||
|                 <TableCell>{state?.[item.uuid] ?? ""}</TableCell> | ||||
|                 <TableCell> | ||||
|                   <GroupVMAction | ||||
|                     group={p.group} | ||||
|                     state={state?.[item.uuid]} | ||||
|                     vm={item} | ||||
|                   /> | ||||
|                 </TableCell> | ||||
|               </TableRow> | ||||
|             ))} | ||||
|           </TableBody> | ||||
|         </Table> | ||||
|       </Card> | ||||
|       <Dialog | ||||
|         open={!!screenshotVM} | ||||
|         onOpenChange={(_event, _data) => { | ||||
|           if (!screenshotVM) setScreenshotVM(undefined); | ||||
|         }} | ||||
|       > | ||||
|         <DialogSurface> | ||||
|           <DialogBody> | ||||
|             <DialogTitle> | ||||
|               <em>{screenshotVM?.name}</em> screen | ||||
|             </DialogTitle> | ||||
|             <DialogContent> | ||||
|               <VMLiveScreenshot vm={screenshotVM!} group={p.group} /> | ||||
|             </DialogContent> | ||||
|             <DialogActions> | ||||
|               <DialogTrigger disableButtonEnhancement> | ||||
|                 <Button | ||||
|                   appearance="secondary" | ||||
|                   onClick={() => setScreenshotVM(undefined)} | ||||
|                 > | ||||
|                   Close | ||||
|                 </Button> | ||||
|               </DialogTrigger> | ||||
|             </DialogActions> | ||||
|           </DialogBody> | ||||
|         </DialogSurface> | ||||
|       </Dialog> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -1,13 +1,47 @@ | ||||
| import { Field, ProgressBar } from "@fluentui/react-components"; | ||||
| import { filesize } from "filesize"; | ||||
| import React from "react"; | ||||
| import { SysInfoApi, SysInfoStatus } from "../api/SysInfoApi"; | ||||
| import { useToast } from "../hooks/providers/ToastProvider"; | ||||
| import { format_duration } from "../utils/time_utils"; | ||||
| import { SysInfoApi, SysInfoConfig, SysInfoStatus } from "../api/SysInfoApi"; | ||||
| import { AsyncWidget } from "./AsyncWidget"; | ||||
| import { SectionContainer } from "./SectionContainer"; | ||||
| import { Field, ProgressBar } from "@fluentui/react-components"; | ||||
| import { filesize } from "filesize"; | ||||
| import { format_duration } from "../utils/time_utils"; | ||||
| import { useToast } from "../hooks/providers/ToastProvider"; | ||||
|  | ||||
| export function SystemInfoWidget(): React.ReactElement { | ||||
|   const [config, setConfig] = React.useState<SysInfoConfig | undefined>(); | ||||
|  | ||||
|   const load = async () => { | ||||
|     setConfig(await SysInfoApi.GetConfig()); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <SectionContainer> | ||||
|       <AsyncWidget | ||||
|         loadKey={1} | ||||
|         load={load} | ||||
|         errMsg="Failed to check system configuration!" | ||||
|         loadingMessage="Checking server configuration..." | ||||
|         build={() => | ||||
|           config?.allowed ? ( | ||||
|             <SystemInfoWidgetInner /> | ||||
|           ) : ( | ||||
|             <SystemInfoWidgetUnavailable /> | ||||
|           ) | ||||
|         } | ||||
|       /> | ||||
|     </SectionContainer> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function SystemInfoWidgetUnavailable(): React.ReactElement { | ||||
|   return ( | ||||
|     <p style={{ textAlign: "center" }}> | ||||
|       Unfortunatley, system information is available. (not enough privileges) | ||||
|     </p> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function SystemInfoWidgetInner(): React.ReactElement { | ||||
|   const toast = useToast(); | ||||
|  | ||||
|   const [status, setStatus] = React.useState<SysInfoStatus | undefined>(); | ||||
| @@ -29,51 +63,49 @@ export function SystemInfoWidget(): React.ReactElement { | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <SectionContainer> | ||||
|       <AsyncWidget | ||||
|         loadKey={1} | ||||
|         load={load} | ||||
|         loadingMessage="Loading system status..." | ||||
|         errMsg="Failed to load system status!" | ||||
|         build={() => ( | ||||
|           <div | ||||
|             style={{ | ||||
|               display: "flex", | ||||
|               flexDirection: "row", | ||||
|               alignItems: "center", | ||||
|               justifyContent: "center", | ||||
|             }} | ||||
|     <AsyncWidget | ||||
|       loadKey={1} | ||||
|       load={load} | ||||
|       loadingMessage="Loading system status..." | ||||
|       errMsg="Failed to load system status!" | ||||
|       build={() => ( | ||||
|         <div | ||||
|           style={{ | ||||
|             display: "flex", | ||||
|             flexDirection: "row", | ||||
|             alignItems: "center", | ||||
|             justifyContent: "center", | ||||
|           }} | ||||
|         > | ||||
|           <Field | ||||
|             validationMessage={`${filesize( | ||||
|               status!.system.used_memory | ||||
|             )} of memory used out of ${filesize( | ||||
|               status!.system.available_memory + status!.system.used_memory | ||||
|             )}`} | ||||
|             validationState="none" | ||||
|             style={{ flex: 2 }} | ||||
|           > | ||||
|             <Field | ||||
|               validationMessage={`${filesize( | ||||
|                 status!.system.used_memory | ||||
|               )} of memory used out of ${filesize( | ||||
|                 status!.system.available_memory + status!.system.used_memory | ||||
|               )}`} | ||||
|               validationState="none" | ||||
|               style={{ flex: 2 }} | ||||
|             > | ||||
|               <ProgressBar | ||||
|                 value={ | ||||
|                   status!.system.used_memory / | ||||
|                   (status!.system.available_memory + status!.system.used_memory) | ||||
|                 } | ||||
|               /> | ||||
|             </Field> | ||||
|             <div style={{ width: "20px" }}></div> | ||||
|             <div style={{ flex: 1 }}> | ||||
|               <p> | ||||
|                 Load average: {status!.system.load_average.one}{" "} | ||||
|                 {status!.system.load_average.five}{" "} | ||||
|                 {status!.system.load_average.fifteen} | ||||
|               </p> | ||||
|               <UptimeWidget uptime={status!.system.uptime} /> | ||||
|               Number physical cores: {status!.system.physical_core_count} | ||||
|             </div> | ||||
|             <ProgressBar | ||||
|               value={ | ||||
|                 status!.system.used_memory / | ||||
|                 (status!.system.available_memory + status!.system.used_memory) | ||||
|               } | ||||
|             /> | ||||
|           </Field> | ||||
|           <div style={{ width: "20px" }}></div> | ||||
|           <div style={{ flex: 1 }}> | ||||
|             <p> | ||||
|               Load average: {status!.system.load_average.one}{" "} | ||||
|               {status!.system.load_average.five}{" "} | ||||
|               {status!.system.load_average.fifteen} | ||||
|             </p> | ||||
|             <UptimeWidget uptime={status!.system.uptime} /> | ||||
|             Number physical cores: {status!.system.physical_core_count} | ||||
|           </div> | ||||
|         )} | ||||
|       /> | ||||
|     </SectionContainer> | ||||
|         </div> | ||||
|       )} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,13 +1,8 @@ | ||||
| import React from "react"; | ||||
| import { GroupApi } from "../api/GroupApi"; | ||||
| import { VMGroup } from "../api/ServerApi"; | ||||
| import { VMApi, VMInfo } from "../api/VMApi"; | ||||
| import { useToast } from "../hooks/providers/ToastProvider"; | ||||
|  | ||||
| export function VMLiveScreenshot(p: { | ||||
|   vm: VMInfo; | ||||
|   group?: VMGroup; | ||||
| }): React.ReactElement { | ||||
| export function VMLiveScreenshot(p: { vm: VMInfo }): React.ReactElement { | ||||
|   const toast = useToast(); | ||||
|  | ||||
|   const [screenshotURL, setScreenshotURL] = React.useState< | ||||
| @@ -19,9 +14,7 @@ export function VMLiveScreenshot(p: { | ||||
|   React.useEffect(() => { | ||||
|     const refresh = async () => { | ||||
|       try { | ||||
|         const screenshot = p.group | ||||
|           ? await GroupApi.ScreenshotVM(p.group, p.vm) | ||||
|           : await VMApi.Screenshot(p.vm); | ||||
|         const screenshot = await VMApi.Screenshot(p.vm); | ||||
|         const u = URL.createObjectURL(screenshot); | ||||
|         setScreenshotURL(u); | ||||
|       } catch (e) { | ||||
|   | ||||
| @@ -21,10 +21,10 @@ import { | ||||
| } from "@fluentui/react-icons"; | ||||
| import { filesize } from "filesize"; | ||||
| import React from "react"; | ||||
| import { Rights } from "../api/ServerApi"; | ||||
| import { VMApi, VMInfo, VMInfoAndCaps, VMState } from "../api/VMApi"; | ||||
| import { VMApi, VMInfo, VMState } from "../api/VMApi"; | ||||
| import { useConfirm } from "../hooks/providers/ConfirmDialogProvider"; | ||||
| import { useToast } from "../hooks/providers/ToastProvider"; | ||||
| import { AsyncWidget } from "./AsyncWidget"; | ||||
| import { SectionContainer } from "./SectionContainer"; | ||||
| import { VMLiveScreenshot } from "./VMLiveScreenshot"; | ||||
|  | ||||
| @@ -33,28 +33,43 @@ const useStyles = makeStyles({ | ||||
|   caption1: typographyStyles.caption1, | ||||
| }); | ||||
|  | ||||
| export function VirtualMachinesWidget(p: { | ||||
|   rights: Rights; | ||||
| }): React.ReactElement { | ||||
| export function VirtualMachinesWidget(): React.ReactElement { | ||||
|   const [list, setList] = React.useState<VMInfo[] | undefined>(); | ||||
|   const load = async () => { | ||||
|     setList(await VMApi.GetList()); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <SectionContainer> | ||||
|       <div | ||||
|         style={{ | ||||
|           display: "flex", | ||||
|           flexDirection: "row", | ||||
|           flexWrap: "wrap", | ||||
|           justifyContent: "center", | ||||
|         }} | ||||
|       > | ||||
|         {p.rights.vms.map((v, n) => ( | ||||
|           <VMWidget key={n} vm={v} /> | ||||
|         ))} | ||||
|       </div> | ||||
|       <AsyncWidget | ||||
|         loadKey={1} | ||||
|         load={load} | ||||
|         loadingMessage="Loading the list virtual machines..." | ||||
|         errMsg="Failed to load the list of virtual machines!" | ||||
|         build={() => <VirtualMachinesWidgetInner list={list!} />} | ||||
|       /> | ||||
|     </SectionContainer> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function VMWidget(p: { vm: VMInfoAndCaps }): React.ReactElement { | ||||
| function VirtualMachinesWidgetInner(p: { list: VMInfo[] }): React.ReactElement { | ||||
|   return ( | ||||
|     <div | ||||
|       style={{ | ||||
|         display: "flex", | ||||
|         flexDirection: "row", | ||||
|         flexWrap: "wrap", | ||||
|         justifyContent: "center", | ||||
|       }} | ||||
|     > | ||||
|       {p.list.map((v, n) => ( | ||||
|         <VMWidget key={n} vm={v} /> | ||||
|       ))}{" "} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function VMWidget(p: { vm: VMInfo }): React.ReactElement { | ||||
|   const toast = useToast(); | ||||
|  | ||||
|   const [state, setState] = React.useState<VMState | undefined>(); | ||||
| @@ -107,7 +122,7 @@ function VMWidget(p: { vm: VMInfoAndCaps }): React.ReactElement { | ||||
|         } | ||||
|       /> | ||||
|       <p className={styles.caption1} style={{ margin: "0px auto" }}> | ||||
|         {p.vm.architecture} • RAM : {filesize(p.vm.memory)}{" "} | ||||
|         {p.vm.architecture} • RAM : {filesize(p.vm.memory * 1000 * 1000)}{" "} | ||||
|         • {p.vm.number_vcpu} vCPU | ||||
|       </p> | ||||
|  | ||||
| @@ -189,10 +204,7 @@ function VMWidget(p: { vm: VMInfoAndCaps }): React.ReactElement { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function VMPreview(p: { | ||||
|   vm: VMInfoAndCaps; | ||||
|   state?: VMState; | ||||
| }): React.ReactElement { | ||||
| function VMPreview(p: { vm: VMInfo; state?: VMState }): React.ReactElement { | ||||
|   const styles = useStyles(); | ||||
|   if (!p.vm.can_screenshot || p.state !== "Running") { | ||||
|     return ( | ||||
|   | ||||
| @@ -1,26 +0,0 @@ | ||||
| { | ||||
|   "compilerOptions": { | ||||
|     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", | ||||
|     "target": "ES2020", | ||||
|     "useDefineForClassFields": true, | ||||
|     "lib": ["ES2020", "DOM", "DOM.Iterable"], | ||||
|     "module": "ESNext", | ||||
|     "skipLibCheck": true, | ||||
|  | ||||
|     /* Bundler mode */ | ||||
|     "moduleResolution": "bundler", | ||||
|     "allowImportingTsExtensions": true, | ||||
|     "isolatedModules": true, | ||||
|     "moduleDetection": "force", | ||||
|     "noEmit": true, | ||||
|     "jsx": "react-jsx", | ||||
|  | ||||
|     /* Linting */ | ||||
|     "strict": true, | ||||
|     "noUnusedLocals": true, | ||||
|     "noUnusedParameters": true, | ||||
|     "noFallthroughCasesInSwitch": true, | ||||
|     "noUncheckedSideEffectImports": true | ||||
|   }, | ||||
|   "include": ["src"] | ||||
| } | ||||
| @@ -1,7 +1,25 @@ | ||||
| { | ||||
|   "files": [], | ||||
|   "references": [ | ||||
|     { "path": "./tsconfig.app.json" }, | ||||
|     { "path": "./tsconfig.node.json" } | ||||
|   ] | ||||
|   "compilerOptions": { | ||||
|     "target": "ES2020", | ||||
|     "useDefineForClassFields": true, | ||||
|     "lib": ["ES2020", "DOM", "DOM.Iterable"], | ||||
|     "module": "ESNext", | ||||
|     "skipLibCheck": true, | ||||
|  | ||||
|     /* Bundler mode */ | ||||
|     "moduleResolution": "bundler", | ||||
|     "allowImportingTsExtensions": true, | ||||
|     "resolveJsonModule": true, | ||||
|     "isolatedModules": true, | ||||
|     "noEmit": true, | ||||
|     "jsx": "react-jsx", | ||||
|  | ||||
|     /* Linting */ | ||||
|     "strict": true, | ||||
|     "noUnusedLocals": true, | ||||
|     "noUnusedParameters": true, | ||||
|     "noFallthroughCasesInSwitch": true | ||||
|   }, | ||||
|   "include": ["src"], | ||||
|   "references": [{ "path": "./tsconfig.node.json" }] | ||||
| } | ||||
|   | ||||
| @@ -1,24 +1,11 @@ | ||||
| { | ||||
|   "compilerOptions": { | ||||
|     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", | ||||
|     "target": "ES2022", | ||||
|     "lib": ["ES2023"], | ||||
|     "module": "ESNext", | ||||
|     "composite": true, | ||||
|     "skipLibCheck": true, | ||||
|  | ||||
|     /* Bundler mode */ | ||||
|     "module": "ESNext", | ||||
|     "moduleResolution": "bundler", | ||||
|     "allowImportingTsExtensions": true, | ||||
|     "isolatedModules": true, | ||||
|     "moduleDetection": "force", | ||||
|     "noEmit": true, | ||||
|  | ||||
|     /* Linting */ | ||||
|     "strict": true, | ||||
|     "noUnusedLocals": true, | ||||
|     "noUnusedParameters": true, | ||||
|     "noFallthroughCasesInSwitch": true, | ||||
|     "noUncheckedSideEffectImports": true | ||||
|     "allowSyntheticDefaultImports": true, | ||||
|     "strict": true | ||||
|   }, | ||||
|   "include": ["vite.config.ts"] | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { defineConfig } from 'vite' | ||||
| import react from '@vitejs/plugin-react' | ||||
|  | ||||
| // https://vite.dev/config/ | ||||
| // https://vitejs.dev/config/ | ||||
| export default defineConfig({ | ||||
|   plugins: [react()], | ||||
| }) | ||||
|   | ||||
| @@ -1,3 +1,9 @@ | ||||
| { | ||||
|   "extends": ["local>renovate/presets"] | ||||
|   "$schema": "https://docs.renovatebot.com/renovate-schema.json", | ||||
|   "packageRules": [ | ||||
|     { | ||||
|       "matchUpdateTypes": ["major", "minor", "patch"], | ||||
|       "automerge": true | ||||
|     } | ||||
|   ] | ||||
| } | ||||
		Reference in New Issue
	
	Block a user