1 Commits

Author SHA1 Message Date
6df52262b7 Update dependency @mui/x-charts to ^8.26.0
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-01-23 00:33:00 +00:00
41 changed files with 2836 additions and 3087 deletions

View File

@@ -5,13 +5,13 @@ name: default
steps: steps:
- name: web_build - name: web_build
image: node:24 image: node:23
volumes: volumes:
- name: web_app - name: web_app
path: /tmp/web_build path: /tmp/web_build
commands: commands:
- cd virtweb_frontend - cd virtweb_frontend
- npm install --legacy-peer-deps # TODO remove --legacy-peer-deps ASAP - npm install
- npm run lint - npm run lint
- npm run build - npm run build
- mv dist /tmp/web_build - mv dist /tmp/web_build

File diff suppressed because it is too large Load Diff

View File

@@ -7,43 +7,43 @@ edition = "2024"
[dependencies] [dependencies]
log = "0.4.29" log = "0.4.29"
env_logger = "0.11.10" env_logger = "0.11.8"
clap = { version = "4.6.0", features = ["derive", "env"] } clap = { version = "4.5.54", features = ["derive", "env"] }
light-openid = { version = "1.1.0", features = ["crypto-wrapper"] } light-openid = { version = "1.0.4", features = ["crypto-wrapper"] }
lazy_static = "1.5.0" lazy_static = "1.5.0"
actix = "0.13.5" actix = "0.13.5"
actix-web = "4.13.0" actix-web = "4.12.1"
actix-remote-ip = "1.0.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.1"
actix-files = "0.6.10" actix-files = "0.6.9"
actix-ws = "0.4.0" actix-ws = "0.3.1"
actix-http = "3.12.0" actix-http = "3.11.2"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
serde_yml = "0.0.12" serde_yml = "0.0.12"
quick-xml = { version = "0.39.2", features = ["serialize", "overlapped-lists"] } quick-xml = { version = "0.38.4", features = ["serialize", "overlapped-lists"] }
futures-util = "0.3.32" futures-util = "0.3.31"
anyhow = "1.0.102" anyhow = "1.0.100"
actix-multipart = "0.7.2" actix-multipart = "0.7.2"
tempfile = "3.27.0" tempfile = "3.20.0"
reqwest = { version = "0.13.2", features = ["stream"] } reqwest = { version = "0.12.28", features = ["stream"] }
url = "2.5.8" url = "2.5.8"
virt = "0.4.3" virt = "0.4.3"
sysinfo = { version = "0.38.4", features = ["serde"] } sysinfo = { version = "0.36.1", features = ["serde"] }
uuid = { version = "1.22.0", features = ["v4", "serde"] } uuid = { version = "1.17.0", features = ["v4", "serde"] }
lazy-regex = "3.6.0" lazy-regex = "3.4.2"
thiserror = "2.0.18" thiserror = "2.0.17"
image = "0.25.10" image = "0.25.9"
rand = "0.10.0" rand = "0.9.2"
tokio = { version = "1.50.0", features = ["rt", "time", "macros"] } tokio = { version = "1.47.1", features = ["rt", "time", "macros"] }
futures = "0.3.32" futures = "0.3.31"
ipnetwork = { version = "0.21.1", features = ["serde"] } ipnetwork = { version = "0.21.1", features = ["serde"] }
num = "0.4.3" num = "0.4.3"
rust-embed = { version = "8.11.0", features = ["mime-guess"] } rust-embed = { version = "8.7.2", features = ["mime-guess"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
nix = { version = "0.31.2", features = ["net"] } nix = { version = "0.30.1", features = ["net"] }
basic-jwt = "0.4.0" basic-jwt = "0.3.0"
zip = "8.4.0" zip = "4.3.0"
chrono = "0.4.44" chrono = "0.4.43"

View File

@@ -83,8 +83,7 @@ pub struct Token {
pub pub_key: Option<JWTPublicKey>, pub pub_key: Option<JWTPublicKey>,
pub rights: TokenRights, pub rights: TokenRights,
pub last_used: u64, pub last_used: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")] pub ip_restriction: Option<ipnetwork::IpNetwork>,
pub allowed_ip_networks: Vec<ipnetwork::IpNetwork>,
pub max_inactivity: Option<u64>, pub max_inactivity: Option<u64>,
} }
@@ -162,8 +161,7 @@ pub struct NewToken {
pub name: String, pub name: String,
pub description: String, pub description: String,
pub rights: TokenRights, pub rights: TokenRights,
#[serde(default, skip_serializing_if = "Vec::is_empty")] pub ip_restriction: Option<ipnetwork::IpNetwork>,
pub allowed_ip_networks: Vec<ipnetwork::IpNetwork>,
pub max_inactivity: Option<u64>, pub max_inactivity: Option<u64>,
} }
@@ -214,7 +212,7 @@ pub async fn create(t: &NewToken) -> anyhow::Result<(Token, JWTPrivateKey)> {
pub_key: Some(pub_key), pub_key: Some(pub_key),
rights: t.rights.clone(), rights: t.rights.clone(),
last_used: time(), last_used: time(),
allowed_ip_networks: t.allowed_ip_networks.clone(), ip_restriction: t.ip_restriction,
max_inactivity: t.max_inactivity, max_inactivity: t.max_inactivity,
}; };

View File

@@ -2,7 +2,6 @@ use crate::libvirt_client::LibVirtClient;
use actix_http::StatusCode; use actix_http::StatusCode;
use actix_web::body::BoxBody; use actix_web::body::BoxBody;
use actix_web::{HttpResponse, web}; use actix_web::{HttpResponse, web};
use light_openid::errors::OpenIdError;
use std::error::Error; use std::error::Error;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use zip::result::ZipError; use zip::result::ZipError;
@@ -110,18 +109,11 @@ impl From<ZipError> for HttpErr {
} }
} }
impl From<OpenIdError> for HttpErr {
fn from(err: OpenIdError) -> HttpErr {
HttpErr::Err(std::io::Error::other(err.to_string()).into())
}
}
impl From<HttpResponse> for HttpErr { impl From<HttpResponse> for HttpErr {
fn from(value: HttpResponse) -> Self { fn from(value: HttpResponse) -> Self {
HttpErr::HTTPResponse(value) HttpErr::HTTPResponse(value)
} }
} }
pub type HttpResult = Result<HttpResponse, HttpErr>; pub type HttpResult = Result<HttpResponse, HttpErr>;
pub type LibVirtReq = web::Data<LibVirtClient>; pub type LibVirtReq = web::Data<LibVirtClient>;

View File

@@ -9,7 +9,7 @@ use actix_web::{Error, FromRequest, HttpRequest};
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] #[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct TokenClaims { pub struct TokenClaims {
pub sub: String, pub sub: String,
pub iat: usize, pub iat: usize,
@@ -128,11 +128,8 @@ impl FromRequest for ApiAuthExtractor {
)); ));
} }
if !token.allowed_ip_networks.is_empty() if let Some(ip) = token.ip_restriction
&& !token && !ip.contains(remote_ip.0)
.allowed_ip_networks
.iter()
.any(|n| n.contains(remote_ip.0))
{ {
log::error!( log::error!(
"Attempt to use a token for an unauthorized IP! {remote_ip:?} token_id={}", "Attempt to use a token for an unauthorized IP! {remote_ip:?} token_id={}",

View File

@@ -1,7 +1,7 @@
use actix::Actor; use actix::Actor;
use actix_cors::Cors; use actix_cors::Cors;
use actix_identity::IdentityMiddleware; use actix_identity::IdentityMiddleware;
use actix_identity::config::LogoutBehavior; use actix_identity::config::LogoutBehaviour;
use actix_multipart::form::MultipartFormConfig; use actix_multipart::form::MultipartFormConfig;
use actix_multipart::form::tempfile::TempFileConfig; use actix_multipart::form::tempfile::TempFileConfig;
use actix_remote_ip::RemoteIPConfig; use actix_remote_ip::RemoteIPConfig;
@@ -98,7 +98,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(MAX_INACTIVITY_DURATION))) .visit_deadline(Some(Duration::from_secs(MAX_INACTIVITY_DURATION)))
.login_deadline(Some(Duration::from_secs(MAX_SESSION_DURATION))) .login_deadline(Some(Duration::from_secs(MAX_SESSION_DURATION)))
.build(); .build();
@@ -123,9 +123,9 @@ async fn main() -> std::io::Result<()> {
.wrap(cors) .wrap(cors)
.app_data(state_manager.clone()) .app_data(state_manager.clone())
.app_data(vnc_tokens.clone()) .app_data(vnc_tokens.clone())
.app_data(Data::new(RemoteIPConfig::parse_opt( .app_data(Data::new(RemoteIPConfig {
AppConfig::get().proxy_ip.clone(), proxy: AppConfig::get().proxy_ip.clone(),
))) }))
.app_data(conn.clone()) .app_data(conn.clone())
// Uploaded files // Uploaded files
.app_data(MultipartFormConfig::default().total_limit( .app_data(MultipartFormConfig::default().total_limit(

View File

@@ -49,7 +49,6 @@ export default tseslint.config(
"@typescript-eslint/no-unsafe-member-access": "off", "@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-argument": "off", "@typescript-eslint/no-unsafe-argument": "off",
"react-refresh/only-export-components": "off", "react-refresh/only-export-components": "off",
"react-hooks/immutability": "off",
}, },
} }
); );

File diff suppressed because it is too large Load Diff

View File

@@ -12,47 +12,45 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.10", "@fontsource/roboto": "^5.2.9",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^7.3.9", "@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.9", "@mui/material": "^7.3.7",
"@mui/x-charts": "^8.28.0", "@mui/x-charts": "^8.26.0",
"@mui/x-data-grid": "^8.28.0", "@mui/x-data-grid": "^8.23.0",
"date-and-time": "^4.3.1", "date-and-time": "^3.6.0",
"filesize": "^11.0.13", "filesize": "^10.1.6",
"humanize-duration": "^3.33.2", "humanize-duration": "^3.33.2",
"is-cidr": "^6.0.3", "monaco-editor": "^0.52.2",
"monaco-editor": "^0.55.1", "monaco-yaml": "^5.4.0",
"monaco-yaml": "^5.4.1", "react": "^19.2.3",
"react": "^19.2.4", "react-dom": "^19.2.3",
"react-dom": "^19.2.4", "react-router-dom": "^7.9.6",
"react-router-dom": "^7.13.2", "react-syntax-highlighter": "^15.6.6",
"react-syntax-highlighter": "^16.1.1", "react-vnc": "^3.1.0",
"react-vnc": "^3.2.0", "uuid": "^11.1.0",
"uuid": "^13.0.0", "xml-formatter": "^3.6.6",
"xml-formatter": "^3.7.0", "yaml": "^2.8.2"
"yaml": "^2.8.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^9.39.2",
"@types/humanize-duration": "^3.27.4", "@types/humanize-duration": "^3.27.4",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^25.5.0", "@types/react": "^19.2.9",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^4.7.0",
"eslint": "^10.1.0", "eslint": "^9.39.2",
"eslint-plugin-react-dom": "^3.0.0", "eslint-plugin-react-dom": "^1.53.1",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-react-x": "^3.0.0", "eslint-plugin-react-x": "^1.53.1",
"globals": "^17.4.0", "globals": "^16.3.0",
"typescript": "^6.0.2", "typescript": "^5.9.3",
"typescript-eslint": "^8.57.2", "typescript-eslint": "^8.43.0",
"vite": "^8.0.2" "vite": "^6.3.6"
} }
} }

View File

@@ -16,7 +16,7 @@ export interface APIToken {
updated: number; updated: number;
rights: TokenRight[]; rights: TokenRight[];
last_used: number; last_used: number;
allowed_ip_networks?: string[]; ip_restriction?: string;
max_inactivity?: number; max_inactivity?: number;
} }

View File

@@ -61,7 +61,6 @@ export function DiskImagesRoute(): React.ReactElement {
} }
> >
<AsyncWidget <AsyncWidget
// eslint-disable-next-line react-hooks/refs
loadKey={loadKey.current} loadKey={loadKey.current}
errMsg="Failed to load disk images list!" errMsg="Failed to load disk images list!"
load={load} load={load}
@@ -85,7 +84,7 @@ function UploadDiskImageCard(p: {
const [value, setValue] = React.useState<File | null>(null); const [value, setValue] = React.useState<File | null>(null);
const [uploadProgress, setUploadProgress] = React.useState<number | null>( const [uploadProgress, setUploadProgress] = React.useState<number | null>(
null, null
); );
const handleChange = (newValue: File | null) => { const handleChange = (newValue: File | null) => {
@@ -95,8 +94,8 @@ function UploadDiskImageCard(p: {
) { ) {
alert( alert(
`The file is too big (max size allowed: ${filesize( `The file is too big (max size allowed: ${filesize(
ServerApi.Config.constraints.disk_image_max_size, ServerApi.Config.constraints.disk_image_max_size
)}`, )}`
); );
return; return;
} }
@@ -201,7 +200,7 @@ function DiskImageList(p: {
const deleteDiskImage = async (entry: DiskImage) => { const deleteDiskImage = async (entry: DiskImage) => {
if ( if (
!(await confirm( !(await confirm(
`Do you really want to delete this disk image (${entry.file_name}) ?`, `Do you really want to delete this disk image (${entry.file_name}) ?`
)) ))
) )
return; return;

View File

@@ -28,16 +28,14 @@ export function CreateApiTokenRoute(): React.ReactElement {
CreatedAPIToken | undefined CreatedAPIToken | undefined
>(); >();
const [token] = React.useState<APIToken>(() => { const [token] = React.useState<APIToken>({
return { id: "",
id: "", name: "",
name: "", description: "",
description: "", created: time(),
created: time(), updated: time(),
updated: time(), last_used: time(),
last_used: time(), rights: [],
rights: [],
};
}); });
const createApiToken = async (n: APIToken) => { const createApiToken = async (n: APIToken) => {
@@ -117,11 +115,8 @@ function EditApiTokenRouteInner(p: {
const [changed, setChanged] = React.useState(false); const [changed, setChanged] = React.useState(false);
// eslint-disable-next-line react-x/use-state
const [, updateState] = React.useState<any>(); const [, updateState] = React.useState<any>();
const forceUpdate = React.useCallback(() => { const forceUpdate = React.useCallback(() => { updateState({}); }, []);
updateState({});
}, []);
const valueChanged = () => { const valueChanged = () => {
setChanged(true); setChanged(true);

View File

@@ -15,7 +15,7 @@ export function CreateNWFilterRoute(): React.ReactElement {
const snackbar = useSnackbar(); const snackbar = useSnackbar();
const navigate = useNavigate(); const navigate = useNavigate();
const [nwFilter, setNWFilter] = React.useState<NWFilter>({ const [nwfilter, setNWFilter] = React.useState<NWFilter>({
name: "my-filter", name: "my-filter",
chain: { protocol: "root" }, chain: { protocol: "root" },
join_filters: [], join_filters: [],
@@ -35,7 +35,7 @@ export function CreateNWFilterRoute(): React.ReactElement {
return ( return (
<EditNetworkFilterRouteInner <EditNetworkFilterRouteInner
nwfilter={nwFilter} nwfilter={nwfilter}
creating={true} creating={true}
onCancel={() => navigate("/nwfilter")} onCancel={() => navigate("/nwfilter")}
onSave={createNWFilter} onSave={createNWFilter}
@@ -51,7 +51,7 @@ export function EditNWFilterRoute(): React.ReactElement {
const { uuid } = useParams(); const { uuid } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [nwFilter, setNWFilter] = React.useState<NWFilter | undefined>(); const [nwfilter, setNWFilter] = React.useState<NWFilter | undefined>();
const load = async () => { const load = async () => {
setNWFilter(await NWFilterApi.GetSingle(uuid!)); setNWFilter(await NWFilterApi.GetSingle(uuid!));
@@ -61,7 +61,7 @@ export function EditNWFilterRoute(): React.ReactElement {
try { try {
await NWFilterApi.Update(n); await NWFilterApi.Update(n);
snackbar("The network filter was successfully updated!"); snackbar("The network filter was successfully updated!");
navigate(NWFilterURL(nwFilter!)); navigate(NWFilterURL(nwfilter!));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert(`Failed to update network filter!\n${e}`); alert(`Failed to update network filter!\n${e}`);
@@ -71,12 +71,12 @@ export function EditNWFilterRoute(): React.ReactElement {
return ( return (
<AsyncWidget <AsyncWidget
loadKey={uuid} loadKey={uuid}
ready={nwFilter !== undefined} ready={nwfilter !== undefined}
errMsg="Failed to fetch network filter information!" errMsg="Failed to fetch network filter information!"
load={load} load={load}
build={() => ( build={() => (
<EditNetworkFilterRouteInner <EditNetworkFilterRouteInner
nwfilter={nwFilter!} nwfilter={nwfilter!}
creating={false} creating={false}
onCancel={() => navigate(`/nwfilter/${uuid}`)} onCancel={() => navigate(`/nwfilter/${uuid}`)}
onSave={updateNetworkFilter} onSave={updateNetworkFilter}
@@ -98,11 +98,8 @@ function EditNetworkFilterRouteInner(p: {
const [changed, setChanged] = React.useState(false); const [changed, setChanged] = React.useState(false);
// eslint-disable-next-line react-x/use-state
const [, updateState] = React.useState<any>(); const [, updateState] = React.useState<any>();
const forceUpdate = React.useCallback(() => { const forceUpdate = React.useCallback(() => { updateState({}); }, []);
updateState({});
}, []);
const valueChanged = () => { const valueChanged = () => {
setChanged(true); setChanged(true);

View File

@@ -96,11 +96,8 @@ function EditNetworkRouteInner(p: {
const [changed, setChanged] = React.useState(false); const [changed, setChanged] = React.useState(false);
// eslint-disable-next-line react-x/use-state
const [, updateState] = React.useState<any>(); const [, updateState] = React.useState<any>();
const forceUpdate = React.useCallback(() => { const forceUpdate = React.useCallback(() => { updateState({}); }, []);
updateState({});
}, []);
const valueChanged = () => { const valueChanged = () => {
setChanged(true); setChanged(true);

View File

@@ -102,7 +102,6 @@ function EditVMInner(p: {
const [changed, setChanged] = React.useState(false); const [changed, setChanged] = React.useState(false);
// eslint-disable-next-line react-x/use-state
const [, updateState] = React.useState<any>(); const [, updateState] = React.useState<any>();
const forceUpdate = React.useCallback(() => { const forceUpdate = React.useCallback(() => {
updateState({}); updateState({});

View File

@@ -46,7 +46,6 @@ export function IsoFilesRoute(): React.ReactElement {
return ( return (
<> <>
<AsyncWidget <AsyncWidget
// eslint-disable-next-line react-hooks/refs
loadKey={loadKey.current} loadKey={loadKey.current}
errMsg="Failed to load ISO files list!" errMsg="Failed to load ISO files list!"
load={load} load={load}
@@ -57,11 +56,7 @@ export function IsoFilesRoute(): React.ReactElement {
actions={ actions={
<span> <span>
<Tooltip title="Open the ISO catalog"> <Tooltip title="Open the ISO catalog">
<IconButton <IconButton onClick={() => { setIsoCatalog(true); }}>
onClick={() => {
setIsoCatalog(true);
}}
>
<MenuBookIcon /> <MenuBookIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@@ -81,9 +76,7 @@ export function IsoFilesRoute(): React.ReactElement {
/> />
<IsoCatalogDialog <IsoCatalogDialog
open={isoCatalog} open={isoCatalog}
onClose={() => { onClose={() => { setIsoCatalog(false); }}
setIsoCatalog(false);
}}
/> />
</> </>
); );
@@ -97,15 +90,15 @@ function UploadIsoFileCard(p: {
const [value, setValue] = React.useState<File | null>(null); const [value, setValue] = React.useState<File | null>(null);
const [uploadProgress, setUploadProgress] = React.useState<number | null>( const [uploadProgress, setUploadProgress] = React.useState<number | null>(
null, null
); );
const handleChange = (newValue: File | null) => { const handleChange = (newValue: File | null) => {
if (newValue && newValue.size > ServerApi.Config.constraints.iso_max_size) { if (newValue && newValue.size > ServerApi.Config.constraints.iso_max_size) {
alert( alert(
`The file is too big (max size allowed: ${filesize( `The file is too big (max size allowed: ${filesize(
ServerApi.Config.constraints.iso_max_size, ServerApi.Config.constraints.iso_max_size
)}`, )}`
); );
return; return;
} }
@@ -251,7 +244,7 @@ function IsoFilesList(p: {
const deleteIso = async (entry: IsoFile) => { const deleteIso = async (entry: IsoFile) => {
if ( if (
!(await confirm( !(await confirm(
`Do you really want to delete this file (${entry.filename}) ?`, `Do you really want to delete this file (${entry.filename}) ?`
)) ))
) )
return; return;

View File

@@ -6,7 +6,7 @@ import {
mdiNetwork, mdiNetwork,
mdiPackageVariantClosed, mdiPackageVariantClosed,
} from "@mdi/js"; } from "@mdi/js";
import { Icon } from "@mdi/react"; import Icon from "@mdi/react";
import { import {
Box, Box,
IconButton, IconButton,
@@ -81,7 +81,7 @@ export function SysInfoRouteInner(p: {
free: prev.free + disk.available_space, free: prev.free + disk.available_space,
}; };
}, },
{ used: 0, free: 0 }, { used: 0, free: 0 }
); );
return ( return (
@@ -199,7 +199,6 @@ export function SysInfoRouteInner(p: {
}, },
{ {
label: "Bootime", label: "Bootime",
// eslint-disable-next-line react-x/purity
value: new Date(p.info.system.boot_time * 1000).toString(), value: new Date(p.info.system.boot_time * 1000).toString(),
}, },
{ {
@@ -320,7 +319,7 @@ function DiskDetailsTable(p: { disks: DiskInfo[] }): React.ReactElement {
{p.disks.map((e, c) => ( {p.disks.map((e, c) => (
<TableRow hover key={c}> <TableRow hover key={c}>
<TableCell>{e.name}</TableCell> <TableCell>{e.name}</TableCell>
<TableCell>{e.DiskKind}</TableCell> <TableCell>{String(e.DiskKind)}</TableCell>
<TableCell>{e.mount_point}</TableCell> <TableCell>{e.mount_point}</TableCell>
<TableCell>{filesize(e.total_space)}</TableCell> <TableCell>{filesize(e.total_space)}</TableCell>
<TableCell>{filesize(e.available_space)}</TableCell> <TableCell>{filesize(e.available_space)}</TableCell>

View File

@@ -69,7 +69,7 @@ export function TokensListRouteInner(p: {
<TableCell>Created</TableCell> <TableCell>Created</TableCell>
<TableCell>Updated</TableCell> <TableCell>Updated</TableCell>
<TableCell>Last used</TableCell> <TableCell>Last used</TableCell>
<TableCell>Allowed networks</TableCell> <TableCell>IP restriction</TableCell>
<TableCell>Max inactivity</TableCell> <TableCell>Max inactivity</TableCell>
<TableCell>Rights</TableCell> <TableCell>Rights</TableCell>
<TableCell>Actions</TableCell> <TableCell>Actions</TableCell>
@@ -97,7 +97,7 @@ export function TokensListRouteInner(p: {
<TableCell> <TableCell>
<TimeWidget time={t.last_used} /> <TimeWidget time={t.last_used} />
</TableCell> </TableCell>
<TableCell>{t.allowed_ip_networks?.join(", ")}</TableCell> <TableCell>{t.ip_restriction}</TableCell>
<TableCell> <TableCell>
{t.max_inactivity && timeDiff(0, t.max_inactivity)} {t.max_inactivity && timeDiff(0, t.max_inactivity)}
</TableCell> </TableCell>

View File

@@ -47,7 +47,6 @@ export function VMListRoute(): React.ReactElement {
return ( return (
<AsyncWidget <AsyncWidget
// eslint-disable-next-line react-hooks/refs
loadKey={loadKey.current} loadKey={loadKey.current}
errMsg="Failed to load Virtual Machines list!" errMsg="Failed to load Virtual Machines list!"
load={load} load={load}
@@ -77,11 +76,11 @@ function VMListWidget(p: {
}): React.ReactElement { }): React.ReactElement {
const navigate = useNavigate(); const navigate = useNavigate();
const [hiddenGroups, setHiddenGroups] = React.useState(() => new Set()); const [hiddenGroups, setHiddenGroups] = React.useState<
Set<string | undefined>
>(new Set());
const [runningVMs, setRunningVMs] = React.useState<Set<string>>( const [runningVMs, setRunningVMs] = React.useState<Set<string>>(new Set());
() => new Set(),
);
const toggleHiddenGroup = (g: string | undefined) => { const toggleHiddenGroup = (g: string | undefined) => {
if (hiddenGroups.has(g)) hiddenGroups.delete(g); if (hiddenGroups.has(g)) hiddenGroups.delete(g);
@@ -187,7 +186,7 @@ function VMListWidget(p: {
{filesize( {filesize(
p.list p.list
.filter((v) => runningVMs.has(v.name)) .filter((v) => runningVMs.has(v.name))
.reduce((s, v) => s + v.memory, 0), .reduce((s, v) => s + v.memory, 0)
)} )}
{" / "} {" / "}
{filesize(p.list.reduce((s, v) => s + v.memory, 0))} {filesize(p.list.reduce((s, v) => s + v.memory, 0))}

View File

@@ -88,7 +88,6 @@ function VNCInner(p: { vm: VMInfo }): React.ReactElement {
}; };
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
connect(false); connect(false);
if (vncRef.current) { if (vncRef.current) {

View File

@@ -15,7 +15,7 @@ import { NWFilterDetails } from "../widgets/nwfilter/NWFilterDetails";
export function ViewNWFilterRoute() { export function ViewNWFilterRoute() {
const { uuid } = useParams(); const { uuid } = useParams();
const [nwFilter, setNWFilter] = React.useState<NWFilter | undefined>(); const [nwfilter, setNWFilter] = React.useState<NWFilter | undefined>();
const load = async () => { const load = async () => {
setNWFilter(await NWFilterApi.GetSingle(uuid!)); setNWFilter(await NWFilterApi.GetSingle(uuid!));
@@ -24,10 +24,10 @@ export function ViewNWFilterRoute() {
return ( return (
<AsyncWidget <AsyncWidget
loadKey={uuid} loadKey={uuid}
ready={nwFilter !== undefined} ready={nwfilter !== undefined}
errMsg="Failed to fetch network filter information!" errMsg="Failed to fetch network filter information!"
load={load} load={load}
build={() => <ViewNetworkFilterRouteInner nwfilter={nwFilter!} />} build={() => <ViewNetworkFilterRouteInner nwfilter={nwfilter!} />}
/> />
); );
} }

View File

@@ -41,14 +41,12 @@ export function LoginRoute(): React.ReactElement {
}; };
const handleMouseDownPassword = ( const handleMouseDownPassword = (
event: React.MouseEvent<HTMLButtonElement>, event: React.MouseEvent<HTMLButtonElement>
) => { ) => {
event.preventDefault(); event.preventDefault();
}; };
const handleLoginSubmit = async ( const handleLoginSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event: React.SubmitEvent<HTMLFormElement>,
) => {
event.preventDefault(); event.preventDefault();
if (!canSubmit) return; if (!canSubmit) return;

View File

@@ -1,11 +0,0 @@
import isCidr from "is-cidr";
/**
* Check whether a given IP network address is valid or not
*
* @param ip The IP network to check
* @returns true if the address is valid, false otherwise
*/
export function isIPNetworkValid(ip: string): boolean {
return isCidr(ip) !== 0;
}

View File

@@ -1,25 +1,26 @@
import { Alert, Box, Button, CircularProgress } from "@mui/material"; import { Alert, Box, Button, CircularProgress } from "@mui/material";
import React from "react"; import React, { useEffect, useRef, useState } from "react";
const State = { enum State {
Loading: 0, Loading,
Ready: 1, Ready,
Error: 2, Error,
} as const; }
type State = keyof typeof State;
export function AsyncWidget(p: { export function AsyncWidget(p: {
loadKey: unknown; loadKey: any;
load: () => Promise<void>; load: () => Promise<void>;
errMsg: string; errMsg: string;
build: () => React.ReactElement; build: () => React.ReactElement;
ready?: boolean;
buildLoading?: () => React.ReactElement; buildLoading?: () => React.ReactElement;
buildError?: (e: string) => React.ReactElement; buildError?: (e: string) => React.ReactElement;
ready?: boolean;
errAdditionalElement?: () => React.ReactElement; errAdditionalElement?: () => React.ReactElement;
}): React.ReactElement { }): React.ReactElement {
const [state, setState] = React.useState(State.Loading as number); const [state, setState] = useState(State.Loading);
const counter = useRef<any>(null);
const load = async () => { const load = async () => {
try { try {
setState(State.Loading); setState(State.Loading);
@@ -31,10 +32,12 @@ export function AsyncWidget(p: {
} }
}; };
React.useEffect(() => { useEffect(() => {
if (counter.current === p.loadKey) return;
counter.current = p.loadKey;
load(); load();
// eslint-disable-next-line react-hooks/exhaustive-deps, react-x/exhaustive-deps });
}, [p.loadKey]);
if (state === State.Error) if (state === State.Error)
return ( return (
@@ -48,6 +51,10 @@ export function AsyncWidget(p: {
height: "100%", height: "100%",
flex: "1", flex: "1",
flexDirection: "column", flexDirection: "column",
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}} }}
> >
<Alert <Alert
@@ -76,6 +83,10 @@ export function AsyncWidget(p: {
alignItems: "center", alignItems: "center",
height: "100%", height: "100%",
flex: "1", flex: "1",
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}} }}
> >
<CircularProgress /> <CircularProgress />

View File

@@ -8,7 +8,7 @@ import {
mdiLan, mdiLan,
mdiSecurityNetwork, mdiSecurityNetwork,
} from "@mdi/js"; } from "@mdi/js";
import { Icon } from "@mdi/react"; import Icon from "@mdi/react";
import { import {
Box, Box,
List, List,

View File

@@ -1,6 +1,5 @@
/* eslint-disable react-x/purity */
import { mdiServer } from "@mdi/js"; import { mdiServer } from "@mdi/js";
import { Icon } from "@mdi/react"; import Icon from "@mdi/react";
import Avatar from "@mui/material/Avatar"; import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import CssBaseline from "@mui/material/CssBaseline"; import CssBaseline from "@mui/material/CssBaseline";

View File

@@ -1,7 +1,5 @@
import React from "react";
export function DateWidget(p: { time: number }): React.ReactElement { export function DateWidget(p: { time: number }): React.ReactElement {
const date = React.useMemo(() => new Date(p.time * 1000), [p.time]); const date = new Date(p.time * 1000);
return ( return (
<> <>

View File

@@ -2,7 +2,7 @@ import { Avatar, ListItem, ListItemAvatar, ListItemText } from "@mui/material";
import { DiskImage } from "../api/DiskImageApi"; import { DiskImage } from "../api/DiskImageApi";
import { mdiHarddisk } from "@mdi/js"; import { mdiHarddisk } from "@mdi/js";
import { filesize } from "filesize"; import { filesize } from "filesize";
import { Icon } from "@mdi/react"; import Icon from "@mdi/react";
export function FileDiskImageWidget(p: { export function FileDiskImageWidget(p: {
image: DiskImage; image: DiskImage;

View File

@@ -1,11 +1,11 @@
import { Tooltip } from "@mui/material"; import { Tooltip } from "@mui/material";
import { format } from "date-and-time"; import date from "date-and-time";
import { time } from "../utils/DateUtils"; import { time } from "../utils/DateUtils";
export function formatDate(time: number): string { export function formatDate(time: number): string {
const t = new Date(); const t = new Date();
t.setTime(1000 * time); t.setTime(1000 * time);
return format(t, "DD/MM/YYYY HH:mm:ss"); return date.format(t, "DD/MM/YYYY HH:mm:ss");
} }
export function timeDiff(a: number, b: number): string { export function timeDiff(a: number, b: number): string {

View File

@@ -1,5 +1,5 @@
import { mdiLogout, mdiServer } from "@mdi/js"; import { mdiLogout, mdiServer } from "@mdi/js";
import { Icon } from "@mdi/react"; import Icon from "@mdi/react";
import { AppBar, IconButton, Toolbar, Typography } from "@mui/material"; import { AppBar, IconButton, Toolbar, Typography } from "@mui/material";
import { RouterLink } from "./RouterLink"; import { RouterLink } from "./RouterLink";
import { AsyncWidget } from "./AsyncWidget"; import { AsyncWidget } from "./AsyncWidget";

View File

@@ -33,7 +33,6 @@ export function IPInputWithMask(p: {
const currValue = const currValue =
p.ipAndMask ?? p.ipAndMask ??
// eslint-disable-next-line react-hooks/refs
`${p.ip ?? ""}${p.mask || showSlash.current ? "/" : ""}${p.mask ?? ""}`; `${p.ip ?? ""}${p.mask || showSlash.current ? "/" : ""}${p.mask ?? ""}`;
const { onValueChange, ...props } = p; const { onValueChange, ...props } = p;
@@ -59,7 +58,7 @@ export function IPInputWithMask(p: {
onValueChange?.( onValueChange?.(
ip, ip,
mask, mask,
mask || showSlash.current ? `${ip}/${mask ?? ""}` : ip, mask || showSlash.current ? `${ip}/${mask ?? ""}` : ip
); );
}} }}
value={currValue} value={currValue}

View File

@@ -20,13 +20,12 @@ export function NWFSelectReferencedFilters(p: {
p.excludedFilters p.excludedFilters
? p.nwFiltersList.filter((f) => !p.excludedFilters!.includes(f.name)) ? p.nwFiltersList.filter((f) => !p.excludedFilters!.includes(f.name))
: p.nwFiltersList, : p.nwFiltersList,
[p.excludedFilters, p.nwFiltersList], [p.excludedFilters]
); );
const selectedFilters = React.useMemo( const selectedFilters = React.useMemo(
() => p.selected.map((f) => p.nwFiltersList.find((s) => s.name === f)), () => p.selected.map((f) => p.nwFiltersList.find((s) => s.name === f)),
// eslint-disable-next-line react-x/exhaustive-deps [p.selected.length]
[p.selected.length, p.nwFiltersList],
); );
return ( return (

View File

@@ -1,6 +1,6 @@
/* eslint-disable react-x/no-array-index-key */ /* eslint-disable react-x/no-array-index-key */
import { mdiIp } from "@mdi/js"; import { mdiIp } from "@mdi/js";
import { Icon } from "@mdi/react"; import Icon from "@mdi/react";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import { import {
Avatar, Avatar,

View File

@@ -1,28 +0,0 @@
import { isIPNetworkValid } from "../../utils/NetworkUtils";
import { TextInput } from "./TextInput";
function rebuildNetworksList(val?: string): string[] | undefined {
if (!val || val.trim() === "") return undefined;
return val.split(",").map((v) => v.trim());
}
export function NetworksInput(p: {
editable: boolean;
label: string;
value?: string[];
onChange: (n: string[] | undefined) => void;
}): React.ReactElement {
const textValue = (p.value ?? []).join(", ").trim();
return (
<TextInput
{...p}
type="string"
value={textValue}
onValueChange={(i) => {
p.onChange(rebuildNetworksList(i));
}}
checkValue={(v) => (rebuildNetworksList(v) ?? []).every(isIPNetworkValid)}
/>
);
}

View File

@@ -1,5 +1,5 @@
import { mdiHarddiskPlus } from "@mdi/js"; import { mdiHarddiskPlus } from "@mdi/js";
import { Icon } from "@mdi/react"; import Icon from "@mdi/react";
import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import ExpandIcon from "@mui/icons-material/Expand"; import ExpandIcon from "@mui/icons-material/Expand";
@@ -125,7 +125,7 @@ function DiskInfo(p: {
`You asked to delete the disk ${p.disk.name}. Do you want to keep the block file or not ? `, `You asked to delete the disk ${p.disk.name}. Do you want to keep the block file or not ? `,
"Delete disk", "Delete disk",
"Keep the file", "Keep the file",
"Delete the file", "Delete the file"
); );
if (!(await confirm("Do you really want to delete this disk?"))) return; if (!(await confirm("Do you really want to delete this disk?"))) return;

View File

@@ -1,6 +1,6 @@
/* eslint-disable react-x/no-array-index-key */ /* eslint-disable react-x/no-array-index-key */
import { mdiNetworkOutline } from "@mdi/js"; import { mdiNetworkOutline } from "@mdi/js";
import { Icon } from "@mdi/react"; import Icon from "@mdi/react";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import { import {
Avatar, Avatar,
@@ -191,7 +191,7 @@ function NetworkInfoWidget(p: {
editable={p.editable} editable={p.editable}
label="Defined network" label="Defined network"
options={p.networksList.map((n) => { options={p.networksList.map((n) => {
const chars = [n.forward_mode as string]; const chars = [n.forward_mode.toString()];
if (n.ip_v4) chars.push("IPv4"); if (n.ip_v4) chars.push("IPv4");
if (n.ip_v6) chars.push("IPv6"); if (n.ip_v6) chars.push("IPv6");
if (n.description) chars.push(n.description); if (n.description) chars.push(n.description);

View File

@@ -11,7 +11,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import { mdiDisc } from "@mdi/js"; import { mdiDisc } from "@mdi/js";
import { Icon } from "@mdi/react"; import Icon from "@mdi/react";
export function VMSelectIsoInput(p: { export function VMSelectIsoInput(p: {
editable: boolean; editable: boolean;

View File

@@ -1,5 +1,5 @@
import { mdiSecurityNetwork } from "@mdi/js"; import { mdiSecurityNetwork } from "@mdi/js";
import { Icon } from "@mdi/react"; import Icon from "@mdi/react";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import { import {

View File

@@ -14,7 +14,8 @@ import { useSnackbar } from "../../hooks/providers/SnackbarProvider";
import { AsyncWidget } from "../AsyncWidget"; import { AsyncWidget } from "../AsyncWidget";
import { TabsWidget } from "../TabsWidget"; import { TabsWidget } from "../TabsWidget";
import { EditSection } from "../forms/EditSection"; import { EditSection } from "../forms/EditSection";
import { NetworksInput } from "../forms/NetworksInput"; import { IPInputWithMask } from "../forms/IPInput";
import { RadioGroupInput } from "../forms/RadioGroupInput";
import { TextInput } from "../forms/TextInput"; import { TextInput } from "../forms/TextInput";
import { TokenRawRightsEditor } from "./TokenRawRightsEditor"; import { TokenRawRightsEditor } from "./TokenRawRightsEditor";
import { TokenRightsEditor } from "./TokenRightsEditor"; import { TokenRightsEditor } from "./TokenRightsEditor";
@@ -34,17 +35,17 @@ interface DetailsProps {
} }
export function APITokenDetails(p: DetailsProps): React.ReactElement { export function APITokenDetails(p: DetailsProps): React.ReactElement {
const [vms, setVms] = React.useState<VMInfo[]>(); const [vms, setVMs] = React.useState<VMInfo[]>();
const [groups, setGroups] = React.useState<string[]>(); const [groups, setGroups] = React.useState<string[]>();
const [networks, setNetworks] = React.useState<NetworkInfo[]>(); const [networks, setNetworks] = React.useState<NetworkInfo[]>();
const [nwFilters, setNwFilters] = React.useState<NWFilter[]>(); const [nwFilters, setNetworkFilters] = React.useState<NWFilter[]>();
const [tokens, setTokens] = React.useState<APIToken[]>(); const [tokens, setTokens] = React.useState<APIToken[]>();
const load = async () => { const load = async () => {
setVms(await VMApi.GetList()); setVMs(await VMApi.GetList());
setGroups(await GroupApi.GetList()); setGroups(await GroupApi.GetList());
setNetworks(await NetworkApi.GetList()); setNetworks(await NetworkApi.GetList());
setNwFilters(await NWFilterApi.GetList()); setNetworkFilters(await NWFilterApi.GetList());
setTokens(await TokensApi.GetList()); setTokens(await TokensApi.GetList());
}; };
@@ -120,6 +121,10 @@ function APITokenDetailsInner(p: DetailsInnerProps): React.ReactElement {
} }
function APITokenTabGeneral(p: DetailsInnerProps): React.ReactElement { function APITokenTabGeneral(p: DetailsInnerProps): React.ReactElement {
const [ipVersion, setIpVersion] = React.useState<4 | 6>(
(p.token.ip_restriction ?? "").includes(":") ? 6 : 4
);
return ( return (
<Grid container spacing={2}> <Grid container spacing={2}>
{/* Metadata section */} {/* Metadata section */}
@@ -153,13 +158,29 @@ function APITokenTabGeneral(p: DetailsInnerProps): React.ReactElement {
</EditSection> </EditSection>
<EditSection title="General settings"> <EditSection title="General settings">
<NetworksInput {p.status === TokenWidgetStatus.Create && (
<RadioGroupInput
{...p}
editable={true}
options={[
{ label: "IPv4", value: "4" },
{ label: "IPv6", value: "6" },
]}
value={ipVersion.toString()}
onValueChange={(v) => {
setIpVersion(Number(v) as 4 | 6);
}}
label="Token IP restriction version"
/>
)}
<IPInputWithMask
{...p} {...p}
label="Allowd IP networks (comma-separated list, leave empty to allow from anywhere)" label="Token IP network restriction"
ipAndMask={p.token.ip_restriction}
editable={p.status === TokenWidgetStatus.Create} editable={p.status === TokenWidgetStatus.Create}
value={p.token.allowed_ip_networks} version={ipVersion}
onChange={(v) => { onValueChange={(_ip, _mask, ipAndMask) => {
p.token.allowed_ip_networks = v; p.token.ip_restriction = ipAndMask;
p.onChange?.(); p.onChange?.();
}} }}
/> />
@@ -218,7 +239,7 @@ function APITokenTabDanger(p: DetailsInnerProps): React.ReactElement {
!(await confirm( !(await confirm(
"Do you really want to delete this API token?", "Do you really want to delete this API token?",
`Delete API token ${p.token.name}`, `Delete API token ${p.token.name}`,
"Delete", "Delete"
)) ))
) )
return; return;

View File

@@ -6,7 +6,6 @@
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
"types": ["node"],
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",