Implement router provisioning lifecycle and deployment UI
This commit is contained in:
Generated
+20
@@ -9,6 +9,8 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.1.0",
|
||||
@@ -1435,6 +1437,24 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-fs": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz",
|
||||
"integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.1.0",
|
||||
|
||||
Generated
+144
-1
@@ -1963,6 +1963,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-opener",
|
||||
]
|
||||
|
||||
@@ -2230,6 +2232,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2820,6 +2823,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -3502,6 +3529,48 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.4"
|
||||
@@ -4592,6 +4661,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -4625,13 +4703,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
@@ -4662,6 +4757,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4674,6 +4775,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -4686,12 +4793,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4704,6 +4823,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -4716,6 +4841,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -4728,6 +4859,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4740,6 +4877,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
|
||||
@@ -22,4 +22,6 @@ tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-fs = "2.5.1"
|
||||
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"fs:default",
|
||||
"fs:allow-write-file"
|
||||
]
|
||||
}
|
||||
@@ -7,8 +7,8 @@ fn greet(name: &str) -> String {
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -12,9 +12,10 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "lr-openvpn-desktop",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
"title": "LR OpenVPN Tool",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+414
-63
@@ -9,46 +9,75 @@ body {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
button {
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
min-width: 1280px;
|
||||
display: flex;
|
||||
background: #f2f2f2;
|
||||
}
|
||||
|
||||
/* SIDEBAR */
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
width: 250px;
|
||||
background: #0d0d0d;
|
||||
color: white;
|
||||
padding: 24px 16px;
|
||||
padding: 26px 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #171717;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 36px;
|
||||
gap: 8px;
|
||||
margin-bottom: 34px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 18px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, #5da8ff, #b7e236);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 900;
|
||||
font-size: 24px;
|
||||
color: #0d0d0d;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 115px;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.5px;
|
||||
color: #5da8ff;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #b7e236;
|
||||
letter-spacing: 2px;
|
||||
font-size: 13px;
|
||||
color: white;
|
||||
letter-spacing: 4px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
nav {
|
||||
@@ -60,12 +89,13 @@ nav button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #d1d5db;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
gap: 11px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
nav button.active,
|
||||
@@ -76,12 +106,24 @@ nav button:hover {
|
||||
|
||||
.status-card {
|
||||
margin-top: auto;
|
||||
background: #1a1a1a;
|
||||
background: #181818;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-card strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-card p {
|
||||
margin: 0;
|
||||
color: #b7e236;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
@@ -91,107 +133,181 @@ nav button:hover {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* MAIN */
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
padding: 30px;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
font-size: 26px;
|
||||
line-height: 1.1;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 4px 0 0;
|
||||
margin: 6px 0 0;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.health,
|
||||
.primary {
|
||||
.primary,
|
||||
.secondary {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 12px 18px;
|
||||
font-weight: 700;
|
||||
border-radius: 9px;
|
||||
padding: 11px 18px;
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.health {
|
||||
background: white;
|
||||
color: #111827;
|
||||
box-shadow: 0 5px 18px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.health::before {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #b7e236;
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
margin-right: 9px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #b7e236;
|
||||
color: #0d0d0d;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* CARDS */
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.panel {
|
||||
background: white;
|
||||
border-radius: 18px;
|
||||
padding: 22px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid #eceff3;
|
||||
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 118px;
|
||||
}
|
||||
|
||||
.metric-card span {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
display: block;
|
||||
font-size: 34px;
|
||||
margin-top: 8px;
|
||||
margin-top: 10px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* TABLE */
|
||||
|
||||
.panel {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 15px 18px;
|
||||
border-bottom: 1px solid #edf0f3;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
background: #fbfbfc;
|
||||
}
|
||||
|
||||
td {
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
background: #e5e7eb;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.badge.provisioned {
|
||||
.badge::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.badge.provisioned,
|
||||
.badge.active,
|
||||
.badge.online,
|
||||
.badge.success {
|
||||
color: #16a34a;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.badge.pending {
|
||||
.badge.pending,
|
||||
.badge.inactive,
|
||||
.badge.warning {
|
||||
color: #ca8a04;
|
||||
background: #fef9c3;
|
||||
}
|
||||
|
||||
.badge.failed {
|
||||
.badge.failed,
|
||||
.badge.offline {
|
||||
color: #dc2626;
|
||||
background: #fee2e2;
|
||||
}
|
||||
@@ -201,95 +317,330 @@ th {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
/* ERROR */
|
||||
|
||||
.error-banner {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 18px;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* MODAL */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 520px;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.25);
|
||||
width: 760px;
|
||||
max-width: calc(100vw - 48px);
|
||||
background: #f7f8fa;
|
||||
border-radius: 18px;
|
||||
padding: 22px;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 22px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.modal-header p {
|
||||
margin: 4px 0 0;
|
||||
margin: 5px 0 0;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
border: 0;
|
||||
background: #f3f4f6;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.router-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.router-form label {
|
||||
background: white;
|
||||
border: 1px solid #eceff3;
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.router-form input {
|
||||
height: 42px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 10px;
|
||||
height: 40px;
|
||||
border: 1px solid #dfe3e8;
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
font: inherit;
|
||||
background: #fbfbfc;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.router-form input:focus {
|
||||
outline: 2px solid rgba(93, 168, 255, 0.25);
|
||||
border-color: #5da8ff;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
border: 0;
|
||||
/* RESPONSIVE */
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.router-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.router-form select {
|
||||
height: 40px;
|
||||
border: 1px solid #dfe3e8;
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
background: #fbfbfc;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.router-form select:focus {
|
||||
outline: 2px solid rgba(93, 168, 255, 0.25);
|
||||
border-color: #5da8ff;
|
||||
}
|
||||
|
||||
.custom-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.custom-select-button {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border: 1px solid #dfe3e8;
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
background: #fbfbfc;
|
||||
color: #111827;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.custom-select-menu {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 12px 18px;
|
||||
background: #f3f4f6;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.18);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.primary:disabled {
|
||||
opacity: 0.6;
|
||||
.custom-select-option {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #111827;
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.custom-select-option:hover {
|
||||
background: #eef6ff;
|
||||
}
|
||||
|
||||
.table-action {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.table-action.danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.table-action.danger:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
width: 430px;
|
||||
background: white;
|
||||
border-radius: 18px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.confirm-dialog h2 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.confirm-dialog p {
|
||||
margin: 10px 0 22px;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
padding: 11px 18px;
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.danger-button:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.panel-empty {
|
||||
padding: 22px;
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.small-action {
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
background: #b7e236;
|
||||
color: #0d0d0d;
|
||||
}
|
||||
|
||||
.table-action.warning {
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
.table-action.warning:hover {
|
||||
background: #fef9c3;
|
||||
}
|
||||
|
||||
.table-action:disabled,
|
||||
.small-action:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.warning-action {
|
||||
color: #ca8a04;
|
||||
background: #fef9c3;
|
||||
}
|
||||
|
||||
.table-action:disabled,
|
||||
.small-action:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.deployment-modal {
|
||||
width: 720px;
|
||||
max-width: calc(100vw - 48px);
|
||||
background: white;
|
||||
border-radius: 18px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.deployment-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 800;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.deployment-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.log-box {
|
||||
background: #050505;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
min-height: 220px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.log-box pre {
|
||||
margin: 0;
|
||||
color: #b7e236;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
font-family: "JetBrains Mono", Consolas, monospace;
|
||||
}
|
||||
+255
-195
@@ -1,39 +1,35 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { apiGet, apiPost } from "./api";
|
||||
import { apiDelete, apiGet, apiPost } from "./api";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { CreateRouterModal } from "./components/CreateRouterModal";
|
||||
import { ConfirmDialog } from "./components/ConfirmDialog";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { RoutersPage } from "./pages/RoutersPage";
|
||||
import type { CreateRouterRequest, RouterItem } from "./types/router";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeFile } from "@tauri-apps/plugin-fs";
|
||||
import type { DeploymentResponse } from "./types/deployment";
|
||||
import { DeploymentResultModal } from "./components/DeploymentResultModal";
|
||||
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Router,
|
||||
Shield,
|
||||
Activity,
|
||||
Server,
|
||||
Settings,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
availableSubnets,
|
||||
firstAvailableSubnet,
|
||||
gatewayFromSubnet,
|
||||
} from "./lib/network";
|
||||
|
||||
import "./App.css";
|
||||
|
||||
type RouterItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
serialNumber?: string;
|
||||
lanIp: string;
|
||||
lanSubnet: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type CreateRouterRequest = {
|
||||
name: string;
|
||||
serialNumber: string;
|
||||
lanIp: string;
|
||||
lanSubnet: string;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [page, setPage] = useState("dashboard");
|
||||
const [routers, setRouters] = useState<RouterItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [routerToDelete, setRouterToDelete] = useState<RouterItem | null>(null);
|
||||
const [routerToRemove, setRouterToRemove] = useState<RouterItem | null>(null);
|
||||
const [latestDeployment, setLatestDeployment] = useState<DeploymentResponse | null>(null);
|
||||
|
||||
const [form, setForm] = useState<CreateRouterRequest>({
|
||||
name: "",
|
||||
@@ -56,16 +52,50 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateRouterModal() {
|
||||
const nextSubnet = firstAvailableSubnet(routers);
|
||||
|
||||
setForm({
|
||||
name: "",
|
||||
serialNumber: "",
|
||||
lanSubnet: nextSubnet,
|
||||
lanIp: gatewayFromSubnet(nextSubnet),
|
||||
});
|
||||
|
||||
setCreateOpen(true);
|
||||
}
|
||||
|
||||
async function createRouter(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
let createdRouter: RouterItem | null = null;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
await apiPost<RouterItem, CreateRouterRequest>("/api/routers", form);
|
||||
createdRouter = await apiPost<RouterItem, CreateRouterRequest>(
|
||||
"/api/routers",
|
||||
form
|
||||
);
|
||||
|
||||
console.log("Router created:", createdRouter);
|
||||
|
||||
const allocation = await apiPost<
|
||||
unknown,
|
||||
{
|
||||
clientName: string;
|
||||
allocationMode: "AUTOMATIC";
|
||||
}
|
||||
>(`/api/routers/${createdRouter.id}/ip-allocation`, {
|
||||
clientName: form.name,
|
||||
allocationMode: "AUTOMATIC",
|
||||
});
|
||||
|
||||
console.log("Allocation created:", allocation);
|
||||
|
||||
setCreateOpen(false);
|
||||
|
||||
setForm({
|
||||
name: "",
|
||||
serialNumber: "",
|
||||
@@ -76,200 +106,230 @@ function App() {
|
||||
await loadRouters();
|
||||
setPage("routers");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (createdRouter) {
|
||||
await apiDelete(`/api/routers/${createdRouter.id}`).catch(() => null);
|
||||
await loadRouters();
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : "Failed to create router");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function provisionRouter(router: RouterItem) {
|
||||
try {
|
||||
setActionLoading(router.id);
|
||||
setError(null);
|
||||
|
||||
const deployment = await apiPost<DeploymentResponse, Record<string, never>>(
|
||||
`/api/routers/${router.id}/provision`,
|
||||
{}
|
||||
);
|
||||
|
||||
console.log("Provision deployment:", deployment);
|
||||
|
||||
setLatestDeployment(deployment);
|
||||
|
||||
if (deployment.status !== "SUCCESS") {
|
||||
setError(
|
||||
deployment.errorMessage ||
|
||||
deployment.stderr ||
|
||||
deployment.logs ||
|
||||
"Provision failed. Check deployment logs."
|
||||
);
|
||||
}
|
||||
|
||||
await loadRouters();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to provision router");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRouter(router: RouterItem) {
|
||||
try {
|
||||
setActionLoading(router.id);
|
||||
setError(null);
|
||||
|
||||
const deployment = await apiPost<DeploymentResponse, Record<string, never>>(
|
||||
`/api/routers/${router.id}/remove`,
|
||||
{}
|
||||
);
|
||||
|
||||
console.log("Remove deployment:", deployment);
|
||||
|
||||
setLatestDeployment(deployment);
|
||||
setRouterToRemove(null);
|
||||
|
||||
if (deployment.status !== "SUCCESS") {
|
||||
setError(
|
||||
deployment.errorMessage ||
|
||||
deployment.stderr ||
|
||||
deployment.logs ||
|
||||
"Remove failed. Check deployment logs."
|
||||
);
|
||||
}
|
||||
|
||||
await loadRouters();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to remove router");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRouter(router: RouterItem) {
|
||||
try {
|
||||
setActionLoading(router.id);
|
||||
setError(null);
|
||||
|
||||
await apiDelete(`/api/routers/${router.id}`);
|
||||
|
||||
setRouterToDelete(null);
|
||||
await loadRouters();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete router");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBundle(router: RouterItem) {
|
||||
try {
|
||||
setActionLoading(router.id);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE}/api/routers/${router.id}/bundle`,
|
||||
{
|
||||
headers: {
|
||||
"X-API-Key": import.meta.env.VITE_API_KEY,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
|
||||
console.log("Bundle response status:", response.status);
|
||||
console.log("Bundle content-type:", response.headers.get("content-type"));
|
||||
console.log("Bundle size:", bytes.length);
|
||||
console.log("Bundle first bytes:", Array.from(bytes.slice(0, 20)));
|
||||
console.log(
|
||||
"Bundle preview:",
|
||||
new TextDecoder().decode(bytes.slice(0, 200))
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
new TextDecoder().decode(bytes) || `API error ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const isZip = bytes[0] === 0x50 && bytes[1] === 0x4b;
|
||||
const isGzip = bytes[0] === 0x1f && bytes[1] === 0x8b;
|
||||
|
||||
if (!isZip && !isGzip) {
|
||||
throw new Error("Downloaded bundle is not a valid ZIP or GZIP file");
|
||||
}
|
||||
|
||||
const defaultFilename = isZip
|
||||
? `${router.name}-bundle.zip`
|
||||
: `${router.name}-bundle.tar.gz`;
|
||||
|
||||
const savePath = await save({
|
||||
title: "Save OpenVPN Bundle",
|
||||
defaultPath: defaultFilename,
|
||||
filters: [
|
||||
{
|
||||
name: isZip ? "ZIP Archive" : "GZIP Archive",
|
||||
extensions: isZip ? ["zip"] : ["tar.gz", "gz"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!savePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(savePath, bytes);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to download bundle");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRouters();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="logo">LR</div>
|
||||
<div>
|
||||
<strong>LITORALREGAS</strong>
|
||||
<span>PRO</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<button className={page === "dashboard" ? "active" : ""} onClick={() => setPage("dashboard")}>
|
||||
<LayoutDashboard size={18} /> Dashboard
|
||||
</button>
|
||||
<button className={page === "routers" ? "active" : ""} onClick={() => setPage("routers")}>
|
||||
<Router size={18} /> Routers
|
||||
</button>
|
||||
<button className={page === "clients" ? "active" : ""} onClick={() => setPage("clients")}>
|
||||
<Shield size={18} /> OpenVPN Clients
|
||||
</button>
|
||||
<button className={page === "deployments" ? "active" : ""} onClick={() => setPage("deployments")}>
|
||||
<Activity size={18} /> Deployments
|
||||
</button>
|
||||
<button className={page === "servers" ? "active" : ""} onClick={() => setPage("servers")}>
|
||||
<Server size={18} /> VPS Servers
|
||||
</button>
|
||||
<button className={page === "settings" ? "active" : ""} onClick={() => setPage("settings")}>
|
||||
<Settings size={18} /> Settings
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="status-card">
|
||||
<strong>OpenVPN Status</strong>
|
||||
<p><span className="dot" /> Online</p>
|
||||
</div>
|
||||
</aside>
|
||||
<Sidebar page={page} onPageChange={setPage} />
|
||||
|
||||
<main className="content">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{page === "dashboard" && (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Overview of your OpenVPN infrastructure</p>
|
||||
</div>
|
||||
<button className="health">System Healthy</button>
|
||||
</div>
|
||||
|
||||
<div className="cards">
|
||||
<Metric title="Routers" value={routers.length} />
|
||||
<Metric title="OpenVPN Clients" value="-" />
|
||||
<Metric title="VPS Servers" value="1" />
|
||||
<Metric title="Deployments" value="-" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{page === "dashboard" && <DashboardPage routers={routers} />}
|
||||
|
||||
{page === "routers" && (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Routers</h1>
|
||||
<p>Manage routers and OpenVPN provisioning</p>
|
||||
</div>
|
||||
<button className="primary" onClick={() => setCreateOpen(true)}>
|
||||
+ New Router
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
{loading ? (
|
||||
<p>Loading routers...</p>
|
||||
) : routers.length === 0 ? (
|
||||
<p>No routers found</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Serial</th>
|
||||
<th>LAN IP</th>
|
||||
<th>LAN Subnet</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{routers.map((router) => (
|
||||
<tr key={router.id}>
|
||||
<td>{router.name}</td>
|
||||
<td>{router.serialNumber || "-"}</td>
|
||||
<td>{router.lanIp}</td>
|
||||
<td>{router.lanSubnet}</td>
|
||||
<td>
|
||||
<span className={`badge ${router.status.toLowerCase()}`}>
|
||||
{router.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<RoutersPage
|
||||
routers={routers}
|
||||
loading={loading}
|
||||
actionLoading={actionLoading}
|
||||
onCreateClick={openCreateRouterModal}
|
||||
onDelete={setRouterToDelete}
|
||||
onProvision={provisionRouter}
|
||||
onRemove={setRouterToRemove}
|
||||
onDownloadBundle={downloadBundle}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{createOpen && (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2>New Router</h2>
|
||||
<p>Create a router before allocating VPN details.</p>
|
||||
</div>
|
||||
<button className="icon-button" onClick={() => setCreateOpen(false)}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={createRouter} className="router-form">
|
||||
<label>
|
||||
Router Name
|
||||
<input
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="Ex: Loja Braga"
|
||||
<CreateRouterModal
|
||||
form={form}
|
||||
saving={saving}
|
||||
availableLanSubnets={availableSubnets(routers)}
|
||||
onChange={setForm}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSubmit={createRouter}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Serial Number
|
||||
<input
|
||||
value={form.serialNumber}
|
||||
onChange={(e) => setForm({ ...form, serialNumber: e.target.value })}
|
||||
placeholder="Ex: LR-001"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
LAN IP
|
||||
<input
|
||||
required
|
||||
value={form.lanIp}
|
||||
onChange={(e) => setForm({ ...form, lanIp: e.target.value })}
|
||||
placeholder="Ex: 192.168.60.1"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
LAN Subnet
|
||||
<input
|
||||
required
|
||||
value={form.lanSubnet}
|
||||
onChange={(e) => setForm({ ...form, lanSubnet: e.target.value })}
|
||||
placeholder="Ex: 192.168.60.0/24"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="secondary" onClick={() => setCreateOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="primary" disabled={saving}>
|
||||
{saving ? "Saving..." : "Save Router"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ title, value }: { title: string; value: string | number }) {
|
||||
return (
|
||||
<div className="metric-card">
|
||||
<span>{title}</span>
|
||||
<strong>{value}</strong>
|
||||
{routerToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete Router"
|
||||
message={`Are you sure you want to permanently delete "${routerToDelete.name}"?`}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onCancel={() => setRouterToDelete(null)}
|
||||
onConfirm={() => deleteRouter(routerToDelete)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{routerToRemove && (
|
||||
<ConfirmDialog
|
||||
title="Remove OpenVPN Client"
|
||||
message={`Remove the OpenVPN client configuration for "${routerToRemove.name}"?`}
|
||||
confirmLabel="Remove"
|
||||
danger
|
||||
onCancel={() => setRouterToRemove(null)}
|
||||
onConfirm={() => removeRouter(routerToRemove)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{latestDeployment && (
|
||||
<DeploymentResultModal
|
||||
deployment={latestDeployment}
|
||||
onClose={() => setLatestDeployment(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+15
@@ -38,3 +38,18 @@ export async function apiPost<TResponse, TBody>(
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function apiDelete(path: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"X-API-Key": API_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => null);
|
||||
|
||||
throw new Error(error?.error || `API error ${response.status}`);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
@@ -0,0 +1,40 @@
|
||||
type Props = {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function ConfirmDialog({
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
danger = false,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<div className="confirm-dialog">
|
||||
<h2>{title}</h2>
|
||||
<p>{message}</p>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={danger ? "danger-button" : "primary"}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import type { CreateRouterRequest } from "../types/router";
|
||||
import { gatewayFromSubnet } from "../lib/network";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
form: CreateRouterRequest;
|
||||
saving: boolean;
|
||||
onChange: (form: CreateRouterRequest) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
availableLanSubnets: string[];
|
||||
};
|
||||
|
||||
export function CreateRouterModal({
|
||||
form,
|
||||
saving,
|
||||
availableLanSubnets,
|
||||
onChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const [subnetOpen, setSubnetOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2>New Router</h2>
|
||||
<p>Create a router before allocating VPN details.</p>
|
||||
</div>
|
||||
|
||||
<button className="icon-button" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="router-form">
|
||||
<label>
|
||||
Router Name
|
||||
<input
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => onChange({ ...form, name: e.target.value })}
|
||||
placeholder="Ex: Loja Braga"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Serial Number
|
||||
<input
|
||||
value={form.serialNumber}
|
||||
onChange={(e) => onChange({ ...form, serialNumber: e.target.value })}
|
||||
placeholder="Ex: LR-001"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
LAN Subnet
|
||||
|
||||
<div className="custom-select">
|
||||
<button
|
||||
type="button"
|
||||
className="custom-select-button"
|
||||
onClick={() => setSubnetOpen(!subnetOpen)}
|
||||
>
|
||||
<span>{form.lanSubnet || "Select LAN subnet"}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
|
||||
{subnetOpen && (
|
||||
<div className="custom-select-menu">
|
||||
{availableLanSubnets.map((subnet) => (
|
||||
<button
|
||||
type="button"
|
||||
key={subnet}
|
||||
className="custom-select-option"
|
||||
onClick={() => {
|
||||
onChange({
|
||||
...form,
|
||||
lanSubnet: subnet,
|
||||
lanIp: gatewayFromSubnet(subnet),
|
||||
});
|
||||
|
||||
setSubnetOpen(false);
|
||||
}}
|
||||
>
|
||||
{subnet}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Gateway IP
|
||||
<input
|
||||
required
|
||||
value={form.lanIp}
|
||||
onChange={(e) => onChange({ ...form, lanIp: e.target.value })}
|
||||
placeholder="Ex: 192.168.60.1"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button type="submit" className="primary" disabled={saving}>
|
||||
{saving ? "Saving..." : "Save Router"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { CheckCircle2, X, XCircle } from "lucide-react";
|
||||
import type { DeploymentResponse } from "../types/deployment";
|
||||
|
||||
type Props = {
|
||||
deployment: DeploymentResponse;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function DeploymentResultModal({ deployment, onClose }: Props) {
|
||||
const successful = deployment.status === "SUCCESS";
|
||||
|
||||
const logs =
|
||||
deployment.stdout ||
|
||||
deployment.stderr ||
|
||||
deployment.logs ||
|
||||
deployment.errorMessage ||
|
||||
"No logs returned.";
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<div className="deployment-modal">
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2>Deployment Result</h2>
|
||||
<p>
|
||||
{deployment.action} · {deployment.routerName || "Router"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button className="icon-button" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="deployment-summary">
|
||||
<div className="deployment-status">
|
||||
{successful ? <CheckCircle2 size={20} /> : <XCircle size={20} />}
|
||||
<span className={`badge ${successful ? "success" : "failed"}`}>
|
||||
{deployment.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span>{deployment.finishedAt || deployment.createdAt || "-"}</span>
|
||||
</div>
|
||||
|
||||
<div className="log-box">
|
||||
<pre>{logs}</pre>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="primary" onClick={onClose}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function Metric({ title, value }: { title: string; value: string | number }) {
|
||||
return (
|
||||
<div className="metric-card">
|
||||
<span>{title}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Activity,
|
||||
LayoutDashboard,
|
||||
Router,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
page: string;
|
||||
onPageChange: (page: string) => void;
|
||||
};
|
||||
|
||||
export function Sidebar({ page, onPageChange }: Props) {
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<img className="brand-logo" src="/src/assets/lr-logo.png" alt="LitoralRegas" />
|
||||
<div>
|
||||
<strong>OpenVPN Tool</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<button className={page === "dashboard" ? "active" : ""} onClick={() => onPageChange("dashboard")}>
|
||||
<LayoutDashboard size={18} /> Dashboard
|
||||
</button>
|
||||
|
||||
<button className={page === "routers" ? "active" : ""} onClick={() => onPageChange("routers")}>
|
||||
<Router size={18} /> Routers
|
||||
</button>
|
||||
|
||||
<button className={page === "clients" ? "active" : ""} onClick={() => onPageChange("clients")}>
|
||||
<Shield size={18} /> OpenVPN Clients
|
||||
</button>
|
||||
|
||||
<button className={page === "deployments" ? "active" : ""} onClick={() => onPageChange("deployments")}>
|
||||
<Activity size={18} /> Deployments
|
||||
</button>
|
||||
|
||||
<button className={page === "servers" ? "active" : ""} onClick={() => onPageChange("servers")}>
|
||||
<Server size={18} /> VPS Servers
|
||||
</button>
|
||||
|
||||
<button className={page === "settings" ? "active" : ""} onClick={() => onPageChange("settings")}>
|
||||
<Settings size={18} /> Settings
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="status-card">
|
||||
<strong>OpenVPN Status</strong>
|
||||
<p>
|
||||
<span className="dot" /> Online
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { RouterItem } from "../types/router";
|
||||
|
||||
export const LAN_SUBNET_PRESETS = Array.from(
|
||||
{ length: 200 },
|
||||
(_, index) => `192.168.${index + 10}.0/24`
|
||||
);
|
||||
|
||||
export function gatewayFromSubnet(subnet: string): string {
|
||||
const [network] = subnet.split("/");
|
||||
const parts = network.split(".");
|
||||
|
||||
if (parts.length !== 4) return "";
|
||||
|
||||
return `${parts[0]}.${parts[1]}.${parts[2]}.1`;
|
||||
}
|
||||
|
||||
export function firstAvailableSubnet(routers: RouterItem[]): string {
|
||||
const usedSubnets = new Set(routers.map((router) => router.lanSubnet));
|
||||
|
||||
return (
|
||||
LAN_SUBNET_PRESETS.find((subnet) => !usedSubnets.has(subnet)) ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
export function availableSubnets(routers: RouterItem[]): string[] {
|
||||
const usedSubnets = new Set(routers.map((router) => router.lanSubnet));
|
||||
|
||||
return LAN_SUBNET_PRESETS.filter((subnet) => !usedSubnets.has(subnet));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Metric } from "../components/Metric";
|
||||
import type { RouterItem } from "../types/router";
|
||||
|
||||
export function DashboardPage({ routers }: { routers: RouterItem[] }) {
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Overview of your OpenVPN infrastructure</p>
|
||||
</div>
|
||||
<button className="health">System Healthy</button>
|
||||
</div>
|
||||
|
||||
<div className="cards">
|
||||
<Metric title="Routers" value={routers.length} />
|
||||
<Metric title="OpenVPN Clients" value="-" />
|
||||
<Metric title="VPS Servers" value="1" />
|
||||
<Metric title="Deployments" value="-" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { RouterItem } from "../types/router";
|
||||
import { Download, Play, RotateCcw, Trash2 } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
routers: RouterItem[];
|
||||
loading: boolean;
|
||||
actionLoading: string | null;
|
||||
onCreateClick: () => void;
|
||||
onDelete: (router: RouterItem) => void;
|
||||
onProvision: (router: RouterItem) => void;
|
||||
onRemove: (router: RouterItem) => void;
|
||||
onDownloadBundle: (router: RouterItem) => void;
|
||||
};
|
||||
|
||||
export function RoutersPage({
|
||||
routers,
|
||||
loading,
|
||||
actionLoading,
|
||||
onCreateClick,
|
||||
onDelete,
|
||||
onProvision,
|
||||
onRemove,
|
||||
onDownloadBundle,
|
||||
}: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Routers</h1>
|
||||
<p>Manage routers and OpenVPN provisioning</p>
|
||||
</div>
|
||||
|
||||
<button className="primary" onClick={onCreateClick}>
|
||||
+ New Router
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
{loading ? (
|
||||
<p className="panel-empty">Loading routers...</p>
|
||||
) : routers.length === 0 ? (
|
||||
<p className="panel-empty">No routers found</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Serial</th>
|
||||
<th>LAN IP</th>
|
||||
<th>LAN Subnet</th>
|
||||
<th>VPN IP</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{routers.map((router) => {
|
||||
const canProvision =
|
||||
router.status === "PENDING" || router.status === "FAILED";
|
||||
|
||||
const isProvisioned = router.status === "PROVISIONED";
|
||||
const isBusy = actionLoading === router.id;
|
||||
const canDelete =
|
||||
router.status === "PENDING" ||
|
||||
router.status === "FAILED" ||
|
||||
router.status === "REMOVED";
|
||||
return (
|
||||
<tr key={router.id}>
|
||||
<td>{router.name}</td>
|
||||
<td>{router.serialNumber || "-"}</td>
|
||||
<td>{router.lanIp}</td>
|
||||
<td>{router.lanSubnet}</td>
|
||||
<td>{router.vpnIp || "-"}</td>
|
||||
<td>
|
||||
<span className={`badge ${router.status.toLowerCase()}`}>
|
||||
{isBusy ? "WORKING" : router.status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div className="table-actions">
|
||||
{canProvision && (
|
||||
<button
|
||||
className="small-action primary-action"
|
||||
onClick={() => onProvision(router)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Play size={14} />
|
||||
{isBusy ? "Working..." : "Provision"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isProvisioned && (
|
||||
<>
|
||||
<button
|
||||
className="small-action"
|
||||
onClick={() => onDownloadBundle(router)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Download size={14} />
|
||||
Bundle
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="small-action warning-action"
|
||||
onClick={() => onRemove(router)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
Remove
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canDelete && (
|
||||
<button
|
||||
className="table-action danger"
|
||||
onClick={() => onDelete(router)}
|
||||
disabled={isBusy}
|
||||
title="Delete router"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type DeploymentResponse = {
|
||||
id: string;
|
||||
routerId?: string;
|
||||
routerName?: string;
|
||||
action: string;
|
||||
status: string;
|
||||
stdout?: string | null;
|
||||
stderr?: string | null;
|
||||
logs?: string | null;
|
||||
errorMessage?: string | null;
|
||||
createdAt?: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export type RouterItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
serialNumber?: string;
|
||||
lanIp: string;
|
||||
lanSubnet: string;
|
||||
status: string;
|
||||
|
||||
vpnIp?: string;
|
||||
clientName?: string;
|
||||
};
|
||||
|
||||
export type CreateRouterRequest = {
|
||||
name: string;
|
||||
serialNumber: string;
|
||||
lanIp: string;
|
||||
lanSubnet: string;
|
||||
};
|
||||
|
||||
export type IpAllocationRequest = {
|
||||
clientName: string;
|
||||
allocationMode: "AUTOMATIC" | "MANUAL";
|
||||
};
|
||||
Reference in New Issue
Block a user