80 Commits

Author SHA1 Message Date
f850ca5cb7 Can rename disk image files
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-05-31 10:45:15 +02:00
4ee01cad4b Add legacy boot mode
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-31 10:05:13 +02:00
5518b45219 Fix ESLint issues
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-31 09:45:23 +02:00
0279907ca9 Can change disk bus after disk creation
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-31 09:41:12 +02:00
5fe481ffed Refactorize disks list
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-31 09:05:10 +02:00
c7cc15d8d0 Can select disk bus type when adding new disk to VM
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-31 08:52:07 +02:00
22416badcf Can change network interface type
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 20:30:30 +02:00
ef0d77f1d6 Minor fixes
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 15:25:46 +02:00
1d4af8c74e Can restore disk image when adding disks to virtual machine
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 14:41:48 +02:00
ec9492c933 Show a message on tokens list route when no token was created yet
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 11:35:00 +02:00
fa03ae885f Add new REST API routes to token rights editor
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 11:30:27 +02:00
ea98aaf856 Fix ESLint issue
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 11:20:19 +02:00
794d16bdaa Simplify raw disks definition
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-30 11:15:21 +02:00
a3ac56f849 Replace success snackbar with alert message in convert disk image dialog 2025-05-30 11:05:14 +02:00
6130f37336 Attempt to increase requests timeout 2025-05-30 11:04:38 +02:00
6b6fef5ccc Can backup vm disks as images 2025-05-30 10:55:40 +02:00
83df7e1b20 Prepare UI for disks backups 2025-05-30 10:28:54 +02:00
a18310e04a Simplify RAM management
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-30 09:20:49 +02:00
dd7f9176fa Clarify some constants 2025-05-30 09:03:00 +02:00
d5fbc24c96 Fix ESLint issues
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-29 15:52:23 +02:00
d765f9c2c3 Improve raw sparse conversion
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-29 15:44:21 +02:00
21fd5de139 Can decompress raw files to sparse file 2025-05-29 15:41:08 +02:00
42f22c110c Can compress raw files 2025-05-29 15:33:32 +02:00
9822c5a72a Can convert QCow2 to QCow2 2025-05-29 15:13:31 +02:00
452a395525 Can convert QCow2 to raw 2025-05-29 15:00:14 +02:00
80d81c34bb Add support of sparse files for QCow2 to sparse conversion 2025-05-29 14:51:57 +02:00
b9353326f5 Can convert QCow2 to raw file 2025-05-29 14:47:29 +02:00
3ffc64f129 Can compress QCow2 2025-05-29 14:22:09 +02:00
e869517bb1 Can decompress QCow2 2025-05-29 14:18:27 +02:00
90f4bf35e9 Add checkbox for sparse file conversion
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-29 12:18:26 +02:00
80d6fe0298 Can perform basic file copy 2025-05-29 12:15:52 +02:00
e017fe96d5 Add the logic which will call disk image conversion from disk image route
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-29 11:37:11 +02:00
e7ac0198ab Can download disk images
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-29 10:23:26 +02:00
927a51cda7 Can delete uploaded disk images 2025-05-29 08:45:38 +02:00
615dc1ed83 Display the list of uploaded disk images 2025-05-29 08:33:19 +02:00
20de618568 Generalize disk file creation logic
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-29 08:03:42 +02:00
7451f1b7b4 Split file_disks_utils into two files
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-29 07:40:42 +02:00
5814b0ab6a Clarify disk structures names
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-27 21:42:46 +02:00
5a5450070a Process size of disks in bytes instead of megabytes
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-27 21:29:16 +02:00
19ec9992be Can get the list of uploaded disk images
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-27 21:17:16 +02:00
b55880b43c Can upload disk images on the server
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-27 20:40:48 +02:00
6a7af7e6c4 Add support to bridge option on Web UI
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-26 21:02:02 +02:00
a8171375a8 Added REST route to get the list of bridges 2025-05-26 20:43:19 +02:00
de33c7d521 Check if qemu-img is present before startup
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-26 19:21:40 +02:00
ff372800bd Add backend bridge support 2025-05-26 19:12:18 +02:00
aac05b7faf Update dependency @types/react to ^19.1.5
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-05-25 00:10:08 +00:00
61e5a42ef7 Update dependency @types/humanize-duration to ^3.27.4
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-05-24 00:10:02 +00:00
19f2f6d730 Update dependency @mdi/js to ^7.4.47
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-05-23 00:09:22 +00:00
dce17062a3 Can define OEMStrings from webui
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-22 22:03:57 +02:00
dcb0743cbe Fix VM creation / update by adding missing oem_string property
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-22 21:16:47 +02:00
644fd6f1bb Add backend SMBios support
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-22 21:13:00 +02:00
94ee8f8c78 Add support for QCow2 file format in web ui
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-22 18:41:04 +02:00
53a8963fc4 Fix coding issues
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-21 22:53:35 +02:00
56ab7065ac cargo fmt
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-21 22:37:02 +02:00
1e8394b3c4 Add QCow2 file format support on backend 2025-05-21 22:32:08 +02:00
01f26c1a79 Improve ISO list route UI
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-21 20:45:48 +02:00
8c27010396 Add ISO catalog 2025-05-21 20:28:46 +02:00
35e7f4b59c Fix ESLint issues
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-20 21:58:58 +02:00
d08516a72d Updated backend dependencies
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-20 21:57:26 +02:00
479cc1fa0f Remove mui-file-input dependency
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-20 21:42:05 +02:00
3c636406af Update virtweb_backend/src/constants.rs
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-04-09 09:25:28 +00:00
578f1432a0 Fix typo
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-07 11:14:12 +00:00
f403c85f0a Add release configuration
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-04-07 12:31:42 +02:00
db25c7e426 Update Rust crate sysinfo to 0.34.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-02 00:39:29 +00:00
98b67534cb Update Rust crate rust-embed to 8.6.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-03-31 20:54:10 +00:00
09d3cf08f3 Update Rust crate quick-xml to 0.37.3
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-03-31 00:38:34 +00:00
9b3d32811f Update Rust crate num to 0.4.3
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-03-30 02:24:42 +00:00
26d391ea96 Update Rust crate log to 0.4.27
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-03-29 23:01:14 +00:00
c044996014 Update Rust crate image to 0.25.6
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-03-29 18:17:12 +00:00
34b04968b2 Update renovate.json
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-29 17:30:56 +00:00
c44a3f2673 Merge pull request 'Update Rust crate sysinfo to v0.34.1' (#305) from renovate/sysinfo-0.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #305
2025-03-29 12:52:01 +00:00
e46adcd1da Merge pull request 'Update Rust crate basic-jwt to 0.3.0' (#306) from renovate/basic-jwt-0.x into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #306
2025-03-29 12:51:53 +00:00
d1506f26ab Update renovate.json
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-29 09:04:05 +00:00
23194d13d2 Update Rust crate basic-jwt to 0.3.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-29 00:34:32 +00:00
131dec892d Update Rust crate sysinfo to v0.34.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-29 00:34:22 +00:00
c2e6105aff Reload page when signedIn state change
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-28 13:10:47 +01:00
f5202f596d Fix all ESLint errors
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-28 12:25:04 +01:00
3bf8859ff9 WIP ESLint
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-28 12:12:11 +01:00
9a905e83f7 WIP ESLint fixes 2025-03-28 11:35:51 +01:00
4b9df95721 Remove deprecated code 2025-03-28 11:29:31 +01:00
108 changed files with 4346 additions and 2211 deletions

View File

@@ -11,7 +11,8 @@ steps:
path: /tmp/web_build path: /tmp/web_build
commands: commands:
- cd virtweb_frontend - cd virtweb_frontend
- npm install --legacy-peer-deps # TODO : remove when mui-file-input is updated - npm install
- npm run lint
- npm run build - npm run build
- mv dist /tmp/web_build - mv dist /tmp/web_build
@@ -35,6 +36,8 @@ steps:
path: /usr/local/cargo/registry path: /usr/local/cargo/registry
- name: web_app - name: web_app
path: /tmp/web_build path: /tmp/web_build
- name: release
path: /tmp/release
depends_on: depends_on:
- backend_check - backend_check
- web_build - web_build
@@ -44,10 +47,30 @@ steps:
- mv /tmp/web_build/dist static - mv /tmp/web_build/dist static
- cargo build --release - cargo build --release
- ls -lah target/release/virtweb_backend - ls -lah target/release/virtweb_backend
- cp target/release/virtweb_backend /tmp/release
- name: gitea_release
image: plugins/gitea-release
depends_on:
- backend_compile
when:
event:
- tag
volumes:
- name: release
path: /tmp/release
environment:
PLUGIN_API_KEY:
from_secret: API_KEY
settings:
base_url: https://gitea.communiquons.org
files: /tmp/release/*
checksum: sha512
volumes: volumes:
- name: rust_registry - name: rust_registry
temp: {} temp: {}
- name: web_app - name: web_app
temp: {} temp: {}
- name: release
temp: {}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -6,13 +6,13 @@ edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
log = "0.4.26" log = "0.4.27"
env_logger = "0.11.7" env_logger = "0.11.8"
clap = { version = "4.5.34", features = ["derive", "env"] } clap = { version = "4.5.38", features = ["derive", "env"] }
light-openid = { version = "1.0.4", 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.10.2" actix-web = "4.11.0"
actix-remote-ip = "0.1.0" actix-remote-ip = "0.1.0"
actix-session = { version = "0.10.1", features = ["cookie-session"] } actix-session = { version = "0.10.1", features = ["cookie-session"] }
actix-identity = "0.8.0" actix-identity = "0.8.0"
@@ -22,27 +22,25 @@ actix-ws = "0.3.0"
actix-http = "3.10.0" actix-http = "3.10.0"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140" serde_json = "1.0.140"
quick-xml = { version = "0.37.2", features = ["serialize", "overlapped-lists"] } quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] }
futures-util = "0.3.31" futures-util = "0.3.31"
anyhow = "1.0.97" anyhow = "1.0.98"
actix-multipart = "0.7.2" actix-multipart = "0.7.2"
tempfile = "3.19.1" tempfile = "3.20.0"
reqwest = { version = "0.12.15", features = ["stream"] } reqwest = { version = "0.12.15", features = ["stream"] }
url = "2.5.4" url = "2.5.4"
virt = "0.4.2" virt = "0.4.2"
sysinfo = { version = "0.34.0", features = ["serde"] } sysinfo = { version = "0.35.1", features = ["serde"] }
uuid = { version = "1.16.0", features = ["v4", "serde"] } uuid = { version = "1.16.0", features = ["v4", "serde"] }
lazy-regex = "3.4.1" lazy-regex = "3.4.1"
thiserror = "2.0.12" thiserror = "2.0.12"
image = "0.25.5" image = "0.25.6"
rand = "0.9.0" rand = "0.9.1"
bytes = "1.10.1" tokio = { version = "1.45.0", features = ["rt", "time", "macros"] }
tokio = { version = "1.44.1", features = ["rt", "time", "macros"] }
futures = "0.3.31" futures = "0.3.31"
ipnetwork = { version = "0.21.1", features = ["serde"] } ipnetwork = { version = "0.21.1", features = ["serde"] }
num = "0.4.2" num = "0.4.3"
rust-embed = { version = "8.5.0" } rust-embed = { version = "8.7.2", features = ["mime-guess"] }
mime_guess = "2.0.5"
dotenvy = "0.15.7" dotenvy = "0.15.7"
nix = { version = "0.29.0", features = ["net"] } nix = { version = "0.30.1", features = ["net"] }
basic-jwt = "0.2.0" basic-jwt = "0.3.0"

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 10.0, SVG Export Plug-In . SVG Version: 3.0.0 Build 77) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd" [
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
<!ENTITY ns_svg "http://www.w3.org/2000/svg">
<!ENTITY ns_xlink "http://www.w3.org/1999/xlink">
]>
<svg
xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;" i:viewOrigin="262 450" i:rulerOrigin="0 0" i:pageBounds="0 792 612 0"
xmlns="&ns_svg;" xmlns:xlink="&ns_xlink;" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
width="87.041" height="108.445" viewBox="0 0 87.041 108.445" overflow="visible" enable-background="new 0 0 87.041 108.445"
xml:space="preserve">
<metadata>
<variableSets xmlns="&ns_vars;">
<variableSet varSetName="binding1" locked="none">
<variables></variables>
<v:sampleDataSets xmlns="&ns_custom;" xmlns:v="&ns_vars;"></v:sampleDataSets>
</variableSet>
</variableSets>
<sfw xmlns="&ns_sfw;">
<slices></slices>
<sliceSourceBounds y="341.555" x="262" width="87.041" height="108.445" bottomLeftOrigin="true"></sliceSourceBounds>
</sfw>
</metadata>
<g id="Layer_1" i:layer="yes" i:dimmedPercent="50" i:rgbTrio="#4F008000FFFF">
<g>
<path i:knockout="Off" fill="#A80030" d="M51.986,57.297c-1.797,0.025,0.34,0.926,2.686,1.287
c0.648-0.506,1.236-1.018,1.76-1.516C54.971,57.426,53.484,57.434,51.986,57.297"/>
<path i:knockout="Off" fill="#A80030" d="M61.631,54.893c1.07-1.477,1.85-3.094,2.125-4.766c-0.24,1.192-0.887,2.221-1.496,3.307
c-3.359,2.115-0.316-1.256-0.002-2.537C58.646,55.443,61.762,53.623,61.631,54.893"/>
<path i:knockout="Off" fill="#A80030" d="M65.191,45.629c0.217-3.236-0.637-2.213-0.924-0.978
C64.602,44.825,64.867,46.932,65.191,45.629"/>
<path i:knockout="Off" fill="#A80030" d="M45.172,1.399c0.959,0.172,2.072,0.304,1.916,0.533
C48.137,1.702,48.375,1.49,45.172,1.399"/>
<path i:knockout="Off" fill="#A80030" d="M47.088,1.932l-0.678,0.14l0.631-0.056L47.088,1.932"/>
<path i:knockout="Off" fill="#A80030" d="M76.992,46.856c0.107,2.906-0.85,4.316-1.713,6.812l-1.553,0.776
c-1.271,2.468,0.123,1.567-0.787,3.53c-1.984,1.764-6.021,5.52-7.313,5.863c-0.943-0.021,0.639-1.113,0.846-1.541
c-2.656,1.824-2.131,2.738-6.193,3.846l-0.119-0.264c-10.018,4.713-23.934-4.627-23.751-17.371
c-0.107,0.809-0.304,0.607-0.526,0.934c-0.517-6.557,3.028-13.143,9.007-15.832c5.848-2.895,12.704-1.707,16.893,2.197
c-2.301-3.014-6.881-6.209-12.309-5.91c-5.317,0.084-10.291,3.463-11.951,7.131c-2.724,1.715-3.04,6.611-4.227,7.507
C31.699,56.271,36.3,61.342,44.083,67.307c1.225,0.826,0.345,0.951,0.511,1.58c-2.586-1.211-4.954-3.039-6.901-5.277
c1.033,1.512,2.148,2.982,3.589,4.137c-2.438-0.826-5.695-5.908-6.646-6.115c4.203,7.525,17.052,13.197,23.78,10.383
c-3.113,0.115-7.068,0.064-10.566-1.229c-1.469-0.756-3.467-2.322-3.11-2.615c9.182,3.43,18.667,2.598,26.612-3.771
c2.021-1.574,4.229-4.252,4.867-4.289c-0.961,1.445,0.164,0.695-0.574,1.971c2.014-3.248-0.875-1.322,2.082-5.609l1.092,1.504
c-0.406-2.696,3.348-5.97,2.967-10.234c0.861-1.304,0.961,1.403,0.047,4.403c1.268-3.328,0.334-3.863,0.66-6.609
c0.352,0.923,0.814,1.904,1.051,2.878c-0.826-3.216,0.848-5.416,1.262-7.285c-0.408-0.181-1.275,1.422-1.473-2.377
c0.029-1.65,0.459-0.865,0.625-1.271c-0.324-0.186-1.174-1.451-1.691-3.877c0.375-0.57,1.002,1.478,1.512,1.562
c-0.328-1.929-0.893-3.4-0.916-4.88c-1.49-3.114-0.527,0.415-1.736-1.337c-1.586-4.947,1.316-1.148,1.512-3.396
c2.404,3.483,3.775,8.881,4.404,11.117c-0.48-2.726-1.256-5.367-2.203-7.922c0.73,0.307-1.176-5.609,0.949-1.691
c-2.27-8.352-9.715-16.156-16.564-19.818c0.838,0.767,1.896,1.73,1.516,1.881c-3.406-2.028-2.807-2.186-3.295-3.043
c-2.775-1.129-2.957,0.091-4.795,0.002c-5.23-2.774-6.238-2.479-11.051-4.217l0.219,1.023c-3.465-1.154-4.037,0.438-7.782,0.004
c-0.228-0.178,1.2-0.644,2.375-0.815c-3.35,0.442-3.193-0.66-6.471,0.122c0.808-0.567,1.662-0.942,2.524-1.424
c-2.732,0.166-6.522,1.59-5.352,0.295c-4.456,1.988-12.37,4.779-16.811,8.943l-0.14-0.933c-2.035,2.443-8.874,7.296-9.419,10.46
l-0.544,0.127c-1.059,1.793-1.744,3.825-2.584,5.67c-1.385,2.36-2.03,0.908-1.833,1.278c-2.724,5.523-4.077,10.164-5.246,13.97
c0.833,1.245,0.02,7.495,0.335,12.497c-1.368,24.704,17.338,48.69,37.785,54.228c2.997,1.072,7.454,1.031,11.245,1.141
c-4.473-1.279-5.051-0.678-9.408-2.197c-3.143-1.48-3.832-3.17-6.058-5.102l0.881,1.557c-4.366-1.545-2.539-1.912-6.091-3.037
l0.941-1.229c-1.415-0.107-3.748-2.385-4.386-3.646l-1.548,0.061c-1.86-2.295-2.851-3.949-2.779-5.23l-0.5,0.891
c-0.567-0.973-6.843-8.607-3.587-6.83c-0.605-0.553-1.409-0.9-2.281-2.484l0.663-0.758c-1.567-2.016-2.884-4.6-2.784-5.461
c0.836,1.129,1.416,1.34,1.99,1.533c-3.957-9.818-4.179-0.541-7.176-9.994l0.634-0.051c-0.486-0.732-0.781-1.527-1.172-2.307
l0.276-2.75C4.667,58.121,6.719,47.409,7.13,41.534c0.285-2.389,2.378-4.932,3.97-8.92l-0.97-0.167
c1.854-3.234,10.586-12.988,14.63-12.486c1.959-2.461-0.389-0.009-0.772-0.629c4.303-4.453,5.656-3.146,8.56-3.947
c3.132-1.859-2.688,0.725-1.203-0.709c5.414-1.383,3.837-3.144,10.9-3.846c0.745,0.424-1.729,0.655-2.35,1.205
c4.511-2.207,14.275-1.705,20.617,1.225c7.359,3.439,15.627,13.605,15.953,23.17l0.371,0.1
c-0.188,3.802,0.582,8.199-0.752,12.238L76.992,46.856"/>
<path i:knockout="Off" fill="#A80030" d="M32.372,59.764l-0.252,1.26c1.181,1.604,2.118,3.342,3.626,4.596
C34.661,63.502,33.855,62.627,32.372,59.764"/>
<path i:knockout="Off" fill="#A80030" d="M35.164,59.654c-0.625-0.691-0.995-1.523-1.409-2.352
c0.396,1.457,1.207,2.709,1.962,3.982L35.164,59.654"/>
<path i:knockout="Off" fill="#A80030" d="M84.568,48.916l-0.264,0.662c-0.484,3.438-1.529,6.84-3.131,9.994
C82.943,56.244,84.088,52.604,84.568,48.916"/>
<path i:knockout="Off" fill="#A80030" d="M45.527,0.537C46.742,0.092,48.514,0.293,49.803,0c-1.68,0.141-3.352,0.225-5.003,0.438
L45.527,0.537"/>
<path i:knockout="Off" fill="#A80030" d="M2.872,23.219c0.28,2.592-1.95,3.598,0.494,1.889
C4.676,22.157,2.854,24.293,2.872,23.219"/>
<path i:knockout="Off" fill="#A80030" d="M0,35.215c0.563-1.728,0.665-2.766,0.88-3.766C-0.676,33.438,0.164,33.862,0,35.215"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 100">
<circle fill="#f47421" cy="50" cx="50" r="45"/>
<circle fill="none" stroke="#ffffff" stroke-width="8.55" cx="50" cy="50" r="21.825"/>
<g id="friend"><circle fill="#f47421" cx="19.4" cy="50" r="8.4376"/>
<path stroke="#f47421" stroke-width="3.2378" d="M67,50H77"/>
<circle fill="#ffffff" cx="19.4" cy="50" r="6.00745"/></g>
<use xlink:href="#friend" transform="rotate(120,50,50)"/>
<use xlink:href="#friend" transform="rotate(240,50,50)"/></svg>

After

Width:  |  Height:  |  Size: 550 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="88" width="88" xmlns:v="https://vecta.io/nano"><path d="M0 12.402l35.687-4.86.016 34.423-35.67.203zm35.67 33.529l.028 34.453L.028 75.48.026 45.7zm4.326-39.025L87.314 0v41.527l-47.318.376zm47.329 39.349l-.011 41.34-47.318-6.678-.066-34.739z" fill="#00adef"/></svg>

After

Width:  |  Height:  |  Size: 311 B

View File

@@ -0,0 +1,47 @@
[
{
"name": "Ubuntu releases",
"url": "https://releases.ubuntu.com",
"image": "/assets/img/ubuntu.svg"
},
{
"name": "Old ubuntu releases",
"url": "https://old-releases.ubuntu.com/releases/",
"image": "/assets/img/ubuntu.svg"
},
{
"name": "Current Debian releases (amd64)",
"url": "https://cdimage.debian.org/mirror/cdimage/release/current/amd64/iso-dvd/",
"image": "/assets/img/debian.svg"
},
{
"name": "Old Debian releases",
"url": "https://cdimage.debian.org/mirror/cdimage/archive/",
"image": "/assets/img/debian.svg"
},
{
"name": "Latest stable Virtio driver",
"url": "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso",
"image": "/assets/img/kvm.png"
},
{
"name": "Windows server 2025",
"url": "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2025",
"image": "/assets/img/windows.svg"
},
{
"name": "Windows server 2022",
"url": "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2022",
"image": "/assets/img/windows.svg"
},
{
"name": "Windows 11",
"url": "https://www.microsoft.com/en-us/software-download/windows11",
"image": "/assets/img/windows.svg"
},
{
"name": "Windows 11 Iot Enterprise LTSC 2024",
"url": "https://www.microsoft.com/en-us/evalcenter/download-windows-11-iot-enterprise-ltsc-eval",
"image": "/assets/img/windows.svg"
}
]

View File

@@ -245,11 +245,21 @@ impl AppConfig {
storage_path.canonicalize().unwrap() storage_path.canonicalize().unwrap()
} }
/// Get iso storage directory /// Get iso files storage directory
pub fn iso_storage_path(&self) -> PathBuf { pub fn iso_storage_path(&self) -> PathBuf {
self.storage_path().join("iso") self.storage_path().join("iso")
} }
/// Get disk images storage directory
pub fn disk_images_storage_path(&self) -> PathBuf {
self.storage_path().join("disk_images")
}
/// Get the path of a disk image file
pub fn disk_images_file_path(&self, name: &str) -> PathBuf {
self.disk_images_storage_path().join(name)
}
/// Get VM vnc sockets directory /// Get VM vnc sockets directory
pub fn vnc_sockets_path(&self) -> PathBuf { pub fn vnc_sockets_path(&self) -> PathBuf {
self.storage_path().join("vnc") self.storage_path().join("vnc")
@@ -260,15 +270,17 @@ impl AppConfig {
self.vnc_sockets_path().join(format!("vnc-{}", name)) self.vnc_sockets_path().join(format!("vnc-{}", name))
} }
/// Get VM vnc sockets directory /// Get VM root disks storage directory
pub fn disks_storage_path(&self) -> PathBuf { pub fn root_vm_disks_storage_path(&self) -> PathBuf {
self.storage_path().join("disks") self.storage_path().join("disks")
} }
/// Get specific VM disk storage directory
pub fn vm_storage_path(&self, id: XMLUuid) -> PathBuf { pub fn vm_storage_path(&self, id: XMLUuid) -> PathBuf {
self.disks_storage_path().join(id.as_string()) self.root_vm_disks_storage_path().join(id.as_string())
} }
/// Get the path were VM definitions are backed up
pub fn definitions_path(&self) -> PathBuf { pub fn definitions_path(&self) -> PathBuf {
self.storage_path().join("definitions") self.storage_path().join("definitions")
} }

View File

@@ -1,3 +1,5 @@
use crate::utils::file_size_utils::FileSize;
/// Name of the cookie that contains session information /// Name of the cookie that contains session information
pub const SESSION_COOKIE_NAME: &str = "X-auth-token"; pub const SESSION_COOKIE_NAME: &str = "X-auth-token";
@@ -17,20 +19,31 @@ pub const ROUTES_WITHOUT_AUTH: [&str; 5] = [
]; ];
/// Allowed ISO mimetypes /// Allowed ISO mimetypes
pub const ALLOWED_ISO_MIME_TYPES: [&str; 3] = [ pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [
"application/x-cd-image", "application/x-cd-image",
"application/x-iso9660-image", "application/x-iso9660-image",
"application/octet-stream", "application/octet-stream",
"application/vnd.efi.iso",
]; ];
/// ISO max size /// ISO max size
pub const ISO_MAX_SIZE: usize = 10 * 1000 * 1000 * 1000; pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10);
/// Min VM memory size (MB) /// Allowed uploaded disk images formats
pub const MIN_VM_MEMORY: usize = 100; pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 3] = [
"application/x-qemu-disk",
"application/gzip",
"application/octet-stream",
];
/// Max VM memory size (MB) /// Disk image max size
pub const MAX_VM_MEMORY: usize = 64000; pub const DISK_IMAGE_MAX_SIZE: FileSize = FileSize::from_gb(10 * 1000);
/// Min VM memory size
pub const MIN_VM_MEMORY: FileSize = FileSize::from_mb(100);
/// Max VM memory size
pub const MAX_VM_MEMORY: FileSize = FileSize::from_gb(64);
/// Disk name min length /// Disk name min length
pub const DISK_NAME_MIN_LEN: usize = 2; pub const DISK_NAME_MIN_LEN: usize = 2;
@@ -38,11 +51,11 @@ pub const DISK_NAME_MIN_LEN: usize = 2;
/// Disk name max length /// Disk name max length
pub const DISK_NAME_MAX_LEN: usize = 10; pub const DISK_NAME_MAX_LEN: usize = 10;
/// Disk size min (MB) /// Disk size min (B)
pub const DISK_SIZE_MIN: usize = 100; pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50);
/// Disk size max (MB) /// Disk size max (B)
pub const DISK_SIZE_MAX: usize = 1000 * 1000 * 2; pub const DISK_SIZE_MAX: FileSize = FileSize::from_gb(20000);
/// Net nat entry comment max size /// Net nat entry comment max size
pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250; pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250;
@@ -107,3 +120,21 @@ pub const API_TOKEN_DESCRIPTION_MAX_LENGTH: usize = 30;
/// API token right path max length /// API token right path max length
pub const API_TOKEN_RIGHT_PATH_MAX_LENGTH: usize = 255; pub const API_TOKEN_RIGHT_PATH_MAX_LENGTH: usize = 255;
/// Qemu image program path
pub const QEMU_IMAGE_PROGRAM: &str = "/usr/bin/qemu-img";
/// IP program path
pub const IP_PROGRAM: &str = "/usr/sbin/ip";
/// Copy program path
pub const COPY_PROGRAM: &str = "/bin/cp";
/// Gzip program path
pub const GZIP_PROGRAM: &str = "/usr/bin/gzip";
/// Bash program
pub const BASH_PROGRAM: &str = "/usr/bin/bash";
/// DD program
pub const DD_PROGRAM: &str = "/usr/bin/dd";

View File

@@ -0,0 +1,247 @@
use crate::app_config::AppConfig;
use crate::constants;
use crate::controllers::{HttpResult, LibVirtReq};
use crate::libvirt_lib_structures::XMLUuid;
use crate::libvirt_rest_structures::vm::VMInfo;
use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo};
use crate::utils::files_utils;
use actix_files::NamedFile;
use actix_multipart::form::MultipartForm;
use actix_multipart::form::tempfile::TempFile;
use actix_web::{HttpRequest, HttpResponse, web};
#[derive(Debug, MultipartForm)]
pub struct UploadDiskImageForm {
#[multipart(rename = "file")]
files: Vec<TempFile>,
}
/// Upload disk image file
pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>) -> HttpResult {
if form.files.is_empty() {
log::error!("Missing uploaded disk file!");
return Ok(HttpResponse::BadRequest().json("Missing file!"));
}
let file = form.files.remove(0);
// Check uploaded file size
if file.size > constants::DISK_IMAGE_MAX_SIZE.as_bytes() {
return Ok(HttpResponse::BadRequest().json("Disk image max size exceeded!"));
}
// Check file mime type
if let Some(mime_type) = file.content_type {
if !constants::ALLOWED_DISK_IMAGES_MIME_TYPES.contains(&mime_type.as_ref()) {
return Ok(HttpResponse::BadRequest().json(format!(
"Unsupported file type for disk upload: {}",
mime_type
)));
}
}
// Extract and check file name
let Some(file_name) = file.file_name else {
return Ok(HttpResponse::BadRequest().json("Missing file name of uploaded file!"));
};
if !files_utils::check_file_name(&file_name) {
return Ok(HttpResponse::BadRequest().json("Invalid uploaded file name!"));
}
// Check if a file with the same name already exists
let dest_path = AppConfig::get().disk_images_file_path(&file_name);
if dest_path.is_file() {
return Ok(HttpResponse::Conflict().json("A file with the same name already exists!"));
}
// Copy the file to the destination
file.file.persist(dest_path)?;
Ok(HttpResponse::Ok().json("Successfully uploaded disk image!"))
}
/// Get disk images list
pub async fn get_list() -> HttpResult {
let mut list = vec![];
for entry in AppConfig::get().disk_images_storage_path().read_dir()? {
let entry = entry?;
list.push(DiskFileInfo::load_file(&entry.path())?);
}
Ok(HttpResponse::Ok().json(list))
}
#[derive(serde::Deserialize)]
pub struct DiskFilePath {
filename: String,
}
/// Download disk image
pub async fn download(p: web::Path<DiskFilePath>, req: HttpRequest) -> HttpResult {
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
}
let file_path = AppConfig::get().disk_images_file_path(&p.filename);
if !file_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
}
Ok(NamedFile::open(file_path)?.into_response(&req))
}
#[derive(serde::Deserialize)]
pub struct ConvertDiskImageRequest {
dest_file_name: String,
#[serde(flatten)]
format: DiskFileFormat,
}
/// Convert disk image into a new format
pub async fn convert(
p: web::Path<DiskFilePath>,
req: web::Json<ConvertDiskImageRequest>,
) -> HttpResult {
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid src file name!"));
}
let src_file_path = AppConfig::get().disk_images_file_path(&p.filename);
let src = DiskFileInfo::load_file(&src_file_path)?;
handle_convert_request(src, &req).await
}
#[derive(serde::Deserialize)]
pub struct BackupVMDiskPath {
uid: XMLUuid,
diskid: String,
}
/// Perform disk backup
pub async fn backup_disk(
client: LibVirtReq,
path: web::Path<BackupVMDiskPath>,
req: web::Json<ConvertDiskImageRequest>,
) -> HttpResult {
// Get the VM information
let info = match client.get_single_domain(path.uid).await {
Ok(i) => i,
Err(e) => {
log::error!("Failed to get domain info! {e}");
return Ok(HttpResponse::InternalServerError().json(e.to_string()));
}
};
let vm = VMInfo::from_domain(info)?;
// Load disk information
let Some(disk) = vm
.file_disks
.into_iter()
.find(|disk| disk.name == path.diskid)
else {
return Ok(HttpResponse::NotFound()
.json(format!("Disk {} not found for vm {}", path.diskid, vm.name)));
};
let src_path = disk.disk_path(vm.uuid.expect("Missing VM uuid!"));
let src_disk = DiskFileInfo::load_file(&src_path)?;
// Perform conversion
handle_convert_request(src_disk, &req).await
}
/// Generic controller code that performs image conversion to create a disk image file
pub async fn handle_convert_request(
src: DiskFileInfo,
req: &ConvertDiskImageRequest,
) -> HttpResult {
// Check destination file
if !files_utils::check_file_name(&req.dest_file_name) {
return Ok(HttpResponse::BadRequest().json("Invalid destination file name!"));
}
if !req
.format
.ext()
.iter()
.any(|e| req.dest_file_name.ends_with(e))
{
return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!"));
}
let dst_file_path = AppConfig::get().disk_images_file_path(&req.dest_file_name);
if dst_file_path.exists() {
return Ok(HttpResponse::Conflict().json("Specified destination file already exists!"));
}
// Perform conversion
if let Err(e) = src.convert(&dst_file_path, req.format) {
log::error!("Disk file conversion error: {e}");
return Ok(
HttpResponse::InternalServerError().json(format!("Disk file conversion error: {e}"))
);
}
Ok(HttpResponse::Accepted().json("Successfully converted disk file"))
}
#[derive(serde::Deserialize)]
pub struct RenameDiskImageRequest {
name: String,
}
/// Rename disk image
pub async fn rename(
p: web::Path<DiskFilePath>,
req: web::Json<RenameDiskImageRequest>,
) -> HttpResult {
// Check source
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid src file name!"));
}
let src_path = AppConfig::get().disk_images_file_path(&p.filename);
if !src_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
}
// Check destination
if !files_utils::check_file_name(&req.name) {
return Ok(HttpResponse::BadRequest().json("Invalid dst file name!"));
}
let dst_path = AppConfig::get().disk_images_file_path(&req.name);
if dst_path.exists() {
return Ok(HttpResponse::Conflict().json("Destination name already exists!"));
}
// Check extension
let disk = DiskFileInfo::load_file(&src_path)?;
if !disk.format.ext().iter().any(|e| req.name.ends_with(e)) {
return Ok(HttpResponse::BadRequest().json("Invalid destination file extension!"));
}
// Perform rename
std::fs::rename(&src_path, &dst_path)?;
Ok(HttpResponse::Accepted().finish())
}
/// Delete a disk image
pub async fn delete(p: web::Path<DiskFilePath>) -> HttpResult {
if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
}
let file_path = AppConfig::get().disk_images_file_path(&p.filename);
if !file_path.exists() {
return Ok(HttpResponse::NotFound().json("Disk image does not exists!"));
}
std::fs::remove_file(file_path)?;
Ok(HttpResponse::Accepted().finish())
}

View File

@@ -26,7 +26,7 @@ pub async fn upload_file(MultipartForm(mut form): MultipartForm<UploadIsoForm>)
let file = form.files.remove(0); let file = form.files.remove(0);
if file.size > constants::ISO_MAX_SIZE { if file.size > constants::ISO_MAX_SIZE.as_bytes() {
log::error!("Uploaded ISO file is too large!"); log::error!("Uploaded ISO file is too large!");
return Ok(HttpResponse::BadRequest().json("File is too large!")); return Ok(HttpResponse::BadRequest().json("File is too large!"));
} }
@@ -88,7 +88,7 @@ pub async fn upload_from_url(req: web::Json<DownloadFromURLReq>) -> HttpResult {
let response = reqwest::get(&req.url).await?; let response = reqwest::get(&req.url).await?;
if let Some(len) = response.content_length() { if let Some(len) = response.content_length() {
if len > constants::ISO_MAX_SIZE as u64 { if len > constants::ISO_MAX_SIZE.as_bytes() as u64 {
return Ok(HttpResponse::BadRequest().json("File is too large!")); return Ok(HttpResponse::BadRequest().json("File is too large!"));
} }
} }
@@ -132,12 +132,12 @@ pub async fn get_list() -> HttpResult {
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct DownloadFilePath { pub struct IsoFilePath {
filename: String, filename: String,
} }
/// Download ISO file /// Download ISO file
pub async fn download_file(p: web::Path<DownloadFilePath>, req: HttpRequest) -> HttpResult { pub async fn download_file(p: web::Path<IsoFilePath>, req: HttpRequest) -> HttpResult {
if !files_utils::check_file_name(&p.filename) { if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid file name!")); return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
} }
@@ -152,7 +152,7 @@ pub async fn download_file(p: web::Path<DownloadFilePath>, req: HttpRequest) ->
} }
/// Delete ISO file /// Delete ISO file
pub async fn delete_file(p: web::Path<DownloadFilePath>) -> HttpResult { pub async fn delete_file(p: web::Path<IsoFilePath>) -> HttpResult {
if !files_utils::check_file_name(&p.filename) { if !files_utils::check_file_name(&p.filename) {
return Ok(HttpResponse::BadRequest().json("Invalid file name!")); return Ok(HttpResponse::BadRequest().json("Invalid file name!"));
} }

View File

@@ -4,10 +4,10 @@ use actix_web::body::BoxBody;
use actix_web::{HttpResponse, web}; use actix_web::{HttpResponse, web};
use std::error::Error; use std::error::Error;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::io::ErrorKind;
pub mod api_tokens_controller; pub mod api_tokens_controller;
pub mod auth_controller; pub mod auth_controller;
pub mod disk_images_controller;
pub mod groups_controller; pub mod groups_controller;
pub mod iso_controller; pub mod iso_controller;
pub mod network_controller; pub mod network_controller;
@@ -62,7 +62,7 @@ impl From<serde_json::Error> for HttpErr {
impl From<Box<dyn Error>> for HttpErr { impl From<Box<dyn Error>> for HttpErr {
fn from(value: Box<dyn Error>) -> Self { fn from(value: Box<dyn Error>) -> Self {
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into()) HttpErr::Err(std::io::Error::other(value.to_string()).into())
} }
} }
@@ -98,7 +98,7 @@ impl From<reqwest::header::ToStrError> for HttpErr {
impl From<actix_web::Error> for HttpErr { impl From<actix_web::Error> for HttpErr {
fn from(value: actix_web::Error) -> Self { fn from(value: actix_web::Error) -> Self {
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into()) HttpErr::Err(std::io::Error::other(value.to_string()).into())
} }
} }

View File

@@ -16,6 +16,7 @@ struct StaticConfig {
local_auth_enabled: bool, local_auth_enabled: bool,
oidc_auth_enabled: bool, oidc_auth_enabled: bool,
iso_mimetypes: &'static [&'static str], iso_mimetypes: &'static [&'static str],
disk_images_mimetypes: &'static [&'static str],
net_mac_prefix: &'static str, net_mac_prefix: &'static str,
builtin_nwfilter_rules: &'static [&'static str], builtin_nwfilter_rules: &'static [&'static str],
nwfilter_chains: &'static [&'static str], nwfilter_chains: &'static [&'static str],
@@ -37,6 +38,7 @@ struct SLenConstraints {
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
struct ServerConstraints { struct ServerConstraints {
iso_max_size: usize, iso_max_size: usize,
disk_image_max_size: usize,
vnc_token_duration: u64, vnc_token_duration: u64,
vm_name_size: LenConstraints, vm_name_size: LenConstraints,
vm_title_size: LenConstraints, vm_title_size: LenConstraints,
@@ -44,6 +46,7 @@ struct ServerConstraints {
memory_size: LenConstraints, memory_size: LenConstraints,
disk_name_size: LenConstraints, disk_name_size: LenConstraints,
disk_size: LenConstraints, disk_size: LenConstraints,
disk_image_name_size: LenConstraints,
net_name_size: LenConstraints, net_name_size: LenConstraints,
net_title_size: LenConstraints, net_title_size: LenConstraints,
net_nat_comment_size: LenConstraints, net_nat_comment_size: LenConstraints,
@@ -63,11 +66,13 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
local_auth_enabled: *local_auth, local_auth_enabled: *local_auth,
oidc_auth_enabled: !AppConfig::get().disable_oidc, oidc_auth_enabled: !AppConfig::get().disable_oidc,
iso_mimetypes: &constants::ALLOWED_ISO_MIME_TYPES, iso_mimetypes: &constants::ALLOWED_ISO_MIME_TYPES,
disk_images_mimetypes: &constants::ALLOWED_DISK_IMAGES_MIME_TYPES,
net_mac_prefix: constants::NET_MAC_ADDR_PREFIX, net_mac_prefix: constants::NET_MAC_ADDR_PREFIX,
builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES, builtin_nwfilter_rules: &constants::BUILTIN_NETWORK_FILTER_RULES,
nwfilter_chains: &constants::NETWORK_CHAINS, nwfilter_chains: &constants::NETWORK_CHAINS,
constraints: ServerConstraints { constraints: ServerConstraints {
iso_max_size: constants::ISO_MAX_SIZE, iso_max_size: constants::ISO_MAX_SIZE.as_bytes(),
disk_image_max_size: constants::DISK_IMAGE_MAX_SIZE.as_bytes(),
vnc_token_duration: VNC_TOKEN_LIFETIME, vnc_token_duration: VNC_TOKEN_LIFETIME,
@@ -75,18 +80,20 @@ pub async fn static_config(local_auth: LocalAuthEnabled) -> impl Responder {
vm_title_size: LenConstraints { min: 0, max: 50 }, vm_title_size: LenConstraints { min: 0, max: 50 },
group_id_size: LenConstraints { min: 3, max: 50 }, group_id_size: LenConstraints { min: 3, max: 50 },
memory_size: LenConstraints { memory_size: LenConstraints {
min: constants::MIN_VM_MEMORY, min: constants::MIN_VM_MEMORY.as_bytes(),
max: constants::MAX_VM_MEMORY, max: constants::MAX_VM_MEMORY.as_bytes(),
}, },
disk_name_size: LenConstraints { disk_name_size: LenConstraints {
min: DISK_NAME_MIN_LEN, min: DISK_NAME_MIN_LEN,
max: DISK_NAME_MAX_LEN, max: DISK_NAME_MAX_LEN,
}, },
disk_size: LenConstraints { disk_size: LenConstraints {
min: DISK_SIZE_MIN, min: DISK_SIZE_MIN.as_bytes(),
max: DISK_SIZE_MAX, max: DISK_SIZE_MAX.as_bytes(),
}, },
disk_image_name_size: LenConstraints { min: 5, max: 220 },
net_name_size: LenConstraints { min: 2, max: 50 }, net_name_size: LenConstraints { min: 2, max: 50 },
net_title_size: LenConstraints { min: 0, max: 50 }, net_title_size: LenConstraints { min: 0, max: 50 },
net_nat_comment_size: LenConstraints { net_nat_comment_size: LenConstraints {
@@ -188,3 +195,7 @@ pub async fn number_vcpus() -> HttpResult {
pub async fn networks_list() -> HttpResult { pub async fn networks_list() -> HttpResult {
Ok(HttpResponse::Ok().json(net_utils::net_list())) Ok(HttpResponse::Ok().json(net_utils::net_list()))
} }
pub async fn bridges_list() -> HttpResult {
Ok(HttpResponse::Ok().json(net_utils::bridges_list()?))
}

View File

@@ -3,6 +3,27 @@ pub use serve_static_debug::{root_index, serve_static_content};
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
pub use serve_static_release::{root_index, serve_static_content}; pub use serve_static_release::{root_index, serve_static_content};
/// Static API assets hosting
pub mod serve_assets {
use actix_web::{HttpResponse, web};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "assets/"]
struct Asset;
/// Serve API assets
pub async fn serve_api_assets(file: web::Path<String>) -> HttpResponse {
match Asset::get(&file) {
None => HttpResponse::NotFound().body("File not found"),
Some(asset) => HttpResponse::Ok()
.content_type(asset.metadata.mimetype())
.body(asset.data),
}
}
}
/// Web asset hosting placeholder in debug mode
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
mod serve_static_debug { mod serve_static_debug {
use actix_web::{HttpResponse, Responder}; use actix_web::{HttpResponse, Responder};
@@ -16,6 +37,7 @@ mod serve_static_debug {
} }
} }
/// Web asset hosting in release mode
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
mod serve_static_release { mod serve_static_release {
use actix_web::{HttpResponse, Responder, web}; use actix_web::{HttpResponse, Responder, web};
@@ -23,12 +45,12 @@ mod serve_static_release {
#[derive(RustEmbed)] #[derive(RustEmbed)]
#[folder = "static/"] #[folder = "static/"]
struct Asset; struct WebAsset;
fn handle_embedded_file(path: &str, can_fallback: bool) -> HttpResponse { fn handle_embedded_file(path: &str, can_fallback: bool) -> HttpResponse {
match (Asset::get(path), can_fallback) { match (WebAsset::get(path), can_fallback) {
(Some(content), _) => HttpResponse::Ok() (Some(content), _) => HttpResponse::Ok()
.content_type(mime_guess::from_path(path).first_or_octet_stream().as_ref()) .content_type(content.metadata.mimetype())
.body(content.data.into_owned()), .body(content.data.into_owned()),
(None, false) => HttpResponse::NotFound().body("404 Not Found"), (None, false) => HttpResponse::NotFound().body("404 Not Found"),
(None, true) => handle_embedded_file("index.html", false), (None, true) => handle_embedded_file("index.html", false),

View File

@@ -22,10 +22,14 @@ pub struct DomainMetadataXML {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename = "os")] #[serde(rename = "os")]
pub struct OSXML { pub struct OSXML {
#[serde(rename = "@firmware", default)] #[serde(rename = "@firmware", default, skip_serializing_if = "Option::is_none")]
pub firmware: String, pub firmware: Option<String>,
pub r#type: OSTypeXML, pub r#type: OSTypeXML,
#[serde(skip_serializing_if = "Option::is_none")]
pub loader: Option<OSLoaderXML>, pub loader: Option<OSLoaderXML>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bootmenu: Option<OSBootMenuXML>,
pub smbios: Option<OSSMBiosXML>,
} }
/// OS Type information /// OS Type information
@@ -48,6 +52,24 @@ pub struct OSLoaderXML {
pub secure: String, pub secure: String,
} }
/// Legacy boot menu information
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename = "bootmenu")]
pub struct OSBootMenuXML {
#[serde(rename = "@enable")]
pub enable: String,
#[serde(rename = "@timeout")]
pub timeout: usize,
}
/// SMBIOS System information
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename = "smbios")]
pub struct OSSMBiosXML {
#[serde(rename = "@mode")]
pub mode: String,
}
/// Hypervisor features /// Hypervisor features
#[derive(serde::Serialize, serde::Deserialize, Clone, Default, Debug)] #[derive(serde::Serialize, serde::Deserialize, Clone, Default, Debug)]
#[serde(rename = "features")] #[serde(rename = "features")]
@@ -71,7 +93,9 @@ pub struct NetMacAddress {
#[serde(rename = "source")] #[serde(rename = "source")]
pub struct NetIntSourceXML { pub struct NetIntSourceXML {
#[serde(rename = "@network")] #[serde(rename = "@network")]
pub network: String, pub network: Option<String>,
#[serde(rename = "@bridge")]
pub bridge: Option<String>,
} }
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -305,6 +329,29 @@ pub struct DomainCPUXML {
pub topology: Option<DomainCPUTopology>, pub topology: Option<DomainCPUTopology>,
} }
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename = "entry")]
pub struct OEMStringEntryXML {
#[serde(rename = "$text", default)]
pub content: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename = "oemStrings")]
pub struct OEMStringsXML {
#[serde(rename = "entry")]
pub entries: Vec<OEMStringEntryXML>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename = "sysinfo")]
pub struct SysInfoXML {
#[serde(rename = "@type")]
pub r#type: String,
#[serde(rename = "oemStrings")]
pub oem_strings: Option<OEMStringsXML>,
}
/// Domain information, see https://libvirt.org/formatdomain.html /// Domain information, see https://libvirt.org/formatdomain.html
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename = "domain")] #[serde(rename = "domain")]
@@ -335,6 +382,10 @@ pub struct DomainXML {
/// CPU information /// CPU information
pub cpu: DomainCPUXML, pub cpu: DomainCPUXML,
/// SMBios strings
pub sysinfo: Option<SysInfoXML>,
/// Behavior when guest state change
pub on_poweroff: String, pub on_poweroff: String,
pub on_reboot: String, pub on_reboot: String,
pub on_crash: String, pub on_crash: String,

View File

@@ -4,9 +4,9 @@ use crate::libvirt_lib_structures::XMLUuid;
use crate::libvirt_lib_structures::domain::*; use crate::libvirt_lib_structures::domain::*;
use crate::libvirt_rest_structures::LibVirtStructError; use crate::libvirt_rest_structures::LibVirtStructError;
use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction; use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction;
use crate::utils::disks_utils::Disk; use crate::utils::file_size_utils::FileSize;
use crate::utils::files_utils; use crate::utils::files_utils;
use crate::utils::files_utils::convert_size_unit_to_mb; use crate::utils::vm_file_disks_utils::{VMDiskBus, VMDiskFormat, VMFileDisk};
use lazy_regex::regex; use lazy_regex::regex;
use num::Integer; use num::Integer;
@@ -17,6 +17,7 @@ pub struct VMGroupId(pub String);
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
pub enum BootType { pub enum BootType {
Legacy,
UEFI, UEFI,
UEFISecureBoot, UEFISecureBoot,
} }
@@ -29,6 +30,12 @@ pub enum VMArchitecture {
X86_64, X86_64,
} }
#[derive(serde::Serialize, serde::Deserialize)]
pub enum NetworkInterfaceModelType {
Virtio,
E1000,
}
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
pub struct NWFilterParam { pub struct NWFilterParam {
name: String, name: String,
@@ -46,6 +53,7 @@ pub struct Network {
#[serde(flatten)] #[serde(flatten)]
r#type: NetworkType, r#type: NetworkType,
mac: String, mac: String,
model: NetworkInterfaceModelType,
nwfilterref: Option<NWFilterRef>, nwfilterref: Option<NWFilterRef>,
} }
@@ -53,7 +61,8 @@ pub struct Network {
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum NetworkType { pub enum NetworkType {
UserspaceSLIRPStack, UserspaceSLIRPStack,
DefinedNetwork { network: String }, // TODO : complete network types DefinedNetwork { network: String },
Bridge { bridge: String },
} }
#[derive(serde::Serialize, serde::Deserialize)] #[derive(serde::Serialize, serde::Deserialize)]
@@ -69,20 +78,22 @@ pub struct VMInfo {
pub group: Option<VMGroupId>, pub group: Option<VMGroupId>,
pub boot_type: BootType, pub boot_type: BootType,
pub architecture: VMArchitecture, pub architecture: VMArchitecture,
/// VM allocated memory, in megabytes /// VM allocated RAM memory
pub memory: usize, pub memory: FileSize,
/// Number of vCPU for the VM /// Number of vCPU for the VM
pub number_vcpu: usize, pub number_vcpu: usize,
/// Enable VNC access through admin console /// Enable VNC access through admin console
pub vnc_access: bool, pub vnc_access: bool,
/// Attach ISO file(s) /// Attach ISO file(s)
pub iso_files: Vec<String>, pub iso_files: Vec<String>,
/// Storage - https://access.redhat.com/documentation/fr-fr/red_hat_enterprise_linux/6/html/virtualization_administration_guide/sect-virtualization-virtualized_block_devices-adding_storage_devices_to_guests#sect-Virtualization-Adding_storage_devices_to_guests-Adding_file_based_storage_to_a_guest /// File Storage - https://access.redhat.com/documentation/fr-fr/red_hat_enterprise_linux/6/html/virtualization_administration_guide/sect-virtualization-virtualized_block_devices-adding_storage_devices_to_guests#sect-Virtualization-Adding_storage_devices_to_guests-Adding_file_based_storage_to_a_guest
pub disks: Vec<Disk>, pub file_disks: Vec<VMFileDisk>,
/// Network cards /// Network cards
pub networks: Vec<Network>, pub networks: Vec<Network>,
/// Add a TPM v2.0 module /// Add a TPM v2.0 module
pub tpm_module: bool, pub tpm_module: bool,
/// Strings injected as OEM Strings in SMBios configuration
pub oem_strings: Vec<String>,
} }
impl VMInfo { impl VMInfo {
@@ -129,6 +140,7 @@ impl VMInfo {
let mut disks = vec![]; let mut disks = vec![];
// Add ISO files
for iso_file in &self.iso_files { for iso_file in &self.iso_files {
if !files_utils::check_file_name(iso_file) { if !files_utils::check_file_name(iso_file) {
return Err(StructureExtraction("ISO filename is invalid!").into()); return Err(StructureExtraction("ISO filename is invalid!").into());
@@ -192,7 +204,11 @@ impl VMInfo {
}; };
let model = Some(NetIntModelXML { let model = Some(NetIntModelXML {
r#type: "virtio".to_string(), r#type: match n.model {
NetworkInterfaceModelType::Virtio => "virtio",
NetworkInterfaceModelType::E1000 => "e1000",
}
.to_string(),
}); });
let filterref = if let Some(n) = &n.nwfilterref { let filterref = if let Some(n) = &n.nwfilterref {
@@ -237,7 +253,18 @@ impl VMInfo {
mac, mac,
r#type: "network".to_string(), r#type: "network".to_string(),
source: Some(NetIntSourceXML { source: Some(NetIntSourceXML {
network: network.to_string(), network: Some(network.to_string()),
bridge: None,
}),
model,
filterref,
},
NetworkType::Bridge { bridge } => DomainNetInterfaceXML {
r#type: "bridge".to_string(),
mac,
source: Some(NetIntSourceXML {
network: None,
bridge: Some(bridge.to_string()),
}), }),
model, model,
filterref, filterref,
@@ -246,15 +273,21 @@ impl VMInfo {
} }
// Check disks name for duplicates // Check disks name for duplicates
for disk in &self.disks { for disk in &self.file_disks {
if self.disks.iter().filter(|d| d.name == disk.name).count() > 1 { if self
.file_disks
.iter()
.filter(|d| d.name == disk.name)
.count()
> 1
{
return Err(StructureExtraction("Two different disks have the same name!").into()); return Err(StructureExtraction("Two different disks have the same name!").into());
} }
} }
// Apply disks configuration. Starting from now, the function should ideally never fail due to // Apply disks configuration. Starting from now, the function should ideally never fail due to
// bad user input // bad user input
for disk in &self.disks { for disk in &self.file_disks {
disk.check_config()?; disk.check_config()?;
disk.apply_config(uuid)?; disk.apply_config(uuid)?;
@@ -267,7 +300,10 @@ impl VMInfo {
device: "disk".to_string(), device: "disk".to_string(),
driver: DiskDriverXML { driver: DiskDriverXML {
name: "qemu".to_string(), name: "qemu".to_string(),
r#type: "raw".to_string(), r#type: match disk.format {
VMDiskFormat::Raw { .. } => "raw".to_string(),
VMDiskFormat::QCow2 => "qcow2".to_string(),
},
cache: "none".to_string(), cache: "none".to_string(),
}, },
source: DiskSourceXML { source: DiskSourceXML {
@@ -278,7 +314,11 @@ impl VMInfo {
"vd{}", "vd{}",
["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"][disks.len()] ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"][disks.len()]
), ),
bus: "virtio".to_string(), bus: match disk.bus {
VMDiskBus::Virtio => "virtio",
VMDiskBus::SATA => "sata",
}
.to_string(),
}, },
readonly: None, readonly: None,
boot: DiskBootXML { boot: DiskBootXML {
@@ -312,12 +352,28 @@ impl VMInfo {
machine: "q35".to_string(), machine: "q35".to_string(),
body: "hvm".to_string(), body: "hvm".to_string(),
}, },
firmware: "efi".to_string(), firmware: match self.boot_type {
loader: Some(OSLoaderXML { BootType::Legacy => None,
secure: match self.boot_type { _ => Some("efi".to_string()),
BootType::UEFI => "no".to_string(),
BootType::UEFISecureBoot => "yes".to_string(),
}, },
loader: match self.boot_type {
BootType::Legacy => None,
_ => Some(OSLoaderXML {
secure: match self.boot_type {
BootType::UEFISecureBoot => "yes".to_string(),
_ => "no".to_string(),
},
}),
},
bootmenu: match self.boot_type {
BootType::Legacy => Some(OSBootMenuXML {
enable: "yes".to_string(),
timeout: 3000,
}),
_ => None,
},
smbios: Some(OSSMBiosXML {
mode: "sysinfo".to_string(),
}), }),
}, },
@@ -353,7 +409,7 @@ impl VMInfo {
memory: DomainMemoryXML { memory: DomainMemoryXML {
unit: "MB".to_string(), unit: "MB".to_string(),
memory: self.memory, memory: self.memory.as_mb(),
}, },
vcpu: DomainVCPUXML { vcpu: DomainVCPUXML {
@@ -375,6 +431,17 @@ impl VMInfo {
}), }),
}, },
sysinfo: Some(SysInfoXML {
r#type: "smbios".to_string(),
oem_strings: Some(OEMStringsXML {
entries: self
.oem_strings
.iter()
.map(|s| OEMStringEntryXML { content: s.clone() })
.collect(),
}),
}),
on_poweroff: "destroy".to_string(), on_poweroff: "destroy".to_string(),
on_reboot: "restart".to_string(), on_reboot: "restart".to_string(),
on_crash: "destroy".to_string(), on_crash: "destroy".to_string(),
@@ -396,9 +463,10 @@ impl VMInfo {
.virtweb .virtweb
.group .group
.map(VMGroupId), .map(VMGroupId),
boot_type: match domain.os.loader { boot_type: match (domain.os.loader, domain.os.bootmenu) {
None => BootType::UEFI, (_, Some(_)) => BootType::Legacy,
Some(l) => match l.secure.as_str() { (None, _) => BootType::UEFI,
(Some(l), _) => match l.secure.as_str() {
"yes" => BootType::UEFISecureBoot, "yes" => BootType::UEFISecureBoot,
_ => BootType::UEFI, _ => BootType::UEFI,
}, },
@@ -414,7 +482,7 @@ impl VMInfo {
} }
}, },
number_vcpu: domain.vcpu.body, number_vcpu: domain.vcpu.body,
memory: convert_size_unit_to_mb(&domain.memory.unit, domain.memory.memory)?, memory: FileSize::from_size_unit(&domain.memory.unit, domain.memory.memory)?,
vnc_access: domain.devices.graphics.is_some(), vnc_access: domain.devices.graphics.is_some(),
iso_files: domain iso_files: domain
.devices .devices
@@ -424,12 +492,15 @@ impl VMInfo {
.map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string()) .map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string())
.collect(), .collect(),
disks: domain file_disks: domain
.devices .devices
.disks .disks
.iter() .iter()
.filter(|d| d.device == "disk") .filter(|d| d.device == "disk")
.map(|d| Disk::load_from_file(&d.source.file).unwrap()) .map(|d| {
VMFileDisk::load_from_file(&d.source.file, &d.target.bus)
.expect("Failed to load file disk information!")
})
.collect(), .collect(),
networks: domain networks: domain
@@ -442,7 +513,34 @@ impl VMInfo {
r#type: match d.r#type.as_str() { r#type: match d.r#type.as_str() {
"user" => NetworkType::UserspaceSLIRPStack, "user" => NetworkType::UserspaceSLIRPStack,
"network" => NetworkType::DefinedNetwork { "network" => NetworkType::DefinedNetwork {
network: d.source.as_ref().unwrap().network.to_string(), network: d
.source
.as_ref()
.unwrap()
.network
.as_deref()
.ok_or_else(|| {
LibVirtStructError::DomainExtraction(
"Missing source network for defined network!"
.to_string(),
)
})?
.to_string(),
},
"bridge" => NetworkType::Bridge {
bridge: d
.source
.as_ref()
.unwrap()
.bridge
.as_deref()
.ok_or_else(|| {
LibVirtStructError::DomainExtraction(
"Missing bridge name for bridge connection!"
.to_string(),
)
})?
.to_string(),
}, },
a => { a => {
return Err(LibVirtStructError::DomainExtraction(format!( return Err(LibVirtStructError::DomainExtraction(format!(
@@ -450,6 +548,18 @@ impl VMInfo {
))); )));
} }
}, },
model: match d.model.as_ref() {
None => NetworkInterfaceModelType::Virtio,
Some(model) => match model.r#type.as_str() {
"virtio" => NetworkInterfaceModelType::Virtio,
"e1000" => NetworkInterfaceModelType::E1000,
model => {
return Err(LibVirtStructError::DomainExtraction(format!(
"Unknown network interface model type: {model}! "
)));
}
},
},
nwfilterref: d.filterref.as_ref().map(|f| NWFilterRef { nwfilterref: d.filterref.as_ref().map(|f| NWFilterRef {
name: f.filter.to_string(), name: f.filter.to_string(),
parameters: f parameters: f
@@ -466,6 +576,12 @@ impl VMInfo {
.collect::<Result<Vec<_>, _>>()?, .collect::<Result<Vec<_>, _>>()?,
tpm_module: domain.devices.tpm.is_some(), tpm_module: domain.devices.tpm.is_some(),
oem_strings: domain
.sysinfo
.and_then(|s| s.oem_strings)
.map(|s| s.entries.iter().map(|o| o.content.to_string()).collect())
.unwrap_or_default(),
}) })
} }
} }

View File

@@ -13,6 +13,7 @@ use actix_web::middleware::Logger;
use actix_web::web::Data; use actix_web::web::Data;
use actix_web::{App, HttpServer, web}; use actix_web::{App, HttpServer, web};
use light_openid::basic_state_manager::BasicStateManager; use light_openid::basic_state_manager::BasicStateManager;
use std::cmp::max;
use std::time::Duration; use std::time::Duration;
use virtweb_backend::actors::libvirt_actor::LibVirtActor; use virtweb_backend::actors::libvirt_actor::LibVirtActor;
use virtweb_backend::actors::vnc_tokens_actor::VNCTokensManager; use virtweb_backend::actors::vnc_tokens_actor::VNCTokensManager;
@@ -22,13 +23,14 @@ use virtweb_backend::constants::{
MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME, MAX_INACTIVITY_DURATION, MAX_SESSION_DURATION, SESSION_COOKIE_NAME,
}; };
use virtweb_backend::controllers::{ use virtweb_backend::controllers::{
api_tokens_controller, auth_controller, groups_controller, iso_controller, network_controller, api_tokens_controller, auth_controller, disk_images_controller, groups_controller,
nwfilter_controller, server_controller, static_controller, vm_controller, iso_controller, network_controller, nwfilter_controller, server_controller, static_controller,
vm_controller,
}; };
use virtweb_backend::libvirt_client::LibVirtClient; use virtweb_backend::libvirt_client::LibVirtClient;
use virtweb_backend::middlewares::auth_middleware::AuthChecker; use virtweb_backend::middlewares::auth_middleware::AuthChecker;
use virtweb_backend::nat::nat_conf_mode; use virtweb_backend::nat::nat_conf_mode;
use virtweb_backend::utils::files_utils; use virtweb_backend::utils::{exec_utils, files_utils};
#[actix_web::main] #[actix_web::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
@@ -43,11 +45,23 @@ async fn main() -> std::io::Result<()> {
// Load additional config from file, if requested // Load additional config from file, if requested
AppConfig::parse_env_file().unwrap(); AppConfig::parse_env_file().unwrap();
log::debug!("Checking for required programs");
exec_utils::check_program(
constants::QEMU_IMAGE_PROGRAM,
"QEMU disk image utility is required to manipulate QCow2 files!",
);
exec_utils::check_program(
constants::IP_PROGRAM,
"ip is required to access bridges information!",
);
log::debug!("Create required directory, if missing"); log::debug!("Create required directory, if missing");
files_utils::create_directory_if_missing(AppConfig::get().iso_storage_path()).unwrap(); files_utils::create_directory_if_missing(AppConfig::get().iso_storage_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().disk_images_storage_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap(); files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap();
files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap(); files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().disks_storage_path()).unwrap(); files_utils::create_directory_if_missing(AppConfig::get().root_vm_disks_storage_path())
.unwrap();
files_utils::create_directory_if_missing(AppConfig::get().nat_path()).unwrap(); files_utils::create_directory_if_missing(AppConfig::get().nat_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().definitions_path()).unwrap(); files_utils::create_directory_if_missing(AppConfig::get().definitions_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().api_tokens_path()).unwrap(); files_utils::create_directory_if_missing(AppConfig::get().api_tokens_path()).unwrap();
@@ -108,7 +122,9 @@ async fn main() -> std::io::Result<()> {
})) }))
.app_data(conn.clone()) .app_data(conn.clone())
// Uploaded files // Uploaded files
.app_data(MultipartFormConfig::default().total_limit(constants::ISO_MAX_SIZE)) .app_data(MultipartFormConfig::default().total_limit(
max(constants::DISK_IMAGE_MAX_SIZE, constants::ISO_MAX_SIZE).as_bytes(),
))
.app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir)) .app_data(TempFileConfig::default().directory(&AppConfig::get().temp_dir))
// Server controller // Server controller
.route( .route(
@@ -131,6 +147,10 @@ async fn main() -> std::io::Result<()> {
"/api/server/networks", "/api/server/networks",
web::get().to(server_controller::networks_list), web::get().to(server_controller::networks_list),
) )
.route(
"/api/server/bridges",
web::get().to(server_controller::bridges_list),
)
// Auth controller // Auth controller
.route( .route(
"/api/auth/local", "/api/auth/local",
@@ -315,6 +335,35 @@ async fn main() -> std::io::Result<()> {
"/api/nwfilter/{uid}", "/api/nwfilter/{uid}",
web::delete().to(nwfilter_controller::delete), web::delete().to(nwfilter_controller::delete),
) )
// Disk images library
.route(
"/api/disk_images/upload",
web::post().to(disk_images_controller::upload),
)
.route(
"/api/disk_images/list",
web::get().to(disk_images_controller::get_list),
)
.route(
"/api/disk_images/{filename}",
web::get().to(disk_images_controller::download),
)
.route(
"/api/disk_images/{filename}/convert",
web::post().to(disk_images_controller::convert),
)
.route(
"/api/disk_images/{filename}/rename",
web::post().to(disk_images_controller::rename),
)
.route(
"/api/disk_images/{filename}",
web::delete().to(disk_images_controller::delete),
)
.route(
"/api/vm/{uid}/disk/{diskid}/backup",
web::post().to(disk_images_controller::backup_disk),
)
// API tokens controller // API tokens controller
.route( .route(
"/api/token/create", "/api/token/create",
@@ -337,6 +386,11 @@ async fn main() -> std::io::Result<()> {
web::delete().to(api_tokens_controller::delete), web::delete().to(api_tokens_controller::delete),
) )
// Static assets // Static assets
.route(
"/api/assets/{tail:.*}",
web::get().to(static_controller::serve_assets::serve_api_assets),
)
// Static web frontend
.route("/", web::get().to(static_controller::root_index)) .route("/", web::get().to(static_controller::root_index))
.route( .route(
"/{tail:.*}", "/{tail:.*}",

View File

@@ -1,133 +0,0 @@
use crate::app_config::AppConfig;
use crate::constants;
use crate::libvirt_lib_structures::XMLUuid;
use crate::utils::files_utils;
use lazy_regex::regex;
use std::os::linux::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(thiserror::Error, Debug)]
enum DisksError {
#[error("DiskParseError: {0}")]
Parse(&'static str),
#[error("DiskConfigError: {0}")]
Config(&'static str),
#[error("DiskCreateError")]
Create,
}
/// Type of disk allocation
#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum DiskAllocType {
Fixed,
Sparse,
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Disk {
/// Disk size, in megabytes
pub size: usize,
/// Disk name
pub name: String,
pub alloc_type: DiskAllocType,
/// Set this variable to true to delete the disk
pub delete: bool,
}
impl Disk {
pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
let file = Path::new(path);
if !file.is_file() {
return Err(DisksError::Parse("Path is not a file!").into());
}
let metadata = file.metadata()?;
// Approximate estimation
let is_sparse = metadata.len() / 512 >= metadata.st_blocks();
Ok(Self {
size: metadata.len() as usize / (1000 * 1000),
name: path.rsplit_once('/').unwrap().1.to_string(),
alloc_type: match is_sparse {
true => DiskAllocType::Sparse,
false => DiskAllocType::Fixed,
},
delete: false,
})
}
pub fn check_config(&self) -> anyhow::Result<()> {
if constants::DISK_NAME_MIN_LEN > self.name.len()
|| constants::DISK_NAME_MAX_LEN < self.name.len()
{
return Err(DisksError::Config("Disk name length is invalid").into());
}
if !regex!("^[a-zA-Z0-9]+$").is_match(&self.name) {
return Err(DisksError::Config("Disk name contains invalid characters!").into());
}
if self.size < constants::DISK_SIZE_MIN || self.size > constants::DISK_SIZE_MAX {
return Err(DisksError::Config("Disk size is invalid!").into());
}
Ok(())
}
/// Get disk path
pub fn disk_path(&self, id: XMLUuid) -> PathBuf {
let domain_dir = AppConfig::get().vm_storage_path(id);
domain_dir.join(&self.name)
}
/// Apply disk configuration
pub fn apply_config(&self, id: XMLUuid) -> anyhow::Result<()> {
self.check_config()?;
let file = self.disk_path(id);
files_utils::create_directory_if_missing(file.parent().unwrap())?;
// Delete file if requested
if self.delete {
if !file.exists() {
log::debug!("File {file:?} does not exists, so it was not deleted");
return Ok(());
}
log::info!("Deleting {file:?}");
std::fs::remove_file(file)?;
return Ok(());
}
if file.exists() {
log::debug!("File {file:?} does not exists, so it was not touched");
return Ok(());
}
let mut cmd = Command::new("/usr/bin/dd");
cmd.arg("if=/dev/zero")
.arg(format!("of={}", file.to_string_lossy()))
.arg("bs=1M");
match self.alloc_type {
DiskAllocType::Fixed => cmd.arg(format!("count={}", self.size)),
DiskAllocType::Sparse => cmd.arg(format!("seek={}", self.size)).arg("count=0"),
};
let res = cmd.output()?;
if !res.status.success() {
log::error!(
"Failed to create disk! stderr={} stdout={}",
String::from_utf8_lossy(&res.stderr),
String::from_utf8_lossy(&res.stdout)
);
return Err(DisksError::Create.into());
}
Ok(())
}
}

View File

@@ -0,0 +1,10 @@
use std::path::Path;
/// Check the existence of a required program
pub fn check_program(name: &str, description: &str) {
let path = Path::new(name);
if !path.exists() {
panic!("{name} does not exist! {description}");
}
}

View File

@@ -0,0 +1,352 @@
use crate::app_config::AppConfig;
use crate::constants;
use crate::utils::file_size_utils::FileSize;
use std::fs::File;
use std::os::linux::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::UNIX_EPOCH;
#[derive(thiserror::Error, Debug)]
enum DisksError {
#[error("DiskParseError: {0}")]
Parse(&'static str),
#[error("DiskCreateError")]
Create,
#[error("DiskConvertError: {0}")]
Convert(String),
}
#[derive(Debug, serde::Serialize, serde::Deserialize, Copy, Clone, PartialEq, Eq)]
#[serde(tag = "format")]
pub enum DiskFileFormat {
Raw {
#[serde(default)]
is_sparse: bool,
},
QCow2 {
#[serde(default)]
virtual_size: FileSize,
},
CompressedRaw,
CompressedQCow2,
}
impl DiskFileFormat {
pub fn ext(&self) -> &'static [&'static str] {
match self {
DiskFileFormat::Raw { .. } => &["raw", ""],
DiskFileFormat::QCow2 { .. } => &["qcow2"],
DiskFileFormat::CompressedRaw => &["raw.gz"],
DiskFileFormat::CompressedQCow2 => &["qcow2.gz"],
}
}
}
/// Disk file information
#[derive(serde::Serialize)]
pub struct DiskFileInfo {
pub file_path: PathBuf,
pub file_size: FileSize,
#[serde(flatten)]
pub format: DiskFileFormat,
pub file_name: String,
pub name: String,
pub created: u64,
}
impl DiskFileInfo {
/// Get disk image file information
pub fn load_file(file: &Path) -> anyhow::Result<Self> {
if !file.is_file() {
return Err(DisksError::Parse("Path is not a file!").into());
}
// Get file metadata
let metadata = file.metadata()?;
let mut name = file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("disk")
.to_string();
let ext = file.extension().and_then(|s| s.to_str()).unwrap_or("raw");
// Determine file format
let format = match ext {
"qcow2" => DiskFileFormat::QCow2 {
virtual_size: qcow_virt_size(file)?,
},
"raw" => DiskFileFormat::Raw {
is_sparse: metadata.len() / 512 >= metadata.st_blocks(),
},
"gz" if name.ends_with(".qcow2") => {
name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string();
DiskFileFormat::CompressedQCow2
}
"gz" => DiskFileFormat::CompressedRaw,
_ => anyhow::bail!("Unsupported disk extension: {ext}!"),
};
Ok(Self {
file_path: file.to_path_buf(),
name,
file_size: FileSize::from_bytes(metadata.len() as usize),
format,
file_name: file
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string(),
created: metadata
.created()?
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
})
}
/// Create a new empty disk
pub fn create(file: &Path, format: DiskFileFormat, size: FileSize) -> anyhow::Result<()> {
// Prepare command to create file
let res = match format {
DiskFileFormat::Raw { is_sparse } => {
let mut cmd = Command::new("/usr/bin/dd");
cmd.arg("if=/dev/zero")
.arg(format!("of={}", file.to_string_lossy()))
.arg("bs=1M");
match is_sparse {
false => cmd.arg(format!("count={}", size.as_mb())),
true => cmd.arg(format!("seek={}", size.as_mb())).arg("count=0"),
};
cmd.output()?
}
DiskFileFormat::QCow2 { virtual_size } => {
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.arg("create")
.arg("-f")
.arg("qcow2")
.arg(file)
.arg(format!("{}M", virtual_size.as_mb()));
cmd.output()?
}
_ => anyhow::bail!("Cannot create disk file image of this format: {format:?}!"),
};
// Execute Linux command
if !res.status.success() {
log::error!(
"Failed to create disk! stderr={} stdout={}",
String::from_utf8_lossy(&res.stderr),
String::from_utf8_lossy(&res.stdout)
);
return Err(DisksError::Create.into());
}
Ok(())
}
/// Copy / convert file disk image into a new destination with optionally a new file format
pub fn convert(&self, dest_file: &Path, dest_format: DiskFileFormat) -> anyhow::Result<()> {
// Create a temporary directory to perform the operation
let temp_dir = tempfile::tempdir_in(&AppConfig::get().temp_dir)?;
let temp_file = temp_dir
.path()
.join(format!("temp_file.{}", dest_format.ext()[0]));
// Prepare the conversion
let mut cmd = match (self.format, dest_format) {
// Decompress QCow2
(DiskFileFormat::CompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
let mut cmd = Command::new(constants::GZIP_PROGRAM);
cmd.arg("--keep")
.arg("--decompress")
.arg("--to-stdout")
.arg(&self.file_path)
.stdout(File::create(&temp_file)?);
cmd
}
// Compress QCow2
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::CompressedQCow2) => {
let mut cmd = Command::new(constants::GZIP_PROGRAM);
cmd.arg("--keep")
.arg("--to-stdout")
.arg(&self.file_path)
.stdout(File::create(&temp_file)?);
cmd
}
// Convert QCow2 to Raw file
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => {
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.arg("convert").arg(&self.file_path).arg(&temp_file);
if !is_sparse {
cmd.args(["-S", "0"]);
}
cmd
}
// Clone a QCow file, using qemu-image instead of cp might improve "sparsification" of
// file
(DiskFileFormat::QCow2 { .. }, DiskFileFormat::QCow2 { .. }) => {
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.arg("convert")
.arg("-O")
.arg("qcow2")
.arg(&self.file_path)
.arg(&temp_file);
cmd
}
// Convert Raw to QCow2 file
(DiskFileFormat::Raw { .. }, DiskFileFormat::QCow2 { .. }) => {
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.arg("convert").arg(&self.file_path).arg(&temp_file);
cmd
}
// Render raw file non sparse
(DiskFileFormat::Raw { is_sparse: true }, DiskFileFormat::Raw { is_sparse: false }) => {
let mut cmd = Command::new(constants::COPY_PROGRAM);
cmd.arg("--sparse=never")
.arg(&self.file_path)
.arg(&temp_file);
cmd
}
// Render raw file sparse
(DiskFileFormat::Raw { is_sparse: false }, DiskFileFormat::Raw { is_sparse: true }) => {
let mut cmd = Command::new(constants::DD_PROGRAM);
cmd.arg("conv=sparse")
.arg(format!("if={}", self.file_path.display()))
.arg(format!("of={}", temp_file.display()));
cmd
}
// Compress Raw
(DiskFileFormat::Raw { .. }, DiskFileFormat::CompressedRaw) => {
let mut cmd = Command::new(constants::GZIP_PROGRAM);
cmd.arg("--keep")
.arg("--to-stdout")
.arg(&self.file_path)
.stdout(File::create(&temp_file)?);
cmd
}
// Decompress Raw to not sparse file
(DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => {
let mut cmd = Command::new(constants::GZIP_PROGRAM);
cmd.arg("--keep")
.arg("--decompress")
.arg("--to-stdout")
.arg(&self.file_path)
.stdout(File::create(&temp_file)?);
cmd
}
// Decompress Raw to sparse file
// https://benou.fr/www/ben/decompressing-sparse-files.html
(DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => {
let mut cmd = Command::new(constants::BASH_PROGRAM);
cmd.arg("-c").arg(format!(
"{} -d -c {} | {} conv=sparse of={}",
constants::GZIP_PROGRAM,
self.file_path.display(),
constants::DD_PROGRAM,
temp_file.display()
));
cmd
}
// Dumb copy of file
(a, b) if a == b => {
let mut cmd = Command::new(constants::COPY_PROGRAM);
cmd.arg("--sparse=auto")
.arg(&self.file_path)
.arg(&temp_file);
cmd
}
// By default, conversion is unsupported
(src, dest) => {
return Err(DisksError::Convert(format!(
"Conversion from {src:?} to {dest:?} is not supported!"
))
.into());
}
};
// Execute the conversion
let command_s = format!(
"{} {}",
cmd.get_program().display(),
cmd.get_args()
.map(|a| format!("'{}'", a.display()))
.collect::<Vec<String>>()
.join(" ")
);
let cmd_output = cmd.output()?;
if !cmd_output.status.success() {
return Err(DisksError::Convert(format!(
"Command failed:\n{command_s}\nStatus: {}\nstdout: {}\nstderr: {}",
cmd_output.status,
String::from_utf8_lossy(&cmd_output.stdout),
String::from_utf8_lossy(&cmd_output.stderr)
))
.into());
}
// Check the file was created
if !temp_file.is_file() {
return Err(DisksError::Convert(
"Temporary was not created after execution of command!".to_string(),
)
.into());
}
// Move the file to its final location
std::fs::rename(temp_file, dest_file)?;
Ok(())
}
}
#[derive(serde::Deserialize)]
struct QCowInfoOutput {
#[serde(rename = "virtual-size")]
virtual_size: usize,
}
/// Get QCow2 virtual size
fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> {
// Run qemu-img
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.args([
"info",
path.to_str().unwrap_or(""),
"--output",
"json",
"--force-share",
]);
let output = cmd.output()?;
if !output.status.success() {
anyhow::bail!(
"{} info failed, status: {}, stderr: {}",
constants::QEMU_IMAGE_PROGRAM,
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
let res_json = String::from_utf8(output.stdout)?;
// Decode JSON
let decoded: QCowInfoOutput = serde_json::from_str(&res_json)?;
Ok(FileSize::from_bytes(decoded.virtual_size))
}

View File

@@ -0,0 +1,91 @@
use std::ops::Mul;
#[derive(thiserror::Error, Debug)]
enum FilesSizeUtilsError {
#[error("UnitConvertError: {0}")]
UnitConvert(String),
}
/// Holds a data size, convertible in any form
#[derive(
serde::Serialize,
serde::Deserialize,
Copy,
Clone,
Debug,
Eq,
PartialEq,
PartialOrd,
Ord,
Default,
)]
pub struct FileSize(usize);
impl FileSize {
pub const fn from_bytes(size: usize) -> Self {
Self(size)
}
pub const fn from_mb(mb: usize) -> Self {
Self(mb * 1000 * 1000)
}
pub const fn from_gb(gb: usize) -> Self {
Self(gb * 1000 * 1000 * 1000)
}
/// Convert size unit to MB
pub fn from_size_unit(unit: &str, value: usize) -> anyhow::Result<Self> {
let fact = match unit {
"bytes" | "b" => 1f64,
"KB" => 1000f64,
"MB" => 1000f64 * 1000f64,
"GB" => 1000f64 * 1000f64 * 1000f64,
"TB" => 1000f64 * 1000f64 * 1000f64 * 1000f64,
"k" | "KiB" => 1024f64,
"M" | "MiB" => 1024f64 * 1024f64,
"G" | "GiB" => 1024f64 * 1024f64 * 1024f64,
"T" | "TiB" => 1024f64 * 1024f64 * 1024f64 * 1024f64,
_ => {
return Err(
FilesSizeUtilsError::UnitConvert(format!("Unknown size unit: {unit}")).into(),
);
}
};
Ok(Self((value as f64).mul(fact).ceil() as usize))
}
/// Get file size as bytes
pub fn as_bytes(&self) -> usize {
self.0
}
/// Get file size as megabytes
pub fn as_mb(&self) -> usize {
self.0 / (1000 * 1000)
}
}
#[cfg(test)]
mod tests {
use crate::utils::file_size_utils::FileSize;
#[test]
fn convert_units_mb() {
assert_eq!(FileSize::from_size_unit("MB", 1).unwrap().as_mb(), 1);
assert_eq!(FileSize::from_size_unit("MB", 1000).unwrap().as_mb(), 1000);
assert_eq!(
FileSize::from_size_unit("GB", 1000).unwrap().as_mb(),
1000 * 1000
);
assert_eq!(FileSize::from_size_unit("GB", 1).unwrap().as_mb(), 1000);
assert_eq!(FileSize::from_size_unit("GiB", 3).unwrap().as_mb(), 3221);
assert_eq!(
FileSize::from_size_unit("KiB", 488281).unwrap().as_mb(),
499
);
}
}

View File

@@ -1,13 +1,6 @@
use std::ops::{Div, Mul};
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::path::Path; use std::path::Path;
#[derive(thiserror::Error, Debug)]
enum FilesUtilsError {
#[error("UnitConvertError: {0}")]
UnitConvert(String),
}
const INVALID_CHARS: [&str; 19] = [ const INVALID_CHARS: [&str; 19] = [
"@", "\\", "/", ":", ",", "<", ">", "%", "'", "\"", "?", "{", "}", "$", "*", "|", ";", "=", "@", "\\", "/", ":", ",", "<", ">", "%", "'", "\"", "?", "{", "}", "$", "*", "|", ";", "=",
"\t", "\t",
@@ -35,31 +28,9 @@ pub fn set_file_permission<P: AsRef<Path>>(path: P, mode: u32) -> anyhow::Result
Ok(()) Ok(())
} }
/// Convert size unit to MB
pub fn convert_size_unit_to_mb(unit: &str, value: usize) -> anyhow::Result<usize> {
let fact = match unit {
"bytes" | "b" => 1f64,
"KB" => 1000f64,
"MB" => 1000f64 * 1000f64,
"GB" => 1000f64 * 1000f64 * 1000f64,
"TB" => 1000f64 * 1000f64 * 1000f64 * 1000f64,
"k" | "KiB" => 1024f64,
"M" | "MiB" => 1024f64 * 1024f64,
"G" | "GiB" => 1024f64 * 1024f64 * 1024f64,
"T" | "TiB" => 1024f64 * 1024f64 * 1024f64 * 1024f64,
_ => {
return Err(FilesUtilsError::UnitConvert(format!("Unknown size unit: {unit}")).into());
}
};
Ok((value as f64).mul(fact.div((1000 * 1000) as f64)).ceil() as usize)
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::utils::files_utils::{check_file_name, convert_size_unit_to_mb}; use crate::utils::files_utils::check_file_name;
#[test] #[test]
fn empty_file_name() { fn empty_file_name() {
@@ -85,14 +56,4 @@ mod test {
fn valid_file_name() { fn valid_file_name() {
assert!(check_file_name("test.iso")); assert!(check_file_name("test.iso"));
} }
#[test]
fn convert_units_mb() {
assert_eq!(convert_size_unit_to_mb("MB", 1).unwrap(), 1);
assert_eq!(convert_size_unit_to_mb("MB", 1000).unwrap(), 1000);
assert_eq!(convert_size_unit_to_mb("GB", 1000).unwrap(), 1000 * 1000);
assert_eq!(convert_size_unit_to_mb("GB", 1).unwrap(), 1000);
assert_eq!(convert_size_unit_to_mb("GiB", 3).unwrap(), 3222);
assert_eq!(convert_size_unit_to_mb("KiB", 488281).unwrap(), 500);
}
} }

View File

@@ -1,6 +1,9 @@
pub mod disks_utils; pub mod exec_utils;
pub mod file_disks_utils;
pub mod file_size_utils;
pub mod files_utils; pub mod files_utils;
pub mod net_utils; pub mod net_utils;
pub mod rand_utils; pub mod rand_utils;
pub mod time_utils; pub mod time_utils;
pub mod url_utils; pub mod url_utils;
pub mod vm_file_disks_utils;

View File

@@ -1,6 +1,8 @@
use crate::constants;
use nix::sys::socket::{AddressFamily, SockaddrLike}; use nix::sys::socket::{AddressFamily, SockaddrLike};
use std::collections::HashMap; use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::process::Command;
use std::str::FromStr; use std::str::FromStr;
use sysinfo::Networks; use sysinfo::Networks;
@@ -68,7 +70,7 @@ pub fn net_list() -> Vec<String> {
/// Get the list of available network interfaces associated with their IP address /// Get the list of available network interfaces associated with their IP address
pub fn net_list_and_ips() -> anyhow::Result<HashMap<String, Vec<IpAddr>>> { pub fn net_list_and_ips() -> anyhow::Result<HashMap<String, Vec<IpAddr>>> {
let addrs = nix::ifaddrs::getifaddrs().unwrap(); let addrs = nix::ifaddrs::getifaddrs()?;
let mut res = HashMap::new(); let mut res = HashMap::new();
@@ -136,6 +138,31 @@ pub fn net_list_and_ips() -> anyhow::Result<HashMap<String, Vec<IpAddr>>> {
Ok(res) Ok(res)
} }
#[derive(serde::Deserialize)]
struct IPBridgeInfo {
ifname: String,
}
/// Get the list of bridge interfaces
pub fn bridges_list() -> anyhow::Result<Vec<String>> {
let mut cmd = Command::new(constants::IP_PROGRAM);
cmd.args(["-json", "link", "show", "type", "bridge"]);
let output = cmd.output()?;
if !output.status.success() {
anyhow::bail!(
"{} failed, status: {}, stderr: {}",
constants::IP_PROGRAM,
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
// Parse JSON result
let res: Vec<IPBridgeInfo> = serde_json::from_str(&String::from_utf8_lossy(&output.stdout))?;
Ok(res.iter().map(|ip| ip.ifname.clone()).collect())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::utils::net_utils::{ use crate::utils::net_utils::{

View File

@@ -0,0 +1,174 @@
use crate::app_config::AppConfig;
use crate::constants;
use crate::libvirt_lib_structures::XMLUuid;
use crate::utils::file_disks_utils::{DiskFileFormat, DiskFileInfo};
use crate::utils::file_size_utils::FileSize;
use crate::utils::files_utils;
use lazy_regex::regex;
use std::path::{Path, PathBuf};
#[derive(thiserror::Error, Debug)]
enum VMDisksError {
#[error("DiskConfigError: {0}")]
Config(&'static str),
}
#[derive(serde::Serialize, serde::Deserialize)]
pub enum VMDiskBus {
Virtio,
SATA,
}
/// Disk allocation type
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(tag = "format")]
pub enum VMDiskFormat {
Raw {
/// Is raw file a sparse file?
is_sparse: bool,
},
QCow2,
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct VMFileDisk {
/// Disk name
pub name: String,
/// Disk size, in bytes
pub size: FileSize,
/// Disk bus
pub bus: VMDiskBus,
/// Disk format
#[serde(flatten)]
pub format: VMDiskFormat,
/// When creating a new disk, specify the disk image template to use
#[serde(skip_serializing_if = "Option::is_none")]
pub from_image: Option<String>,
/// Set this variable to true to delete the disk
pub delete: bool,
}
impl VMFileDisk {
pub fn load_from_file(path: &str, bus: &str) -> anyhow::Result<Self> {
let file = Path::new(path);
let info = DiskFileInfo::load_file(file)?;
Ok(Self {
name: info.name,
// Get only the virtual size of the file
size: match info.format {
DiskFileFormat::Raw { .. } => info.file_size,
DiskFileFormat::QCow2 { virtual_size } => virtual_size,
_ => anyhow::bail!("Unsupported image format: {:?}", info.format),
},
format: match info.format {
DiskFileFormat::Raw { is_sparse } => VMDiskFormat::Raw { is_sparse },
DiskFileFormat::QCow2 { .. } => VMDiskFormat::QCow2,
_ => anyhow::bail!("Unsupported image format: {:?}", info.format),
},
bus: match bus {
"virtio" => VMDiskBus::Virtio,
"sata" => VMDiskBus::SATA,
_ => anyhow::bail!("Unsupported disk bus type: {bus}"),
},
delete: false,
from_image: None,
})
}
pub fn check_config(&self) -> anyhow::Result<()> {
if constants::DISK_NAME_MIN_LEN > self.name.len()
|| constants::DISK_NAME_MAX_LEN < self.name.len()
{
return Err(VMDisksError::Config("Disk name length is invalid").into());
}
if !regex!("^[a-zA-Z0-9]+$").is_match(&self.name) {
return Err(VMDisksError::Config("Disk name contains invalid characters!").into());
}
// Check disk size
if !(constants::DISK_SIZE_MIN..=constants::DISK_SIZE_MAX).contains(&self.size) {
return Err(VMDisksError::Config("Disk size is invalid!").into());
}
// Check specified disk image template
if let Some(disk_image) = &self.from_image {
if !files_utils::check_file_name(disk_image) {
return Err(VMDisksError::Config("Disk image template name is not valid!").into());
}
if !AppConfig::get().disk_images_file_path(disk_image).is_file() {
return Err(
VMDisksError::Config("Specified disk image file does not exist!").into(),
);
}
}
Ok(())
}
/// Get disk path on file system
pub fn disk_path(&self, id: XMLUuid) -> PathBuf {
let domain_dir = AppConfig::get().vm_storage_path(id);
let file_name = match self.format {
VMDiskFormat::Raw { .. } => self.name.to_string(),
VMDiskFormat::QCow2 => format!("{}.qcow2", self.name),
};
domain_dir.join(&file_name)
}
/// Apply disk configuration
pub fn apply_config(&self, id: XMLUuid) -> anyhow::Result<()> {
self.check_config()?;
let file = self.disk_path(id);
files_utils::create_directory_if_missing(file.parent().unwrap())?;
// Delete file if requested
if self.delete {
if !file.exists() {
log::debug!("File {file:?} does not exists, so it was not deleted");
return Ok(());
}
log::info!("Deleting {file:?}");
std::fs::remove_file(file)?;
return Ok(());
}
if file.exists() {
log::debug!("File {file:?} does not exists, so it was not touched");
return Ok(());
}
let format = match self.format {
VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
virtual_size: self.size,
},
};
// Create / Restore disk file
match &self.from_image {
// Create disk file
None => {
DiskFileInfo::create(&file, format, self.size)?;
}
// Restore disk image template
Some(disk_img) => {
let src_file =
DiskFileInfo::load_file(&AppConfig::get().disk_images_file_path(disk_img))?;
src_file.convert(&file, format)?;
}
}
Ok(())
}
}

47
virtweb_docs/BRIDGE.md Normal file
View File

@@ -0,0 +1,47 @@
# Bridges
Bridges can be used to connect virtual machines to networks.
## Setup Bridge on Ubuntu
1. Install dependencies:
```bash
sudo apt install bridge-utils
```
2. Adapt your netplan configuration to set the following:
```yaml
network:
version: 2
renderer: networkd
ethernets:
enp2s0:
dhcp4: no
bridges:
br0: # Bridge name
dhcp4: yes
interfaces:
- enp2s0 # Set to your interface
```
3. Apply netplan configuration:
```bash
sudo netplan apply
```
4. Get the state and the list of bridges in the system:
```bash
sudo brctl show
# Or
ip link show type bridge
```
## Reference
[How to Configure Network Bridge in Ubuntu](https://www.tecmint.com/create-network-bridge-in-ubuntu/)

11
virtweb_docs/REFERENCE.md Normal file
View File

@@ -0,0 +1,11 @@
## References
### LibVirt XML documentation
* Online: https://libvirt.org/format.html
* Offline with Ubuntu:
```bash
sudo apt install libvirt-doc
firefox /usr/share/doc/libvirt-doc/html/index.html
```

View File

@@ -34,7 +34,7 @@ docker compose up
sudo mkdir /var/virtweb sudo mkdir /var/virtweb
sudo chown $USER:$USER /var/virtweb sudo chown $USER:$USER /var/virtweb
cd virtweb_backend cd virtweb_backend
cargo fmt && cargo clippy && cargo run -- -s /var/virtweb --hypervisor-uri "qemu:///system" cargo fmt && cargo clippy && cargo run -- -s /var/virtweb --hypervisor-uri "qemu:///system" --website-origin "http://localhost:5173"
``` ```
7. Run the frontend 7. Run the frontend

View File

@@ -61,7 +61,7 @@ STORAGE=/home/virtweb/storage
HYPERVISOR_URI=qemu:///system HYPERVISOR_URI=qemu:///system
``` ```
> Note: `HYPERVISOR_URI=qemu:///system` is used to sepcify that we want to use the main hypervisor. > Note: `HYPERVISOR_URI=qemu:///system` is used to specify that we want to use the main hypervisor.
## Register Virtweb service ## Register Virtweb service
Before registering service, check that the configuration works correctly: Before registering service, check that the configuration works correctly:

View File

@@ -1,12 +1,11 @@
import js from "@eslint/js"; import js from "@eslint/js";
import reactDom from 'eslint-plugin-react-dom'; import reactDom from "eslint-plugin-react-dom";
import reactHooks from "eslint-plugin-react-hooks"; import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh"; import reactRefresh from "eslint-plugin-react-refresh";
import reactX from 'eslint-plugin-react-x'; import reactX from "eslint-plugin-react-x";
import globals from "globals"; import globals from "globals";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
export default tseslint.config( export default tseslint.config(
{ ignores: ["dist"] }, { ignores: ["dist"] },
{ {
@@ -38,6 +37,18 @@ export default tseslint.config(
], ],
...reactX.configs["recommended-typescript"].rules, ...reactX.configs["recommended-typescript"].rules,
...reactDom.configs.recommended.rules, ...reactDom.configs.recommended.rules,
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/no-extraneous-class": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"react-refresh/only-export-components": "off",
}, },
} }
); );

File diff suppressed because it is too large Load Diff

View File

@@ -13,41 +13,40 @@
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.2.5", "@fontsource/roboto": "^5.2.5",
"@mdi/js": "^7.2.96", "@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
"@mui/icons-material": "^7.0.0", "@mui/icons-material": "^7.1.0",
"@mui/material": "^7.0.0", "@mui/material": "^7.1.0",
"@mui/x-charts": "^7.28.0", "@mui/x-charts": "^8.3.1",
"@mui/x-data-grid": "^7.28.1", "@mui/x-data-grid": "^8.3.1",
"date-and-time": "^3.6.0", "date-and-time": "^3.6.0",
"filesize": "^10.1.6", "filesize": "^10.1.6",
"humanize-duration": "^3.29.0", "humanize-duration": "^3.32.2",
"mui-file-input": "^7.0.0", "react": "^19.1.0",
"react": "^19.0.0", "react-dom": "^19.1.0",
"react-dom": "^19.0.0", "react-router-dom": "^7.6.0",
"react-router-dom": "^7.4.0",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"react-vnc": "^3.0.7", "react-vnc": "^3.1.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"xml-formatter": "^3.6.0" "xml-formatter": "^3.6.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.21.0", "@eslint/js": "^9.27.0",
"@types/humanize-duration": "^3.27.1", "@types/humanize-duration": "^3.27.4",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/react": "^19.0.12", "@types/react": "^19.1.5",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.1.5",
"@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": "^4.3.4", "@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.21.0", "eslint": "^9.27.0",
"eslint-plugin-react-dom": "^1.38.3", "eslint-plugin-react-dom": "^1.49.0",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-react-x": "^1.38.3", "eslint-plugin-react-x": "^1.49.0",
"globals": "^15.15.0", "globals": "^16.1.0",
"typescript": "^5.8.2", "typescript": "^5.8.3",
"typescript-eslint": "^8.24.1", "typescript-eslint": "^8.32.1",
"vite": "^6.2.3" "vite": "^6.3.5"
} }
} }

View File

@@ -38,6 +38,7 @@ import { LoginRoute } from "./routes/auth/LoginRoute";
import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute"; import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
import { BaseLoginPage } from "./widgets/BaseLoginPage"; import { BaseLoginPage } from "./widgets/BaseLoginPage";
import { DiskImagesRoute } from "./routes/DiskImagesRoute";
interface AuthContext { interface AuthContext {
signedIn: boolean; signedIn: boolean;
@@ -51,7 +52,10 @@ export function App() {
const context: AuthContext = { const context: AuthContext = {
signedIn: signedIn, signedIn: signedIn,
setSignedIn: (s) => setSignedIn(s), setSignedIn: (s) => {
setSignedIn(s);
location.reload();
},
}; };
const router = createBrowserRouter( const router = createBrowserRouter(
@@ -60,6 +64,8 @@ export function App() {
<Route path="*" element={<BaseAuthenticatedPage />}> <Route path="*" element={<BaseAuthenticatedPage />}>
<Route path="" element={<HomeRoute />} /> <Route path="" element={<HomeRoute />} />
<Route path="disk_images" element={<DiskImagesRoute />} />
<Route path="iso" element={<IsoFilesRoute />} /> <Route path="iso" element={<IsoFilesRoute />} />
<Route path="vms" element={<VMListRoute />} /> <Route path="vms" element={<VMListRoute />} />
@@ -97,12 +103,12 @@ export function App() {
); );
return ( return (
<AuthContextK.Provider value={context}> <AuthContextK value={context}>
<RouterProvider router={router} /> <RouterProvider router={router} />
</AuthContextK.Provider> </AuthContextK>
); );
} }
export function useAuth(): AuthContext { export function useAuth(): AuthContext {
return React.useContext(AuthContextK)!; return React.use(AuthContextK)!;
} }

View File

@@ -26,7 +26,7 @@ export class APIClient {
* Get backend URL * Get backend URL
*/ */
static backendURL(): string { static backendURL(): string {
const URL = import.meta.env.VITE_APP_BACKEND ?? ""; const URL = String(import.meta.env.VITE_APP_BACKEND ?? "");
if (URL.length === 0) throw new Error("Backend URL undefined!"); if (URL.length === 0) throw new Error("Backend URL undefined!");
return URL; return URL;
} }
@@ -44,7 +44,7 @@ export class APIClient {
*/ */
static async exec(args: RequestParams): Promise<APIResponse> { static async exec(args: RequestParams): Promise<APIResponse> {
let body: string | undefined | FormData = undefined; let body: string | undefined | FormData = undefined;
let headers: any = {}; const headers: any = {};
// JSON request // JSON request
if (args.jsonData) { if (args.jsonData) {
@@ -66,22 +66,25 @@ export class APIClient {
if (args.upProgress) { if (args.upProgress) {
const res: XMLHttpRequest = await new Promise((resolve, reject) => { const res: XMLHttpRequest = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => xhr.upload.addEventListener("progress", (e) => {
args.upProgress!(e.loaded / e.total) args.upProgress!(e.loaded / e.total);
); });
xhr.addEventListener("load", () => resolve(xhr)); xhr.addEventListener("load", () => {
xhr.addEventListener("error", () => resolve(xhr);
reject(new Error("File upload failed")) });
); xhr.addEventListener("error", () => {
xhr.addEventListener("abort", () => reject(new Error("File upload failed"));
reject(new Error("File upload aborted")) });
); xhr.addEventListener("abort", () => {
xhr.addEventListener("timeout", () => reject(new Error("File upload aborted"));
reject(new Error("File upload timeout")) });
); xhr.addEventListener("timeout", () => {
reject(new Error("File upload timeout"));
});
xhr.open(args.method, url, true); xhr.open(args.method, url, true);
xhr.withCredentials = true; xhr.withCredentials = true;
for (const key in headers) { for (const key in headers) {
// eslint-disable-next-line no-prototype-builtins
if (headers.hasOwnProperty(key)) if (headers.hasOwnProperty(key))
xhr.setRequestHeader(key, headers[key]); xhr.setRequestHeader(key, headers[key]);
} }
@@ -100,6 +103,7 @@ export class APIClient {
body: body, body: body,
headers: headers, headers: headers,
credentials: "include", credentials: "include",
signal: AbortSignal.timeout(50 * 1000 * 1000),
}); });
// Process response // Process response

View File

@@ -0,0 +1,117 @@
import { APIClient } from "./ApiClient";
import { VMFileDisk, VMInfo } from "./VMApi";
export type DiskImageFormat =
| { format: "Raw"; is_sparse: boolean }
| { format: "QCow2"; virtual_size?: number }
| { format: "CompressedQCow2" }
| { format: "CompressedRaw" };
export type DiskImage = {
file_size: number;
file_name: string;
name: string;
created: number;
} & DiskImageFormat;
export class DiskImageApi {
/**
* Upload a new disk image file to the server
*/
static async Upload(
file: File,
progress: (progress: number) => void
): Promise<void> {
const fd = new FormData();
fd.append("file", file);
await APIClient.exec({
method: "POST",
uri: "/disk_images/upload",
formData: fd,
upProgress: progress,
});
}
/**
* Get the list of disk images
*/
static async GetList(): Promise<DiskImage[]> {
return (
await APIClient.exec({
method: "GET",
uri: "/disk_images/list",
})
).data;
}
/**
* Download disk image file
*/
static async Download(
file: DiskImage,
progress: (p: number) => void
): Promise<Blob> {
return (
await APIClient.exec({
method: "GET",
uri: `/disk_images/${file.file_name}`,
downProgress(e) {
progress(Math.floor(100 * (e.progress / e.total)));
},
})
).data;
}
/**
* Convert disk image file
*/
static async Convert(
file: DiskImage,
dest_file_name: string,
dest_format: DiskImageFormat
): Promise<void> {
await APIClient.exec({
method: "POST",
uri: `/disk_images/${file.file_name}/convert`,
jsonData: { ...dest_format, dest_file_name },
});
}
/**
* Backup VM disk into image disks library
*/
static async BackupVMDisk(
vm: VMInfo,
disk: VMFileDisk,
dest_file_name: string,
format: DiskImageFormat
): Promise<void> {
await APIClient.exec({
uri: `/vm/${vm.uuid}/disk/${disk.name}/backup`,
method: "POST",
jsonData: { ...format, dest_file_name },
});
}
/**
* Rename disk image file
*/
static async Rename(file: DiskImage, name: string): Promise<void> {
await APIClient.exec({
method: "POST",
uri: `/disk_images/${file.file_name}/rename`,
jsonData: { name },
});
}
/**
* Delete disk image file
*/
static async Delete(file: DiskImage): Promise<void> {
await APIClient.exec({
method: "DELETE",
uri: `/disk_images/${file.file_name}`,
});
}
}

View File

@@ -5,6 +5,15 @@ export interface IsoFile {
size: number; size: number;
} }
/**
* ISO catalog entries
*/
export interface ISOCatalogEntry {
name: string;
url: string;
image: string;
}
export class IsoFilesApi { export class IsoFilesApi {
/** /**
* Upload a new ISO file to the server * Upload a new ISO file to the server
@@ -74,4 +83,23 @@ export class IsoFilesApi {
uri: `/iso/${file.filename}`, uri: `/iso/${file.filename}`,
}); });
} }
/**
* Get iso catalog
*/
static async Catalog(): Promise<ISOCatalogEntry[]> {
return (
await APIClient.exec({
method: "GET",
uri: "/assets/iso_catalog.json",
})
).data;
}
/**
* Get catalog image URL
*/
static CatalogImageURL(entry: ISOCatalogEntry): string {
return APIClient.backendURL() + entry.image;
}
} }

View File

@@ -140,7 +140,7 @@ export interface NWFilter {
rules: NWFilterRule[]; rules: NWFilterRule[];
} }
export function NWFilterURL(n: NWFilter, edit: boolean = false): string { export function NWFilterURL(n: NWFilter, edit = false): string {
return `/nwfilter/${n.uuid}${edit ? "/edit" : ""}`; return `/nwfilter/${n.uuid}${edit ? "/edit" : ""}`;
} }
@@ -221,7 +221,7 @@ export class NWFilterApi {
static async Delete(n: NWFilter): Promise<void> { static async Delete(n: NWFilter): Promise<void> {
await APIClient.exec({ await APIClient.exec({
method: "DELETE", method: "DELETE",
uri: `/nwfilter/${n.uuid}`, uri: `/nwfilter/${n.uuid!}`,
}); });
} }
} }

View File

@@ -53,7 +53,7 @@ export interface NetworkInfo {
export type NetworkStatus = "Started" | "Stopped"; export type NetworkStatus = "Started" | "Stopped";
export function NetworkURL(n: NetworkInfo, edit: boolean = false): string { export function NetworkURL(n: NetworkInfo, edit = false): string {
return `/net/${n.uuid}${edit ? "/edit" : ""}`; return `/net/${n.uuid}${edit ? "/edit" : ""}`;
} }

View File

@@ -5,6 +5,7 @@ export interface ServerConfig {
local_auth_enabled: boolean; local_auth_enabled: boolean;
oidc_auth_enabled: boolean; oidc_auth_enabled: boolean;
iso_mimetypes: string[]; iso_mimetypes: string[];
disk_images_mimetypes: string[];
net_mac_prefix: string; net_mac_prefix: string;
builtin_nwfilter_rules: string[]; builtin_nwfilter_rules: string[];
nwfilter_chains: string[]; nwfilter_chains: string[];
@@ -13,6 +14,7 @@ export interface ServerConfig {
export interface ServerConstraints { export interface ServerConstraints {
iso_max_size: number; iso_max_size: number;
disk_image_max_size: number;
vnc_token_duration: number; vnc_token_duration: number;
vm_name_size: LenConstraint; vm_name_size: LenConstraint;
vm_title_size: LenConstraint; vm_title_size: LenConstraint;
@@ -20,6 +22,7 @@ export interface ServerConstraints {
memory_size: LenConstraint; memory_size: LenConstraint;
disk_name_size: LenConstraint; disk_name_size: LenConstraint;
disk_size: LenConstraint; disk_size: LenConstraint;
disk_image_name_size: LenConstraint;
net_name_size: LenConstraint; net_name_size: LenConstraint;
net_title_size: LenConstraint; net_title_size: LenConstraint;
net_nat_comment_size: LenConstraint; net_nat_comment_size: LenConstraint;
@@ -217,4 +220,16 @@ export class ServerApi {
}) })
).data; ).data;
} }
/**
* Get host networks bridges list
*/
static async GetNetworksBridgesList(): Promise<string[]> {
return (
await APIClient.exec({
method: "GET",
uri: "/server/bridges",
})
).data;
}
} }

View File

@@ -20,7 +20,7 @@ export interface APIToken {
max_inactivity?: number; max_inactivity?: number;
} }
export function APITokenURL(t: APIToken, edit: boolean = false): string { export function APITokenURL(t: APIToken, edit = false): string {
return `/token/${t.id}${edit ? "/edit" : ""}`; return `/token/${t.id}${edit ? "/edit" : ""}`;
} }

View File

@@ -17,19 +17,34 @@ export type VMState =
| "PowerManagementSuspended" | "PowerManagementSuspended"
| "Other"; | "Other";
export type DiskAllocType = "Sparse" | "Fixed"; export type VMFileDisk = BaseFileVMDisk & (RawVMDisk | QCow2Disk);
export interface VMDisk { export type DiskBusType = "Virtio" | "SATA";
export interface BaseFileVMDisk {
size: number; size: number;
name: string; name: string;
alloc_type: DiskAllocType; bus: DiskBusType;
delete: boolean; delete: boolean;
// application attribute // For new disk only
from_image?: string;
// application attributes
new?: boolean; new?: boolean;
deleteType?: "keepfile" | "deletefile"; deleteType?: "keepfile" | "deletefile";
} }
interface RawVMDisk {
format: "Raw";
is_sparse: boolean;
}
interface QCow2Disk {
format: "QCow2";
}
export interface VMNetInterfaceFilterParams { export interface VMNetInterfaceFilterParams {
name: string; name: string;
value: string; value: string;
@@ -40,11 +55,16 @@ export interface VMNetInterfaceFilter {
parameters: VMNetInterfaceFilterParams[]; parameters: VMNetInterfaceFilterParams[];
} }
export type VMNetInterface = (VMNetUserspaceSLIRPStack | VMNetDefinedNetwork) & export type VMNetInterface = (
| VMNetUserspaceSLIRPStack
| VMNetDefinedNetwork
| VMNetBridge
) &
VMNetInterfaceBase; VMNetInterfaceBase;
export interface VMNetInterfaceBase { export interface VMNetInterfaceBase {
mac: string; mac: string;
model: "Virtio" | "E1000";
nwfilterref?: VMNetInterfaceFilter; nwfilterref?: VMNetInterfaceFilter;
} }
@@ -57,6 +77,13 @@ export interface VMNetDefinedNetwork {
network: string; network: string;
} }
export interface VMNetBridge {
type: "Bridge";
bridge: string;
}
export type VMBootType = "UEFI" | "UEFISecureBoot" | "Legacy";
interface VMInfoInterface { interface VMInfoInterface {
name: string; name: string;
uuid?: string; uuid?: string;
@@ -64,15 +91,16 @@ interface VMInfoInterface {
title?: string; title?: string;
description?: string; description?: string;
group?: string; group?: string;
boot_type: "UEFI" | "UEFISecureBoot"; boot_type: VMBootType;
architecture: "i686" | "x86_64"; architecture: "i686" | "x86_64";
memory: number; memory: number;
number_vcpu: number; number_vcpu: number;
vnc_access: boolean; vnc_access: boolean;
iso_files: string[]; iso_files: string[];
disks: VMDisk[]; file_disks: VMFileDisk[];
networks: VMNetInterface[]; networks: VMNetInterface[];
tpm_module: boolean; tpm_module: boolean;
oem_strings: string[];
} }
export class VMInfo implements VMInfoInterface { export class VMInfo implements VMInfoInterface {
@@ -82,15 +110,16 @@ export class VMInfo implements VMInfoInterface {
title?: string; title?: string;
description?: string; description?: string;
group?: string; group?: string;
boot_type: "UEFI" | "UEFISecureBoot"; boot_type: VMBootType;
architecture: "i686" | "x86_64"; architecture: "i686" | "x86_64";
number_vcpu: number; number_vcpu: number;
memory: number; memory: number;
vnc_access: boolean; vnc_access: boolean;
iso_files: string[]; iso_files: string[];
disks: VMDisk[]; file_disks: VMFileDisk[];
networks: VMNetInterface[]; networks: VMNetInterface[];
tpm_module: boolean; tpm_module: boolean;
oem_strings: string[];
constructor(int: VMInfoInterface) { constructor(int: VMInfoInterface) {
this.name = int.name; this.name = int.name;
@@ -105,9 +134,10 @@ export class VMInfo implements VMInfoInterface {
this.memory = int.memory; this.memory = int.memory;
this.vnc_access = int.vnc_access; this.vnc_access = int.vnc_access;
this.iso_files = int.iso_files; this.iso_files = int.iso_files;
this.disks = int.disks; this.file_disks = int.file_disks;
this.networks = int.networks; this.networks = int.networks;
this.tpm_module = int.tpm_module; this.tpm_module = int.tpm_module;
this.oem_strings = int.oem_strings;
} }
static NewEmpty(): VMInfo { static NewEmpty(): VMInfo {
@@ -115,13 +145,14 @@ export class VMInfo implements VMInfoInterface {
name: "", name: "",
boot_type: "UEFI", boot_type: "UEFI",
architecture: "x86_64", architecture: "x86_64",
memory: 1024, memory: 1000 * 1000 * 1000,
number_vcpu: 1, number_vcpu: 1,
vnc_access: true, vnc_access: true,
iso_files: [], iso_files: [],
disks: [], file_disks: [],
networks: [], networks: [],
tpm_module: true, tpm_module: true,
oem_strings: [],
}); });
} }
@@ -194,8 +225,8 @@ export class VMApi {
*/ */
static async UpdateSingle(vm: VMInfo): Promise<VMInfo> { static async UpdateSingle(vm: VMInfo): Promise<VMInfo> {
// Process disks list, looking for removal // Process disks list, looking for removal
vm.disks = vm.disks.filter((d) => d.deleteType !== "keepfile"); vm.file_disks = vm.file_disks.filter((d) => d.deleteType !== "keepfile");
vm.disks.forEach((d) => { vm.file_disks.forEach((d) => {
if (d.deleteType === "deletefile") d.delete = true; if (d.deleteType === "deletefile") d.delete = true;
}); });

View File

@@ -0,0 +1,144 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import React from "react";
import { DiskImage, DiskImageApi, DiskImageFormat } from "../api/DiskImageApi";
import { ServerApi } from "../api/ServerApi";
import { VMFileDisk, VMInfo } from "../api/VMApi";
import { useAlert } from "../hooks/providers/AlertDialogProvider";
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
import { FileDiskImageWidget } from "../widgets/FileDiskImageWidget";
import { CheckboxInput } from "../widgets/forms/CheckboxInput";
import { SelectInput } from "../widgets/forms/SelectInput";
import { TextInput } from "../widgets/forms/TextInput";
import { VMDiskFileWidget } from "../widgets/vms/VMDiskFileWidget";
export function ConvertDiskImageDialog(
p: {
onCancel: () => void;
onFinished: () => void;
} & (
| { backup?: false; image: DiskImage }
| { backup: true; disk: VMFileDisk; vm: VMInfo }
)
): React.ReactElement {
const alert = useAlert();
const loadingMessage = useLoadingMessage();
const [format, setFormat] = React.useState<DiskImageFormat>({
format: "QCow2",
});
const origFilename = p.backup ? p.disk.name : p.image.file_name;
const [filename, setFilename] = React.useState(origFilename + ".qcow2");
const handleFormatChange = (value?: string) => {
setFormat({ format: value ?? ("QCow2" as any) });
if (value === "QCow2") setFilename(`${origFilename}.qcow2`);
if (value === "CompressedQCow2") setFilename(`${origFilename}.qcow2.gz`);
if (value === "Raw") {
setFilename(`${origFilename}.raw`);
// Check sparse checkbox by default
setFormat({ format: "Raw", is_sparse: true });
}
if (value === "CompressedRaw") setFilename(`${origFilename}.raw.gz`);
};
const handleSubmit = async () => {
try {
loadingMessage.show(
p.backup ? "Performing backup..." : "Converting image..."
);
// Perform the conversion / backup operation
if (p.backup)
await DiskImageApi.BackupVMDisk(p.vm, p.disk, filename, format);
else await DiskImageApi.Convert(p.image, filename, format);
p.onFinished();
alert(p.backup ? "Backup successful!" : "Conversion successful!");
} catch (e) {
console.error("Failed to perform backup/conversion!", e);
alert(
p.backup
? `Failed to perform backup! ${e}`
: `Failed to convert image! ${e}`
);
} finally {
loadingMessage.hide();
}
};
return (
<Dialog open onClose={p.onCancel}>
<DialogTitle>
{p.backup ? `Backup disk ${p.disk.name}` : "Convert disk image"}
</DialogTitle>
<DialogContent>
<DialogContentText>
Select the destination format for this image:
</DialogContentText>
{/* Show details of of the image */}
{p.backup ? (
<VMDiskFileWidget {...p} />
) : (
<FileDiskImageWidget {...p} />
)}
{/* New image format */}
<SelectInput
editable
label="Target format"
value={format.format}
onValueChange={handleFormatChange}
options={[
{ value: "QCow2" },
{ value: "Raw" },
{ value: "CompressedRaw" },
{ value: "CompressedQCow2" },
]}
/>
{/* Check for sparse file */}
{format.format === "Raw" && (
<CheckboxInput
editable
label="Sparse file"
checked={format.is_sparse}
onValueChange={(c) => {
setFormat({ format: "Raw", is_sparse: c });
}}
/>
)}
{/* New image name */}
<TextInput
editable
label="New image name"
value={filename}
onValueChange={(s) => {
setFilename(s ?? "");
}}
size={ServerApi.Config.constraints.disk_image_name_size}
helperText="The image name shall contain the proper file extension for the selected target format"
/>
</DialogContent>
<DialogActions>
<Button onClick={p.onCancel}>Cancel</Button>
<Button onClick={handleSubmit} autoFocus>
{p.backup ? "Perform backup" : "Convert image"}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,75 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List,
ListItem,
ListItemAvatar,
ListItemButton,
ListItemText,
} from "@mui/material";
import React from "react";
import { ISOCatalogEntry, IsoFilesApi } from "../api/IsoFilesApi";
import { AsyncWidget } from "../widgets/AsyncWidget";
export function IsoCatalogDialog(p: {
open: boolean;
onClose: () => void;
}): React.ReactElement {
const [catalog, setCatalog] = React.useState<ISOCatalogEntry[] | undefined>();
const load = async () => {
setCatalog(await IsoFilesApi.Catalog());
};
return (
<Dialog open={p.open} onClose={p.onClose}>
<DialogTitle>ISO catalog</DialogTitle>
<DialogContent>
<AsyncWidget
loadKey={1}
load={load}
errMsg="Failed to load catalog"
build={() => <IsoCatalogDialogInner catalog={catalog!} />}
/>
</DialogContent>
<DialogActions>
<Button autoFocus onClick={p.onClose}>
Close
</Button>
</DialogActions>
</Dialog>
);
}
export function IsoCatalogDialogInner(p: {
catalog: ISOCatalogEntry[];
}): React.ReactElement {
return (
<List dense>
{p.catalog.map((entry) => (
<a
key={entry.name}
href={entry.url}
target="_blank"
rel="noopener"
style={{ color: "inherit", textDecoration: "none" }}
>
<ListItem>
<ListItemButton>
<ListItemAvatar>
<img
src={IsoFilesApi.CatalogImageURL(entry)}
style={{ width: "2em" }}
/>
</ListItemAvatar>
<ListItemText primary={entry.name} />
</ListItemButton>
</ListItem>
</a>
))}
</List>
);
}

View File

@@ -39,7 +39,7 @@ export function AlertDialogProvider(p: PropsWithChildren): React.ReactElement {
return ( return (
<> <>
<AlertContextK.Provider value={hook}>{p.children}</AlertContextK.Provider> <AlertContextK value={hook}>{p.children}</AlertContextK>
<Dialog <Dialog
open={open} open={open}
@@ -67,5 +67,5 @@ export function AlertDialogProvider(p: PropsWithChildren): React.ReactElement {
} }
export function useAlert(): AlertContext { export function useAlert(): AlertContext {
return React.useContext(AlertContextK)!; return React.use(AlertContextK)!;
} }

View File

@@ -59,13 +59,13 @@ export function ConfirmDialogProvider(
return ( return (
<> <>
<ConfirmContextK.Provider value={hook}> <ConfirmContextK value={hook}>
{p.children} {p.children}
</ConfirmContextK.Provider> </ConfirmContextK>
<Dialog <Dialog
open={open} open={open}
onClose={() => handleClose(false)} onClose={() => { handleClose(false); }}
aria-labelledby="alert-dialog-title" aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description" aria-describedby="alert-dialog-description"
> >
@@ -76,10 +76,10 @@ export function ConfirmDialogProvider(
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => handleClose(false)} autoFocus> <Button onClick={() => { handleClose(false); }} autoFocus>
{cancelButton ?? "Cancel"} {cancelButton ?? "Cancel"}
</Button> </Button>
<Button onClick={() => handleClose(true)} color="error"> <Button onClick={() => { handleClose(true); }} color="error">
{confirmButton ?? "Confirm"} {confirmButton ?? "Confirm"}
</Button> </Button>
</DialogActions> </DialogActions>
@@ -89,5 +89,5 @@ export function ConfirmDialogProvider(
} }
export function useConfirm(): ConfirmContext { export function useConfirm(): ConfirmContext {
return React.useContext(ConfirmContextK)!; return React.use(ConfirmContextK)!;
} }

View File

@@ -6,10 +6,10 @@ import {
} from "@mui/material"; } from "@mui/material";
import React, { PropsWithChildren } from "react"; import React, { PropsWithChildren } from "react";
type LoadingMessageContext = { interface LoadingMessageContext {
show: (message: string) => void; show: (message: string) => void;
hide: () => void; hide: () => void;
}; }
const LoadingMessageContextK = const LoadingMessageContextK =
React.createContext<LoadingMessageContext | null>(null); React.createContext<LoadingMessageContext | null>(null);
@@ -34,9 +34,9 @@ export function LoadingMessageProvider(
return ( return (
<> <>
<LoadingMessageContextK.Provider value={hook}> <LoadingMessageContextK value={hook}>
{p.children} {p.children}
</LoadingMessageContextK.Provider> </LoadingMessageContextK>
<Dialog open={open}> <Dialog open={open}>
<DialogContent> <DialogContent>
@@ -60,5 +60,5 @@ export function LoadingMessageProvider(
} }
export function useLoadingMessage(): LoadingMessageContext { export function useLoadingMessage(): LoadingMessageContext {
return React.useContext(LoadingMessageContextK)!; return React.use(LoadingMessageContextK)!;
} }

View File

@@ -24,9 +24,9 @@ export function SnackbarProvider(p: PropsWithChildren): React.ReactElement {
return ( return (
<> <>
<SnackbarContextK.Provider value={hook}> <SnackbarContextK value={hook}>
{p.children} {p.children}
</SnackbarContextK.Provider> </SnackbarContextK>
<Snackbar <Snackbar
open={open} open={open}
@@ -39,5 +39,5 @@ export function SnackbarProvider(p: PropsWithChildren): React.ReactElement {
} }
export function useSnackbar(): SnackbarContext { export function useSnackbar(): SnackbarContext {
return React.useContext(SnackbarContextK)!; return React.use(SnackbarContextK)!;
} }

View File

@@ -21,7 +21,7 @@ const darkTheme = createTheme({
}); });
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement document.getElementById("root")!
); );
root.render( root.render(
<React.StrictMode> <React.StrictMode>

View File

@@ -0,0 +1,417 @@
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import LoopIcon from "@mui/icons-material/Loop";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import RefreshIcon from "@mui/icons-material/Refresh";
import {
Alert,
Button,
CircularProgress,
IconButton,
LinearProgress,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Tooltip,
Typography,
} from "@mui/material";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { filesize } from "filesize";
import React from "react";
import { DiskImage, DiskImageApi } from "../api/DiskImageApi";
import { ServerApi } from "../api/ServerApi";
import { ConvertDiskImageDialog } from "../dialogs/ConvertDiskImageDialog";
import { useAlert } from "../hooks/providers/AlertDialogProvider";
import { useConfirm } from "../hooks/providers/ConfirmDialogProvider";
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
import { useSnackbar } from "../hooks/providers/SnackbarProvider";
import { downloadBlob } from "../utils/FilesUtils";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { DateWidget } from "../widgets/DateWidget";
import { FileInput } from "../widgets/forms/FileInput";
import { VirtWebPaper } from "../widgets/VirtWebPaper";
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
export function DiskImagesRoute(): React.ReactElement {
const [list, setList] = React.useState<DiskImage[] | undefined>();
const loadKey = React.useRef(1);
const load = async () => {
setList(await DiskImageApi.GetList());
};
const reload = () => {
loadKey.current += 1;
setList(undefined);
};
return (
<VirtWebRouteContainer
label="Disk images management"
actions={
<span>
<Tooltip title="Refresh Disk images list">
<IconButton onClick={reload}>
<RefreshIcon />
</IconButton>
</Tooltip>
</span>
}
>
<AsyncWidget
loadKey={loadKey.current}
errMsg="Failed to load disk images list!"
load={load}
ready={list !== undefined}
build={() => (
<>
<UploadDiskImageCard onFileUploaded={reload} />
<DiskImageList list={list!} onReload={reload} />
</>
)}
/>
</VirtWebRouteContainer>
);
}
function UploadDiskImageCard(p: {
onFileUploaded: () => void;
}): React.ReactElement {
const alert = useAlert();
const snackbar = useSnackbar();
const [value, setValue] = React.useState<File | null>(null);
const [uploadProgress, setUploadProgress] = React.useState<number | null>(
null
);
const handleChange = (newValue: File | null) => {
if (
newValue &&
newValue.size > ServerApi.Config.constraints.disk_image_max_size
) {
alert(
`The file is too big (max size allowed: ${filesize(
ServerApi.Config.constraints.disk_image_max_size
)}`
);
return;
}
if (
newValue &&
newValue.type.length > 0 &&
!ServerApi.Config.disk_images_mimetypes.includes(newValue.type)
) {
alert(`Selected file mimetype is not allowed! (${newValue.type})`);
return;
}
setValue(newValue);
};
const upload = async () => {
try {
setUploadProgress(0);
await DiskImageApi.Upload(value!, setUploadProgress);
setValue(null);
snackbar("The file was successfully uploaded!");
p.onFileUploaded();
} catch (e) {
console.error(e);
await alert(`Failed to perform file upload! ${e}`);
}
setUploadProgress(null);
};
if (uploadProgress !== null) {
return (
<VirtWebPaper label="File upload" noHorizontalMargin>
<Typography variant="body1">
Upload in progress ({Math.floor(uploadProgress * 100)}%)...
</Typography>
<LinearProgress variant="determinate" value={uploadProgress * 100} />
</VirtWebPaper>
);
}
return (
<VirtWebPaper label="Disk image upload" noHorizontalMargin>
<div style={{ display: "flex", alignItems: "center" }}>
<FileInput
value={value}
onChange={handleChange}
style={{ flex: 1 }}
slotProps={{
htmlInput: {
accept: ServerApi.Config.disk_images_mimetypes.join(","),
},
}}
/>
{value && <Button onClick={upload}>Upload</Button>}
</div>
</VirtWebPaper>
);
}
function DiskImageList(p: {
list: DiskImage[];
onReload: () => void;
}): React.ReactElement {
const alert = useAlert();
const snackbar = useSnackbar();
const confirm = useConfirm();
const loadingMessage = useLoadingMessage();
const [dlProgress, setDlProgress] = React.useState<undefined | number>();
const [currConversion, setCurrConversion] = React.useState<
DiskImage | undefined
>();
// Download disk image file
const downloadDiskImage = async (entry: DiskImage) => {
setDlProgress(0);
try {
const blob = await DiskImageApi.Download(entry, setDlProgress);
downloadBlob(blob, entry.file_name);
} catch (e) {
console.error(e);
alert(`Failed to download disk image file! ${e}`);
}
setDlProgress(undefined);
};
// Convert disk image file
const convertDiskImage = (entry: DiskImage) => {
setCurrConversion(entry);
};
// Delete disk image
const deleteDiskImage = async (entry: DiskImage) => {
if (
!(await confirm(
`Do you really want to delete this disk image (${entry.file_name}) ?`
))
)
return;
loadingMessage.show("Deleting disk image file...");
try {
await DiskImageApi.Delete(entry);
snackbar("The disk image has been successfully deleted!");
p.onReload();
} catch (e) {
console.error(e);
alert(`Failed to delete disk image!\n${e}`);
}
loadingMessage.hide();
};
if (p.list.length === 0)
return (
<Typography variant="body1" style={{ textAlign: "center" }}>
No disk image uploaded for now.
</Typography>
);
const columns: GridColDef<(typeof p.list)[number]>[] = [
{ field: "file_name", headerName: "File name", flex: 3, editable: true },
{
field: "format",
headerName: "Format",
flex: 1,
renderCell(params) {
let content = params.row.format;
if (params.row.format === "Raw") {
content += params.row.is_sparse ? " (Sparse)" : " (Fixed)";
}
return content;
},
},
{
field: "file_size",
headerName: "File size",
flex: 1,
renderCell(params) {
let res = filesize(params.row.file_size);
if (params.row.format === "QCow2") {
res += ` (${filesize(params.row.virtual_size!)})`;
}
return res;
},
},
{
field: "created",
headerName: "Created",
flex: 1,
renderCell(params) {
return <DateWidget time={params.row.created} />;
},
},
{
field: "actions",
type: "actions",
headerName: "",
width: 55,
cellClassName: "actions",
editable: false,
getActions: (params) => {
return [
<DiskImageActionMenu
key="menu"
diskImage={params.row}
onDownload={downloadDiskImage}
onConvert={convertDiskImage}
onDelete={deleteDiskImage}
/>,
];
},
},
];
return (
<>
{/* Download notification */}
{dlProgress !== undefined && (
<Alert severity="info">
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
overflow: "hidden",
}}
>
<Typography variant="body1">
Downloading... {dlProgress}%
</Typography>
<CircularProgress
variant="determinate"
size={"1.5rem"}
style={{ marginLeft: "10px" }}
value={dlProgress}
/>
</div>
</Alert>
)}
{/* Disk image conversion dialog */}
{currConversion && (
<ConvertDiskImageDialog
image={currConversion}
onCancel={() => {
setCurrConversion(undefined);
}}
onFinished={() => {
setCurrConversion(undefined);
p.onReload();
}}
/>
)}
{/* The table itself */}
<DataGrid<DiskImage>
getRowId={(c) => c.file_name}
rows={p.list}
columns={columns}
processRowUpdate={async (n, o) => {
try {
await DiskImageApi.Rename(o, n.file_name);
return n;
} catch (e) {
console.error("Failed to rename disk image!", e);
alert(`Failed to rename disk image! ${e}`);
throw e;
} finally {
p.onReload();
}
}}
/>
</>
);
}
function DiskImageActionMenu(p: {
diskImage: DiskImage;
onDownload: (d: DiskImage) => void;
onConvert: (d: DiskImage) => void;
onDelete: (d: DiskImage) => void;
}): React.ReactElement {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<>
<IconButton
aria-label="Actions"
aria-haspopup="true"
onClick={handleClick}
>
<MoreVertIcon />
</IconButton>
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
{/* Download disk image */}
<MenuItem
onClick={() => {
handleClose();
p.onDownload(p.diskImage);
}}
>
<ListItemIcon>
<DownloadIcon />
</ListItemIcon>
<ListItemText secondary={"Download disk image"}>
Download
</ListItemText>
</MenuItem>
{/* Convert disk image */}
<MenuItem
onClick={() => {
handleClose();
p.onConvert(p.diskImage);
}}
>
<ListItemIcon>
<LoopIcon />
</ListItemIcon>
<ListItemText secondary={"Convert disk image"}>Convert</ListItemText>
</MenuItem>
{/* Delete disk image */}
<MenuItem
onClick={() => {
handleClose();
p.onDelete(p.diskImage);
}}
>
<ListItemIcon>
<DeleteIcon color="error" />
</ListItemIcon>
<ListItemText secondary={"Delete disk image"}>Delete</ListItemText>
</MenuItem>
</Menu>
</>
);
}

View File

@@ -116,7 +116,7 @@ function EditApiTokenRouteInner(p: {
const [changed, setChanged] = React.useState(false); const [changed, setChanged] = React.useState(false);
const [, updateState] = React.useState<any>(); const [, updateState] = React.useState<any>();
const forceUpdate = React.useCallback(() => updateState({}), []); const forceUpdate = React.useCallback(() => { updateState({}); }, []);
const valueChanged = () => { const valueChanged = () => {
setChanged(true); setChanged(true);

View File

@@ -99,7 +99,7 @@ function EditNetworkFilterRouteInner(p: {
const [changed, setChanged] = React.useState(false); const [changed, setChanged] = React.useState(false);
const [, updateState] = React.useState<any>(); const [, updateState] = React.useState<any>();
const forceUpdate = React.useCallback(() => updateState({}), []); const forceUpdate = React.useCallback(() => { updateState({}); }, []);
const valueChanged = () => { const valueChanged = () => {
setChanged(true); setChanged(true);

View File

@@ -97,7 +97,7 @@ function EditNetworkRouteInner(p: {
const [changed, setChanged] = React.useState(false); const [changed, setChanged] = React.useState(false);
const [, updateState] = React.useState<any>(); const [, updateState] = React.useState<any>();
const forceUpdate = React.useCallback(() => updateState({}), []); const forceUpdate = React.useCallback(() => { updateState({}); }, []);
const valueChanged = () => { const valueChanged = () => {
setChanged(true); setChanged(true);

View File

@@ -15,7 +15,7 @@ export function CreateVMRoute(): React.ReactElement {
const alert = useAlert(); const alert = useAlert();
const navigate = useNavigate(); const navigate = useNavigate();
const [vm, setVM] = React.useState(VMInfo.NewEmpty); const [vm, setVM] = React.useState(VMInfo.NewEmpty());
const create = async (v: VMInfo) => { const create = async (v: VMInfo) => {
try { try {
@@ -103,7 +103,9 @@ function EditVMInner(p: {
const [changed, setChanged] = React.useState(false); const [changed, setChanged] = React.useState(false);
const [, updateState] = React.useState<any>(); const [, updateState] = React.useState<any>();
const forceUpdate = React.useCallback(() => updateState({}), []); const forceUpdate = React.useCallback(() => {
updateState({});
}, []);
const valueChanged = () => { const valueChanged = () => {
setChanged(true); setChanged(true);

View File

@@ -1,4 +1,7 @@
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import MenuBookIcon from "@mui/icons-material/MenuBook";
import RefreshIcon from "@mui/icons-material/Refresh";
import { import {
Alert, Alert,
Button, Button,
@@ -9,24 +12,25 @@ import {
Tooltip, Tooltip,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import DownloadIcon from "@mui/icons-material/Download";
import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { filesize } from "filesize"; import { filesize } from "filesize";
import { MuiFileInput } from "mui-file-input";
import React from "react"; import React from "react";
import { IsoFile, IsoFilesApi } from "../api/IsoFilesApi"; import { IsoFile, IsoFilesApi } from "../api/IsoFilesApi";
import { ServerApi } from "../api/ServerApi"; import { ServerApi } from "../api/ServerApi";
import { IsoCatalogDialog } from "../dialogs/IsoCatalogDialog";
import { useAlert } from "../hooks/providers/AlertDialogProvider"; import { useAlert } from "../hooks/providers/AlertDialogProvider";
import { useConfirm } from "../hooks/providers/ConfirmDialogProvider";
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider"; import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
import { useSnackbar } from "../hooks/providers/SnackbarProvider"; import { useSnackbar } from "../hooks/providers/SnackbarProvider";
import { downloadBlob } from "../utils/FilesUtils";
import { AsyncWidget } from "../widgets/AsyncWidget"; import { AsyncWidget } from "../widgets/AsyncWidget";
import { FileInput } from "../widgets/forms/FileInput";
import { VirtWebPaper } from "../widgets/VirtWebPaper"; import { VirtWebPaper } from "../widgets/VirtWebPaper";
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
import { useConfirm } from "../hooks/providers/ConfirmDialogProvider";
import { downloadBlob } from "../utils/FilesUtils";
export function IsoFilesRoute(): React.ReactElement { export function IsoFilesRoute(): React.ReactElement {
const [list, setList] = React.useState<IsoFile[] | undefined>(); const [list, setList] = React.useState<IsoFile[] | undefined>();
const [isoCatalog, setIsoCatalog] = React.useState(false);
const loadKey = React.useRef(1); const loadKey = React.useRef(1);
@@ -40,19 +44,41 @@ export function IsoFilesRoute(): React.ReactElement {
}; };
return ( return (
<>
<AsyncWidget <AsyncWidget
loadKey={loadKey.current} loadKey={loadKey.current}
errMsg="Failed to load ISO files list!" errMsg="Failed to load ISO files list!"
load={load} load={load}
ready={list !== undefined} ready={list !== undefined}
build={() => ( build={() => (
<VirtWebRouteContainer label="ISO files management"> <VirtWebRouteContainer
label="ISO files management"
actions={
<span>
<Tooltip title="Open the ISO catalog">
<IconButton onClick={() => { setIsoCatalog(true); }}>
<MenuBookIcon />
</IconButton>
</Tooltip>
<Tooltip title="Refresh ISO list">
<IconButton onClick={reload}>
<RefreshIcon />
</IconButton>
</Tooltip>
</span>
}
>
<UploadIsoFileCard onFileUploaded={reload} /> <UploadIsoFileCard onFileUploaded={reload} />
<UploadIsoFileFromUrlCard onFileUploaded={reload} /> <UploadIsoFileFromUrlCard onFileUploaded={reload} />
<IsoFilesList list={list!} onReload={reload} /> <IsoFilesList list={list!} onReload={reload} />
</VirtWebRouteContainer> </VirtWebRouteContainer>
)} )}
/> />
<IsoCatalogDialog
open={isoCatalog}
onClose={() => { setIsoCatalog(false); }}
/>
</>
); );
} }
@@ -96,7 +122,7 @@ function UploadIsoFileCard(p: {
p.onFileUploaded(); p.onFileUploaded();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
await alert("Failed to perform file upload! " + e); await alert(`Failed to perform file upload! ${e}`);
} }
setUploadProgress(null); setUploadProgress(null);
@@ -104,7 +130,7 @@ function UploadIsoFileCard(p: {
if (uploadProgress !== null) { if (uploadProgress !== null) {
return ( return (
<VirtWebPaper label="File upload"> <VirtWebPaper label="File upload" noHorizontalMargin>
<Typography variant="body1"> <Typography variant="body1">
Upload in progress ({Math.floor(uploadProgress * 100)}%)... Upload in progress ({Math.floor(uploadProgress * 100)}%)...
</Typography> </Typography>
@@ -114,13 +140,15 @@ function UploadIsoFileCard(p: {
} }
return ( return (
<VirtWebPaper label="File upload"> <VirtWebPaper label="File upload" noHorizontalMargin>
<div style={{ display: "flex", alignItems: "center" }}> <div style={{ display: "flex", alignItems: "center" }}>
<MuiFileInput <FileInput
value={value} value={value}
onChange={handleChange} onChange={handleChange}
style={{ flex: 1 }} style={{ flex: 1 }}
inputProps={{ accept: ServerApi.Config.iso_mimetypes.join(",") }} slotProps={{
htmlInput: { accept: ServerApi.Config.iso_mimetypes.join(",") },
}}
/> />
{value && <Button onClick={upload}>Upload file</Button>} {value && <Button onClick={upload}>Upload file</Button>}
@@ -160,20 +188,24 @@ function UploadIsoFileFromUrlCard(p: {
}; };
return ( return (
<VirtWebPaper label="File upload from URL"> <VirtWebPaper label="File upload from URL" noHorizontalMargin>
<div style={{ display: "flex", alignItems: "center" }}> <div style={{ display: "flex", alignItems: "center" }}>
<TextField <TextField
label="URL" label="URL"
value={url} value={url}
style={{ flex: 3 }} style={{ flex: 3 }}
onChange={(e) => setURL(e.target.value)} onChange={(e) => {
setURL(e.target.value);
}}
/> />
<span style={{ width: "10px" }}></span> <span style={{ width: "10px" }}></span>
<TextField <TextField
label="Filename" label="Filename"
value={actualFileName} value={actualFileName}
style={{ flex: 2 }} style={{ flex: 2 }}
onChange={(e) => setFilename(e.target.value)} onChange={(e) => {
setFilename(e.target.value);
}}
/> />
{url !== "" && actualFileName !== "" && ( {url !== "" && actualFileName !== "" && (
<Button onClick={upload}>Upload file</Button> <Button onClick={upload}>Upload file</Button>
@@ -200,7 +232,7 @@ function IsoFilesList(p: {
try { try {
const blob = await IsoFilesApi.Download(entry, setDlProgress); const blob = await IsoFilesApi.Download(entry, setDlProgress);
await downloadBlob(blob, entry.filename); downloadBlob(blob, entry.filename);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert("Failed to download iso file!"); alert("Failed to download iso file!");
@@ -238,7 +270,7 @@ function IsoFilesList(p: {
</Typography> </Typography>
); );
const columns: GridColDef[] = [ const columns: GridColDef<IsoFile>[] = [
{ field: "filename", headerName: "File name", flex: 3 }, { field: "filename", headerName: "File name", flex: 3 },
{ {
field: "size", field: "size",
@@ -273,7 +305,6 @@ function IsoFilesList(p: {
return ( return (
<> <>
<VirtWebPaper label="Files list">
{/* Download notification */} {/* Download notification */}
{dlProgress !== undefined && ( {dlProgress !== undefined && (
<Alert severity="info"> <Alert severity="info">
@@ -297,15 +328,8 @@ function IsoFilesList(p: {
</div> </div>
</Alert> </Alert>
)} )}
{/* ISO files list table */}
{/* Files list table */} <DataGrid getRowId={(c) => c.filename} rows={p.list} columns={columns} />
<DataGrid
getRowId={(c) => c.filename}
rows={p.list}
columns={columns}
autoHeight={true}
/>
</VirtWebPaper>
</> </>
); );
} }

View File

@@ -66,7 +66,7 @@ function NetworkFiltersListRouteInner(p: {
const onlyBuiltin = visibleFilters === VisibleFilters.Builtin; const onlyBuiltin = visibleFilters === VisibleFilters.Builtin;
return p.list.filter((f) => NWFilterIsBuiltin(f) === onlyBuiltin); return p.list.filter((f) => NWFilterIsBuiltin(f) === onlyBuiltin);
}, [visibleFilters]); }, [visibleFilters, p.list]);
return ( return (
<VirtWebRouteContainer <VirtWebRouteContainer
@@ -78,7 +78,9 @@ function NetworkFiltersListRouteInner(p: {
size="small" size="small"
value={visibleFilters} value={visibleFilters}
exclusive exclusive
onChange={(_ev, v) => setVisibleFilters(v)} onChange={(_ev, v) => {
setVisibleFilters(v);
}}
aria-label="visible filters" aria-label="visible filters"
> >
<ToggleButton value={VisibleFilters.All}>All</ToggleButton> <ToggleButton value={VisibleFilters.All}>All</ToggleButton>
@@ -130,8 +132,8 @@ function NetworkFiltersListRouteInner(p: {
</TableCell> </TableCell>
<TableCell> <TableCell>
<ul> <ul>
{t.join_filters.map((f, n) => ( {t.join_filters.map((f) => (
<li key={n}>{f}</li> <li key={f}>{f}</li>
))} ))}
</ul> </ul>
</TableCell> </TableCell>

View File

@@ -3,7 +3,15 @@ import { RouterLink } from "../widgets/RouterLink";
export function NotFoundRoute(): React.ReactElement { export function NotFoundRoute(): React.ReactElement {
return ( return (
<div style={{ textAlign: "center" }}> <div
style={{
textAlign: "center",
flex: 1,
justifyContent: "center",
display: "flex",
flexDirection: "column",
}}
>
<h1>404 Not found</h1> <h1>404 Not found</h1>
<p>The page you requested was not found!</p> <p>The page you requested was not found!</p>
<RouterLink to="/"> <RouterLink to="/">

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-x/no-array-index-key */
import { import {
mdiHarddisk, mdiHarddisk,
mdiInformation, mdiInformation,
@@ -236,7 +237,7 @@ export function SysInfoRouteInner(p: {
function SysInfoDetailsTable(p: { function SysInfoDetailsTable(p: {
label: string; label: string;
icon: React.ReactElement; icon: React.ReactElement;
entries: Array<{ label: string; value: string | number }>; entries: { label: string; value: string | number }[];
}): React.ReactElement { }): React.ReactElement {
return ( return (
<VirtWebPaper <VirtWebPaper

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-x/no-array-index-key */
import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityIcon from "@mui/icons-material/Visibility";
import { import {
Button, Button,
@@ -9,6 +10,7 @@ import {
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
Typography,
} from "@mui/material"; } from "@mui/material";
import React from "react"; import React from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -57,6 +59,7 @@ export function TokensListRouteInner(p: {
</RouterLink> </RouterLink>
} }
> >
{p.list.length > 0 && (
<TableContainer component={Paper}> <TableContainer component={Paper}>
<Table> <Table>
<TableHead> <TableHead>
@@ -99,9 +102,9 @@ export function TokensListRouteInner(p: {
{t.max_inactivity && timeDiff(0, t.max_inactivity)} {t.max_inactivity && timeDiff(0, t.max_inactivity)}
</TableCell> </TableCell>
<TableCell> <TableCell>
{t.rights.map((r) => { {t.rights.map((r, n) => {
return ( return (
<div> <div key={n}>
{r.verb} {r.path} {r.verb} {r.path}
</div> </div>
); );
@@ -121,6 +124,13 @@ export function TokensListRouteInner(p: {
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
)}
{p.list.length === 0 && (
<Typography style={{ textAlign: "center" }}>
No API token created yet.
</Typography>
)}
</VirtWebRouteContainer> </VirtWebRouteContainer>
); );
} }

View File

@@ -25,13 +25,13 @@ import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
import { VMStatusWidget } from "../widgets/vms/VMStatusWidget"; import { VMStatusWidget } from "../widgets/vms/VMStatusWidget";
export function VMListRoute(): React.ReactElement { export function VMListRoute(): React.ReactElement {
const [groups, setGroups] = React.useState<Array<string | undefined>>(); const [groups, setGroups] = React.useState<(string | undefined)[]>();
const [list, setList] = React.useState<VMInfo[] | undefined>(); const [list, setList] = React.useState<VMInfo[] | undefined>();
const loadKey = React.useRef(1); const loadKey = React.useRef(1);
const load = async () => { const load = async () => {
const groups: Array<string | undefined> = await GroupApi.GetList(); const groups: (string | undefined)[] = await GroupApi.GetList();
const list = await VMApi.GetList(); const list = await VMApi.GetList();
if (list.find((v) => !v.group) !== undefined) groups.push(undefined); if (list.find((v) => !v.group) !== undefined) groups.push(undefined);
@@ -70,7 +70,7 @@ export function VMListRoute(): React.ReactElement {
} }
function VMListWidget(p: { function VMListWidget(p: {
groups: Array<string | undefined>; groups: (string | undefined)[];
list: VMInfo[]; list: VMInfo[];
onReload: () => void; onReload: () => void;
}): React.ReactElement { }): React.ReactElement {
@@ -115,8 +115,8 @@ function VMListWidget(p: {
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{p.groups.map((g, num) => ( {p.groups.map((g) => (
<React.Fragment key={num}> <React.Fragment key={g}>
{p.groups.length > 1 && ( {p.groups.length > 1 && (
<TableRow> <TableRow>
<TableCell <TableCell
@@ -125,9 +125,11 @@ function VMListWidget(p: {
> >
<IconButton <IconButton
size="small" size="small"
onClick={() => toggleHiddenGroup(g)} onClick={() => {
toggleHiddenGroup(g);
}}
> >
{!hiddenGroups?.has(g) ? ( {!hiddenGroups.has(g) ? (
<KeyboardArrowUpIcon /> <KeyboardArrowUpIcon />
) : ( ) : (
<KeyboardArrowDownIcon /> <KeyboardArrowDownIcon />
@@ -152,12 +154,14 @@ function VMListWidget(p: {
{row.name} {row.name}
</TableCell> </TableCell>
<TableCell>{row.description ?? ""}</TableCell> <TableCell>{row.description ?? ""}</TableCell>
<TableCell>{vmMemoryToHuman(row.memory)}</TableCell> <TableCell>{filesize(row.memory)}</TableCell>
<TableCell>{row.number_vcpu}</TableCell> <TableCell>{row.number_vcpu}</TableCell>
<TableCell> <TableCell>
<VMStatusWidget <VMStatusWidget
vm={row} vm={row}
onChange={(s) => updateVMState(row, s)} onChange={(s) => {
updateVMState(row, s);
}}
/> />
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -179,13 +183,13 @@ function VMListWidget(p: {
<TableCell></TableCell> <TableCell></TableCell>
<TableCell></TableCell> <TableCell></TableCell>
<TableCell> <TableCell>
{vmMemoryToHuman( {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)
)} )}
{" / "} {" / "}
{vmMemoryToHuman(p.list.reduce((s, v) => s + v.memory, 0))} {filesize(p.list.reduce((s, v) => s + v.memory, 0))}
</TableCell> </TableCell>
<TableCell> <TableCell>
{p.list {p.list
@@ -202,7 +206,3 @@ function VMListWidget(p: {
</TableContainer> </TableContainer>
); );
} }
function vmMemoryToHuman(size: number): string {
return filesize(size * 1000 * 1000);
}

View File

@@ -59,6 +59,7 @@ function VMRouteBody(p: { vm: VMInfo }): React.ReactElement {
<VMDetails <VMDetails
vm={p.vm} vm={p.vm}
editable={false} editable={false}
state={state}
screenshot={p.vm.vnc_access && state === "Running"} screenshot={p.vm.vnc_access && state === "Running"}
/> />
</VirtWebRouteContainer> </VirtWebRouteContainer>

View File

@@ -44,8 +44,8 @@ function VNCInner(p: { vm: VMInfo }): React.ReactElement {
const [counter, setCounter] = React.useState(1); const [counter, setCounter] = React.useState(1);
const [connected, setConnected] = React.useState(false); const [connected, setConnected] = React.useState(false);
const vncRef = React.createRef<HTMLDivElement>(); const vncRef = React.useRef<HTMLDivElement>(null);
const vncScreenRef = React.createRef<VncScreenHandle>(); const vncScreenRef = React.useRef<VncScreenHandle>(null);
const connect = async (force: boolean) => { const connect = async (force: boolean) => {
try { try {
@@ -91,7 +91,9 @@ function VNCInner(p: { vm: VMInfo }): React.ReactElement {
connect(false); connect(false);
if (vncRef.current) { if (vncRef.current) {
vncRef.current.onfullscreenchange = () => setCounter(counter + 1); vncRef.current.onfullscreenchange = () => {
setCounter(counter + 1);
};
} }
}); });
@@ -140,10 +142,12 @@ function VNCInner(p: { vm: VMInfo }): React.ReactElement {
ref={vncScreenRef} ref={vncScreenRef}
url={token.url} url={token.url}
onDisconnect={() => { onDisconnect={() => {
console.info("VNC disconnected " + token?.url); console.info("VNC disconnected " + token.url);
disconnected(); disconnected();
}} }}
onConnect={() => setConnected(true)} onConnect={() => {
setConnected(true);
}}
/> />
</div> </div>
</div> </div>

View File

@@ -36,7 +36,7 @@ export function LoginRoute(): React.ReactElement {
const canSubmit = username.length > 0 && password.length > 0; const canSubmit = username.length > 0 && password.length > 0;
const [showPassword, setShowPassword] = React.useState(false); const [showPassword, setShowPassword] = React.useState(false);
const handleClickShowPassword = () => setShowPassword((show) => !show); const handleClickShowPassword = () => { setShowPassword((show) => !show); };
const handleMouseDownPassword = ( const handleMouseDownPassword = (
event: React.MouseEvent<HTMLButtonElement> event: React.MouseEvent<HTMLButtonElement>
@@ -105,7 +105,7 @@ export function LoginRoute(): React.ReactElement {
label="Username" label="Username"
name="username" name="username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => { setUsername(e.target.value); }}
autoComplete="username" autoComplete="username"
autoFocus autoFocus
/> />
@@ -120,7 +120,7 @@ export function LoginRoute(): React.ReactElement {
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
id="password" id="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => { setPassword(e.target.value); }}
autoComplete="current-password" autoComplete="current-password"
endAdornment={ endAdornment={
<InputAdornment position="end"> <InputAdornment position="end">

View File

@@ -1,4 +1,4 @@
export async function downloadBlob(blob: Blob, filename: string) { export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");

View File

@@ -2,8 +2,9 @@
* Generate a random MAC address * Generate a random MAC address
*/ */
export function randomMacAddress(prefix: string | undefined): string { export function randomMacAddress(prefix: string | undefined): string {
prefix = prefix ?? "";
let mac = "XX:XX:XX:XX:XX:XX"; let mac = "XX:XX:XX:XX:XX:XX";
mac = prefix + mac.slice(prefix?.length); mac = prefix + mac.slice(prefix.length);
return mac.replace(/X/g, () => return mac.replace(/X/g, () =>
"0123456789abcdef".charAt(Math.floor(Math.random() * 16)) "0123456789abcdef".charAt(Math.floor(Math.random() * 16))

View File

@@ -19,7 +19,7 @@ export function AsyncWidget(p: {
}): React.ReactElement { }): React.ReactElement {
const [state, setState] = useState(State.Loading); const [state, setState] = useState(State.Loading);
const counter = useRef<any | null>(null); const counter = useRef<any>(null);
const load = async () => { const load = async () => {
try { try {
@@ -67,7 +67,7 @@ export function AsyncWidget(p: {
<Button onClick={load}>Try again</Button> <Button onClick={load}>Try again</Button>
{p.errAdditionalElement && p.errAdditionalElement()} {p.errAdditionalElement?.()}
</Box> </Box>
) )
); );

View File

@@ -2,6 +2,7 @@ import {
mdiApi, mdiApi,
mdiBoxShadow, mdiBoxShadow,
mdiDisc, mdiDisc,
mdiHarddisk,
mdiHome, mdiHome,
mdiInformation, mdiInformation,
mdiLan, mdiLan,
@@ -13,7 +14,6 @@ import {
List, List,
ListItemButton, ListItemButton,
ListItemIcon, ListItemIcon,
ListItemSecondaryAction,
ListItemText, ListItemText,
} from "@mui/material"; } from "@mui/material";
import { Outlet, useLocation } from "react-router-dom"; import { Outlet, useLocation } from "react-router-dom";
@@ -67,6 +67,11 @@ export function BaseAuthenticatedPage(): React.ReactElement {
uri="/nwfilter" uri="/nwfilter"
icon={<Icon path={mdiSecurityNetwork} size={1} />} icon={<Icon path={mdiSecurityNetwork} size={1} />}
/> />
<NavLink
label="Disk images"
uri="/disk_images"
icon={<Icon path={mdiHarddisk} size={1} />}
/>
<NavLink <NavLink
label="ISO files" label="ISO files"
uri="/iso" uri="/iso"
@@ -83,7 +88,15 @@ export function BaseAuthenticatedPage(): React.ReactElement {
icon={<Icon path={mdiInformation} size={1} />} icon={<Icon path={mdiInformation} size={1} />}
/> />
</List> </List>
<div style={{ flex: 1 }}> <div
style={{
flexGrow: 1,
flexShrink: 0,
flexBasis: 0,
minWidth: 0,
display: "flex",
}}
>
<Outlet /> <Outlet />
</div> </div>
</Box> </Box>
@@ -95,7 +108,6 @@ function NavLink(p: {
icon: React.ReactElement; icon: React.ReactElement;
uri: string; uri: string;
label: string; label: string;
secondaryAction?: React.ReactElement;
}): React.ReactElement { }): React.ReactElement {
const location = useLocation(); const location = useLocation();
return ( return (
@@ -103,9 +115,6 @@ function NavLink(p: {
<ListItemButton selected={p.uri === location.pathname}> <ListItemButton selected={p.uri === location.pathname}>
<ListItemIcon>{p.icon}</ListItemIcon> <ListItemIcon>{p.icon}</ListItemIcon>
<ListItemText primary={p.label} /> <ListItemText primary={p.label} />
{p.secondaryAction && (
<ListItemSecondaryAction>{p.secondaryAction}</ListItemSecondaryAction>
)}
</ListItemButton> </ListItemButton>
</RouterLink> </RouterLink>
); );

View File

@@ -31,14 +31,16 @@ export function ConfigImportExportButtons(p: {
fileEl.click(); fileEl.click();
// Wait for a file to be chosen // Wait for a file to be chosen
await new Promise((res, _rej) => await new Promise((res) => {
fileEl.addEventListener("change", () => res(null)) fileEl.addEventListener("change", () => {
); res(null);
});
});
if ((fileEl.files?.length ?? 0) === 0) return null; if ((fileEl.files?.length ?? 0) === 0) return null;
// Import conf // Import conf
let file = fileEl.files![0]; const file = fileEl.files![0];
const content = await file.text(); const content = await file.text();
p.importConf?.(JSON.parse(content)); p.importConf?.(JSON.parse(content));
} catch (e) { } catch (e) {

View File

@@ -0,0 +1,13 @@
export function DateWidget(p: { time: number }): React.ReactElement {
const date = new Date(p.time * 1000);
return (
<>
{pad(date.getDate())}/{pad(date.getMonth() + 1)}/{date.getFullYear()}
</>
);
}
function pad(num: number): string {
return num.toString().padStart(2, "0");
}

View File

@@ -0,0 +1,23 @@
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";
export function FileDiskImageWidget(p: {
image: DiskImage;
}): React.ReactElement {
return (
<ListItem>
<ListItemAvatar>
<Avatar>
<Icon path={mdiHarddisk} />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={p.image.file_name}
secondary={`${p.image.format} - ${filesize(p.image.file_size)}`}
/>
</ListItem>
);
}

View File

@@ -23,7 +23,7 @@ export function StateActionButton<S>(p: {
p.onExecuted(); p.onExecuted();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert("Failed to perform action! " + e); alert(`Failed to perform action! ${e}`);
} }
}; };

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-x/no-array-index-key */
import { Box, Tab, Tabs } from "@mui/material"; import { Box, Tab, Tabs } from "@mui/material";
export interface TabWidgetOption<E> { export interface TabWidgetOption<E> {
@@ -24,7 +25,9 @@ export function TabsWidget<E>(p: {
<Box sx={{ borderBottom: 1, borderColor: "divider" }}> <Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs <Tabs
value={currTabIndex} value={currTabIndex}
onChange={(_ev, newVal) => updateActiveTab(newVal)} onChange={(_ev, newVal) => {
updateActiveTab(newVal);
}}
> >
{activeOptions.map((o, index) => ( {activeOptions.map((o, index) => (
<Tab key={index} label={o.label} style={{ color: o.color }} /> <Tab key={index} label={o.label} style={{ color: o.color }} />

View File

@@ -2,10 +2,19 @@ import { Paper, Typography } from "@mui/material";
import React, { PropsWithChildren } from "react"; import React, { PropsWithChildren } from "react";
export function VirtWebPaper( export function VirtWebPaper(
p: { label: string | React.ReactElement } & PropsWithChildren p: {
label: string | React.ReactElement;
noHorizontalMargin?: boolean;
} & PropsWithChildren
): React.ReactElement { ): React.ReactElement {
return ( return (
<Paper elevation={2} style={{ padding: "10px", margin: "20px" }}> <Paper
elevation={2}
style={{
padding: "10px",
margin: p.noHorizontalMargin ? "20px 0px" : "20px",
}}
>
<Typography <Typography
variant="subtitle1" variant="subtitle1"
style={{ marginBottom: "10px", fontWeight: "bold" }} style={{ marginBottom: "10px", fontWeight: "bold" }}

View File

@@ -8,7 +8,18 @@ export function VirtWebRouteContainer(
} & PropsWithChildren } & PropsWithChildren
): React.ReactElement { ): React.ReactElement {
return ( return (
<div style={{ margin: "50px" }}> <div
style={{
margin: "50px",
flex: "1",
flexGrow: 1,
flexShrink: 0,
flexBasis: 0,
minWidth: 0,
display: "flex",
flexDirection: "column",
}}
>
<div <div
style={{ style={{
display: "flex", display: "flex",

View File

@@ -17,7 +17,7 @@ export function CheckboxInput(p: {
<Checkbox <Checkbox
disabled={!p.editable} disabled={!p.editable}
checked={p.checked} checked={p.checked}
onChange={(e) => p.onValueChange(e.target.checked)} onChange={(e) => { p.onValueChange(e.target.checked); }}
/> />
} }
label={p.label} label={p.label}

View File

@@ -0,0 +1,24 @@
import { DiskBusType } from "../../api/VMApi";
import { SelectInput } from "./SelectInput";
export function DiskBusSelect(p: {
editable: boolean;
value: DiskBusType;
label?: string;
onValueChange: (value: DiskBusType) => void;
size?: "medium" | "small";
disableUnderline?: boolean;
disableBottomMargin?: boolean;
}): React.ReactElement {
return (
<SelectInput
{...p}
label={p.label ?? "Disk bus type"}
options={[
{ label: "virtio", value: "Virtio" },
{ label: "sata", value: "SATA" },
]}
onValueChange={(v) => { p.onValueChange(v as any); }}
/>
);
}

View File

@@ -0,0 +1,40 @@
import {
FormControl,
InputLabel,
MenuItem,
Select,
SelectChangeEvent,
} from "@mui/material";
import React from "react";
import { DiskImage } from "../../api/DiskImageApi";
import { FileDiskImageWidget } from "../FileDiskImageWidget";
/**
* Select a disk image
*/
export function DiskImageSelect(p: {
label: string;
value?: string;
onValueChange: (image: string | undefined) => void;
list: DiskImage[];
}): React.ReactElement {
const handleChange = (event: SelectChangeEvent) => {
p.onValueChange(event.target.value);
};
return (
<FormControl fullWidth variant="standard">
<InputLabel>{p.label}</InputLabel>
<Select value={p.value} label={p.label} onChange={handleChange}>
<MenuItem value={undefined}>
<i>None</i>
</MenuItem>
{p.list.map((d) => (
<MenuItem key={d.file_name} value={d.file_name}>
<FileDiskImageWidget image={d} />
</MenuItem>
))}
</Select>
</FormControl>
);
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import { Paper, Typography } from "@mui/material"; import { Paper, Typography } from "@mui/material";
import React, { PropsWithChildren } from "react"; import React, { PropsWithChildren } from "react";
import Grid from "@mui/material/Grid"; import Grid from "@mui/material/Grid";

View File

@@ -0,0 +1,77 @@
import AttachFileIcon from "@mui/icons-material/AttachFile";
import ClearIcon from "@mui/icons-material/Clear";
import {
IconButton,
InputAdornment,
OutlinedInputProps,
TextField,
TextFieldSlotsAndSlotProps,
Tooltip,
} from "@mui/material";
import { filesize } from "filesize";
import React from "react";
export function FileInput(
p: {
label?: string;
value: File | null;
onChange: (file: File | null) => void;
style?: React.CSSProperties;
} & TextFieldSlotsAndSlotProps<OutlinedInputProps>
): React.ReactElement {
const fileInput = React.useRef<HTMLInputElement>(null);
return (
<TextField
onClick={() => fileInput.current?.click()}
{...p}
value={""}
onChange={console.log}
type="file"
slotProps={{
input: {
startAdornment: (
<>
<InputAdornment position="start">
<AttachFileIcon />
&nbsp;&nbsp;
{p.value ? p.value.name : "Select a file"}
</InputAdornment>
</>
),
endAdornment: p.value ? (
<InputAdornment position="end">
{filesize(p.value.size)}
<Tooltip
title="Remove attached file"
onClick={(e) => {
e.stopPropagation();
}}
>
<IconButton
onClick={(e) => {
e.stopPropagation();
p.onChange(null);
}}
>
<ClearIcon />
</IconButton>
</Tooltip>
</InputAdornment>
) : undefined,
onChange: (e) => {
const files = (e.target as any).files;
p.onChange(files.length > 0 ? files[0] : null);
},
},
htmlInput: {
ref: fileInput,
style: { opacity: 0 },
},
}}
placeholder={"Insert a file"}
variant="outlined"
/>
);
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
import React from "react"; import React from "react";
import { TextInput } from "./TextInput"; import { TextInput } from "./TextInput";
@@ -32,7 +33,7 @@ export function IPInputWithMask(p: {
const currValue = const currValue =
p.ipAndMask ?? p.ipAndMask ??
(p.ip ?? "") + (p.mask || showSlash.current ? "/" : "") + (p.mask ?? ""); `${p.ip ?? ""}${p.mask || showSlash.current ? "/" : ""}${p.mask ?? ""}`;
const { onValueChange, ...props } = p; const { onValueChange, ...props } = p;
return ( return (
@@ -44,7 +45,7 @@ export function IPInputWithMask(p: {
return; return;
} }
const split = v?.split("/"); const split = v.split("/");
const ip = const ip =
p.version === 4 ? sanitizeIpV4(split[0]) : sanitizeIpV6(split[0]); p.version === 4 ? sanitizeIpV4(split[0]) : sanitizeIpV6(split[0]);
let mask = undefined; let mask = undefined;
@@ -69,7 +70,7 @@ export function IPInputWithMask(p: {
function sanitizeIpV4(s: string | undefined): string | undefined { function sanitizeIpV4(s: string | undefined): string | undefined {
if (s === "" || s === undefined) return s; if (s === "" || s === undefined) return s;
let split = s.split("."); const split = s.split(".");
if (split.length > 4) split.splice(4); if (split.length > 4) split.splice(4);
let needAnotherIteration = false; let needAnotherIteration = false;
@@ -106,7 +107,7 @@ function sanitizeIpV6(s: string | undefined): string | undefined {
const num = parseInt(e, 16); const num = parseInt(e, 16);
if (isNaN(num)) return "0"; if (isNaN(num)) return "0";
let s = num.toString(16); const s = num.toString(16);
if (num > 0xffff) { if (num > 0xffff) {
needAnotherIteration = true; needAnotherIteration = true;
return s.slice(0, 4) + ":" + s.slice(4); return s.slice(0, 4) + ":" + s.slice(4);

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
import { TextInput } from "./TextInput"; import { TextInput } from "./TextInput";
export function MACInput(p: { export function MACInput(p: {
@@ -32,7 +33,7 @@ function sanitizeMacAddress(s: string | undefined): string | undefined {
const num = parseInt(e, 16); const num = parseInt(e, 16);
if (isNaN(num)) return "0"; if (isNaN(num)) return "0";
let s = num.toString(16).padStart(2, "0"); const s = num.toString(16).padStart(2, "0");
if (num > 0xff) { if (num > 0xff) {
needAnotherIteration = true; needAnotherIteration = true;
return s.slice(0, 2) + ":" + s.slice(2); return s.slice(0, 2) + ":" + s.slice(2);

View File

@@ -12,7 +12,7 @@ export function NWFConnStateInput(p: {
label="Connection state" label="Connection state"
value={p.value} value={p.value}
onValueChange={(s) => { onValueChange={(s) => {
p.onChange?.(s as any); p.onChange(s as any);
}} }}
options={[ options={[
{ label: "None", value: undefined }, { label: "None", value: undefined },

View File

@@ -1,3 +1,5 @@
/* eslint-disable react-x/no-array-index-key */
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react"; import React from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { NWFilter, NWFilterURL } from "../../api/NWFilterApi"; import { NWFilter, NWFilterURL } from "../../api/NWFilterApi";

View File

@@ -13,7 +13,7 @@ export function NWFilterPriorityInput(p: {
value={p.value?.toString()} value={p.value?.toString()}
type="number" type="number"
onValueChange={(v) => { onValueChange={(v) => {
p.onChange?.(v && v !== "" ? Number(v) : undefined); p.onChange(v && v !== "" ? Number(v) : undefined);
}} }}
size={ServerApi.Config.constraints.nwfilter_priority} size={ServerApi.Config.constraints.nwfilter_priority}
helperText="A lower priority value is accessed before one with a higher value" helperText="A lower priority value is accessed before one with a higher value"

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-x/no-array-index-key */
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
@@ -66,9 +67,19 @@ export function NWFilterRules(p: {
deleteRule(n); deleteRule(n);
}} }}
onGoDown={ onGoDown={
n < p.rules.length - 1 ? () => swapRules(n, n + 1) : undefined n < p.rules.length - 1
? () => {
swapRules(n, n + 1);
}
: undefined
}
onGoUp={
n > 0
? () => {
swapRules(n, n - 1);
}
: undefined
} }
onGoUp={n > 0 ? () => swapRules(n, n - 1) : undefined}
{...p} {...p}
/> />
))} ))}
@@ -153,7 +164,9 @@ function NWRuleEdit(p: {
editable={p.editable} editable={p.editable}
onChange={p.onChange} onChange={p.onChange}
selector={s} selector={s}
onDelete={() => deleteSelector(n)} onDelete={() => {
deleteSelector(n);
}}
/> />
))} ))}
</CardContent> </CardContent>

View File

@@ -25,9 +25,7 @@ export function NWFilterSelectInput(p: {
value={selectedValue} value={selectedValue}
onDelete={p.editable ? () => p.onChange?.(undefined) : undefined} onDelete={p.editable ? () => p.onChange?.(undefined) : undefined}
onClick={ onClick={
!p.editable && selectedValue !p.editable ? () => navigate(NWFilterURL(selectedValue)) : undefined
? () => navigate(NWFilterURL(selectedValue))
: undefined
} }
/> />
); );
@@ -48,7 +46,7 @@ export function NWFilterSelectInput(p: {
renderInput={(params) => ( renderInput={(params) => (
<TextField {...params} variant="standard" label={p.label} /> <TextField {...params} variant="standard" label={p.label} />
)} )}
renderOption={(_props, option, _state) => ( renderOption={(_props, option) => (
<NWFilterItem <NWFilterItem
dense dense
onClick={() => { onClick={() => {

View File

@@ -1,3 +1,4 @@
/* 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";
@@ -130,7 +131,7 @@ function HostReservationWidget(p: {
value={p.host.mac} value={p.host.mac}
onValueChange={(v) => { onValueChange={(v) => {
p.host.mac = v!; p.host.mac = v!;
p.onChange?.(); p.onChange();
}} }}
/> />
)} )}
@@ -142,7 +143,7 @@ function HostReservationWidget(p: {
value={p.host.ip} value={p.host.ip}
onValueChange={(v) => { onValueChange={(v) => {
p.host.ip = v!; p.host.ip = v!;
p.onChange?.(); p.onChange();
}} }}
/> />
</div> </div>

View File

@@ -54,6 +54,7 @@ export function NetNatConfiguration(p: {
<> <>
{p.nat.map((e, num) => ( {p.nat.map((e, num) => (
<NatEntryForm <NatEntryForm
// eslint-disable-next-line react-x/no-array-index-key
key={num} key={num}
{...p} {...p}
entry={e} entry={e}

View File

@@ -0,0 +1,89 @@
/* eslint-disable react-x/no-array-index-key */
import AddIcon from "@mui/icons-material/Add";
import ClearIcon from "@mui/icons-material/Clear";
import {
Alert,
IconButton,
InputAdornment,
TextField,
Tooltip,
} from "@mui/material";
import { VMInfo } from "../../api/VMApi";
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
import { EditSection } from "./EditSection";
export function OEMStringFormWidget(p: {
vm: VMInfo;
editable: boolean;
onChange?: () => void;
}): React.ReactElement {
const confirm = useConfirm();
const handleDeleteOEMString = async (num: number) => {
if (!(await confirm("Do you really want to delete this entry?"))) return;
p.vm.oem_strings.splice(num, 1);
p.onChange?.();
};
return (
<EditSection
title="SMBIOS OEM Strings"
actions={
p.editable ? (
<Tooltip title="Add a new string entry">
<IconButton
onClick={() => {
p.vm.oem_strings.push("");
p.onChange?.();
}}
>
<AddIcon />
</IconButton>
</Tooltip>
) : (
<></>
)
}
>
<Alert severity="info">
You can use the{" "}
<a
href="https://www.nongnu.org/dmidecode/"
target="_blank"
rel="noreferrer noopener"
style={{ color: "inherit" }}
>
<i>dmidecode</i>
</a>{" "}
tool on Linux to extract these strings on the guest.
</Alert>
{p.vm.oem_strings.map((s, num) => (
<TextField
key={num}
fullWidth
disabled={!p.editable}
value={s}
onChange={(e) => {
p.vm.oem_strings[num] = e.target.value;
p.onChange?.();
}}
style={{ marginTop: "5px" }}
slotProps={{
input: {
endAdornment: p.editable ? (
<InputAdornment position="end">
<Tooltip title="Remove entry">
<IconButton onClick={() => handleDeleteOEMString(num)}>
<ClearIcon />
</IconButton>
</Tooltip>
</InputAdornment>
) : undefined,
},
}}
/>
))}
</EditSection>
);
}

View File

@@ -12,7 +12,7 @@ export function PortInput(p: {
value={p.value?.toString() ?? ""} value={p.value?.toString() ?? ""}
type="number" type="number"
onValueChange={(v) => { onValueChange={(v) => {
p.onChange?.(sanitizePort(v)); p.onChange(sanitizePort(v));
}} }}
checkValue={(v) => Number(v) <= 65535} checkValue={(v) => Number(v) <= 65535}
/> />

View File

@@ -24,10 +24,13 @@ export function RadioGroupInput(p: {
<RadioGroup <RadioGroup
row row
value={p.value} value={p.value}
onChange={(_ev, v) => p.onValueChange?.(v)} onChange={(_ev, v) => {
p.onValueChange(v);
}}
> >
{p.options.map((o) => ( {p.options.map((o) => (
<FormControlLabel <FormControlLabel
key={o.value}
disabled={!p.editable} disabled={!p.editable}
value={o.value} value={o.value}
control={<Radio />} control={<Radio />}

View File

@@ -17,8 +17,11 @@ export function SelectInput(p: {
value?: string; value?: string;
editable: boolean; editable: boolean;
label?: string; label?: string;
size?: "medium" | "small";
options: SelectOption[]; options: SelectOption[];
onValueChange: (o?: string) => void; onValueChange: (o?: string) => void;
disableUnderline?: boolean;
disableBottomMargin?: boolean;
}): React.ReactElement { }): React.ReactElement {
if (!p.editable && !p.value) return <></>; if (!p.editable && !p.value) return <></>;
@@ -28,12 +31,18 @@ export function SelectInput(p: {
} }
return ( return (
<FormControl fullWidth variant="standard" style={{ marginBottom: "15px" }}> <FormControl
fullWidth
variant="standard"
style={{ marginBottom: p.disableBottomMargin ? "0px" : "15px" }}
>
{p.label && <InputLabel>{p.label}</InputLabel>} {p.label && <InputLabel>{p.label}</InputLabel>}
<Select <Select
{...p}
value={p.value ?? ""} value={p.value ?? ""}
label={p.label} onChange={(e) => {
onChange={(e) => p.onValueChange(e.target.value)} p.onValueChange(e.target.value);
}}
> >
{p.options.map((e) => ( {p.options.map((e) => (
<MenuItem <MenuItem

View File

@@ -2,7 +2,7 @@ import { TextField } from "@mui/material";
import { LenConstraint } from "../../api/ServerApi"; import { LenConstraint } from "../../api/ServerApi";
/** /**
* Couple / Member property edition * Text input property edition
*/ */
export function TextInput(p: { export function TextInput(p: {
label?: string; label?: string;
@@ -17,6 +17,7 @@ export function TextInput(p: {
type?: React.HTMLInputTypeAttribute; type?: React.HTMLInputTypeAttribute;
style?: React.CSSProperties; style?: React.CSSProperties;
helperText?: string; helperText?: string;
disabled?: boolean;
}): React.ReactElement { }): React.ReactElement {
if (!p.editable && (p.value ?? "") === "") return <></>; if (!p.editable && (p.value ?? "") === "") return <></>;
@@ -35,6 +36,7 @@ export function TextInput(p: {
return ( return (
<TextField <TextField
disabled={p.disabled}
label={p.label} label={p.label}
value={p.value ?? ""} value={p.value ?? ""}
onChange={(e) => onChange={(e) =>
@@ -42,12 +44,14 @@ export function TextInput(p: {
e.target.value.length === 0 ? undefined : e.target.value e.target.value.length === 0 ? undefined : e.target.value
) )
} }
inputProps={{ slotProps={{
htmlInput: {
maxLength: p.size?.max, maxLength: p.size?.max,
}} },
InputProps={{ input: {
readOnly: !p.editable, readOnly: !p.editable,
type: p.type, type: p.type,
},
}} }}
variant={"standard"} variant={"standard"}
style={p.style ?? { width: "100%", marginBottom: "15px" }} style={p.style ?? { width: "100%", marginBottom: "15px" }}

View File

@@ -1,66 +1,96 @@
import { mdiHarddisk } 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 { import { Button, IconButton, Paper, Tooltip } from "@mui/material";
Avatar, import React from "react";
Button, import { DiskImage } from "../../api/DiskImageApi";
IconButton,
ListItem,
ListItemAvatar,
ListItemText,
Paper,
Tooltip,
} from "@mui/material";
import { filesize } from "filesize";
import { ServerApi } from "../../api/ServerApi"; import { ServerApi } from "../../api/ServerApi";
import { VMDisk, VMInfo } from "../../api/VMApi"; import { VMFileDisk, VMInfo, VMState } from "../../api/VMApi";
import { ConvertDiskImageDialog } from "../../dialogs/ConvertDiskImageDialog";
import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider"; import { useConfirm } from "../../hooks/providers/ConfirmDialogProvider";
import { VMDiskFileWidget } from "../vms/VMDiskFileWidget";
import { CheckboxInput } from "./CheckboxInput";
import { DiskBusSelect } from "./DiskBusSelect";
import { DiskImageSelect } from "./DiskImageSelect";
import { SelectInput } from "./SelectInput"; import { SelectInput } from "./SelectInput";
import { TextInput } from "./TextInput"; import { TextInput } from "./TextInput";
export function VMDisksList(p: { export function VMDisksList(p: {
vm: VMInfo; vm: VMInfo;
state?: VMState;
onChange?: () => void; onChange?: () => void;
editable: boolean; editable: boolean;
diskImagesList: DiskImage[];
}): React.ReactElement { }): React.ReactElement {
const [currBackupRequest, setCurrBackupRequest] = React.useState<
VMFileDisk | undefined
>();
const addNewDisk = () => { const addNewDisk = () => {
p.vm.disks.push({ p.vm.file_disks.push({
alloc_type: "Sparse", format: "QCow2",
size: 10000, size: 10000 * 1000 * 1000,
bus: "Virtio",
delete: false, delete: false,
name: `disk${p.vm.disks.length}`, name: `disk${p.vm.file_disks.length}`,
new: true, new: true,
}); });
p.onChange?.(); p.onChange?.();
}; };
const handleBackupRequest = (disk: VMFileDisk) => {
setCurrBackupRequest(disk);
};
const handleFinishBackup = () => {
setCurrBackupRequest(undefined);
};
return ( return (
<> <>
{/* disks list */} {/* disks list */}
{p.vm.disks.map((d, num) => ( {p.vm.file_disks.map((d, num) => (
<DiskInfo <DiskInfo
// eslint-disable-next-line react-x/no-array-index-key
key={num} key={num}
editable={p.editable} editable={p.editable}
canBackup={!p.editable && !d.new && p.state !== "Running"}
disk={d} disk={d}
onChange={p.onChange} onChange={p.onChange}
removeFromList={() => { removeFromList={() => {
p.vm.disks.splice(num, 1); p.vm.file_disks.splice(num, 1);
p.onChange?.(); p.onChange?.();
}} }}
onRequestBackup={handleBackupRequest}
diskImagesList={p.diskImagesList}
/> />
))} ))}
{p.editable && <Button onClick={addNewDisk}>Add new disk</Button>} {p.editable && <Button onClick={addNewDisk}>Add new disk</Button>}
{/* Disk backup */}
{currBackupRequest && (
<ConvertDiskImageDialog
backup
onCancel={handleFinishBackup}
onFinished={handleFinishBackup}
vm={p.vm}
disk={currBackupRequest}
/>
)}
</> </>
); );
} }
function DiskInfo(p: { function DiskInfo(p: {
editable: boolean; editable: boolean;
disk: VMDisk; canBackup: boolean;
disk: VMFileDisk;
onChange?: () => void; onChange?: () => void;
removeFromList: () => void; removeFromList: () => void;
onRequestBackup: (disk: VMFileDisk) => void;
diskImagesList: DiskImage[];
}): React.ReactElement { }): React.ReactElement {
const confirm = useConfirm(); const confirm = useConfirm();
const deleteDisk = async () => { const deleteDisk = async () => {
@@ -85,9 +115,11 @@ function DiskInfo(p: {
if (!p.editable || !p.disk.new) if (!p.editable || !p.disk.new)
return ( return (
<ListItem <VMDiskFileWidget
{...p}
secondaryAction={ secondaryAction={
p.editable && ( <>
{p.editable && (
<IconButton <IconButton
edge="end" edge="end"
aria-label="delete disk" aria-label="delete disk"
@@ -103,36 +135,27 @@ function DiskInfo(p: {
</Tooltip> </Tooltip>
)} )}
</IconButton> </IconButton>
) )}
}
{p.canBackup && (
<Tooltip title="Backup this disk">
<IconButton
onClick={() => {
p.onRequestBackup(p.disk);
}}
> >
<ListItemAvatar> <Icon path={mdiHarddiskPlus} size={1} />
<Avatar> </IconButton>
<Icon path={mdiHarddisk} /> </Tooltip>
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<>
{p.disk.name}{" "}
{p.disk.deleteType && (
<span style={{ color: "red" }}>
{p.disk.deleteType === "deletefile"
? "Remove, DELETING block file"
: "Remove, keeping block file"}
</span>
)} )}
</> </>
} }
secondary={`${filesize(p.disk.size * 1000 * 1000)} - ${
p.disk.alloc_type
}`}
/> />
</ListItem>
); );
return ( return (
<Paper elevation={3} style={{ margin: "10px", padding: "10px" }}> <Paper elevation={3} style={{ margin: "10px", padding: "10px" }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<TextInput <TextInput
editable={true} editable={true}
label="Disk name" label="Disk name"
@@ -144,38 +167,78 @@ function DiskInfo(p: {
p.onChange?.(); p.onChange?.();
}} }}
/> />
<TextInput
editable={true}
label="Disk size (MB)"
size={ServerApi.Config.constraints.disk_size}
value={p.disk.size.toString()}
onValueChange={(v) => {
p.disk.size = Number(v ?? "0");
p.onChange?.();
}}
type="number"
/>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<SelectInput
editable={true}
label="File allocation type"
options={[
{ label: "Sparse allocation", value: "Sparse" },
{ label: "Fixed allocation", value: "Fixed" },
]}
value={p.disk.alloc_type}
onValueChange={(v) => {
p.disk.alloc_type = v as any;
p.onChange?.();
}}
/>
<IconButton onClick={p.removeFromList}> <IconButton onClick={p.removeFromList}>
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
</div> </div>
<SelectInput
editable={true}
label="Disk format"
options={[
{ label: "Raw file", value: "Raw" },
{ label: "QCow2", value: "QCow2" },
]}
value={p.disk.format}
onValueChange={(v) => {
p.disk.format = v as any;
if (p.disk.format === "Raw") p.disk.is_sparse = true;
p.onChange?.();
}}
/>
{/* Bus selection */}
<DiskBusSelect
editable
value={p.disk.bus}
onValueChange={(v) => {
p.disk.bus = v;
p.onChange?.();
}}
/>
{/* Raw disk: choose sparse mode */}
{p.disk.format === "Raw" && (
<CheckboxInput
editable
label="Sparse file"
checked={p.disk.is_sparse}
onValueChange={(v) => {
if (p.disk.format === "Raw") p.disk.is_sparse = v;
p.onChange?.();
}}
/>
)}
<TextInput
editable={true}
label="Disk size (GB)"
size={{
min:
ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000),
max:
ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000),
}}
value={(p.disk.size / (1000 * 1000 * 1000)).toString()}
onValueChange={(v) => {
p.disk.size = Number(v ?? "0") * 1000 * 1000 * 1000;
p.onChange?.();
}}
type="number"
disabled={!!p.disk.from_image}
/>
<DiskImageSelect
label="Use disk image as template"
list={p.diskImagesList}
value={p.disk.from_image}
onValueChange={(v) => {
p.disk.from_image = v;
p.onChange?.();
}}
/>
</Paper> </Paper>
); );
} }

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-x/no-array-index-key */
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import { import {
Button, Button,

View File

@@ -1,3 +1,4 @@
/* 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";
@@ -28,11 +29,13 @@ export function VMNetworksList(p: {
onChange?: () => void; onChange?: () => void;
editable: boolean; editable: boolean;
networksList: NetworkInfo[]; networksList: NetworkInfo[];
bridgesList: string[];
networkFiltersList: NWFilter[]; networkFiltersList: NWFilter[];
}): React.ReactElement { }): React.ReactElement {
const addNew = () => { const addNew = () => {
p.vm.networks.push({ p.vm.networks.push({
type: "UserspaceSLIRPStack", type: "UserspaceSLIRPStack",
model: "Virtio",
mac: randomMacAddress(ServerApi.Config.net_mac_prefix), mac: randomMacAddress(ServerApi.Config.net_mac_prefix),
}); });
p.onChange?.(); p.onChange?.();
@@ -51,7 +54,6 @@ export function VMNetworksList(p: {
{p.vm.networks.map((n, num) => ( {p.vm.networks.map((n, num) => (
<EditSection key={num}> <EditSection key={num}>
<NetworkInfoWidget <NetworkInfoWidget
key={num}
network={n} network={n}
removeFromList={() => { removeFromList={() => {
p.vm.networks.splice(num, 1); p.vm.networks.splice(num, 1);
@@ -72,6 +74,7 @@ function NetworkInfoWidget(p: {
onChange?: () => void; onChange?: () => void;
removeFromList: () => void; removeFromList: () => void;
networksList: NetworkInfo[]; networksList: NetworkInfo[];
bridgesList: string[];
networkFiltersList: NWFilter[]; networkFiltersList: NWFilter[];
}): React.ReactElement { }): React.ReactElement {
const confirm = useConfirm(); const confirm = useConfirm();
@@ -130,6 +133,11 @@ function NetworkInfoWidget(p: {
value: "DefinedNetwork", value: "DefinedNetwork",
description: "Attach to a defined network", description: "Attach to a defined network",
}, },
{
label: "Host bridge",
value: "Bridge",
description: "Attach to an host's bridge",
},
]} ]}
/> />
) : ( ) : (
@@ -139,6 +147,7 @@ function NetworkInfoWidget(p: {
/> />
</ListItem> </ListItem>
<div style={{ marginLeft: "70px" }}> <div style={{ marginLeft: "70px" }}>
{/* MAC address input */}
<MACInput <MACInput
editable={p.editable} editable={p.editable}
label="MAC Address" label="MAC Address"
@@ -149,8 +158,28 @@ function NetworkInfoWidget(p: {
}} }}
/> />
{/* NIC model */}
<SelectInput
editable={p.editable}
label="NIC Model"
value={p.network.model}
onValueChange={(v) => {
p.network.model = v as any;
p.onChange?.();
}}
options={[
{ label: "e1000", value: "E1000" },
{
label: "virtio",
value: "Virtio",
description:
"Recommended model, but will require specific drivers on OS that do not support it.",
},
]}
/>
{/* Defined network selection */}
{p.network.type === "DefinedNetwork" && ( {p.network.type === "DefinedNetwork" && (
<>
<SelectInput <SelectInput
editable={p.editable} editable={p.editable}
label="Defined network" label="Defined network"
@@ -173,7 +202,29 @@ function NetworkInfoWidget(p: {
p.onChange?.(); p.onChange?.();
}} }}
/> />
)}
{/* Bridge selection */}
{p.network.type === "Bridge" && (
<SelectInput
editable={p.editable}
label="Host bridge"
options={p.bridgesList.map((n) => {
return {
label: n,
value: n,
};
})}
value={p.network.bridge}
onValueChange={(v) => {
if (p.network.type === "Bridge") p.network.bridge = v as any;
p.onChange?.();
}}
/>
)}
{p.network.type !== "UserspaceSLIRPStack" && (
<>
{/* Network Filter */} {/* Network Filter */}
<NWFilterSelectInput <NWFilterSelectInput
editable={p.editable} editable={p.editable}

View File

@@ -19,7 +19,7 @@ export function VMSelectIsoInput(p: {
attachedISOs: string[]; attachedISOs: string[];
onChange: (newVal: string[]) => void; onChange: (newVal: string[]) => void;
}): React.ReactElement { }): React.ReactElement {
if (!p.attachedISOs && !p.editable) return <></>; if (p.attachedISOs.length === 0 && !p.editable) return <></>;
return ( return (
<> <>
@@ -27,7 +27,7 @@ export function VMSelectIsoInput(p: {
const iso = p.isoList.find((d) => d.filename === isoName); const iso = p.isoList.find((d) => d.filename === isoName);
return ( return (
<ListItem <ListItem
key={num} key={isoName}
secondaryAction={ secondaryAction={
p.editable && ( p.editable && (
<IconButton <IconButton
@@ -73,8 +73,7 @@ export function VMSelectIsoInput(p: {
label: `${i.filename} ${filesize(i.size)}`, label: `${i.filename} ${filesize(i.size)}`,
value: i.filename, value: i.filename,
}; };
}) })}
}
/> />
</> </>
); );

View File

@@ -25,7 +25,7 @@ interface DetailsProps {
} }
export function NetworkDetails(p: DetailsProps): React.ReactElement { export function NetworkDetails(p: DetailsProps): React.ReactElement {
const [nicsList, setNicsList] = React.useState<string[] | any>(); const [nicsList, setNicsList] = React.useState<string[] | undefined>();
const load = async () => { const load = async () => {
setNicsList(await ServerApi.GetNetworksList()); setNicsList(await ServerApi.GetNetworksList());
@@ -36,7 +36,7 @@ export function NetworkDetails(p: DetailsProps): React.ReactElement {
loadKey={"1"} loadKey={"1"}
load={load} load={load}
errMsg="Failed to load the list of host network cards!" errMsg="Failed to load the list of host network cards!"
build={() => <NetworkDetailsInner nicsList={nicsList} {...p} />} build={() => <NetworkDetailsInner nicsList={nicsList!} {...p} />}
/> />
); );
} }
@@ -260,7 +260,7 @@ function IPSection(p: {
const confirm = useConfirm(); const confirm = useConfirm();
const toggleNetwork = async () => { const toggleNetwork = async () => {
if (!!p.config) { if (p.config) {
if ( if (
!(await confirm( !(await confirm(
`Do you really want to disable IPv${p.version} on this network? Specific configuration will be deleted!` `Do you really want to disable IPv${p.version} on this network? Specific configuration will be deleted!`
@@ -268,11 +268,11 @@ function IPSection(p: {
) )
return; return;
p.onChange?.(undefined); p.onChange(undefined);
return; return;
} }
p.onChange?.({ p.onChange({
bridge_address: p.version === 4 ? "192.168.1.1" : "fd00::1", bridge_address: p.version === 4 ? "192.168.1.1" : "fd00::1",
prefix: p.version === 4 ? 24 : 8, prefix: p.version === 4 ? 24 : 8,
}); });
@@ -298,7 +298,7 @@ function IPSection(p: {
p.config!.dhcp = undefined; p.config!.dhcp = undefined;
} }
p.onChange?.(p.config); p.onChange(p.config);
}; };
const toggleNAT = async (v: boolean) => { const toggleNAT = async (v: boolean) => {
@@ -306,7 +306,7 @@ function IPSection(p: {
p.config!.nat = []; p.config!.nat = [];
} else { } else {
if ( if (
(p.config?.nat?.length ?? 0 > 0) && (p.config?.nat?.length ?? 0) > 0 &&
!(await confirm( !(await confirm(
`Do you really want to disable IPv${p.version} NAT port forwarding on this network? Specific configuration will be deleted!` `Do you really want to disable IPv${p.version} NAT port forwarding on this network? Specific configuration will be deleted!`
)) ))
@@ -315,7 +315,7 @@ function IPSection(p: {
p.config!.nat = undefined; p.config!.nat = undefined;
} }
p.onChange?.(p.config); p.onChange(p.config);
}; };
if (!p.config && !p.editable) return <></>; if (!p.config && !p.editable) return <></>;
@@ -338,10 +338,10 @@ function IPSection(p: {
editable={p.editable} editable={p.editable}
label="Bridge address" label="Bridge address"
version={p.version} version={p.version}
value={p.config?.bridge_address} value={p.config.bridge_address}
onValueChange={(v) => { onValueChange={(v) => {
p.config!.bridge_address = v ?? ""; p.config!.bridge_address = v ?? "";
p.onChange?.(p.config); p.onChange(p.config);
}} }}
/> />
@@ -352,7 +352,7 @@ function IPSection(p: {
type="number" type="number"
onValueChange={(v) => { onValueChange={(v) => {
p.config!.prefix = Number(v); p.config!.prefix = Number(v);
p.onChange?.(p.config); p.onChange(p.config);
}} }}
size={ size={
p.version === 4 ? { min: 0, max: 32 } : { min: 0, max: 128 } p.version === 4 ? { min: 0, max: 32 } : { min: 0, max: 128 }
@@ -407,7 +407,7 @@ function IPSection(p: {
dhcp={p.config.dhcp} dhcp={p.config.dhcp}
onChange={(d) => { onChange={(d) => {
p.config!.dhcp = d; p.config!.dhcp = d;
p.onChange?.(p.config); p.onChange(p.config);
}} }}
/> />
</EditSection> </EditSection>
@@ -431,7 +431,7 @@ function IPSection(p: {
nat={p.config.nat} nat={p.config.nat}
onChange={(n) => { onChange={(n) => {
p.config!.nat = n; p.config!.nat = n;
p.onChange?.(p.config); p.onChange(p.config);
}} }}
/> />
)} )}

View File

@@ -29,13 +29,13 @@ export function NetworkStatusWidget(p: {
} }
}; };
const changedAction = () => setState(undefined); const changedAction = () => { setState(undefined); };
React.useEffect(() => { React.useEffect(() => {
refresh(); refresh();
const i = setInterval(() => refresh(), 3000); const i = setInterval(() => refresh(), 3000);
return () => clearInterval(i); return () => { clearInterval(i); };
}); });
if (state === undefined) if (state === undefined)

Some files were not shown because too many files have changed in this diff Show More