diff --git a/package-lock.json b/package-lock.json index a1a8986..081d916 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index eb24023..32dafb3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 654d005..b6c983e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index dd18217..7e1a7dc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4cdbf49..3b96e47 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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" ] -} +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a277ef..4d0188b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); -} +} \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index baa2457..d8dd12f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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": { diff --git a/src/App.css b/src/App.css index df74672..c669a30 100644 --- a/src/App.css +++ b/src/App.css @@ -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; } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 46b509d..3edfe51 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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([]); const [loading, setLoading] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [saving, setSaving] = useState(false); + const [actionLoading, setActionLoading] = useState(null); const [error, setError] = useState(null); + const [routerToDelete, setRouterToDelete] = useState(null); + const [routerToRemove, setRouterToRemove] = useState(null); + const [latestDeployment, setLatestDeployment] = useState(null); const [form, setForm] = useState({ 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("/api/routers", form); + createdRouter = await apiPost( + "/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,202 +106,232 @@ 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>( + `/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>( + `/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 (
- +
{error &&
{error}
} - {page === "dashboard" && ( - <> -
-
-

Dashboard

-

Overview of your OpenVPN infrastructure

-
- -
- -
- - - - -
- - )} + {page === "dashboard" && } {page === "routers" && ( - <> -
-
-

Routers

-

Manage routers and OpenVPN provisioning

-
- -
- -
- {loading ? ( -

Loading routers...

- ) : routers.length === 0 ? ( -

No routers found

- ) : ( - - - - - - - - - - - - {routers.map((router) => ( - - - - - - - - ))} - -
NameSerialLAN IPLAN SubnetStatus
{router.name}{router.serialNumber || "-"}{router.lanIp}{router.lanSubnet} - - {router.status} - -
- )} -
- + )}
{createOpen && ( -
-
-
-
-

New Router

-

Create a router before allocating VPN details.

-
- -
+ setCreateOpen(false)} + onSubmit={createRouter} + /> + )} -
- + {routerToDelete && ( + setRouterToDelete(null)} + onConfirm={() => deleteRouter(routerToDelete)} + /> + )} - + {routerToRemove && ( + setRouterToRemove(null)} + onConfirm={() => removeRouter(routerToRemove)} + /> + )} - - - - -
- - -
- -
-
+ {latestDeployment && ( + setLatestDeployment(null)} + /> )}
); } -function Metric({ title, value }: { title: string; value: string | number }) { - return ( -
- {title} - {value} -
- ); -} - export default App; \ No newline at end of file diff --git a/src/api.ts b/src/api.ts index cc1ceba..e2020fd 100644 --- a/src/api.ts +++ b/src/api.ts @@ -37,4 +37,19 @@ export async function apiPost( } return response.json(); -} \ No newline at end of file +} + +export async function apiDelete(path: string): Promise { + 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}`); + } +} \ No newline at end of file diff --git a/src/assets/lr-logo.png b/src/assets/lr-logo.png new file mode 100644 index 0000000..0857803 Binary files /dev/null and b/src/assets/lr-logo.png differ diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..005ba17 --- /dev/null +++ b/src/components/ConfirmDialog.tsx @@ -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 ( +
+
+

{title}

+

{message}

+ +
+ + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/CreateRouterModal.tsx b/src/components/CreateRouterModal.tsx new file mode 100644 index 0000000..f983ef5 --- /dev/null +++ b/src/components/CreateRouterModal.tsx @@ -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 ( +
+
+
+
+

New Router

+

Create a router before allocating VPN details.

+
+ + +
+ +
+ + + + + + + + +
+ + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/DeploymentResultModal.tsx b/src/components/DeploymentResultModal.tsx new file mode 100644 index 0000000..2c60b46 --- /dev/null +++ b/src/components/DeploymentResultModal.tsx @@ -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 ( +
+
+
+
+

Deployment Result

+

+ {deployment.action} ยท {deployment.routerName || "Router"} +

+
+ + +
+ +
+
+ {successful ? : } + + {deployment.status} + +
+ + {deployment.finishedAt || deployment.createdAt || "-"} +
+ +
+
{logs}
+
+ +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/Metric.tsx b/src/components/Metric.tsx new file mode 100644 index 0000000..e3fb098 --- /dev/null +++ b/src/components/Metric.tsx @@ -0,0 +1,8 @@ +export function Metric({ title, value }: { title: string; value: string | number }) { + return ( +
+ {title} + {value} +
+ ); +} \ No newline at end of file diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..0c47020 --- /dev/null +++ b/src/components/Sidebar.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/src/lib/network.ts b/src/lib/network.ts new file mode 100644 index 0000000..e75ac04 --- /dev/null +++ b/src/lib/network.ts @@ -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)); +} \ No newline at end of file diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..0acb5b4 --- /dev/null +++ b/src/pages/DashboardPage.tsx @@ -0,0 +1,23 @@ +import { Metric } from "../components/Metric"; +import type { RouterItem } from "../types/router"; + +export function DashboardPage({ routers }: { routers: RouterItem[] }) { + return ( + <> +
+
+

Dashboard

+

Overview of your OpenVPN infrastructure

+
+ +
+ +
+ + + + +
+ + ); +} \ No newline at end of file diff --git a/src/pages/RoutersPage.tsx b/src/pages/RoutersPage.tsx new file mode 100644 index 0000000..f8f351f --- /dev/null +++ b/src/pages/RoutersPage.tsx @@ -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 ( + <> +
+
+

Routers

+

Manage routers and OpenVPN provisioning

+
+ + +
+ +
+ {loading ? ( +

Loading routers...

+ ) : routers.length === 0 ? ( +

No routers found

+ ) : ( + + + + + + + + + + + + + + + {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 ( + + + + + + + + + + + ); + })} + +
NameSerialLAN IPLAN SubnetVPN IPStatusActions
{router.name}{router.serialNumber || "-"}{router.lanIp}{router.lanSubnet}{router.vpnIp || "-"} + + {isBusy ? "WORKING" : router.status} + + +
+ {canProvision && ( + + )} + + {isProvisioned && ( + <> + + + + + )} + + {canDelete && ( + + )} +
+
+ )} +
+ + ); +} \ No newline at end of file diff --git a/src/types/deployment.ts b/src/types/deployment.ts new file mode 100644 index 0000000..032e623 --- /dev/null +++ b/src/types/deployment.ts @@ -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; +}; \ No newline at end of file diff --git a/src/types/router.ts b/src/types/router.ts new file mode 100644 index 0000000..5150a8c --- /dev/null +++ b/src/types/router.ts @@ -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"; +}; \ No newline at end of file