10 Commits

Author SHA1 Message Date
6baefd65bc fix: remove the notion of ip restriction
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-03-23 21:23:44 +01:00
acd27e9a9e feat: can allow multiple networks at once in an api token
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-23 21:21:33 +01:00
ec42dc3d41 chore: upgrade drone to Node v24
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-23 21:04:29 +01:00
2224f24ade fix: restore buildLoading and buildError in AsyncWidget
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-23 21:03:15 +01:00
69e177ecae fix: eslint issues
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-23 20:56:55 +01:00
9d5e35eaa0 fix: eslint issues 2026-03-23 20:43:09 +01:00
4c5b428f1a chore: updated base frontend dependencies 2026-03-23 19:09:17 +01:00
7b25388a60 chore: updated backend dependencies 2026-03-23 19:04:14 +01:00
a173d2cae9 Merge pull request 'Update dependency yaml to ^2.8.3' (#510) from renovate/yaml-2.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-23 00:39:37 +00:00
dfdfdca205 Update dependency yaml to ^2.8.3
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-03-22 00:40:16 +00:00
41 changed files with 3019 additions and 2735 deletions

View File

@@ -5,13 +5,13 @@ name: default
steps:
- name: web_build
image: node:23
image: node:24
volumes:
- name: web_app
path: /tmp/web_build
commands:
- cd virtweb_frontend
- npm install
- npm install --legacy-peer-deps # TODO remove --legacy-peer-deps ASAP
- npm run lint
- npm run 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]
log = "0.4.29"
env_logger = "0.11.9"
clap = { version = "4.5.56", features = ["derive", "env"] }
light-openid = { version = "1.0.4", features = ["crypto-wrapper"] }
env_logger = "0.11.10"
clap = { version = "4.6.0", features = ["derive", "env"] }
light-openid = { version = "1.1.0", features = ["crypto-wrapper"] }
lazy_static = "1.5.0"
actix = "0.13.5"
actix-web = "4.12.1"
actix-remote-ip = "0.1.0"
actix-session = { version = "0.10.1", features = ["cookie-session"] }
actix-identity = "0.8.0"
actix-web = "4.13.0"
actix-remote-ip = "1.0.0"
actix-session = { version = "0.11.0", features = ["cookie-session"] }
actix-identity = "0.9.0"
actix-cors = "0.7.1"
actix-files = "0.6.10"
actix-ws = "0.3.1"
actix-http = "3.11.2"
serde = { version = "1.0.219", features = ["derive"] }
actix-ws = "0.4.0"
actix-http = "3.12.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
serde_yml = "0.0.12"
quick-xml = { version = "0.38.4", features = ["serialize", "overlapped-lists"] }
futures-util = "0.3.31"
quick-xml = { version = "0.39.2", features = ["serialize", "overlapped-lists"] }
futures-util = "0.3.32"
anyhow = "1.0.102"
actix-multipart = "0.7.2"
tempfile = "3.20.0"
reqwest = { version = "0.12.28", features = ["stream"] }
tempfile = "3.27.0"
reqwest = { version = "0.13.2", features = ["stream"] }
url = "2.5.8"
virt = "0.4.3"
sysinfo = { version = "0.36.1", features = ["serde"] }
uuid = { version = "1.17.0", features = ["v4", "serde"] }
lazy-regex = "3.4.2"
sysinfo = { version = "0.38.4", features = ["serde"] }
uuid = { version = "1.22.0", features = ["v4", "serde"] }
lazy-regex = "3.6.0"
thiserror = "2.0.18"
image = "0.25.9"
rand = "0.9.2"
tokio = { version = "1.49.0", features = ["rt", "time", "macros"] }
image = "0.25.10"
rand = "0.10.0"
tokio = { version = "1.50.0", features = ["rt", "time", "macros"] }
futures = "0.3.32"
ipnetwork = { version = "0.21.1", features = ["serde"] }
num = "0.4.3"
rust-embed = { version = "8.7.2", features = ["mime-guess"] }
rust-embed = { version = "8.11.0", features = ["mime-guess"] }
dotenvy = "0.15.7"
nix = { version = "0.30.1", features = ["net"] }
basic-jwt = "0.3.0"
zip = "4.3.0"
nix = { version = "0.31.2", features = ["net"] }
basic-jwt = "0.4.0"
zip = "8.4.0"
chrono = "0.4.44"

View File

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

View File

@@ -2,6 +2,7 @@ use crate::libvirt_client::LibVirtClient;
use actix_http::StatusCode;
use actix_web::body::BoxBody;
use actix_web::{HttpResponse, web};
use light_openid::errors::OpenIdError;
use std::error::Error;
use std::fmt::{Display, Formatter};
use zip::result::ZipError;
@@ -109,11 +110,18 @@ 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 {
fn from(value: HttpResponse) -> Self {
HttpErr::HTTPResponse(value)
}
}
pub type HttpResult = Result<HttpResponse, HttpErr>;
pub type LibVirtReq = web::Data<LibVirtClient>;

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -18,39 +18,41 @@
"@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^7.3.9",
"@mui/material": "^7.3.9",
"@mui/x-charts": "^8.3.1",
"@mui/x-data-grid": "^8.27.5",
"date-and-time": "^3.6.0",
"filesize": "^10.1.6",
"@mui/x-charts": "^8.28.0",
"@mui/x-data-grid": "^8.28.0",
"date-and-time": "^4.3.1",
"filesize": "^11.0.13",
"humanize-duration": "^3.33.2",
"monaco-editor": "^0.52.2",
"is-cidr": "^6.0.3",
"monaco-editor": "^0.55.1",
"monaco-yaml": "^5.4.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.9.6",
"react-syntax-highlighter": "^15.6.6",
"react-vnc": "^3.1.0",
"uuid": "^11.1.0",
"react-router-dom": "^7.13.2",
"react-syntax-highlighter": "^16.1.1",
"react-vnc": "^3.2.0",
"uuid": "^13.0.0",
"xml-formatter": "^3.7.0",
"yaml": "^2.8.2"
"yaml": "^2.8.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@eslint/js": "^10.0.1",
"@types/humanize-duration": "^3.27.4",
"@types/jest": "^30.0.0",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9.39.4",
"eslint-plugin-react-dom": "^1.53.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-react-x": "^1.53.1",
"globals": "^16.3.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.43.0",
"vite": "^6.3.6"
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.1.0",
"eslint-plugin-react-dom": "^3.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-react-x": "^3.0.0",
"globals": "^17.4.0",
"typescript": "^6.0.2",
"typescript-eslint": "^8.57.2",
"vite": "^8.0.2"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
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 { RouterLink } from "./RouterLink";
import { AsyncWidget } from "./AsyncWidget";

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
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 Icon from "@mdi/react";
import { Icon } from "@mdi/react";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import DeleteIcon from "@mui/icons-material/Delete";
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 ? `,
"Delete disk",
"Keep the file",
"Delete the file"
"Delete the file",
);
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 */
import { mdiNetworkOutline } from "@mdi/js";
import Icon from "@mdi/react";
import { Icon } from "@mdi/react";
import DeleteIcon from "@mui/icons-material/Delete";
import {
Avatar,
@@ -191,7 +191,7 @@ function NetworkInfoWidget(p: {
editable={p.editable}
label="Defined network"
options={p.networksList.map((n) => {
const chars = [n.forward_mode.toString()];
const chars = [n.forward_mode as string];
if (n.ip_v4) chars.push("IPv4");
if (n.ip_v6) chars.push("IPv6");
if (n.description) chars.push(n.description);

View File

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

View File

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

View File

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

View File

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