Compare commits
62 Commits
20b00d9904
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6baefd65bc | |||
| acd27e9a9e | |||
| ec42dc3d41 | |||
| 2224f24ade | |||
| 69e177ecae | |||
| 9d5e35eaa0 | |||
| 4c5b428f1a | |||
| 7b25388a60 | |||
| a173d2cae9 | |||
| dfdfdca205 | |||
| 47dcb9fe68 | |||
| 996be30782 | |||
| 12957c0c07 | |||
| 5f8a69d2be | |||
| 7f247755ca | |||
| 13d8f839f8 | |||
| 9e11435f20 | |||
| c1e382c4c8 | |||
| 421e12f3f9 | |||
| 226004163b | |||
| 1ee6ffad16 | |||
| e8ad408b5c | |||
| acf60e64e5 | |||
| b1ef6ecde7 | |||
| 0e0c94bb73 | |||
| 28b757fdb2 | |||
| 8542ebff44 | |||
| c01d322675 | |||
| dd3ebce19f | |||
| 203fd18ea7 | |||
| 9995dc0607 | |||
| bec3b30221 | |||
| aa3cdc5c76 | |||
| 304cc9f12a | |||
| b63cdcab6a | |||
| 11dcc29628 | |||
| 20ed46d917 | |||
| 25d918d74e | |||
| 963e9e950e | |||
| 2aa07be0e2 | |||
| f915acaac1 | |||
| 76a88a5884 | |||
| 702e8ba4fb | |||
| 6dd093502e | |||
| d5c504de36 | |||
| 8dffc4db46 | |||
| 42c8c70609 | |||
| d76a94e8e4 | |||
| 31972b5f84 | |||
| 8e813f33ec | |||
| 3f9858c4c1 | |||
| c8aeaaf823 | |||
| ff5a1c7509 | |||
| 82cf328217 | |||
| 6e120262bb | |||
| 077f35d340 | |||
| e4da5f77c6 | |||
| b10eb4ce30 | |||
| 958e3c6964 | |||
| 4dee0d6f46 | |||
| 1942248a09 | |||
| 19ab088892 |
@@ -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
|
||||
|
||||
2283
virtweb_backend/Cargo.lock
generated
2283
virtweb_backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -7,43 +7,43 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
log = "0.4.29"
|
||||
env_logger = "0.11.8"
|
||||
clap = { version = "4.5.54", 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.9"
|
||||
actix-ws = "0.3.1"
|
||||
actix-http = "3.11.2"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
actix-files = "0.6.10"
|
||||
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"
|
||||
anyhow = "1.0.100"
|
||||
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"
|
||||
thiserror = "2.0.17"
|
||||
image = "0.25.9"
|
||||
rand = "0.9.2"
|
||||
tokio = { version = "1.47.1", features = ["rt", "time", "macros"] }
|
||||
futures = "0.3.31"
|
||||
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.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"
|
||||
chrono = "0.4.43"
|
||||
nix = { version = "0.31.2", features = ["net"] }
|
||||
basic-jwt = "0.4.0"
|
||||
zip = "8.4.0"
|
||||
chrono = "0.4.44"
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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={}",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
3317
virtweb_frontend/package-lock.json
generated
3317
virtweb_frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,45 +12,47 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/roboto": "^5.2.9",
|
||||
"@fontsource/roboto": "^5.2.10",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@mui/icons-material": "^7.3.7",
|
||||
"@mui/material": "^7.3.7",
|
||||
"@mui/x-charts": "^8.3.1",
|
||||
"@mui/x-data-grid": "^8.23.0",
|
||||
"date-and-time": "^3.6.0",
|
||||
"filesize": "^10.1.6",
|
||||
"@mui/icons-material": "^7.3.9",
|
||||
"@mui/material": "^7.3.9",
|
||||
"@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",
|
||||
"monaco-yaml": "^5.4.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-vnc": "^3.1.0",
|
||||
"uuid": "^11.1.0",
|
||||
"xml-formatter": "^3.6.6",
|
||||
"yaml": "^2.8.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.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.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/humanize-duration": "^3.27.4",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "^19.2.9",
|
||||
"@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.2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface APIToken {
|
||||
updated: number;
|
||||
rights: TokenRight[];
|
||||
last_used: number;
|
||||
ip_restriction?: string;
|
||||
allowed_ip_networks?: string[];
|
||||
max_inactivity?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -28,7 +28,8 @@ export function CreateApiTokenRoute(): React.ReactElement {
|
||||
CreatedAPIToken | undefined
|
||||
>();
|
||||
|
||||
const [token] = React.useState<APIToken>({
|
||||
const [token] = React.useState<APIToken>(() => {
|
||||
return {
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
@@ -36,6 +37,7 @@ export function CreateApiTokenRoute(): React.ReactElement {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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!} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
11
virtweb_frontend/src/utils/NetworkUtils.ts
Normal file
11
virtweb_frontend/src/utils/NetworkUtils.ts
Normal 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;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
mdiLan,
|
||||
mdiSecurityNetwork,
|
||||
} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import { Icon } from "@mdi/react";
|
||||
import {
|
||||
Box,
|
||||
List,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
virtweb_frontend/src/widgets/forms/NetworksInput.tsx
Normal file
28
virtweb_frontend/src/widgets/forms/NetworksInput.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
<NetworksInput
|
||||
{...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}
|
||||
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;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
Reference in New Issue
Block a user