3 Commits

Author SHA1 Message Date
26fee59c5d Can determine the rights of the token over the group
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-11-30 15:07:22 +01:00
a8a75328a9 Get information about the VM of a group
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-11-30 11:22:32 +01:00
09f54bf3c1 Refactorize VM information management
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-11-30 10:59:38 +01:00
33 changed files with 2789 additions and 3648 deletions

View File

@@ -5,7 +5,7 @@ name: default
steps:
- name: frontend_build
image: node:24
image: node:23
volumes:
- name: frontend_app
path: /tmp/frontend_build

View File

@@ -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.

1181
remote_backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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.53", features = ["derive", "env"] }
serde = { version = "1.0.228", features = ["derive"] }
light-openid = { version = "1.0.4", features = ["crypto-wrapper"] }
basic-jwt = "0.4.0"
actix-web = "4.12.1"
log = "0.4.21"
env_logger = "0.11.3"
clap = { version = "4.5.21", features = ["derive", "env"] }
serde = { version = "1.0.215", 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.11.0", 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.2"
mime_guess = "2.0.5"
rust-embed = { version = "8.7.2" }
actix-session = { version = "0.10.1", features = ["cookie-session"] }
actix-identity = "0.8.0"
actix-cors = "0.7.0"
lazy_static = "1.4.0"
anyhow = "1.0.93"
reqwest = { version = "0.12.9", features = ["json"] }
thiserror = "2.0.3"
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" }

View File

@@ -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

View File

@@ -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;

View File

@@ -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))
}

View File

@@ -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())
}
}

View File

@@ -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::{GroupID, VMCaps, VMInfo};
use crate::virtweb_client::{GroupID, VMInfo};
use actix_web::HttpResponse;
#[derive(serde::Serialize)]
@@ -29,16 +29,28 @@ pub struct Rights {
pub struct GroupInfo {
id: GroupID,
vms: Vec<VMInfo>,
#[serde(flatten)]
caps: VMCaps,
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,
}
#[derive(Debug, serde::Serialize)]
pub struct VMInfoAndCaps {
#[serde(flatten)]
info: VMInfo,
#[serde(flatten)]
caps: VMCaps,
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,
}
pub async fn rights() -> HttpResult {
@@ -56,16 +68,14 @@ pub async fn rights() -> HttpResult {
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)),
},
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)),
})
}
@@ -74,16 +84,14 @@ pub async fn rights() -> HttpResult {
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()),
},
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()),
})
}

View File

@@ -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)]

View File

@@ -3,7 +3,7 @@
use crate::controllers::HttpResult;
use crate::virtweb_client;
use crate::virtweb_client::VMUuid;
use actix_web::{HttpResponse, web};
use actix_web::{web, HttpResponse};
#[derive(serde::Deserialize)]
pub struct ReqPath {

View File

@@ -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 {

View File

@@ -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,
)))
@@ -87,39 +86,6 @@ 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))

View File

@@ -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;

View File

@@ -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;
@@ -103,15 +102,9 @@ impl GroupID {
}
}
#[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)
@@ -176,18 +169,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,
@@ -287,13 +268,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)?;
@@ -385,68 +365,6 @@ 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

View 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 },
],
},
}

View File

@@ -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 },
],
},
},
)

File diff suppressed because it is too large Load Diff

View File

@@ -10,25 +10,22 @@
"preview": "vite preview"
},
"dependencies": {
"@fluentui/react-components": "^9.72.7",
"@fluentui/react-icons": "^2.0.315",
"filesize": "^11.0.13",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"@fluentui/react-components": "^9.56.3",
"@fluentui/react-icons": "^2.0.266",
"filesize": "^10.1.6",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.48.0",
"@typescript-eslint/parser": "^8.48.0",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.2.4"
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.16.0",
"@typescript-eslint/parser": "^8.16.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"typescript": "^5.7.2",
"vite": "^6.0.1"
}
}

View File

@@ -5,8 +5,6 @@ import {
typographyStyles,
} from "@fluentui/react-components";
import {
AppsListDetailFilled,
AppsListDetailRegular,
DesktopFilled,
DesktopRegular,
InfoFilled,
@@ -20,7 +18,6 @@ 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
@@ -53,17 +48,12 @@ function AppInner(): React.ReactElement {
function AuthenticatedApp(): React.ReactElement {
const styles = useStyles();
const [tab, setTab] = React.useState<"group" | "vm" | "info">("group");
const [tab, setTab] = React.useState<"vm" | "info">("vm");
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");
setRights(await ServerApi.GetRights());
};
return (
@@ -92,27 +82,25 @@ function AuthenticatedApp(): React.ReactElement {
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>
)}
<Tab
value="vm"
icon={<DesktopIcon />}
disabled={rights!.vms.length === 0}
>
Virtual machines
</Tab>
<Tab
value="info"
icon={<InfoIcon />}
disabled={!rights!.sys_info}
>
System info
</Tab>
</TabList>
<div>
<MainMenu />
</div>
</div>
{tab === "group" && <GroupsWidget rights={rights!} />}
{tab === "vm" && <VirtualMachinesWidget rights={rights!} />}
{tab === "info" && <SystemInfoWidget />}
</div>

View File

@@ -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;
}
}

View File

@@ -1,5 +1,5 @@
import { APIClient } from "./ApiClient";
import { VMCaps, VMInfo, VMInfoAndCaps } from "./VMApi";
import { VMInfo } from "./VMApi";
export interface ServerConfig {
authenticated: boolean;
@@ -7,16 +7,8 @@ export interface ServerConfig {
}
export interface Rights {
groups: VMGroup[];
vms: VMInfoAndCaps[];
sys_info: boolean;
}
export type VMGroup = VMGroupInfo & VMCaps;
export interface VMGroupInfo {
id: string;
vms: VMInfo[];
sys_info: boolean;
}
let config: ServerConfig | null = null;

View File

@@ -7,9 +7,6 @@ export interface VMInfo {
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"

View File

@@ -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) {

View File

@@ -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>
);
}

View File

@@ -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} &bull; RAM :{" "}
{filesize(item.memory)} &bull;{" "}
{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>
</>
);
}

View File

@@ -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) {

View File

@@ -22,11 +22,11 @@ import {
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 { SectionContainer } from "./SectionContainer";
import { VMLiveScreenshot } from "./VMLiveScreenshot";
import { SectionContainer } from "./SectionContainer";
const useStyles = makeStyles({
body1Stronger: typographyStyles.body1Stronger,
@@ -54,7 +54,7 @@ export function VirtualMachinesWidget(p: {
);
}
function VMWidget(p: { vm: VMInfoAndCaps }): React.ReactElement {
function VMWidget(p: { vm: VMInfo }): React.ReactElement {
const toast = useToast();
const [state, setState] = React.useState<VMState | undefined>();
@@ -107,7 +107,7 @@ function VMWidget(p: { vm: VMInfoAndCaps }): React.ReactElement {
}
/>
<p className={styles.caption1} style={{ margin: "0px auto" }}>
{p.vm.architecture} &bull; RAM : {filesize(p.vm.memory)}{" "}
{p.vm.architecture} &bull; RAM : {filesize(p.vm.memory * 1000 * 1000)}{" "}
&bull; {p.vm.number_vcpu} vCPU
</p>
@@ -189,10 +189,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 (

View File

@@ -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"]
}

View File

@@ -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" }]
}

View File

@@ -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"]
}

View File

@@ -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()],
})

View File

@@ -1,3 +1,9 @@
{
"extends": ["local>renovate/presets"]
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"packageRules": [
{
"matchUpdateTypes": ["major", "minor", "patch"],
"automerge": true
}
]
}