From 523ce02b0389319c4f9ce7fad1b3d8dd8e2a2e54 Mon Sep 17 00:00:00 2001 From: litoral05 Date: Wed, 6 May 2026 12:32:37 +0100 Subject: [PATCH] feat: build OpenVPN operations dashboard and VPS management UI --- package-lock.json | 413 +++++++++++- package.json | 3 +- src-tauri/tauri.conf.json | 7 +- src/App.css | 788 +++++++++++++++++++++-- src/App.tsx | 193 +++++- src/components/CreateRouterModal.tsx | 24 +- src/components/DeploymentResultModal.tsx | 23 +- src/components/Sidebar.tsx | 76 ++- src/pages/DashboardPage.tsx | 337 +++++++++- src/pages/DeploymentsPage.tsx | 225 +++++++ src/pages/RoutersPage.tsx | 59 +- src/pages/SettingsPage.tsx | 176 +++++ src/pages/VpsServerPage.tsx | 298 +++++++++ src/types/openVpnHealthResponse.ts | 3 + src/types/openVpnStatus.ts | 1 + 15 files changed, 2471 insertions(+), 155 deletions(-) create mode 100644 src/pages/DeploymentsPage.tsx create mode 100644 src/pages/SettingsPage.tsx create mode 100644 src/pages/VpsServerPage.tsx create mode 100644 src/types/openVpnHealthResponse.ts create mode 100644 src/types/openVpnStatus.ts diff --git a/package-lock.json b/package-lock.json index 081d916..e5deb1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "@tauri-apps/plugin-opener": "^2", "lucide-react": "^1.14.0", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "recharts": "^3.8.1" }, "devDependencies": { "@tauri-apps/cli": "^2", @@ -799,6 +800,42 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.6", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.6.tgz", + "integrity": "sha512-uwrF08UBQfxk49i9WcUeCx045wjB1zXEHNJmbYHPVVspxmjwSeWCoKbB8DEIvs3XkBJV6lcRAyLaWJ2+u3MMCw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1195,6 +1232,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tauri-apps/api": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", @@ -1509,6 +1558,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1520,7 +1632,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1536,6 +1648,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1625,6 +1743,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1636,9 +1763,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1657,6 +1905,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.349", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", @@ -1664,6 +1918,16 @@ "dev": true, "license": "ISC" }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -1716,6 +1980,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1759,6 +2029,25 @@ "node": ">=6.9.0" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1914,6 +2203,36 @@ "react": "^19.2.5" } }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1924,6 +2243,57 @@ "node": ">=0.10.0" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", @@ -1995,6 +2365,12 @@ "node": ">=0.10.0" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -2057,6 +2433,37 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", diff --git a/package.json b/package.json index 32dafb3..abafc16 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "@tauri-apps/plugin-opener": "^2", "lucide-react": "^1.14.0", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "recharts": "^3.8.1" }, "devDependencies": { "@tauri-apps/cli": "^2", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d8dd12f..852ba40 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,9 +13,10 @@ "windows": [ { "title": "LR OpenVPN Tool", - "width": 1440, - "height": 900, - "resizable": true + "width": 1600, + "height": 1000, + "resizable": true, + "maximized": true } ], "security": { diff --git a/src/App.css b/src/App.css index e54b82e..5c9073b 100644 --- a/src/App.css +++ b/src/App.css @@ -2,6 +2,15 @@ box-sizing: border-box; } + +body, +html, +#root { + width: 100%; + height: 100%; + overflow: hidden; +} + body { margin: 0; font-family: Inter, system-ui, sans-serif; @@ -20,9 +29,11 @@ button { } .app { - min-height: 100vh; + height: 100vh; + width: 100vw; min-width: 1280px; display: flex; + overflow: hidden; background: #f2f2f2; } @@ -125,21 +136,40 @@ nav button:hover { } .dot { - width: 9px; - height: 9px; - background: #22c55e; + width: 8px; + height: 8px; + border-radius: 999px; display: inline-block; - border-radius: 50%; margin-right: 8px; } +.dot.online { + background: #22c55e; + box-shadow: 0 0 12px rgba(34, 197, 94, 0.7); +} + +.dot.degraded { + background: #f59e0b; + box-shadow: 0 0 12px rgba(245, 158, 11, 0.7); +} + +.dot.offline { + background: #ef4444; + box-shadow: 0 0 12px rgba(239, 68, 68, 0.7); +} + +.dot.checking { + background: #64748b; +} + /* MAIN */ .content { flex: 1; + height: 100vh; padding: 30px; - overflow-x: auto; overflow-y: auto; + overflow-x: hidden; } .page-header { @@ -191,6 +221,16 @@ nav button:hover { .primary { background: #b7e236; color: #0d0d0d; + min-height: 42px; +} + +.primary, +.secondary, +.health { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; } .secondary { @@ -240,47 +280,79 @@ nav button:hover { /* TABLE */ -.panel { - padding: 0; - overflow: visible; -} +/* TABLE */ -.panel table { +.panel { + background: white; + border: 1px solid #edf1f7; + border-radius: 24px; overflow: hidden; + box-shadow: + 0 10px 30px rgba(15, 23, 42, 0.04), + 0 2px 6px rgba(15, 23, 42, 0.03); } table { width: 100%; - border-collapse: collapse; - background: white; + border-collapse: separate; + border-spacing: 0 10px; + padding: 0 14px 14px; } -th, -td { +thead th { text-align: left; - padding: 15px 18px; - border-bottom: 1px solid #edf0f3; - font-size: 14px; -} - -th { - color: #6b7280; + padding: 0 18px 12px; + border: none; + color: #94a3b8; font-size: 12px; font-weight: 800; - background: #fbfbfc; + background: transparent; } -td { - color: #374151; +tbody tr { + background: #fbfcfe; + transition: + transform 0.15s ease, + box-shadow 0.15s ease, + background 0.15s ease; +} + +tbody tr:hover { + background: #ffffff; + transform: translateY(-1px); + box-shadow: + 0 8px 24px rgba(15, 23, 42, 0.06), + 0 2px 8px rgba(15, 23, 42, 0.04); +} + +tbody td { + padding: 18px; + border-top: 1px solid #edf1f7; + border-bottom: 1px solid #edf1f7; + background: inherit; + font-size: 14px; font-weight: 600; + color: #374151; +} + +tbody td:first-child { + border-left: 1px solid #edf1f7; + border-top-left-radius: 16px; + border-bottom-left-radius: 16px; +} + +tbody td:last-child { + border-right: 1px solid #edf1f7; + border-top-right-radius: 16px; + border-bottom-right-radius: 16px; } .badge { - padding: 6px 10px; + padding: 7px 12px; border-radius: 999px; font-size: 12px; font-weight: 800; - background: #e5e7eb; + border: none; display: inline-flex; align-items: center; gap: 6px; @@ -437,16 +509,21 @@ td { .custom-select-button { width: 100%; - height: 40px; - border: 1px solid #dfe3e8; - border-radius: 8px; - padding: 0 12px; - background: #fbfbfc; + height: 46px; + border: 1px solid #edf1f7; + border-radius: 16px; + padding: 0 14px; + background: #f8fafc; color: #111827; display: flex; align-items: center; justify-content: space-between; font-weight: 700; + transition: all 0.15s ease; +} + +.custom-select-button:hover { + background: white; } .custom-select-menu { @@ -545,17 +622,23 @@ td { } .small-action { - height: 34px; - border: 0; - border-radius: 10px; - padding: 0 12px; + height: 36px; + border: 1px solid #edf1f7; + border-radius: 12px; + padding: 0 13px; display: inline-flex; align-items: center; gap: 7px; - background: #f3f4f6; + background: white; color: #374151; font-weight: 800; font-size: 13px; + transition: all 0.15s ease; +} + +.small-action:hover { + transform: translateY(-1px); + background: #f8fafc; } .primary-action { @@ -632,24 +715,30 @@ td { display: flex; justify-content: space-between; gap: 14px; - padding: 16px; - border-bottom: 1px solid #edf0f3; + padding: 18px; background: white; position: relative; z-index: 20; } .search-box { - height: 40px; + height: 46px; min-width: 320px; display: flex; align-items: center; - gap: 9px; - background: #fbfbfc; - border: 1px solid #dfe3e8; - border-radius: 10px; - padding: 0 12px; + gap: 10px; + background: #f8fafc; + border: 1px solid #edf1f7; + border-radius: 16px; + padding: 0 14px; color: #64748b; + transition: all 0.15s ease; +} + +.search-box:focus-within { + background: white; + border-color: #dbe5f0; + box-shadow: 0 0 0 4px rgba(93, 168, 255, 0.08); } .search-box input { @@ -678,4 +767,613 @@ td { .chevron.open { transform: rotate(180deg); +} + +td .small-action { + white-space: nowrap; +} + +.page-stack { + display: grid; + gap: 24px; +} + +.toolbar-filters { + display: flex; + align-items: center; + gap: 12px; +} + +.dashboard-page { + display: flex; + flex-direction: column; + gap: 22px; +} + +.page-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.page-header h1 { + margin: 0; + font-size: 28px; +} + +.page-header p { + margin: 6px 0 0; + color: #64748b; +} + +.system-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: 999px; + background: white; + border: 1px solid #e5e7eb; + font-weight: 700; + font-size: 13px; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.metric-card { + display: flex; + gap: 16px; + align-items: center; + padding: 22px; + border-radius: 18px; + background: white; + border: 1px solid #e5e7eb; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06); +} + +.metric-icon { + width: 48px; + height: 48px; + border-radius: 16px; + display: grid; + place-items: center; + background: #5da8ff; + color: white; +} + +.metric-card p { + margin: 0; + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.metric-card strong { + display: block; + margin-top: 4px; + font-size: 30px; +} + +.metric-card span { + color: #94a3b8; + font-size: 12px; +} + +.dashboard-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 16px; +} + +.dashboard-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 18px; + padding: 20px; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06); +} + +.dashboard-card.wide { + min-height: 260px; +} + +.card-header { + display: flex; + justify-content: space-between; + margin-bottom: 18px; +} + +.card-header h2 { + margin: 0; + font-size: 17px; +} + +.card-header p { + margin: 5px 0 0; + color: #64748b; + font-size: 13px; +} + +.activity-chart-placeholder { + height: 190px; + border-radius: 16px; + border: 1px dashed #cbd5e1; + background: linear-gradient(180deg, #f8fbff, #ffffff); + display: grid; + place-items: center; + color: #64748b; + gap: 8px; +} + +.health-stack { + display: flex; + flex-direction: column; + gap: 14px; +} + +.health-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px; + border-radius: 14px; + background: #f8fafc; +} + +.health-row div { + display: flex; + align-items: center; + font-weight: 700; + gap: 8px; +} + +.health-row strong { + font-size: 13px; +} + +.dashboard-table { + width: 100%; + border-collapse: collapse; +} + +.dashboard-table th, +.dashboard-table td { + padding: 14px 10px; + text-align: left; + border-bottom: 1px solid #eef2f7; + font-size: 13px; +} + +.dashboard-table th { + color: #64748b; + font-weight: 800; +} + +.table-status { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 800; +} + +.table-status.success { + color: #16a34a; +} + +.table-status.failed { + color: #ef4444; +} + +.empty-cell { + color: #94a3b8; + text-align: center !important; +} + +.quick-actions { + display: flex; + flex-direction: column; + gap: 10px; +} + +.quick-actions button { + border: 1px solid #e5e7eb; + background: #f8fafc; + border-radius: 12px; + padding: 12px 14px; + font-weight: 800; + cursor: pointer; + text-align: left; +} + +.quick-actions button:hover { + background: #eef6ff; +} + +.activity-chart { + width: 100%; + height: 220px; +} + +.vps-hero-card { + background: white; + border: 1px solid #edf1f7; + border-radius: 24px; + padding: 24px; + box-shadow: + 0 10px 30px rgba(15, 23, 42, 0.04), + 0 2px 6px rgba(15, 23, 42, 0.03); + display: grid; + gap: 24px; +} + +.vps-hero-main { + display: flex; + align-items: center; + gap: 18px; +} + +.vps-server-icon { + width: 64px; + height: 64px; + border-radius: 20px; + display: grid; + place-items: center; + background: #f3f4f6; + color: #64748b; +} + +.vps-server-icon.online { + background: #dcfce7; + color: #16a34a; +} + +.vps-server-icon.degraded { + background: #fef9c3; + color: #ca8a04; +} + +.vps-server-icon.offline { + background: #fee2e2; + color: #dc2626; +} + +.vps-server-icon.checking { + background: #e5e7eb; + color: #64748b; +} + +.vps-title-row { + display: flex; + align-items: center; + gap: 12px; +} + +.vps-title-row h2 { + margin: 0; + font-size: 22px; +} + +.vps-hero-main p { + margin: 6px 0 0; + color: #64748b; +} + +.vps-health-summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; +} + +.health-mini-card { + display: flex; + align-items: center; + gap: 12px; + background: #f8fafc; + border: 1px solid #edf1f7; + border-radius: 18px; + padding: 16px; +} + +.health-mini-icon { + width: 38px; + height: 38px; + border-radius: 14px; + display: grid; + place-items: center; + background: #e5e7eb; + color: #64748b; +} + +.health-mini-icon.online { + background: #dcfce7; + color: #16a34a; +} + +.health-mini-icon.degraded { + background: #fef9c3; + color: #ca8a04; +} + +.health-mini-icon.offline { + background: #fee2e2; + color: #dc2626; +} + +.health-mini-icon.checking { + background: #e5e7eb; + color: #64748b; +} + +.health-mini-card span { + display: block; + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.health-mini-card strong { + display: block; + margin-top: 3px; + color: #111827; + font-size: 14px; +} + +.vps-grid { + display: grid; + grid-template-columns: 1.3fr 1fr; + gap: 16px; +} + +.wide-panel { + min-height: auto; +} + +.service-check-list { + display: grid; + gap: 12px; +} + +.service-check { + display: flex; + gap: 12px; + align-items: flex-start; + background: #f8fafc; + border: 1px solid #edf1f7; + border-radius: 16px; + padding: 14px; +} + +.service-check-icon { + width: 34px; + height: 34px; + border-radius: 12px; + display: grid; + place-items: center; + background: #e5e7eb; + color: #64748b; + flex-shrink: 0; +} + +.service-check-icon.online { + background: #dcfce7; + color: #16a34a; +} + +.service-check-icon.degraded { + background: #fef9c3; + color: #ca8a04; +} + +.service-check-icon.offline { + background: #fee2e2; + color: #dc2626; +} + +.service-check-icon.checking { + background: #e5e7eb; + color: #64748b; +} + +.service-check strong { + display: block; + color: #111827; +} + +.service-check span { + display: block; + margin-top: 3px; + color: #64748b; + font-size: 13px; +} + +.detail-list { + display: grid; + gap: 10px; +} + +.detail-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 13px 0; + border-bottom: 1px solid #edf1f7; +} + +.detail-row:last-child { + border-bottom: 0; +} + +.detail-row span { + color: #64748b; + font-weight: 700; +} + +.detail-row strong { + color: #111827; +} + +.vps-actions-grid { + display: grid; + gap: 12px; +} + +.vps-action-card { + width: 100%; + border: 1px solid #edf1f7; + background: #f8fafc; + border-radius: 16px; + padding: 16px; + display: flex; + align-items: flex-start; + gap: 12px; + text-align: left; + transition: all 0.15s ease; +} + +.vps-action-card:hover:not(:disabled) { + background: white; + transform: translateY(-1px); + box-shadow: + 0 8px 24px rgba(15, 23, 42, 0.06), + 0 2px 8px rgba(15, 23, 42, 0.04); +} + +.vps-action-card:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.vps-action-card strong { + display: block; + color: #111827; +} + +.vps-action-card span { + display: block; + margin-top: 4px; + color: #64748b; + font-size: 13px; +} + +.status-note { + display: flex; + gap: 12px; + align-items: flex-start; + background: #f8fafc; + border: 1px solid #edf1f7; + border-radius: 16px; + padding: 16px; + color: #64748b; + line-height: 1.5; +} + +.status-note p { + margin: 0; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.settings-card { + background: white; + border: 1px solid #edf1f7; + border-radius: 24px; + padding: 22px; + box-shadow: + 0 10px 30px rgba(15, 23, 42, 0.04), + 0 2px 6px rgba(15, 23, 42, 0.03); +} + +.settings-card-header { + display: flex; + align-items: flex-start; + gap: 14px; + margin-bottom: 20px; +} + +.settings-card-icon { + width: 44px; + height: 44px; + border-radius: 16px; + display: grid; + place-items: center; + background: #eef6ff; + color: #5da8ff; + flex-shrink: 0; +} + +.settings-card h2 { + margin: 0; + font-size: 18px; + color: #111827; +} + +.settings-card p { + margin: 5px 0 0; + color: #64748b; + font-size: 13px; +} + +.settings-list { + display: grid; + gap: 10px; +} + +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 13px 0; + border-bottom: 1px solid #edf1f7; +} + +.setting-row:last-child { + border-bottom: 0; +} + +.setting-row span { + color: #64748b; + font-weight: 700; + font-size: 14px; +} + +.setting-row strong { + display: inline-flex; + align-items: center; + color: #111827; + font-size: 14px; + font-weight: 800; + text-align: right; +} + +.settings-actions { + display: flex; + gap: 10px; + padding-top: 10px; +} + +.settings-actions button { + border: 1px solid #edf1f7; + background: #f8fafc; + border-radius: 14px; + padding: 10px 13px; + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 800; + color: #374151; + transition: all 0.15s ease; +} + +.settings-actions button:hover { + background: white; + transform: translateY(-1px); } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 3edfe51..73a0108 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,11 @@ 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 { DeploymentsPage } from "./pages/DeploymentsPage"; +import type { OpenVpnStatus } from "./types/openVpnStatus"; +import type { OpenVpnHealthResponse } from "./types/openVpnHealthResponse"; +import { VpsServerPage } from "./pages/VpsServerPage"; +import { SettingsPage } from "./pages/SettingsPage"; import { availableSubnets, @@ -30,6 +35,10 @@ function App() { const [routerToDelete, setRouterToDelete] = useState(null); const [routerToRemove, setRouterToRemove] = useState(null); const [latestDeployment, setLatestDeployment] = useState(null); + const [deployments, setDeployments] = useState([]); + const [deploymentsLoading, setDeploymentsLoading] = useState(false); + const [openVpnStatus, setOpenVpnStatus] = useState("checking"); + const [restartOpenVpnConfirmOpen, setRestartOpenVpnConfirmOpen] = useState(false); const [form, setForm] = useState({ name: "", @@ -46,12 +55,32 @@ function App() { const data = await apiGet("/api/routers"); setRouters(data); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load routers"); + setError(err instanceof Error ? err.message : "Falha ao carregar routers"); } finally { setLoading(false); } } + async function loadOpenVpnStatus() { + try { + setOpenVpnStatus("checking"); + + const health = await apiGet( + "/api/health/openvpn" + ); + + setOpenVpnStatus( + health.status === "ONLINE" + ? "online" + : health.status === "DEGRADED" + ? "degraded" + : "offline" + ); + } catch { + setOpenVpnStatus("offline"); + } + } + function openCreateRouterModal() { const nextSubnet = firstAvailableSubnet(routers); @@ -79,7 +108,7 @@ function App() { form ); - console.log("Router created:", createdRouter); + console.log("Router criado:", createdRouter); const allocation = await apiPost< unknown, @@ -92,7 +121,7 @@ function App() { allocationMode: "AUTOMATIC", }); - console.log("Allocation created:", allocation); + console.log("Alocação criada:", allocation); setCreateOpen(false); @@ -113,7 +142,7 @@ function App() { await loadRouters(); } - setError(err instanceof Error ? err.message : "Failed to create router"); + setError(err instanceof Error ? err.message : "Falha ao criar router"); } finally { setSaving(false); } @@ -129,7 +158,7 @@ function App() { {} ); - console.log("Provision deployment:", deployment); + console.log("Deployment de provisionamento:", deployment); setLatestDeployment(deployment); @@ -138,13 +167,15 @@ function App() { deployment.errorMessage || deployment.stderr || deployment.logs || - "Provision failed. Check deployment logs." + "Falha ao provisionar. Verifique os logs do deployment." ); } await loadRouters(); + await loadDeployments(); + await loadOpenVpnStatus(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to provision router"); + setError(err instanceof Error ? err.message : "Falha ao provisionar router"); } finally { setActionLoading(null); } @@ -160,7 +191,7 @@ function App() { {} ); - console.log("Remove deployment:", deployment); + console.log("Deployment de remoção:", deployment); setLatestDeployment(deployment); setRouterToRemove(null); @@ -170,13 +201,15 @@ function App() { deployment.errorMessage || deployment.stderr || deployment.logs || - "Remove failed. Check deployment logs." + "Falha ao remover. Verifique os logs do deployment." ); } await loadRouters(); + await loadDeployments(); + await loadOpenVpnStatus(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to remove router"); + setError(err instanceof Error ? err.message : "Falha ao remover router"); } finally { setActionLoading(null); } @@ -192,7 +225,7 @@ function App() { setRouterToDelete(null); await loadRouters(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to delete router"); + setError(err instanceof Error ? err.message : "Falha ao apagar router"); } finally { setActionLoading(null); } @@ -215,18 +248,13 @@ function App() { 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)) - ); + console.log("Estado da resposta do bundle:", response.status); + console.log("Content-Type do bundle:", response.headers.get("content-type")); + console.log("Tamanho do bundle:", bytes.length); if (!response.ok) { throw new Error( - new TextDecoder().decode(bytes) || `API error ${response.status}` + new TextDecoder().decode(bytes) || `Erro da API ${response.status}` ); } @@ -234,7 +262,7 @@ function App() { const isGzip = bytes[0] === 0x1f && bytes[1] === 0x8b; if (!isZip && !isGzip) { - throw new Error("Downloaded bundle is not a valid ZIP or GZIP file"); + throw new Error("O bundle descarregado não é um ficheiro ZIP ou GZIP válido"); } const defaultFilename = isZip @@ -242,11 +270,11 @@ function App() { : `${router.name}-bundle.tar.gz`; const savePath = await save({ - title: "Save OpenVPN Bundle", + title: "Guardar Bundle OpenVPN", defaultPath: defaultFilename, filters: [ { - name: isZip ? "ZIP Archive" : "GZIP Archive", + name: isZip ? "Arquivo ZIP" : "Arquivo GZIP", extensions: isZip ? ["zip"] : ["tar.gz", "gz"], }, ], @@ -258,24 +286,87 @@ function App() { await writeFile(savePath, bytes); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to download bundle"); + setError(err instanceof Error ? err.message : "Falha ao descarregar bundle"); } finally { setActionLoading(null); } } + async function loadDeployments() { + try { + setDeploymentsLoading(true); + setError(null); + + const data = await apiGet("/api/deployments"); + setDeployments(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Falha ao carregar deployments"); + } finally { + setDeploymentsLoading(false); + } + } + + async function restartOpenVpn() { + try { + setActionLoading("openvpn-restart"); + setError(null); + + await apiPost>( + "/api/vps/openvpn/restart", + {} + ); + + setRestartOpenVpnConfirmOpen(false); + await loadOpenVpnStatus(); + } catch (err) { + setError(err instanceof Error ? err.message : "Falha ao reiniciar OpenVPN"); + } finally { + setActionLoading(null); + } + } + + function viewRecentOpenVpnLogs() { + setPage("deployments"); + } + useEffect(() => { - loadRouters(); + async function loadInitialData() { + await loadRouters(); + await loadDeployments(); + } + + loadInitialData(); + }, []); + + useEffect(() => { + loadOpenVpnStatus(); + + const interval = window.setInterval(loadOpenVpnStatus, 30_000); + + return () => window.clearInterval(interval); }, []); return (
- +
{error &&
{error}
} - {page === "dashboard" && } + {page === "dashboard" && ( + setPage("deployments")} + onRefreshHealth={loadOpenVpnStatus} + /> + )} {page === "routers" && ( )} + + {page === "deployments" && ( + + )} + + {page === "servers" && ( + setRestartOpenVpnConfirmOpen(true)} + /> + )} + + {page === "settings" && ( + setRestartOpenVpnConfirmOpen(true)} + /> + )}
{createOpen && ( @@ -304,9 +420,9 @@ function App() { {routerToDelete && ( setRouterToDelete(null)} onConfirm={() => deleteRouter(routerToDelete)} @@ -315,9 +431,9 @@ function App() { {routerToRemove && ( setRouterToRemove(null)} onConfirm={() => removeRouter(routerToRemove)} @@ -330,6 +446,17 @@ function App() { onClose={() => setLatestDeployment(null)} /> )} + + {restartOpenVpnConfirmOpen && ( + setRestartOpenVpnConfirmOpen(false)} + onConfirm={restartOpenVpn} + /> + )}
); } diff --git a/src/components/CreateRouterModal.tsx b/src/components/CreateRouterModal.tsx index f983ef5..356c729 100644 --- a/src/components/CreateRouterModal.tsx +++ b/src/components/CreateRouterModal.tsx @@ -21,14 +21,14 @@ export function CreateRouterModal({ onSubmit, }: Props) { const [subnetOpen, setSubnetOpen] = useState(false); - + return (
-

New Router

-

Create a router before allocating VPN details.

+

Novo Router

+

Crie um router antes de atribuir os detalhes VPN.

@@ -95,7 +97,7 @@ export function CreateRouterModal({
diff --git a/src/components/DeploymentResultModal.tsx b/src/components/DeploymentResultModal.tsx index 2c60b46..286bc43 100644 --- a/src/components/DeploymentResultModal.tsx +++ b/src/components/DeploymentResultModal.tsx @@ -6,7 +6,10 @@ type Props = { onClose: () => void; }; -export function DeploymentResultModal({ deployment, onClose }: Props) { +export function DeploymentResultModal({ + deployment, + onClose, +}: Props) { const successful = deployment.status === "SUCCESS"; const logs = @@ -14,14 +17,15 @@ export function DeploymentResultModal({ deployment, onClose }: Props) { deployment.stderr || deployment.logs || deployment.errorMessage || - "No logs returned."; + "Sem logs disponíveis."; return (
-

Deployment Result

+

Resultado do Deployment

+

{deployment.action} · {deployment.routerName || "Router"}

@@ -34,13 +38,20 @@ export function DeploymentResultModal({ deployment, onClose }: Props) {
- {successful ? : } + {successful ? ( + + ) : ( + + )} + {deployment.status}
- {deployment.finishedAt || deployment.createdAt || "-"} + + {deployment.finishedAt || deployment.createdAt || "-"} +
@@ -49,7 +60,7 @@ export function DeploymentResultModal({ deployment, onClose }: Props) {
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 0c47020..c2c2ab0 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -7,51 +7,95 @@ import { Shield, } from "lucide-react"; +type OpenVpnStatus = "online" | "degraded" | "offline" | "checking"; + type Props = { page: string; onPageChange: (page: string) => void; + openVpnStatus?: OpenVpnStatus; }; -export function Sidebar({ page, onPageChange }: Props) { +export function Sidebar({ + page, + onPageChange, + openVpnStatus = "checking", +}: Props) { + const statusLabel = { + online: "Online", + degraded: "Degradado", + offline: "Offline", + checking: "A verificar...", + }[openVpnStatus]; + return ( diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 0acb5b4..3979423 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -1,23 +1,336 @@ -import { Metric } from "../components/Metric"; -import type { RouterItem } from "../types/router"; +import { + Activity, + CheckCircle2, + Router, + Server, + Shield, + XCircle, +} from "lucide-react"; + +import { + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, + CartesianGrid, +} from "recharts"; + +import type { RouterItem } from "../types/router"; +import type { DeploymentResponse } from "../types/deployment"; +import type { OpenVpnStatus } from "../types/openVpnStatus"; + +type Props = { + routers: RouterItem[]; + deployments: DeploymentResponse[]; + openVpnStatus: OpenVpnStatus; + onCreateRouter: () => void; + onGoToDeployments: () => void; + onRefreshHealth: () => void; +}; + +function statusLabel(status: OpenVpnStatus) { + return { + online: "Sistema Saudável", + degraded: "Degradado", + offline: "Offline", + checking: "A verificar...", + }[status]; +} + +function deploymentStatusLabel(status: string) { + return status === "SUCCESS" ? "Sucesso" : "Falhou"; +} + +function deploymentActionLabel(action?: string) { + if (action === "PROVISION") return "Provisionar"; + if (action === "REMOVE") return "Remover"; + return "Deployment"; +} + +function formatDate(value?: string) { + if (!value) return "—"; + + return new Date(value).toLocaleString("pt-PT", { + dateStyle: "short", + timeStyle: "short", + }); +} + +export function DashboardPage({ + routers, + deployments, + openVpnStatus, + onCreateRouter, + onGoToDeployments, + onRefreshHealth, +}: Props) { + const totalRouters = routers.length; + + const successfulDeployments = deployments.filter( + (deployment) => deployment.status === "SUCCESS" + ).length; + + const failedDeployments = deployments.filter( + (deployment) => deployment.status !== "SUCCESS" + ).length; + + const latestDeployments = deployments.slice(0, 5); + + const deploymentChartData = Object.values( + deployments.reduce>( + (acc, deployment) => { + const date = deployment.createdAt + ? new Date(deployment.createdAt) + : new Date(); + + const key = date.toISOString().slice(0, 10); + + if (!acc[key]) { + acc[key] = { + name: date.toLocaleDateString("pt-PT", { + month: "short", + day: "numeric", + }), + deployments: 0, + failed: 0, + }; + } + + acc[key].deployments += 1; + + if (deployment.status !== "SUCCESS") { + acc[key].failed += 1; + } + + return acc; + }, + {} + ) + ).slice(-14); -export function DashboardPage({ routers }: { routers: RouterItem[] }) { return ( - <> +

Dashboard

-

Overview of your OpenVPN infrastructure

+

Visão geral da infraestrutura OpenVPN

+
+ +
+ + {statusLabel(openVpnStatus)}
-
-
- - - - +
+ } + label="Routers" + value={totalRouters} + detail="Routers geridos" + /> + + } + label="Clientes OpenVPN" + value={successfulDeployments} + detail="Clientes provisionados" + /> + + } + label="Servidor VPS" + value={1} + detail={openVpnStatus === "online" ? "Online" : "Requer atenção"} + /> + + } + label="Deployments" + value={deployments.length} + detail={`${failedDeployments} falhados`} + />
- + +
+
+
+
+

Atividade de Deployments

+

Ações recentes de provisionamento e remoção

+
+
+ +
+ {deploymentChartData.length === 0 ? ( +
+ + Sem atividade de deployments +
+ ) : ( + + + + + + + + + + + )} +
+
+ +
+
+
+

Estado da VPN

+

Estado em tempo real do serviço na VPS

+
+
+ +
+ + + + + 0 ? "degraded" : "online"} + value={`${failedDeployments} falhadas`} + /> +
+
+ +
+
+
+

Últimos Deployments

+

Alterações operacionais mais recentes

+
+
+ + + + + + + + + + + + + {latestDeployments.length === 0 ? ( + + + + ) : ( + latestDeployments.map((deployment) => ( + + + + + + + )) + )} + +
IDAçãoEstadoData
+ Ainda não existem deployments. +
#{deployment.id}{deploymentActionLabel(deployment.action)} + + {deployment.status === "SUCCESS" ? ( + + ) : ( + + )} + {deploymentStatusLabel(deployment.status)} + + {formatDate(deployment.createdAt)}
+
+ +
+
+
+

Ações Rápidas

+

Fluxos comuns para operadores

+
+
+ +
+ + + +
+
+
+
+ ); +} + +function MetricCard({ + icon, + label, + value, + detail, +}: { + icon: React.ReactNode; + label: string; + value: number; + detail: string; +}) { + return ( +
+
{icon}
+
+

{label}

+ {value} + {detail} +
+
+ ); +} + +function HealthRow({ + label, + status, + value, +}: { + label: string; + status: OpenVpnStatus; + value: string; +}) { + return ( +
+
+ + {label} +
+ {value} +
); } \ No newline at end of file diff --git a/src/pages/DeploymentsPage.tsx b/src/pages/DeploymentsPage.tsx new file mode 100644 index 0000000..87d3f30 --- /dev/null +++ b/src/pages/DeploymentsPage.tsx @@ -0,0 +1,225 @@ +import { useMemo, useState } from "react"; +import { Activity, ChevronDown, Search } from "lucide-react"; +import type { DeploymentResponse } from "../types/deployment"; + +type Props = { + deployments: DeploymentResponse[]; + loading: boolean; + onOpenDeployment: (deployment: DeploymentResponse) => void; +}; + +const STATUS_OPTIONS = ["ALL", "SUCCESS", "FAILED"]; +const ACTION_OPTIONS = ["ALL", "PROVISION", "REMOVE"]; + +function label(value: string) { + const labels: Record = { + ALL: "Todos", + SUCCESS: "Sucesso", + FAILED: "Falhou", + PROVISION: "Provisionar", + REMOVE: "Remover", + }; + + return labels[value] ?? value; +} + +function formatDate(value?: string) { + if (!value) return "-"; + + return new Date(value).toLocaleString("pt-PT", { + dateStyle: "short", + timeStyle: "short", + }); +} + +function deploymentTimestamp(deployment: DeploymentResponse) { + return new Date( + deployment.finishedAt || + deployment.startedAt || + deployment.createdAt || + 0 + ).getTime(); +} + +export function DeploymentsPage({ + deployments, + loading, + onOpenDeployment, +}: Props) { + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("ALL"); + const [actionFilter, setActionFilter] = useState("ALL"); + const [statusOpen, setStatusOpen] = useState(false); + const [actionOpen, setActionOpen] = useState(false); + + const filteredDeployments = useMemo(() => { + const query = search.toLowerCase().trim(); + + return deployments + .filter((deployment) => { + const matchesSearch = + deployment.routerName?.toLowerCase().includes(query) || + deployment.action.toLowerCase().includes(query) || + deployment.status.toLowerCase().includes(query); + + const matchesStatus = + statusFilter === "ALL" || deployment.status === statusFilter; + + const matchesAction = + actionFilter === "ALL" || deployment.action === actionFilter; + + return matchesSearch && matchesStatus && matchesAction; + }) + .sort((a, b) => deploymentTimestamp(b) - deploymentTimestamp(a)); + }, [deployments, search, statusFilter, actionFilter]); + + return ( +
+
+
+

Deployments

+

Histórico de ações de provisionamento e remoção

+
+
+ +
+
+
+ + setSearch(e.target.value)} + placeholder="Pesquisar deployments..." + /> +
+ +
+
+ + + {statusOpen && ( +
+ {STATUS_OPTIONS.map((status) => ( + + ))} +
+ )} +
+ +
+ + + {actionOpen && ( +
+ {ACTION_OPTIONS.map((action) => ( + + ))} +
+ )} +
+
+
+ + {loading ? ( +

A carregar deployments...

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

Nenhum deployment encontrado

+ ) : ( + + + + + + + + + + + + + + {filteredDeployments.map((deployment) => ( + + + + + + + + + ))} + +
RouterAçãoEstadoIniciadoTerminadoDetalhes
{deployment.routerName || "-"}{label(deployment.action)} + + {label(deployment.status)} + + {formatDate(deployment.startedAt || deployment.createdAt)}{formatDate(deployment.finishedAt)} + +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/RoutersPage.tsx b/src/pages/RoutersPage.tsx index afe1611..4a0372e 100644 --- a/src/pages/RoutersPage.tsx +++ b/src/pages/RoutersPage.tsx @@ -14,6 +14,21 @@ type Props = { onDownloadBundle: (router: RouterItem) => void; }; +const STATUS_LABELS: Record = { + ALL: "Todos os Estados", + PENDING: "Pendente", + PROVISIONING: "A provisionar", + PROVISIONED: "Provisionado", + FAILED: "Falhou", + REMOVING: "A remover", + REMOVED: "Removido", + WORKING: "A processar", +}; + +function statusLabel(status: string) { + return STATUS_LABELS[status] ?? status; +} + export function RoutersPage({ routers, loading, @@ -46,21 +61,18 @@ export function RoutersPage({ }); }, [routers, search, statusFilter]); - const selectedStatusLabel = - statusFilter === "ALL" - ? "All Statuses" - : statusFilter.charAt(0) + statusFilter.slice(1).toLowerCase(); - + const selectedStatusLabel = statusLabel(statusFilter); + return ( <>

Routers

-

Manage routers and OpenVPN provisioning

+

Gerir routers e provisionamento OpenVPN

@@ -71,7 +83,7 @@ export function RoutersPage({ setSearch(e.target.value)} - placeholder="Search routers..." + placeholder="Pesquisar routers..." />
@@ -109,10 +121,7 @@ export function RoutersPage({ setStatusOpen(false); }} > - {status === "ALL" - ? "All Statuses" - : status.charAt(0) + - status.slice(1).toLowerCase()} + {statusLabel(status)} ))}
@@ -121,20 +130,20 @@ export function RoutersPage({
{loading ? ( -

Loading routers...

+

A carregar routers...

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

No routers found

+

Nenhum router encontrado

) : ( - - - - - - - + + + + + + + @@ -160,7 +169,7 @@ export function RoutersPage({ @@ -173,7 +182,7 @@ export function RoutersPage({ disabled={isBusy} > - {isBusy ? "Working..." : "Provision"} + {isBusy ? "A processar..." : "Provisionar"} )} @@ -194,7 +203,7 @@ export function RoutersPage({ disabled={isBusy} > - Remove + Remover )} @@ -204,7 +213,7 @@ export function RoutersPage({ className="table-action danger" onClick={() => onDelete(router)} disabled={isBusy} - title="Delete router" + title="Apagar router" > diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..b8f3e0f --- /dev/null +++ b/src/pages/SettingsPage.tsx @@ -0,0 +1,176 @@ +import { + Activity, + Database, + MonitorCog, + RefreshCw, + Server, + Shield, + Wifi, +} from "lucide-react"; + +import type { OpenVpnStatus } from "../types/openVpnStatus"; + +type Props = { + openVpnStatus: OpenVpnStatus; + onRefreshHealth: () => void; + onRestartOpenVpn: () => void; +}; + +export function SettingsPage({ + openVpnStatus, + onRefreshHealth, + onRestartOpenVpn, +}: Props) { + const statusLabel = { + online: "Ligado", + degraded: "Degradado", + offline: "Offline", + checking: "A verificar...", + }[openVpnStatus]; + + return ( +
+
+
+

Definições

+

Visão geral da aplicação, VPS e configuração OpenVPN

+
+ + +
+ +
+ } + title="Ligação Backend" + description="Ligação da aplicação desktop ao backend Spring Boot" + > + + + + + + + + } + title="Configuração VPS" + description="Servidor remoto configurado para operações OpenVPN" + > + + + + + +
+ + + +
+
+ + } + title="Configuração OpenVPN" + description="Configuração base do serviço OpenVPN e túneis" + > + + + + + + + + } + title="Aplicação" + description="Comportamento da aplicação desktop e preferências" + > + + + + + + + + +
+
+ ); +} + +function SettingsCard({ + icon, + title, + description, + children, +}: { + icon: React.ReactNode; + title: string; + description: string; + children: React.ReactNode; +}) { + return ( +
+
+
{icon}
+ +
+

{title}

+

{description}

+
+
+ +
{children}
+
+ ); +} + +function SettingRow({ + label, + value, + status, +}: { + label: string; + value: string; + status?: OpenVpnStatus; +}) { + return ( +
+ {label} + + + {status && } + {value} + +
+ ); +} \ No newline at end of file diff --git a/src/pages/VpsServerPage.tsx b/src/pages/VpsServerPage.tsx new file mode 100644 index 0000000..d070112 --- /dev/null +++ b/src/pages/VpsServerPage.tsx @@ -0,0 +1,298 @@ +import { + Activity, + CheckCircle2, + Clock, + RefreshCw, + Server, + Shield, + Terminal, + Wifi, + XCircle, +} from "lucide-react"; + +import type { OpenVpnStatus } from "../types/openVpnStatus"; + +type Props = { + openVpnStatus: OpenVpnStatus; + onRefreshHealth: () => void; + onViewLogs: () => void; + onRestartOpenVpn: () => void; +}; + +export function VpsServerPage({ + openVpnStatus, + onRefreshHealth, + onViewLogs, + onRestartOpenVpn, +}: Props) { + const isOnline = openVpnStatus === "online"; + const isChecking = openVpnStatus === "checking"; + const isDegraded = openVpnStatus === "degraded"; + const isOffline = openVpnStatus === "offline"; + + const statusLabel = { + online: "Online", + degraded: "Degradado", + offline: "Offline", + checking: "A verificar...", + }[openVpnStatus]; + + return ( +
+
+
+

Servidor VPS

+

Monitorize o estado da VPS configurada e do serviço OpenVPN

+
+ + +
+ +
+
+
+ +
+ +
+
+

VPS Configurada

+ + {statusLabel} + +
+ +

+ Este servidor é usado para provisionamento de clientes OpenVPN, + geração de bundles e gestão de túneis. +

+
+
+ +
+ } + label="Acesso SSH" + value={isOffline ? "Indisponível" : isChecking ? "A verificar" : "Acessível"} + status={isOffline ? "offline" : isChecking ? "checking" : "online"} + /> + + } + label="Serviço OpenVPN" + value={statusLabel} + status={openVpnStatus} + /> + + } + label="Estado dos Túneis" + value={isOnline ? "Operacional" : isDegraded ? "Requer atenção" : "Desconhecido"} + status={openVpnStatus} + /> +
+
+ +
+
+
+
+

Verificações de Serviço

+

Verificações em tempo real a partir da VPS configurada

+
+
+ +
+ + + + + + + +
+
+ +
+
+
+

Detalhes do Servidor

+

Destino configurado no backend

+
+
+ +
+ + + + + +
+
+ +
+
+
+

Ações Operacionais

+

Ações comuns de manutenção para esta VPS

+
+
+ +
+ + + + + +
+
+ +
+
+
+

Notas de Estado

+

O que significa este estado

+
+
+ +
+ {isOnline && ( + <> + +

+ A VPS está acessível e o serviço OpenVPN aparenta estar em execução. +

+ + )} + + {isDegraded && ( + <> + +

+ A VPS está acessível, mas uma ou mais verificações OpenVPN requerem atenção. +

+ + )} + + {isOffline && ( + <> + +

+ O backend não conseguiu verificar a VPS/serviço OpenVPN. Confirme o SSH, + credenciais, acesso de rede ou o serviço OpenVPN. +

+ + )} + + {isChecking && ( + <> + +

A verificar estado da VPS e do OpenVPN...

+ + )} +
+
+
+
+ ); +} + +function HealthMiniCard({ + icon, + label, + value, + status, +}: { + icon: React.ReactNode; + label: string; + value: string; + status: OpenVpnStatus; +}) { + return ( +
+
{icon}
+
+ {label} + {value} +
+
+ ); +} + +function ServiceCheck({ + label, + description, + status, +}: { + label: string; + description: string; + status: OpenVpnStatus; +}) { + const healthy = status === "online"; + + return ( +
+
+ {healthy ? : } +
+ +
+ {label} + {description} +
+
+ ); +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function statusClass(status: OpenVpnStatus) { + if (status === "online") return "success"; + if (status === "degraded") return "warning"; + if (status === "offline") return "failed"; + return "removed"; +} \ No newline at end of file diff --git a/src/types/openVpnHealthResponse.ts b/src/types/openVpnHealthResponse.ts new file mode 100644 index 0000000..c4df7a4 --- /dev/null +++ b/src/types/openVpnHealthResponse.ts @@ -0,0 +1,3 @@ +export type OpenVpnHealthResponse = { + status: "ONLINE" | "DEGRADED" | "OFFLINE"; +}; \ No newline at end of file diff --git a/src/types/openVpnStatus.ts b/src/types/openVpnStatus.ts new file mode 100644 index 0000000..bcec037 --- /dev/null +++ b/src/types/openVpnStatus.ts @@ -0,0 +1 @@ +export type OpenVpnStatus = "online" | "degraded" | "offline" | "checking"; \ No newline at end of file
NameSerialLAN IPLAN SubnetVPN IPStatusActionsNomeSérieIP LANSubnet LANIP VPNEstadoAções
{router.vpnIp || "-"} - {isBusy ? "WORKING" : router.status} + {isBusy ? statusLabel("WORKING") : statusLabel(router.status)}