From 4c6608bf55bccf906957d2ce7ecd443f42cda0bc Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Fri, 6 Dec 2024 18:06:01 +0000 Subject: [PATCH] Add groups support (#146) Reviewed-on: https://gitea.communiquons.org/pierre/VirtWebRemote/pulls/146 --- .../src/controllers/group_controller.rs | 79 +++++++ remote_backend/src/controllers/mod.rs | 1 + .../src/controllers/server_controller.rs | 74 ++++--- remote_backend/src/main.rs | 36 +++- remote_backend/src/virtweb_client.rs | 193 +++++++++++++++++- remote_frontend/src/App.tsx | 44 ++-- remote_frontend/src/api/GroupApi.ts | 107 ++++++++++ remote_frontend/src/api/ServerApi.ts | 12 +- remote_frontend/src/api/VMApi.ts | 23 ++- .../src/hooks/providers/ThemeProvider.tsx | 2 +- remote_frontend/src/widgets/GroupVMAction.tsx | 177 ++++++++++++++++ remote_frontend/src/widgets/GroupsWidget.tsx | 171 ++++++++++++++++ .../src/widgets/VMLiveScreenshot.tsx | 11 +- .../src/widgets/VirtualMachinesWidget.tsx | 11 +- 14 files changed, 874 insertions(+), 67 deletions(-) create mode 100644 remote_backend/src/controllers/group_controller.rs create mode 100644 remote_frontend/src/api/GroupApi.ts create mode 100644 remote_frontend/src/widgets/GroupVMAction.tsx create mode 100644 remote_frontend/src/widgets/GroupsWidget.tsx diff --git a/remote_backend/src/controllers/group_controller.rs b/remote_backend/src/controllers/group_controller.rs new file mode 100644 index 0000000..ad5deb7 --- /dev/null +++ b/remote_backend/src/controllers/group_controller.rs @@ -0,0 +1,79 @@ +use crate::controllers::HttpResult; +use crate::virtweb_client; +use crate::virtweb_client::{GroupID, VMUuid}; +use actix_web::{web, HttpResponse}; + +#[derive(serde::Deserialize)] +pub struct GroupIDInPath { + gid: GroupID, +} + +#[derive(serde::Deserialize)] +pub struct VMIDInQuery { + vm_id: Option, +} + +/// Get the state of one or all VM +pub async fn vm_state( + path: web::Path, + query: web::Query, +) -> 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, + query: web::Query, +) -> 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, + query: web::Query, +) -> 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, query: web::Query) -> 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, + query: web::Query, +) -> 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, + query: web::Query, +) -> 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, + query: web::Query, +) -> 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, + query: web::Query, +) -> 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)) +} diff --git a/remote_backend/src/controllers/mod.rs b/remote_backend/src/controllers/mod.rs index c0d541f..02ad6b9 100644 --- a/remote_backend/src/controllers/mod.rs +++ b/remote_backend/src/controllers/mod.rs @@ -6,6 +6,7 @@ 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; diff --git a/remote_backend/src/controllers/server_controller.rs b/remote_backend/src/controllers/server_controller.rs index 165174b..962dc05 100644 --- a/remote_backend/src/controllers/server_controller.rs +++ b/remote_backend/src/controllers/server_controller.rs @@ -2,7 +2,7 @@ use crate::app_config::AppConfig; use crate::controllers::HttpResult; use crate::extractors::auth_extractor::AuthExtractor; use crate::virtweb_client; -use crate::virtweb_client::VMUuid; +use crate::virtweb_client::{GroupID, VMCaps, VMInfo}; use actix_web::HttpResponse; #[derive(serde::Serialize)] @@ -20,54 +20,70 @@ pub async fn config(auth: AuthExtractor) -> HttpResult { #[derive(Default, Debug, serde::Serialize)] pub struct Rights { + groups: Vec, vms: Vec, sys_info: bool, } +#[derive(Debug, serde::Serialize)] +pub struct GroupInfo { + id: GroupID, + vms: Vec, + #[serde(flatten)] + caps: VMCaps, +} + #[derive(Debug, serde::Serialize)] pub struct VMInfoAndCaps { - uiid: VMUuid, - name: String, - description: Option, - 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, + #[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 { - 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()), + 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()), + }, }) } diff --git a/remote_backend/src/main.rs b/remote_backend/src/main.rs index 90c0a4c..4febb40 100644 --- a/remote_backend/src/main.rs +++ b/remote_backend/src/main.rs @@ -12,7 +12,8 @@ use light_openid::basic_state_manager::BasicStateManager; use remote_backend::app_config::AppConfig; use remote_backend::constants; use remote_backend::controllers::{ - auth_controller, server_controller, static_controller, sys_info_controller, vm_controller, + auth_controller, group_controller, server_controller, static_controller, sys_info_controller, + vm_controller, }; use remote_backend::middlewares::auth_middleware::AuthChecker; use std::time::Duration; @@ -86,6 +87,39 @@ async fn main() -> std::io::Result<()> { "/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/{uid}/state", web::get().to(vm_controller::state)) .route("/api/vm/{uid}/start", web::get().to(vm_controller::start)) diff --git a/remote_backend/src/virtweb_client.rs b/remote_backend/src/virtweb_client.rs index 4b5c5ba..10748f9 100644 --- a/remote_backend/src/virtweb_client.rs +++ b/remote_backend/src/virtweb_client.rs @@ -1,6 +1,7 @@ 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; @@ -12,9 +13,105 @@ pub enum VirtWebClientError { InvalidStatusCode(u16), } -#[derive(Eq, PartialEq, Debug, Copy, Clone, serde::Serialize, serde::Deserialize)] +#[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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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)] 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) @@ -69,7 +166,7 @@ pub struct TokenClaims { pub nonce: String, } -#[derive(serde::Deserialize, Debug)] +#[derive(serde::Deserialize, serde::Serialize, Debug)] pub struct VMInfo { pub uuid: VMUuid, pub name: String, @@ -79,6 +176,18 @@ 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, @@ -147,6 +256,16 @@ impl TokenInfo { false } + /// List the groups with access + pub fn list_groups(&self) -> Vec { + 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::>() + } + /// List the virtual machines with access pub fn list_vm(&self) -> Vec { self.rights @@ -168,12 +287,13 @@ async fn request(uri: D) -> anyhow::Result { 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.to_string(), + path: uri.split_once('?').map(|s| s.0).unwrap_or(&uri).to_string(), nonce: Uuid::new_v4().to_string(), }; let jwt = AppConfig::get().token_private_key().sign_jwt(&jwt)?; @@ -260,6 +380,73 @@ pub async fn vm_screenshot(id: VMUuid) -> anyhow::Result> { .to_vec()) } +/// Get the VM of a group +pub async fn group_vm_info(id: &GroupID) -> anyhow::Result> { + 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, +) -> anyhow::Result> { + 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, +) -> anyhow::Result { + 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, +) -> anyhow::Result { + 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) -> anyhow::Result { + 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, +) -> anyhow::Result { + 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, +) -> anyhow::Result { + 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, +) -> anyhow::Result { + 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) -> anyhow::Result> { + 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 { json_request("/api/server/info").await diff --git a/remote_frontend/src/App.tsx b/remote_frontend/src/App.tsx index c65fc8f..da19d64 100644 --- a/remote_frontend/src/App.tsx +++ b/remote_frontend/src/App.tsx @@ -5,6 +5,8 @@ import { typographyStyles, } from "@fluentui/react-components"; import { + AppsListDetailFilled, + AppsListDetailRegular, DesktopFilled, DesktopRegular, InfoFilled, @@ -18,6 +20,7 @@ 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, @@ -27,6 +30,8 @@ const InfoIcon = bundleIcon(InfoFilled, InfoRegular); const DesktopIcon = bundleIcon(DesktopFilled, DesktopRegular); +const AppListIcon = bundleIcon(AppsListDetailFilled, AppsListDetailRegular); + export function App() { return ( ("vm"); + const [tab, setTab] = React.useState<"group" | "vm" | "info">("group"); const [rights, setRights] = React.useState(); const load = async () => { - setRights(await ServerApi.GetRights()); + 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 ( @@ -82,25 +92,27 @@ function AuthenticatedApp(): React.ReactElement { selectedValue={tab} onTabSelect={(_, d) => setTab(d.value as any)} > - } - disabled={rights!.vms.length === 0} - > - Virtual machines - - } - disabled={!rights!.sys_info} - > - System info - + {rights!.groups.length > 0 && ( + }> + Groups + + )} + {rights!.vms.length > 0 && ( + }> + Virtual machines + + )} + {rights!.sys_info && ( + }> + System info + + )}
+ {tab === "group" && } {tab === "vm" && } {tab === "info" && } diff --git a/remote_frontend/src/api/GroupApi.ts b/remote_frontend/src/api/GroupApi.ts new file mode 100644 index 0000000..a7703b9 --- /dev/null +++ b/remote_frontend/src/api/GroupApi.ts @@ -0,0 +1,107 @@ +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return ( + await APIClient.exec({ + method: "GET", + uri: `/group/${g.id}/vm/screenshot` + (vm ? `?vm_id=${vm.uuid}` : ""), + }) + ).data; + } +} diff --git a/remote_frontend/src/api/ServerApi.ts b/remote_frontend/src/api/ServerApi.ts index ad6b441..7c5abb6 100644 --- a/remote_frontend/src/api/ServerApi.ts +++ b/remote_frontend/src/api/ServerApi.ts @@ -1,5 +1,5 @@ import { APIClient } from "./ApiClient"; -import { VMInfo } from "./VMApi"; +import { VMCaps, VMInfo, VMInfoAndCaps } from "./VMApi"; export interface ServerConfig { authenticated: boolean; @@ -7,10 +7,18 @@ export interface ServerConfig { } export interface Rights { - vms: VMInfo[]; + 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 { diff --git a/remote_frontend/src/api/VMApi.ts b/remote_frontend/src/api/VMApi.ts index 03b0c9d..42902be 100644 --- a/remote_frontend/src/api/VMApi.ts +++ b/remote_frontend/src/api/VMApi.ts @@ -1,12 +1,15 @@ import { APIClient } from "./ApiClient"; export interface VMInfo { - uiid: string; + uuid: 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; @@ -17,6 +20,8 @@ export interface VMInfo { can_screenshot: boolean; } +export type VMInfoAndCaps = VMInfo & VMCaps; + export type VMState = | "NoState" | "Running" @@ -34,7 +39,7 @@ export class VMApi { */ static async State(vm: VMInfo): Promise { return ( - await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/state` }) + await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/state` }) ).data.state; } @@ -42,42 +47,42 @@ export class VMApi { * Request to start VM */ static async StartVM(vm: VMInfo): Promise { - await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/start` }); + await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/start` }); } /** * Request to suspend VM */ static async SuspendVM(vm: VMInfo): Promise { - await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/suspend` }); + await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/suspend` }); } /** * Request to resume VM */ static async ResumeVM(vm: VMInfo): Promise { - await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/resume` }); + await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/resume` }); } /** * Request to shutdown VM */ static async ShutdownVM(vm: VMInfo): Promise { - await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/shutdown` }); + await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/shutdown` }); } /** * Request to kill VM */ static async KillVM(vm: VMInfo): Promise { - await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/kill` }); + await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/kill` }); } /** * Request to reset VM */ static async ResetVM(vm: VMInfo): Promise { - await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/reset` }); + await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/reset` }); } /** @@ -86,7 +91,7 @@ export class VMApi { static async Screenshot(vm: VMInfo): Promise { return ( await APIClient.exec({ - uri: `/vm/${vm.uiid}/screenshot`, + uri: `/vm/${vm.uuid}/screenshot`, method: "GET", }) ).data; diff --git a/remote_frontend/src/hooks/providers/ThemeProvider.tsx b/remote_frontend/src/hooks/providers/ThemeProvider.tsx index fd419f6..efce516 100644 --- a/remote_frontend/src/hooks/providers/ThemeProvider.tsx +++ b/remote_frontend/src/hooks/providers/ThemeProvider.tsx @@ -20,7 +20,7 @@ type ThemeContext = { theme: Theme; set: (theme: Theme) => void }; const ThemeContextK = React.createContext(null); export function ThemeProvider(p: React.PropsWithChildren): React.ReactElement { - const [theme, setTheme] = React.useState("highcontrast"); + const [theme, setTheme] = React.useState("teamsdark"); let fluentTheme = teamsHighContrastTheme; switch (theme) { diff --git a/remote_frontend/src/widgets/GroupVMAction.tsx b/remote_frontend/src/widgets/GroupVMAction.tsx new file mode 100644 index 0000000..b6b9662 --- /dev/null +++ b/remote_frontend/src/widgets/GroupVMAction.tsx @@ -0,0 +1,177 @@ +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 ( + + } + tooltip="Start" + group={p.group} + vm={p.vm} + allowedStates={["Shutdown", "Shutoff", "Crashed"]} + currState={p.state} + needConfirm={false} + action={GroupApi.StartVM} + /> + } + tooltip="Suspend" + group={p.group} + vm={p.vm} + allowedStates={["Running"]} + currState={p.state} + needConfirm={true} + action={GroupApi.SuspendVM} + /> + } + tooltip="Resume" + group={p.group} + vm={p.vm} + allowedStates={["Paused", "PowerManagementSuspended"]} + currState={p.state} + needConfirm={false} + action={GroupApi.ResumeVM} + /> + } + tooltip="Shutdown" + group={p.group} + vm={p.vm} + allowedStates={["Running"]} + currState={p.state} + needConfirm={true} + action={GroupApi.ShutdownVM} + /> + } + tooltip="Kill" + group={p.group} + vm={p.vm} + allowedStates={[ + "Running", + "Paused", + "PowerManagementSuspended", + "Blocked", + ]} + currState={p.state} + needConfirm={true} + action={GroupApi.KillVM} + /> + } + tooltip="Reset" + group={p.group} + vm={p.vm} + allowedStates={[ + "Running", + "Paused", + "PowerManagementSuspended", + "Blocked", + ]} + currState={p.state} + needConfirm={true} + action={GroupApi.ResetVM} + /> + + ); +} + +function GroupVMButton(p: { + enabled: boolean; + icon: React.ReactElement; + action: (group: VMGroup, vm?: VMInfo) => Promise; + 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 ( + + + + + + + + + ); +} diff --git a/remote_frontend/src/widgets/VMLiveScreenshot.tsx b/remote_frontend/src/widgets/VMLiveScreenshot.tsx index 115b958..74250d2 100644 --- a/remote_frontend/src/widgets/VMLiveScreenshot.tsx +++ b/remote_frontend/src/widgets/VMLiveScreenshot.tsx @@ -1,8 +1,13 @@ 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 }): React.ReactElement { +export function VMLiveScreenshot(p: { + vm: VMInfo; + group?: VMGroup; +}): React.ReactElement { const toast = useToast(); const [screenshotURL, setScreenshotURL] = React.useState< @@ -14,7 +19,9 @@ export function VMLiveScreenshot(p: { vm: VMInfo }): React.ReactElement { React.useEffect(() => { const refresh = async () => { try { - const screenshot = await VMApi.Screenshot(p.vm); + const screenshot = p.group + ? await GroupApi.ScreenshotVM(p.group, p.vm) + : await VMApi.Screenshot(p.vm); const u = URL.createObjectURL(screenshot); setScreenshotURL(u); } catch (e) { diff --git a/remote_frontend/src/widgets/VirtualMachinesWidget.tsx b/remote_frontend/src/widgets/VirtualMachinesWidget.tsx index 7fb047d..a674bcd 100644 --- a/remote_frontend/src/widgets/VirtualMachinesWidget.tsx +++ b/remote_frontend/src/widgets/VirtualMachinesWidget.tsx @@ -22,11 +22,11 @@ import { import { filesize } from "filesize"; import React from "react"; import { Rights } from "../api/ServerApi"; -import { VMApi, VMInfo, VMState } from "../api/VMApi"; +import { VMApi, VMInfo, VMInfoAndCaps, VMState } from "../api/VMApi"; import { useConfirm } from "../hooks/providers/ConfirmDialogProvider"; import { useToast } from "../hooks/providers/ToastProvider"; -import { VMLiveScreenshot } from "./VMLiveScreenshot"; import { SectionContainer } from "./SectionContainer"; +import { VMLiveScreenshot } from "./VMLiveScreenshot"; const useStyles = makeStyles({ body1Stronger: typographyStyles.body1Stronger, @@ -54,7 +54,7 @@ export function VirtualMachinesWidget(p: { ); } -function VMWidget(p: { vm: VMInfo }): React.ReactElement { +function VMWidget(p: { vm: VMInfoAndCaps }): React.ReactElement { const toast = useToast(); const [state, setState] = React.useState(); @@ -189,7 +189,10 @@ function VMWidget(p: { vm: VMInfo }): React.ReactElement { ); } -function VMPreview(p: { vm: VMInfo; state?: VMState }): React.ReactElement { +function VMPreview(p: { + vm: VMInfoAndCaps; + state?: VMState; +}): React.ReactElement { const styles = useStyles(); if (!p.vm.can_screenshot || p.state !== "Running") { return (