Implement router provisioning lifecycle and deployment UI

This commit is contained in:
litoral05
2026-05-06 10:22:21 +01:00
parent 20a0dbe794
commit 90bfc090bd
21 changed files with 1380 additions and 268 deletions
+20
View File
@@ -9,6 +9,8 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"react": "^19.1.0", "react": "^19.1.0",
@@ -1435,6 +1437,24 @@
"node": ">= 10" "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": { "node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4", "version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
+2
View File
@@ -11,6 +11,8 @@
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"react": "^19.1.0", "react": "^19.1.0",
+144 -1
View File
@@ -1963,6 +1963,8 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-opener", "tauri-plugin-opener",
] ]
@@ -2230,6 +2232,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -2820,6 +2823,30 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -3502,6 +3529,48 @@ dependencies = [
"walkdir", "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]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.4" version = "2.5.4"
@@ -4592,6 +4661,15 @@ dependencies = [
"windows-targets 0.52.6", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -4625,13 +4703,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 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_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 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]] [[package]]
name = "windows-threading" name = "windows-threading"
version = "0.1.0" version = "0.1.0"
@@ -4662,6 +4757,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -4674,6 +4775,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.42.2" version = "0.42.2"
@@ -4686,12 +4793,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.42.2" version = "0.42.2"
@@ -4704,6 +4823,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.42.2" version = "0.42.2"
@@ -4716,6 +4841,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.42.2" version = "0.42.2"
@@ -4728,6 +4859,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -4740,6 +4877,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 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]] [[package]]
name = "winnow" name = "winnow"
version = "0.5.40" version = "0.5.40"
+2
View File
@@ -22,4 +22,6 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tauri-plugin-dialog = "2.7.1"
tauri-plugin-fs = "2.5.1"
+8 -3
View File
@@ -2,9 +2,14 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": [
"main"
],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default" "opener:default",
"dialog:default",
"fs:default",
"fs:allow-write-file"
] ]
} }
+3 -3
View File
@@ -7,8 +7,8 @@ fn greet(name: &str) -> String {
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![greet]) .plugin(tauri_plugin_fs::init())
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
+4 -3
View File
@@ -12,9 +12,10 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "lr-openvpn-desktop", "title": "LR OpenVPN Tool",
"width": 800, "width": 1440,
"height": 600 "height": 900,
"resizable": true
} }
], ],
"security": { "security": {
+414 -63
View File
@@ -9,46 +9,75 @@ body {
color: #1f2937; color: #1f2937;
} }
button { button,
input,
select {
font: inherit; font: inherit;
} }
button {
cursor: pointer;
}
.app { .app {
min-height: 100vh; min-height: 100vh;
min-width: 1280px;
display: flex; display: flex;
background: #f2f2f2;
} }
/* SIDEBAR */
.sidebar { .sidebar {
width: 260px; width: 250px;
background: #0d0d0d; background: #0d0d0d;
color: white; color: white;
padding: 24px 16px; padding: 26px 16px 18px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-right: 1px solid #171717;
} }
.brand { .brand {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
margin-bottom: 36px; margin-bottom: 34px;
text-align: center;
} }
.logo { .logo {
width: 54px; width: 72px;
height: 54px; height: 72px;
border-radius: 18px; border-radius: 24px;
background: linear-gradient(135deg, #5da8ff, #b7e236); background: linear-gradient(135deg, #5da8ff, #b7e236);
display: grid; display: grid;
place-items: center; place-items: center;
font-weight: 900; 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 { .brand span {
display: block; display: block;
font-size: 12px; font-size: 13px;
color: #b7e236; color: white;
letter-spacing: 2px; letter-spacing: 4px;
margin-top: -2px;
} }
nav { nav {
@@ -60,12 +89,13 @@ nav button {
background: transparent; background: transparent;
border: 0; border: 0;
color: #d1d5db; color: #d1d5db;
padding: 12px; padding: 12px 14px;
border-radius: 10px; border-radius: 9px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 11px;
cursor: pointer; font-size: 14px;
font-weight: 700;
} }
nav button.active, nav button.active,
@@ -76,12 +106,24 @@ nav button:hover {
.status-card { .status-card {
margin-top: auto; margin-top: auto;
background: #1a1a1a; background: #181818;
border: 1px solid #262626;
border-radius: 14px; border-radius: 14px;
padding: 16px; padding: 16px;
font-size: 14px; font-size: 14px;
} }
.status-card strong {
display: block;
margin-bottom: 8px;
}
.status-card p {
margin: 0;
color: #b7e236;
font-weight: 700;
}
.dot { .dot {
width: 9px; width: 9px;
height: 9px; height: 9px;
@@ -91,107 +133,181 @@ nav button:hover {
margin-right: 8px; margin-right: 8px;
} }
/* MAIN */
.content { .content {
flex: 1; flex: 1;
padding: 32px; padding: 30px;
overflow-x: auto;
overflow-y: auto;
} }
.page-header { .page-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
margin-bottom: 24px; margin-bottom: 24px;
} }
.page-header h1 { .page-header h1 {
margin: 0; margin: 0;
font-size: 30px; font-size: 26px;
line-height: 1.1;
color: #111827;
} }
.page-header p { .page-header p {
margin: 4px 0 0; margin: 6px 0 0;
color: #6b7280; color: #6b7280;
font-size: 14px;
} }
.health, .health,
.primary { .primary,
.secondary {
border: 0; border: 0;
border-radius: 12px; border-radius: 9px;
padding: 12px 18px; padding: 11px 18px;
font-weight: 700; font-weight: 800;
font-size: 14px;
} }
.health { .health {
background: white; 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 { .primary {
background: #b7e236; background: #b7e236;
color: #0d0d0d; color: #0d0d0d;
cursor: pointer;
} }
.secondary {
background: #f3f4f6;
color: #374151;
}
.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* CARDS */
.cards { .cards {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 18px; gap: 18px;
margin-bottom: 18px;
} }
.metric-card, .metric-card,
.panel { .panel {
background: white; background: white;
border-radius: 18px; border-radius: 14px;
padding: 22px; padding: 20px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06); border: 1px solid #eceff3;
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.06);
}
.metric-card {
min-height: 118px;
} }
.metric-card span { .metric-card span {
color: #6b7280; color: #6b7280;
font-size: 14px; font-size: 13px;
font-weight: 700;
} }
.metric-card strong { .metric-card strong {
display: block; display: block;
font-size: 34px; font-size: 34px;
margin-top: 8px; margin-top: 10px;
color: #111827;
}
/* TABLE */
.panel {
padding: 0;
overflow: hidden;
overflow-x: auto;
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: white;
} }
th, th,
td { td {
text-align: left; text-align: left;
padding: 14px; padding: 15px 18px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #edf0f3;
font-size: 14px;
} }
th { th {
color: #6b7280; color: #6b7280;
font-size: 13px; font-size: 12px;
font-weight: 800;
background: #fbfbfc;
}
td {
color: #374151;
font-weight: 600;
} }
.badge { .badge {
padding: 6px 10px; padding: 6px 10px;
border-radius: 999px; border-radius: 999px;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 800;
background: #e5e7eb; 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; color: #16a34a;
background: #dcfce7; background: #dcfce7;
} }
.badge.pending { .badge.pending,
.badge.inactive,
.badge.warning {
color: #ca8a04; color: #ca8a04;
background: #fef9c3; background: #fef9c3;
} }
.badge.failed { .badge.failed,
.badge.offline {
color: #dc2626; color: #dc2626;
background: #fee2e2; background: #fee2e2;
} }
@@ -201,95 +317,330 @@ th {
background: #f3f4f6; background: #f3f4f6;
} }
/* ERROR */
.error-banner { .error-banner {
background: #fee2e2; background: #fee2e2;
color: #991b1b; color: #991b1b;
padding: 12px 16px; padding: 12px 16px;
border-radius: 12px; border-radius: 12px;
margin-bottom: 18px; margin-bottom: 18px;
font-weight: 700; font-weight: 800;
} }
/* MODAL */
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.45); background: rgba(0, 0, 0, 0.55);
display: grid; display: grid;
place-items: center; place-items: center;
z-index: 50; z-index: 50;
} }
.modal { .modal {
width: 520px; width: 760px;
background: white; max-width: calc(100vw - 48px);
border-radius: 20px; background: #f7f8fa;
padding: 24px; border-radius: 18px;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.25); padding: 22px;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.35);
} }
.modal-header { .modal-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
margin-bottom: 22px; margin-bottom: 18px;
} }
.modal-header h2 { .modal-header h2 {
margin: 0; margin: 0;
color: #111827;
} }
.modal-header p { .modal-header p {
margin: 4px 0 0; margin: 5px 0 0;
color: #6b7280; color: #6b7280;
font-size: 14px;
} }
.icon-button { .icon-button {
border: 0; border: 0;
background: #f3f4f6; background: white;
border-radius: 10px; border-radius: 10px;
width: 36px; width: 36px;
height: 36px; height: 36px;
display: grid; display: grid;
place-items: center; place-items: center;
cursor: pointer;
} }
.router-form { .router-form {
display: grid; display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px; gap: 16px;
} }
.router-form label { .router-form label {
background: white;
border: 1px solid #eceff3;
border-radius: 14px;
padding: 14px;
display: grid; display: grid;
gap: 7px; gap: 8px;
font-size: 14px; font-size: 13px;
font-weight: 700; font-weight: 800;
color: #374151;
} }
.router-form input { .router-form input {
height: 42px; height: 40px;
border: 1px solid #d1d5db; border: 1px solid #dfe3e8;
border-radius: 10px; border-radius: 8px;
padding: 0 12px; 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 { .modal-actions {
grid-column: 1 / -1;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 12px; gap: 12px;
margin-top: 8px; margin-top: 4px;
} }
.secondary { /* RESPONSIVE */
border: 0;
@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; border-radius: 12px;
padding: 12px 18px; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.18);
background: #f3f4f6; padding: 6px;
cursor: pointer;
} }
.primary:disabled { .custom-select-option {
opacity: 0.6; 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; 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;
} }
+254 -194
View File
@@ -1,39 +1,35 @@
import { FormEvent, useEffect, useState } from "react"; 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 { import {
LayoutDashboard, availableSubnets,
Router, firstAvailableSubnet,
Shield, gatewayFromSubnet,
Activity, } from "./lib/network";
Server,
Settings,
X,
} from "lucide-react";
import "./App.css"; 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() { function App() {
const [page, setPage] = useState("dashboard"); const [page, setPage] = useState("dashboard");
const [routers, setRouters] = useState<RouterItem[]>([]); const [routers, setRouters] = useState<RouterItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [error, setError] = 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>({ const [form, setForm] = useState<CreateRouterRequest>({
name: "", 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) { async function createRouter(event: FormEvent) {
event.preventDefault(); event.preventDefault();
let createdRouter: RouterItem | null = null;
try { try {
setSaving(true); setSaving(true);
setError(null); 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); setCreateOpen(false);
setForm({ setForm({
name: "", name: "",
serialNumber: "", serialNumber: "",
@@ -76,202 +106,232 @@ function App() {
await loadRouters(); await loadRouters();
setPage("routers"); setPage("routers");
} catch (err) { } 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"); setError(err instanceof Error ? err.message : "Failed to create router");
} finally { } finally {
setSaving(false); 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(() => { useEffect(() => {
loadRouters(); loadRouters();
}, []); }, []);
return ( return (
<div className="app"> <div className="app">
<aside className="sidebar"> <Sidebar page={page} onPageChange={setPage} />
<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>
<main className="content"> <main className="content">
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{page === "dashboard" && ( {page === "dashboard" && <DashboardPage routers={routers} />}
<>
<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 === "routers" && ( {page === "routers" && (
<> <RoutersPage
<div className="page-header"> routers={routers}
<div> loading={loading}
<h1>Routers</h1> actionLoading={actionLoading}
<p>Manage routers and OpenVPN provisioning</p> onCreateClick={openCreateRouterModal}
</div> onDelete={setRouterToDelete}
<button className="primary" onClick={() => setCreateOpen(true)}> onProvision={provisionRouter}
+ New Router onRemove={setRouterToRemove}
</button> onDownloadBundle={downloadBundle}
</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>
</>
)} )}
</main> </main>
{createOpen && ( {createOpen && (
<div className="modal-backdrop"> <CreateRouterModal
<div className="modal"> form={form}
<div className="modal-header"> saving={saving}
<div> availableLanSubnets={availableSubnets(routers)}
<h2>New Router</h2> onChange={setForm}
<p>Create a router before allocating VPN details.</p> onClose={() => setCreateOpen(false)}
</div> onSubmit={createRouter}
<button className="icon-button" onClick={() => setCreateOpen(false)}> />
<X size={18} /> )}
</button>
</div>
<form onSubmit={createRouter} className="router-form"> {routerToDelete && (
<label> <ConfirmDialog
Router Name title="Delete Router"
<input message={`Are you sure you want to permanently delete "${routerToDelete.name}"?`}
required confirmLabel="Delete"
value={form.name} danger
onChange={(e) => setForm({ ...form, name: e.target.value })} onCancel={() => setRouterToDelete(null)}
placeholder="Ex: Loja Braga" onConfirm={() => deleteRouter(routerToDelete)}
/> />
</label> )}
<label> {routerToRemove && (
Serial Number <ConfirmDialog
<input title="Remove OpenVPN Client"
value={form.serialNumber} message={`Remove the OpenVPN client configuration for "${routerToRemove.name}"?`}
onChange={(e) => setForm({ ...form, serialNumber: e.target.value })} confirmLabel="Remove"
placeholder="Ex: LR-001" danger
/> onCancel={() => setRouterToRemove(null)}
</label> onConfirm={() => removeRouter(routerToRemove)}
/>
)}
<label> {latestDeployment && (
LAN IP <DeploymentResultModal
<input deployment={latestDeployment}
required onClose={() => setLatestDeployment(null)}
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> </div>
); );
} }
function Metric({ title, value }: { title: string; value: string | number }) {
return (
<div className="metric-card">
<span>{title}</span>
<strong>{value}</strong>
</div>
);
}
export default App; export default App;
+16 -1
View File
@@ -37,4 +37,19 @@ export async function apiPost<TResponse, TBody>(
} }
return response.json(); 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

+40
View File
@@ -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>
);
}
+120
View File
@@ -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>
);
}
+58
View File
@@ -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>
);
}
+8
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+30
View File
@@ -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));
}
+23
View File
@@ -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>
</>
);
}
+138
View File
@@ -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>
</>
);
}
+14
View File
@@ -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;
};
+23
View File
@@ -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";
};