10 Commits

Author SHA1 Message Date
49cde3c8ab Merge branch 'master' into groups_support
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-12-05 22:07:56 +00:00
0c2cce397e Start to implement action buttons for group VMs
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-12-05 23:06:22 +01:00
2a50e41894 Get group VMs state
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2024-12-05 19:39:27 +01:00
59ad5fd722 Display VMs in group
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-12-05 19:32:12 +01:00
d243022810 Ready to implement groups web ui integration
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2024-12-03 21:49:53 +01:00
acb9baee23 Implement routes to shutdown, kill, reset, suspend, resume and take screenshot of VMs of a group
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-12-03 21:26:19 +01:00
269f6027b7 Implement routes to start VMs of a group 2024-12-03 21:18:05 +01:00
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
29 changed files with 2388 additions and 3526 deletions

View File

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

View File

@@ -1,11 +1,4 @@
# VirtWeb Remote # 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. This project aims to use the VirtWeb API to start and stop VM without directly exposing the VirtWEB API to the Internet.
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
```

1046
remote_backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,28 @@
[package] [package]
name = "remote_backend" name = "remote_backend"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
log = "0.4.28" log = "0.4.21"
env_logger = "0.11.8" env_logger = "0.11.3"
clap = { version = "4.5.53", features = ["derive", "env"] } clap = { version = "4.5.21", features = ["derive", "env"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.215", features = ["derive"] }
light-openid = { version = "1.0.4", features = ["crypto-wrapper"] } light-openid = { version = "1.0.2", features = ["crypto-wrapper"] }
basic-jwt = "0.3.0" basic-jwt = "0.2.0"
actix-web = "4.12.1" actix-web = "4.5.1"
actix-remote-ip = "0.1.0" actix-remote-ip = "0.1.0"
actix-session = { version = "0.11.0", features = ["cookie-session"] } actix-session = { version = "0.10.1", features = ["cookie-session"] }
actix-identity = "0.9.0" actix-identity = "0.8.0"
actix-cors = "0.7.1" actix-cors = "0.7.0"
lazy_static = "1.5.0" lazy_static = "1.4.0"
anyhow = "1.0.100" anyhow = "1.0.93"
reqwest = { version = "0.12.24", features = ["json"] } reqwest = { version = "0.12.9", features = ["json"] }
thiserror = "2.0.17" thiserror = "2.0.3"
uuid = { version = "1.18.1", features = ["v4", "serde"] } uuid = { version = "1.8.0", features = ["v4", "serde"] }
futures-util = "0.3.31" futures-util = "0.3.30"
lazy-regex = "3.4.2" lazy-regex = "3.1.0"
mime_guess = "2.0.5" mime_guess = "2.0.4"
rust-embed = { version = "8.9.0" } rust-embed = { version = "8.3.0" }

View File

@@ -6,4 +6,4 @@ RUN apt-get update \
COPY remote_backend /usr/local/bin/remote_backend 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_remote_ip::RemoteIP;
use actix_web::web::Data; 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 light_openid::basic_state_manager::BasicStateManager;
use crate::app_config::AppConfig; use crate::app_config::AppConfig;

View File

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

View File

@@ -1,8 +1,9 @@
use actix_web::HttpResponse;
use actix_web::body::BoxBody; use actix_web::body::BoxBody;
use actix_web::http::StatusCode; use actix_web::http::StatusCode;
use actix_web::HttpResponse;
use std::error::Error; use std::error::Error;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::io::ErrorKind;
pub mod auth_controller; pub mod auth_controller;
pub mod group_controller; pub mod group_controller;
@@ -37,7 +38,7 @@ impl actix_web::error::ResponseError for HttpErr {
} }
} }
fn error_response(&self) -> HttpResponse<BoxBody> { 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!") HttpResponse::InternalServerError().body("Failed to execute request!")
} }
@@ -51,7 +52,7 @@ impl From<anyhow::Error> for HttpErr {
impl From<Box<dyn Error>> for HttpErr { impl From<Box<dyn Error>> for HttpErr {
fn from(value: Box<dyn Error>) -> Self { fn from(value: Box<dyn Error>) -> Self {
HttpErr::Err(std::io::Error::other(value.to_string()).into()) HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
} }
} }
@@ -81,7 +82,7 @@ impl From<reqwest::header::ToStrError> for HttpErr {
impl From<actix_web::Error> for HttpErr { impl From<actix_web::Error> for HttpErr {
fn from(value: actix_web::Error) -> Self { fn from(value: actix_web::Error) -> Self {
HttpErr::Err(std::io::Error::other(value.to_string()).into()) HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
} }
} }

View File

@@ -18,7 +18,7 @@ mod serve_static_debug {
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
mod serve_static_release { mod serve_static_release {
use actix_web::{HttpResponse, Responder, web}; use actix_web::{web, HttpResponse, Responder};
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
#[derive(RustEmbed)] #[derive(RustEmbed)]

View File

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

View File

@@ -1,7 +1,7 @@
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::dev::Payload; use actix_web::dev::Payload;
use actix_web::{Error, FromRequest, HttpMessage, HttpRequest}; use actix_web::{Error, FromRequest, HttpMessage, HttpRequest};
use futures_util::future::{Ready, ready}; use futures_util::future::{ready, Ready};
use std::fmt::Display; use std::fmt::Display;
pub struct AuthExtractor { pub struct AuthExtractor {

View File

@@ -1,13 +1,13 @@
use actix_cors::Cors; use actix_cors::Cors;
use actix_identity::config::LogoutBehaviour;
use actix_identity::IdentityMiddleware; use actix_identity::IdentityMiddleware;
use actix_identity::config::LogoutBehavior;
use actix_remote_ip::RemoteIPConfig; use actix_remote_ip::RemoteIPConfig;
use actix_session::SessionMiddleware;
use actix_session::storage::CookieSessionStore; use actix_session::storage::CookieSessionStore;
use actix_session::SessionMiddleware;
use actix_web::cookie::{Key, SameSite}; use actix_web::cookie::{Key, SameSite};
use actix_web::middleware::Logger; use actix_web::middleware::Logger;
use actix_web::web::Data; 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 light_openid::basic_state_manager::BasicStateManager;
use remote_backend::app_config::AppConfig; use remote_backend::app_config::AppConfig;
use remote_backend::constants; use remote_backend::constants;
@@ -37,7 +37,7 @@ async fn main() -> std::io::Result<()> {
.build(); .build();
let identity_middleware = IdentityMiddleware::builder() let identity_middleware = IdentityMiddleware::builder()
.logout_behavior(LogoutBehavior::PurgeSession) .logout_behaviour(LogoutBehaviour::PurgeSession)
.visit_deadline(Some(Duration::from_secs( .visit_deadline(Some(Duration::from_secs(
constants::MAX_INACTIVITY_DURATION, constants::MAX_INACTIVITY_DURATION,
))) )))

View File

@@ -1,4 +1,4 @@
use std::future::{Ready, ready}; use std::future::{ready, Ready};
use std::rc::Rc; use std::rc::Rc;
use crate::app_config::AppConfig; use crate::app_config::AppConfig;
@@ -7,8 +7,8 @@ use crate::extractors::auth_extractor::AuthExtractor;
use actix_web::body::EitherBody; use actix_web::body::EitherBody;
use actix_web::dev::Payload; use actix_web::dev::Payload;
use actix_web::{ use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error, FromRequest, HttpResponse, Error, FromRequest, HttpResponse,
dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready},
}; };
use futures_util::future::LocalBoxFuture; use futures_util::future::LocalBoxFuture;

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" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fluentui/react-components": "^9.72.7", "@fluentui/react-components": "^9.56.3",
"@fluentui/react-icons": "^2.0.315", "@fluentui/react-icons": "^2.0.266",
"filesize": "^11.0.13", "filesize": "^10.1.6",
"react": "^19.2.0", "react": "^18.2.0",
"react-dom": "^19.2.0" "react-dom": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@types/react": "^18.3.12",
"@types/react": "^19.2.7", "@types/react-dom": "^18.3.1",
"@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.16.0",
"@typescript-eslint/eslint-plugin": "^8.48.0", "@typescript-eslint/parser": "^8.16.0",
"@typescript-eslint/parser": "^8.48.0", "@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^5.1.1", "eslint": "^9.0.0",
"eslint": "^9.39.1", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.14",
"eslint-plugin-react-refresh": "^0.4.24", "typescript": "^5.7.2",
"globals": "^16.5.0", "vite": "^6.0.1"
"typescript": "^5.9.3",
"typescript-eslint": "^8.47.0",
"vite": "^7.2.4"
} }
} }

View File

@@ -58,12 +58,7 @@ function AuthenticatedApp(): React.ReactElement {
const [rights, setRights] = React.useState<Rights | undefined>(); const [rights, setRights] = React.useState<Rights | undefined>();
const load = async () => { const load = async () => {
const rights = await ServerApi.GetRights(); setRights(await ServerApi.GetRights());
setRights(rights);
if (rights!.groups.length > 0) setTab("group");
else if (rights!.vms.length > 0) setTab("vm");
else setTab("info");
}; };
return ( return (
@@ -92,21 +87,27 @@ function AuthenticatedApp(): React.ReactElement {
selectedValue={tab} selectedValue={tab}
onTabSelect={(_, d) => setTab(d.value as any)} onTabSelect={(_, d) => setTab(d.value as any)}
> >
{rights!.groups.length > 0 && ( <Tab
<Tab value="group" icon={<AppListIcon />}> value="group"
icon={<AppListIcon />}
disabled={rights!.groups.length === 0}
>
Groups Groups
</Tab> </Tab>
)} <Tab
{rights!.vms.length > 0 && ( value="vm"
<Tab value="vm" icon={<DesktopIcon />}> icon={<DesktopIcon />}
disabled={rights!.vms.length === 0}
>
Virtual machines Virtual machines
</Tab> </Tab>
)} <Tab
{rights!.sys_info && ( value="info"
<Tab value="info" icon={<InfoIcon />}> icon={<InfoIcon />}
disabled={!rights!.sys_info}
>
System info System info
</Tab> </Tab>
)}
</TabList> </TabList>
<div> <div>
<MainMenu /> <MainMenu />

View File

@@ -1,16 +1,11 @@
import { APIClient } from "./ApiClient"; import { APIClient } from "./ApiClient";
import { VMGroup } from "./ServerApi"; import { VMGroup } from "./ServerApi";
import { VMInfo, VMState } from "./VMApi"; import { VMState } from "./VMApi";
export interface GroupVMState { export interface GroupVMState {
[key: string]: VMState; [key: string]: VMState;
} }
export interface TreatmentResult {
ok: number;
failed: number;
}
export class GroupApi { export class GroupApi {
/** /**
* Get the state of the VMs of a group * Get the state of the VMs of a group
@@ -20,88 +15,4 @@ export class GroupApi {
await APIClient.exec({ method: "GET", uri: `/group/${g.id}/vm/state` }) await APIClient.exec({ method: "GET", uri: `/group/${g.id}/vm/state` })
).data; ).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

@@ -20,7 +20,7 @@ type ThemeContext = { theme: Theme; set: (theme: Theme) => void };
const ThemeContextK = React.createContext<ThemeContext | null>(null); const ThemeContextK = React.createContext<ThemeContext | null>(null);
export function ThemeProvider(p: React.PropsWithChildren): React.ReactElement { 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; let fluentTheme = teamsHighContrastTheme;
switch (theme) { switch (theme) {

View File

@@ -1,19 +1,7 @@
import { Button, Spinner, Toolbar, Tooltip } from "@fluentui/react-components"; import { Button, Toolbar, Tooltip } from "@fluentui/react-components";
import { import { PlayRegular } from "@fluentui/react-icons";
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 { VMGroup } from "../api/ServerApi";
import { VMInfo, VMState } from "../api/VMApi"; 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: { export function GroupVMAction(p: {
group: VMGroup; group: VMGroup;
@@ -24,79 +12,14 @@ export function GroupVMAction(p: {
<Toolbar> <Toolbar>
<GroupVMButton <GroupVMButton
enabled={p.group.can_start} enabled={p.group.can_start}
icon={<PlayFilled />} icon={<PlayRegular />}
tooltip="Start" tooltip="Start"
group={p.group} group={p.group}
vm={p.vm} vm={p.vm}
allowedStates={["Shutdown", "Shutoff", "Crashed"]} allowedStates={["Shutdown", "Shutoff", "Crashed"]}
currState={p.state} currState={p.state}
needConfirm={false} needConfirm={false}
action={GroupApi.StartVM} action={async () => {}}
/>
<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> </Toolbar>
); );
@@ -105,7 +28,7 @@ export function GroupVMAction(p: {
function GroupVMButton(p: { function GroupVMButton(p: {
enabled: boolean; enabled: boolean;
icon: React.ReactElement; icon: React.ReactElement;
action: (group: VMGroup, vm?: VMInfo) => Promise<TreatmentResult>; action: (group: VMGroup, vm?: VMGroup) => Promise<void>;
tooltip: string; tooltip: string;
currState?: VMState; currState?: VMState;
allowedStates: VMState[]; allowedStates: VMState[];
@@ -113,62 +36,24 @@ function GroupVMButton(p: {
vm?: VMInfo; vm?: VMInfo;
needConfirm: boolean; needConfirm: boolean;
}): React.ReactElement { }): React.ReactElement {
const toast = useToast(); const process = () => {};
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 = const allowed =
!p.vm || (p.currState && p.allowedStates.includes(p.currState)); !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 <></>; if (!p.enabled) return <></>;
return ( return (
<Tooltip <Tooltip
content={`${p.tooltip} ${target}`} content={`${p.tooltip} ${
p.vm ? `the VM ${p.vm.name}` : `all the VM of the group ${p.group.id}`
}`}
relationship="description" relationship="description"
withArrow withArrow
> >
<Button <Button
icon={running ? <Spinner size="tiny" /> : p.icon} icon={p.icon}
onClick={allowed ? perform : undefined} onClick={process}
disabled={!allowed} disabled={!allowed}
appearance="subtle" appearance="subtle"
/> />

View File

@@ -1,33 +1,21 @@
import { import {
Button,
Card, Card,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
DialogTrigger,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableCellActions,
TableCellLayout, TableCellLayout,
TableHeader, TableHeader,
TableHeaderCell, TableHeaderCell,
TableRow, TableRow,
Title3, Title3,
Tooltip,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { Desktop24Regular, ScreenshotRegular } from "@fluentui/react-icons"; import { Desktop24Regular } from "@fluentui/react-icons";
import { filesize } from "filesize"; import { filesize } from "filesize";
import React from "react"; import React from "react";
import { GroupApi, GroupVMState } from "../api/GroupApi"; import { GroupApi, GroupVMState } from "../api/GroupApi";
import { Rights, VMGroup } from "../api/ServerApi"; import { Rights, VMGroup } from "../api/ServerApi";
import { VMInfo } from "../api/VMApi";
import { useToast } from "../hooks/providers/ToastProvider"; import { useToast } from "../hooks/providers/ToastProvider";
import { GroupVMAction } from "./GroupVMAction"; import { GroupVMAction } from "./GroupVMAction";
import { VMLiveScreenshot } from "./VMLiveScreenshot";
export function GroupsWidget(p: { rights: Rights }): React.ReactElement { export function GroupsWidget(p: { rights: Rights }): React.ReactElement {
return ( return (
@@ -43,17 +31,12 @@ function GroupInfo(p: { group: VMGroup }): React.ReactElement {
const toast = useToast(); const toast = useToast();
const [state, setState] = React.useState<GroupVMState | undefined>(); const [state, setState] = React.useState<GroupVMState | undefined>();
const [screenshotVM, setScreenshotVM] = React.useState<VMInfo | undefined>();
const load = async () => { const load = async () => {
const newState = await GroupApi.State(p.group); const newState = await GroupApi.State(p.group);
if (state !== newState) setState(newState); if (state !== newState) setState(newState);
}; };
const screenshot = (vm: VMInfo) => {
setScreenshotVM(vm);
};
React.useEffect(() => { React.useEffect(() => {
const interval = setInterval(async () => { const interval = setInterval(async () => {
try { try {
@@ -71,7 +54,6 @@ function GroupInfo(p: { group: VMGroup }): React.ReactElement {
}); });
return ( return (
<>
<Card <Card
style={{ style={{
margin: "50px 10px", margin: "50px 10px",
@@ -103,28 +85,11 @@ function GroupInfo(p: { group: VMGroup }): React.ReactElement {
> >
{item.name} {item.name}
</TableCellLayout> </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>
<TableCell> <TableCell>
{item.architecture} &bull; RAM :{" "} {item.architecture} &bull; RAM :{" "}
{filesize(item.memory)} &bull;{" "} {filesize(item.memory * 1000 * 1000)} &bull; {item.number_vcpu}{" "}
{item.number_vcpu} vCPU vCPU
</TableCell> </TableCell>
<TableCell>{state?.[item.uuid] ?? ""}</TableCell> <TableCell>{state?.[item.uuid] ?? ""}</TableCell>
<TableCell> <TableCell>
@@ -139,33 +104,5 @@ function GroupInfo(p: { group: VMGroup }): React.ReactElement {
</TableBody> </TableBody>
</Table> </Table>
</Card> </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 React from "react";
import { GroupApi } from "../api/GroupApi";
import { VMGroup } from "../api/ServerApi";
import { VMApi, VMInfo } from "../api/VMApi"; import { VMApi, VMInfo } from "../api/VMApi";
import { useToast } from "../hooks/providers/ToastProvider"; import { useToast } from "../hooks/providers/ToastProvider";
export function VMLiveScreenshot(p: { export function VMLiveScreenshot(p: { vm: VMInfo }): React.ReactElement {
vm: VMInfo;
group?: VMGroup;
}): React.ReactElement {
const toast = useToast(); const toast = useToast();
const [screenshotURL, setScreenshotURL] = React.useState< const [screenshotURL, setScreenshotURL] = React.useState<
@@ -19,9 +14,7 @@ export function VMLiveScreenshot(p: {
React.useEffect(() => { React.useEffect(() => {
const refresh = async () => { const refresh = async () => {
try { try {
const screenshot = p.group const screenshot = await VMApi.Screenshot(p.vm);
? await GroupApi.ScreenshotVM(p.group, p.vm)
: await VMApi.Screenshot(p.vm);
const u = URL.createObjectURL(screenshot); const u = URL.createObjectURL(screenshot);
setScreenshotURL(u); setScreenshotURL(u);
} catch (e) { } catch (e) {

View File

@@ -25,8 +25,8 @@ import { Rights } from "../api/ServerApi";
import { VMApi, VMInfo, VMInfoAndCaps, VMState } from "../api/VMApi"; import { VMApi, VMInfo, VMInfoAndCaps, VMState } from "../api/VMApi";
import { useConfirm } from "../hooks/providers/ConfirmDialogProvider"; import { useConfirm } from "../hooks/providers/ConfirmDialogProvider";
import { useToast } from "../hooks/providers/ToastProvider"; import { useToast } from "../hooks/providers/ToastProvider";
import { SectionContainer } from "./SectionContainer";
import { VMLiveScreenshot } from "./VMLiveScreenshot"; import { VMLiveScreenshot } from "./VMLiveScreenshot";
import { SectionContainer } from "./SectionContainer";
const useStyles = makeStyles({ const useStyles = makeStyles({
body1Stronger: typographyStyles.body1Stronger, body1Stronger: typographyStyles.body1Stronger,
@@ -107,7 +107,7 @@ function VMWidget(p: { vm: VMInfoAndCaps }): React.ReactElement {
} }
/> />
<p className={styles.caption1} style={{ margin: "0px auto" }}> <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 &bull; {p.vm.number_vcpu} vCPU
</p> </p>

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": [], "compilerOptions": {
"references": [ "target": "ES2020",
{ "path": "./tsconfig.app.json" }, "useDefineForClassFields": true,
{ "path": "./tsconfig.node.json" } "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": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "composite": true,
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
"module": "ESNext",
/* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowSyntheticDefaultImports": true,
"isolatedModules": true, "strict": true
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View File

@@ -1,7 +1,7 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], 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
}
]
} }