feat: build OpenVPN operations dashboard and VPS management UI

This commit is contained in:
litoral05
2026-05-06 12:32:37 +01:00
parent 69540cb4c4
commit 523ce02b03
15 changed files with 2471 additions and 155 deletions
+410 -3
View File
@@ -14,7 +14,8 @@
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0",
"recharts": "^3.8.1"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
@@ -799,6 +800,42 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1195,6 +1232,18 @@
"win32" "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": { "node_modules/@tauri-apps/api": {
"version": "2.11.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
@@ -1509,6 +1558,69 @@
"@babel/types": "^7.28.2" "@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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1520,7 +1632,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -1536,6 +1648,12 @@
"@types/react": "^19.2.0" "@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": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1625,6 +1743,15 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1636,9 +1763,130 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "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": { "node_modules/electron-to-chromium": {
"version": "1.5.349", "version": "1.5.349",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
@@ -1664,6 +1918,16 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/esbuild": {
"version": "0.27.7", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
@@ -1716,6 +1980,12 @@
"node": ">=6" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1759,6 +2029,25 @@
"node": ">=6.9.0" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1914,6 +2203,36 @@
"react": "^19.2.5" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -1924,6 +2243,57 @@
"node": ">=0.10.0" "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": { "node_modules/rollup": {
"version": "4.60.3", "version": "4.60.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
@@ -1995,6 +2365,12 @@
"node": ">=0.10.0" "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": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -2057,6 +2433,37 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/vite": {
"version": "7.3.2", "version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
+2 -1
View File
@@ -16,7 +16,8 @@
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0",
"recharts": "^3.8.1"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
+4 -3
View File
@@ -13,9 +13,10 @@
"windows": [ "windows": [
{ {
"title": "LR OpenVPN Tool", "title": "LR OpenVPN Tool",
"width": 1440, "width": 1600,
"height": 900, "height": 1000,
"resizable": true "resizable": true,
"maximized": true
} }
], ],
"security": { "security": {
+743 -45
View File
@@ -2,6 +2,15 @@
box-sizing: border-box; box-sizing: border-box;
} }
body,
html,
#root {
width: 100%;
height: 100%;
overflow: hidden;
}
body { body {
margin: 0; margin: 0;
font-family: Inter, system-ui, sans-serif; font-family: Inter, system-ui, sans-serif;
@@ -20,9 +29,11 @@ button {
} }
.app { .app {
min-height: 100vh; height: 100vh;
width: 100vw;
min-width: 1280px; min-width: 1280px;
display: flex; display: flex;
overflow: hidden;
background: #f2f2f2; background: #f2f2f2;
} }
@@ -125,21 +136,40 @@ nav button:hover {
} }
.dot { .dot {
width: 9px; width: 8px;
height: 9px; height: 8px;
background: #22c55e; border-radius: 999px;
display: inline-block; display: inline-block;
border-radius: 50%;
margin-right: 8px; 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 */ /* MAIN */
.content { .content {
flex: 1; flex: 1;
height: 100vh;
padding: 30px; padding: 30px;
overflow-x: auto;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
} }
.page-header { .page-header {
@@ -191,6 +221,16 @@ nav button:hover {
.primary { .primary {
background: #b7e236; background: #b7e236;
color: #0d0d0d; color: #0d0d0d;
min-height: 42px;
}
.primary,
.secondary,
.health {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
} }
.secondary { .secondary {
@@ -240,47 +280,79 @@ nav button:hover {
/* TABLE */ /* TABLE */
.panel { /* TABLE */
padding: 0;
overflow: visible;
}
.panel table { .panel {
background: white;
border: 1px solid #edf1f7;
border-radius: 24px;
overflow: hidden; overflow: hidden;
box-shadow:
0 10px 30px rgba(15, 23, 42, 0.04),
0 2px 6px rgba(15, 23, 42, 0.03);
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: separate;
background: white; border-spacing: 0 10px;
padding: 0 14px 14px;
} }
th, thead th {
td {
text-align: left; text-align: left;
padding: 15px 18px; padding: 0 18px 12px;
border-bottom: 1px solid #edf0f3; border: none;
font-size: 14px; color: #94a3b8;
}
th {
color: #6b7280;
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
background: #fbfbfc; background: transparent;
} }
td { tbody tr {
color: #374151; 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; 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 { .badge {
padding: 6px 10px; padding: 7px 12px;
border-radius: 999px; border-radius: 999px;
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
background: #e5e7eb; border: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -437,16 +509,21 @@ td {
.custom-select-button { .custom-select-button {
width: 100%; width: 100%;
height: 40px; height: 46px;
border: 1px solid #dfe3e8; border: 1px solid #edf1f7;
border-radius: 8px; border-radius: 16px;
padding: 0 12px; padding: 0 14px;
background: #fbfbfc; background: #f8fafc;
color: #111827; color: #111827;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
font-weight: 700; font-weight: 700;
transition: all 0.15s ease;
}
.custom-select-button:hover {
background: white;
} }
.custom-select-menu { .custom-select-menu {
@@ -545,17 +622,23 @@ td {
} }
.small-action { .small-action {
height: 34px; height: 36px;
border: 0; border: 1px solid #edf1f7;
border-radius: 10px; border-radius: 12px;
padding: 0 12px; padding: 0 13px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
background: #f3f4f6; background: white;
color: #374151; color: #374151;
font-weight: 800; font-weight: 800;
font-size: 13px; font-size: 13px;
transition: all 0.15s ease;
}
.small-action:hover {
transform: translateY(-1px);
background: #f8fafc;
} }
.primary-action { .primary-action {
@@ -632,24 +715,30 @@ td {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 14px; gap: 14px;
padding: 16px; padding: 18px;
border-bottom: 1px solid #edf0f3;
background: white; background: white;
position: relative; position: relative;
z-index: 20; z-index: 20;
} }
.search-box { .search-box {
height: 40px; height: 46px;
min-width: 320px; min-width: 320px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 9px; gap: 10px;
background: #fbfbfc; background: #f8fafc;
border: 1px solid #dfe3e8; border: 1px solid #edf1f7;
border-radius: 10px; border-radius: 16px;
padding: 0 12px; padding: 0 14px;
color: #64748b; 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 { .search-box input {
@@ -679,3 +768,612 @@ td {
.chevron.open { .chevron.open {
transform: rotate(180deg); 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);
}
+160 -33
View File
@@ -10,6 +10,11 @@ import { save } from "@tauri-apps/plugin-dialog";
import { writeFile } from "@tauri-apps/plugin-fs"; import { writeFile } from "@tauri-apps/plugin-fs";
import type { DeploymentResponse } from "./types/deployment"; import type { DeploymentResponse } from "./types/deployment";
import { DeploymentResultModal } from "./components/DeploymentResultModal"; 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 { import {
availableSubnets, availableSubnets,
@@ -30,6 +35,10 @@ function App() {
const [routerToDelete, setRouterToDelete] = useState<RouterItem | null>(null); const [routerToDelete, setRouterToDelete] = useState<RouterItem | null>(null);
const [routerToRemove, setRouterToRemove] = useState<RouterItem | null>(null); const [routerToRemove, setRouterToRemove] = useState<RouterItem | null>(null);
const [latestDeployment, setLatestDeployment] = useState<DeploymentResponse | null>(null); const [latestDeployment, setLatestDeployment] = useState<DeploymentResponse | null>(null);
const [deployments, setDeployments] = useState<DeploymentResponse[]>([]);
const [deploymentsLoading, setDeploymentsLoading] = useState(false);
const [openVpnStatus, setOpenVpnStatus] = useState<OpenVpnStatus>("checking");
const [restartOpenVpnConfirmOpen, setRestartOpenVpnConfirmOpen] = useState(false);
const [form, setForm] = useState<CreateRouterRequest>({ const [form, setForm] = useState<CreateRouterRequest>({
name: "", name: "",
@@ -46,12 +55,32 @@ function App() {
const data = await apiGet<RouterItem[]>("/api/routers"); const data = await apiGet<RouterItem[]>("/api/routers");
setRouters(data); setRouters(data);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load routers"); setError(err instanceof Error ? err.message : "Falha ao carregar routers");
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
async function loadOpenVpnStatus() {
try {
setOpenVpnStatus("checking");
const health = await apiGet<OpenVpnHealthResponse>(
"/api/health/openvpn"
);
setOpenVpnStatus(
health.status === "ONLINE"
? "online"
: health.status === "DEGRADED"
? "degraded"
: "offline"
);
} catch {
setOpenVpnStatus("offline");
}
}
function openCreateRouterModal() { function openCreateRouterModal() {
const nextSubnet = firstAvailableSubnet(routers); const nextSubnet = firstAvailableSubnet(routers);
@@ -79,7 +108,7 @@ function App() {
form form
); );
console.log("Router created:", createdRouter); console.log("Router criado:", createdRouter);
const allocation = await apiPost< const allocation = await apiPost<
unknown, unknown,
@@ -92,7 +121,7 @@ function App() {
allocationMode: "AUTOMATIC", allocationMode: "AUTOMATIC",
}); });
console.log("Allocation created:", allocation); console.log("Alocação criada:", allocation);
setCreateOpen(false); setCreateOpen(false);
@@ -113,7 +142,7 @@ function App() {
await loadRouters(); await loadRouters();
} }
setError(err instanceof Error ? err.message : "Failed to create router"); setError(err instanceof Error ? err.message : "Falha ao criar router");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -129,7 +158,7 @@ function App() {
{} {}
); );
console.log("Provision deployment:", deployment); console.log("Deployment de provisionamento:", deployment);
setLatestDeployment(deployment); setLatestDeployment(deployment);
@@ -138,13 +167,15 @@ function App() {
deployment.errorMessage || deployment.errorMessage ||
deployment.stderr || deployment.stderr ||
deployment.logs || deployment.logs ||
"Provision failed. Check deployment logs." "Falha ao provisionar. Verifique os logs do deployment."
); );
} }
await loadRouters(); await loadRouters();
await loadDeployments();
await loadOpenVpnStatus();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to provision router"); setError(err instanceof Error ? err.message : "Falha ao provisionar router");
} finally { } finally {
setActionLoading(null); setActionLoading(null);
} }
@@ -160,7 +191,7 @@ function App() {
{} {}
); );
console.log("Remove deployment:", deployment); console.log("Deployment de remoção:", deployment);
setLatestDeployment(deployment); setLatestDeployment(deployment);
setRouterToRemove(null); setRouterToRemove(null);
@@ -170,13 +201,15 @@ function App() {
deployment.errorMessage || deployment.errorMessage ||
deployment.stderr || deployment.stderr ||
deployment.logs || deployment.logs ||
"Remove failed. Check deployment logs." "Falha ao remover. Verifique os logs do deployment."
); );
} }
await loadRouters(); await loadRouters();
await loadDeployments();
await loadOpenVpnStatus();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to remove router"); setError(err instanceof Error ? err.message : "Falha ao remover router");
} finally { } finally {
setActionLoading(null); setActionLoading(null);
} }
@@ -192,7 +225,7 @@ function App() {
setRouterToDelete(null); setRouterToDelete(null);
await loadRouters(); await loadRouters();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete router"); setError(err instanceof Error ? err.message : "Falha ao apagar router");
} finally { } finally {
setActionLoading(null); setActionLoading(null);
} }
@@ -215,18 +248,13 @@ function App() {
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer); const bytes = new Uint8Array(arrayBuffer);
console.log("Bundle response status:", response.status); console.log("Estado da resposta do bundle:", response.status);
console.log("Bundle content-type:", response.headers.get("content-type")); console.log("Content-Type do bundle:", response.headers.get("content-type"));
console.log("Bundle size:", bytes.length); console.log("Tamanho do bundle:", 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) { if (!response.ok) {
throw new Error( 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; const isGzip = bytes[0] === 0x1f && bytes[1] === 0x8b;
if (!isZip && !isGzip) { 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 const defaultFilename = isZip
@@ -242,11 +270,11 @@ function App() {
: `${router.name}-bundle.tar.gz`; : `${router.name}-bundle.tar.gz`;
const savePath = await save({ const savePath = await save({
title: "Save OpenVPN Bundle", title: "Guardar Bundle OpenVPN",
defaultPath: defaultFilename, defaultPath: defaultFilename,
filters: [ filters: [
{ {
name: isZip ? "ZIP Archive" : "GZIP Archive", name: isZip ? "Arquivo ZIP" : "Arquivo GZIP",
extensions: isZip ? ["zip"] : ["tar.gz", "gz"], extensions: isZip ? ["zip"] : ["tar.gz", "gz"],
}, },
], ],
@@ -258,24 +286,87 @@ function App() {
await writeFile(savePath, bytes); await writeFile(savePath, bytes);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to download bundle"); setError(err instanceof Error ? err.message : "Falha ao descarregar bundle");
} finally { } finally {
setActionLoading(null); setActionLoading(null);
} }
} }
async function loadDeployments() {
try {
setDeploymentsLoading(true);
setError(null);
const data = await apiGet<DeploymentResponse[]>("/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<unknown, Record<string, never>>(
"/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(() => { useEffect(() => {
loadRouters(); async function loadInitialData() {
await loadRouters();
await loadDeployments();
}
loadInitialData();
}, []);
useEffect(() => {
loadOpenVpnStatus();
const interval = window.setInterval(loadOpenVpnStatus, 30_000);
return () => window.clearInterval(interval);
}, []); }, []);
return ( return (
<div className="app"> <div className="app">
<Sidebar page={page} onPageChange={setPage} /> <Sidebar
page={page}
onPageChange={setPage}
openVpnStatus={openVpnStatus}
/>
<main className="content"> <main className="content">
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{page === "dashboard" && <DashboardPage routers={routers} />} {page === "dashboard" && (
<DashboardPage
routers={routers}
deployments={deployments}
openVpnStatus={openVpnStatus}
onCreateRouter={openCreateRouterModal}
onGoToDeployments={() => setPage("deployments")}
onRefreshHealth={loadOpenVpnStatus}
/>
)}
{page === "routers" && ( {page === "routers" && (
<RoutersPage <RoutersPage
@@ -289,6 +380,31 @@ function App() {
onDownloadBundle={downloadBundle} onDownloadBundle={downloadBundle}
/> />
)} )}
{page === "deployments" && (
<DeploymentsPage
deployments={deployments}
loading={deploymentsLoading}
onOpenDeployment={setLatestDeployment}
/>
)}
{page === "servers" && (
<VpsServerPage
openVpnStatus={openVpnStatus}
onRefreshHealth={loadOpenVpnStatus}
onViewLogs={viewRecentOpenVpnLogs}
onRestartOpenVpn={() => setRestartOpenVpnConfirmOpen(true)}
/>
)}
{page === "settings" && (
<SettingsPage
openVpnStatus={openVpnStatus}
onRefreshHealth={loadOpenVpnStatus}
onRestartOpenVpn={() => setRestartOpenVpnConfirmOpen(true)}
/>
)}
</main> </main>
{createOpen && ( {createOpen && (
@@ -304,9 +420,9 @@ function App() {
{routerToDelete && ( {routerToDelete && (
<ConfirmDialog <ConfirmDialog
title="Delete Router" title="Apagar Router"
message={`Are you sure you want to permanently delete "${routerToDelete.name}"?`} message={`Tem a certeza que pretende apagar permanentemente "${routerToDelete.name}"?`}
confirmLabel="Delete" confirmLabel="Apagar"
danger danger
onCancel={() => setRouterToDelete(null)} onCancel={() => setRouterToDelete(null)}
onConfirm={() => deleteRouter(routerToDelete)} onConfirm={() => deleteRouter(routerToDelete)}
@@ -315,9 +431,9 @@ function App() {
{routerToRemove && ( {routerToRemove && (
<ConfirmDialog <ConfirmDialog
title="Remove OpenVPN Client" title="Remover Cliente OpenVPN"
message={`Remove the OpenVPN client configuration for "${routerToRemove.name}"?`} message={`Remover a configuração de cliente OpenVPN para "${routerToRemove.name}"?`}
confirmLabel="Remove" confirmLabel="Remover"
danger danger
onCancel={() => setRouterToRemove(null)} onCancel={() => setRouterToRemove(null)}
onConfirm={() => removeRouter(routerToRemove)} onConfirm={() => removeRouter(routerToRemove)}
@@ -330,6 +446,17 @@ function App() {
onClose={() => setLatestDeployment(null)} onClose={() => setLatestDeployment(null)}
/> />
)} )}
{restartOpenVpnConfirmOpen && (
<ConfirmDialog
title="Reiniciar OpenVPN"
message="Tem a certeza que pretende reiniciar o serviço OpenVPN na VPS? As ligações de túnel existentes podem ser interrompidas temporariamente."
confirmLabel="Reiniciar"
danger
onCancel={() => setRestartOpenVpnConfirmOpen(false)}
onConfirm={restartOpenVpn}
/>
)}
</div> </div>
); );
} }
+12 -10
View File
@@ -27,8 +27,8 @@ export function CreateRouterModal({
<div className="modal"> <div className="modal">
<div className="modal-header"> <div className="modal-header">
<div> <div>
<h2>New Router</h2> <h2>Novo Router</h2>
<p>Create a router before allocating VPN details.</p> <p>Crie um router antes de atribuir os detalhes VPN.</p>
</div> </div>
<button className="icon-button" onClick={onClose}> <button className="icon-button" onClick={onClose}>
@@ -38,7 +38,7 @@ export function CreateRouterModal({
<form onSubmit={onSubmit} className="router-form"> <form onSubmit={onSubmit} className="router-form">
<label> <label>
Router Name Nome do Router
<input <input
required required
value={form.name} value={form.name}
@@ -48,16 +48,18 @@ export function CreateRouterModal({
</label> </label>
<label> <label>
Serial Number Número de Série
<input <input
value={form.serialNumber} value={form.serialNumber}
onChange={(e) => onChange({ ...form, serialNumber: e.target.value })} onChange={(e) =>
onChange({ ...form, serialNumber: e.target.value })
}
placeholder="Ex: LR-001" placeholder="Ex: LR-001"
/> />
</label> </label>
<label> <label>
LAN Subnet Subnet LAN
<div className="custom-select"> <div className="custom-select">
<button <button
@@ -65,7 +67,7 @@ export function CreateRouterModal({
className="custom-select-button" className="custom-select-button"
onClick={() => setSubnetOpen(!subnetOpen)} onClick={() => setSubnetOpen(!subnetOpen)}
> >
<span>{form.lanSubnet || "Select LAN subnet"}</span> <span>{form.lanSubnet || "Selecionar subnet LAN"}</span>
<ChevronDown size={16} /> <ChevronDown size={16} />
</button> </button>
@@ -95,7 +97,7 @@ export function CreateRouterModal({
</label> </label>
<label> <label>
Gateway IP IP Gateway
<input <input
required required
value={form.lanIp} value={form.lanIp}
@@ -106,11 +108,11 @@ export function CreateRouterModal({
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="secondary" onClick={onClose}> <button type="button" className="secondary" onClick={onClose}>
Cancel Cancelar
</button> </button>
<button type="submit" className="primary" disabled={saving}> <button type="submit" className="primary" disabled={saving}>
{saving ? "Saving..." : "Save Router"} {saving ? "A guardar..." : "Guardar Router"}
</button> </button>
</div> </div>
</form> </form>
+17 -6
View File
@@ -6,7 +6,10 @@ type Props = {
onClose: () => void; onClose: () => void;
}; };
export function DeploymentResultModal({ deployment, onClose }: Props) { export function DeploymentResultModal({
deployment,
onClose,
}: Props) {
const successful = deployment.status === "SUCCESS"; const successful = deployment.status === "SUCCESS";
const logs = const logs =
@@ -14,14 +17,15 @@ export function DeploymentResultModal({ deployment, onClose }: Props) {
deployment.stderr || deployment.stderr ||
deployment.logs || deployment.logs ||
deployment.errorMessage || deployment.errorMessage ||
"No logs returned."; "Sem logs disponíveis.";
return ( return (
<div className="modal-backdrop"> <div className="modal-backdrop">
<div className="deployment-modal"> <div className="deployment-modal">
<div className="modal-header"> <div className="modal-header">
<div> <div>
<h2>Deployment Result</h2> <h2>Resultado do Deployment</h2>
<p> <p>
{deployment.action} · {deployment.routerName || "Router"} {deployment.action} · {deployment.routerName || "Router"}
</p> </p>
@@ -34,13 +38,20 @@ export function DeploymentResultModal({ deployment, onClose }: Props) {
<div className="deployment-summary"> <div className="deployment-summary">
<div className="deployment-status"> <div className="deployment-status">
{successful ? <CheckCircle2 size={20} /> : <XCircle size={20} />} {successful ? (
<CheckCircle2 size={20} />
) : (
<XCircle size={20} />
)}
<span className={`badge ${successful ? "success" : "failed"}`}> <span className={`badge ${successful ? "success" : "failed"}`}>
{deployment.status} {deployment.status}
</span> </span>
</div> </div>
<span>{deployment.finishedAt || deployment.createdAt || "-"}</span> <span>
{deployment.finishedAt || deployment.createdAt || "-"}
</span>
</div> </div>
<div className="log-box"> <div className="log-box">
@@ -49,7 +60,7 @@ export function DeploymentResultModal({ deployment, onClose }: Props) {
<div className="modal-actions"> <div className="modal-actions">
<button className="primary" onClick={onClose}> <button className="primary" onClick={onClose}>
Done Fechar
</button> </button>
</div> </div>
</div> </div>
+60 -16
View File
@@ -7,51 +7,95 @@ import {
Shield, Shield,
} from "lucide-react"; } from "lucide-react";
type OpenVpnStatus = "online" | "degraded" | "offline" | "checking";
type Props = { type Props = {
page: string; page: string;
onPageChange: (page: string) => void; 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 ( return (
<aside className="sidebar"> <aside className="sidebar">
<div className="brand"> <div className="brand">
<img className="brand-logo" src="/src/assets/lr-logo.png" alt="LitoralRegas" /> <img
className="brand-logo"
src="/src/assets/lr-logo.png"
alt="LitoralRegas"
/>
<div> <div>
<strong>OpenVPN Tool</strong> <strong>OpenVPN Tool</strong>
</div> </div>
</div> </div>
<nav> <nav>
<button className={page === "dashboard" ? "active" : ""} onClick={() => onPageChange("dashboard")}> <button
<LayoutDashboard size={18} /> Dashboard className={page === "dashboard" ? "active" : ""}
onClick={() => onPageChange("dashboard")}
>
<LayoutDashboard size={18} />
Dashboard
</button> </button>
<button className={page === "routers" ? "active" : ""} onClick={() => onPageChange("routers")}> <button
<Router size={18} /> Routers className={page === "routers" ? "active" : ""}
onClick={() => onPageChange("routers")}
>
<Router size={18} />
Routers
</button> </button>
<button className={page === "clients" ? "active" : ""} onClick={() => onPageChange("clients")}> <button
<Shield size={18} /> OpenVPN Clients className={page === "clients" ? "active" : ""}
onClick={() => onPageChange("clients")}
>
<Shield size={18} />
Clientes OpenVPN
</button> </button>
<button className={page === "deployments" ? "active" : ""} onClick={() => onPageChange("deployments")}> <button
<Activity size={18} /> Deployments className={page === "deployments" ? "active" : ""}
onClick={() => onPageChange("deployments")}
>
<Activity size={18} />
Deployments
</button> </button>
<button className={page === "servers" ? "active" : ""} onClick={() => onPageChange("servers")}> <button
<Server size={18} /> VPS Servers className={page === "servers" ? "active" : ""}
onClick={() => onPageChange("servers")}
>
<Server size={18} />
Servidor VPS
</button> </button>
<button className={page === "settings" ? "active" : ""} onClick={() => onPageChange("settings")}> <button
<Settings size={18} /> Settings className={page === "settings" ? "active" : ""}
onClick={() => onPageChange("settings")}
>
<Settings size={18} />
Definições
</button> </button>
</nav> </nav>
<div className="status-card"> <div className="status-card">
<strong>OpenVPN Status</strong> <strong>Estado OpenVPN</strong>
<p> <p>
<span className="dot" /> Online <span className={`dot ${openVpnStatus}`} /> {statusLabel}
</p> </p>
</div> </div>
</aside> </aside>
+326 -13
View File
@@ -1,23 +1,336 @@
import { Metric } from "../components/Metric"; import {
import type { RouterItem } from "../types/router"; 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<Record<string, { name: string; deployments: number; failed: number }>>(
(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 ( return (
<> <section className="dashboard-page">
<div className="page-header"> <div className="page-header">
<div> <div>
<h1>Dashboard</h1> <h1>Dashboard</h1>
<p>Overview of your OpenVPN infrastructure</p> <p>Visão geral da infraestrutura OpenVPN</p>
</div>
<button className="health">System Healthy</button>
</div> </div>
<div className="cards"> <div className={`system-pill ${openVpnStatus}`}>
<Metric title="Routers" value={routers.length} /> <span className={`dot ${openVpnStatus}`} />
<Metric title="OpenVPN Clients" value="-" /> {statusLabel(openVpnStatus)}
<Metric title="VPS Servers" value="1" /> </div>
<Metric title="Deployments" value="-" /> </div>
<div className="metric-grid">
<MetricCard
icon={<Router size={24} />}
label="Routers"
value={totalRouters}
detail="Routers geridos"
/>
<MetricCard
icon={<Shield size={24} />}
label="Clientes OpenVPN"
value={successfulDeployments}
detail="Clientes provisionados"
/>
<MetricCard
icon={<Server size={24} />}
label="Servidor VPS"
value={1}
detail={openVpnStatus === "online" ? "Online" : "Requer atenção"}
/>
<MetricCard
icon={<Activity size={24} />}
label="Deployments"
value={deployments.length}
detail={`${failedDeployments} falhados`}
/>
</div>
<div className="dashboard-grid">
<div className="dashboard-card wide">
<div className="card-header">
<div>
<h2>Atividade de Deployments</h2>
<p>Ações recentes de provisionamento e remoção</p>
</div>
</div>
<div className="activity-chart">
{deploymentChartData.length === 0 ? (
<div className="activity-chart-placeholder">
<Activity size={28} />
<span>Sem atividade de deployments</span>
</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={deploymentChartData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="name" tickLine={false} axisLine={false} />
<YAxis allowDecimals={false} tickLine={false} axisLine={false} />
<Tooltip />
<Line
type="monotone"
dataKey="deployments"
strokeWidth={3}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
/>
<Line
type="monotone"
dataKey="failed"
strokeWidth={3}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
<div className="dashboard-card">
<div className="card-header">
<div>
<h2>Estado da VPN</h2>
<p>Estado em tempo real do serviço na VPS</p>
</div>
</div>
<div className="health-stack">
<HealthRow label="Backend API" status="online" value="Acessível" />
<HealthRow
label="Serviço OpenVPN"
status={openVpnStatus}
value={statusLabel(openVpnStatus)}
/>
<HealthRow
label="Falhas de Deployment"
status={failedDeployments > 0 ? "degraded" : "online"}
value={`${failedDeployments} falhadas`}
/>
</div>
</div>
<div className="dashboard-card wide">
<div className="card-header">
<div>
<h2>Últimos Deployments</h2>
<p>Alterações operacionais mais recentes</p>
</div>
</div>
<table className="dashboard-table">
<thead>
<tr>
<th>ID</th>
<th>Ação</th>
<th>Estado</th>
<th>Data</th>
</tr>
</thead>
<tbody>
{latestDeployments.length === 0 ? (
<tr>
<td colSpan={4} className="empty-cell">
Ainda não existem deployments.
</td>
</tr>
) : (
latestDeployments.map((deployment) => (
<tr key={deployment.id}>
<td>#{deployment.id}</td>
<td>{deploymentActionLabel(deployment.action)}</td>
<td>
<span
className={`table-status ${
deployment.status === "SUCCESS" ? "success" : "failed"
}`}
>
{deployment.status === "SUCCESS" ? (
<CheckCircle2 size={14} />
) : (
<XCircle size={14} />
)}
{deploymentStatusLabel(deployment.status)}
</span>
</td>
<td>{formatDate(deployment.createdAt)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="dashboard-card">
<div className="card-header">
<div>
<h2>Ações Rápidas</h2>
<p>Fluxos comuns para operadores</p>
</div>
</div>
<div className="quick-actions">
<button onClick={onCreateRouter}>Criar Router</button>
<button onClick={onGoToDeployments}>Ver Deployments</button>
<button onClick={onRefreshHealth}>Verificar Estado da VPS</button>
</div>
</div>
</div>
</section>
);
}
function MetricCard({
icon,
label,
value,
detail,
}: {
icon: React.ReactNode;
label: string;
value: number;
detail: string;
}) {
return (
<div className="metric-card">
<div className="metric-icon">{icon}</div>
<div>
<p>{label}</p>
<strong>{value}</strong>
<span>{detail}</span>
</div>
</div>
);
}
function HealthRow({
label,
status,
value,
}: {
label: string;
status: OpenVpnStatus;
value: string;
}) {
return (
<div className="health-row">
<div>
<span className={`dot ${status}`} />
{label}
</div>
<strong>{value}</strong>
</div> </div>
</>
); );
} }
+225
View File
@@ -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<string, string> = {
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 (
<div className="page-stack">
<div className="page-header">
<div>
<h1>Deployments</h1>
<p>Histórico de ações de provisionamento e remoção</p>
</div>
</div>
<div className="panel">
<div className="table-toolbar">
<div className="search-box">
<Search size={16} />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Pesquisar deployments..."
/>
</div>
<div className="toolbar-filters">
<div className="custom-select toolbar-select">
<button
type="button"
className="custom-select-button"
onClick={() => {
setStatusOpen(!statusOpen);
setActionOpen(false);
}}
>
<span>
{statusFilter === "ALL"
? "Todos os Estados"
: label(statusFilter)}
</span>
<ChevronDown
size={16}
className={statusOpen ? "chevron open" : "chevron"}
/>
</button>
{statusOpen && (
<div className="custom-select-menu">
{STATUS_OPTIONS.map((status) => (
<button
key={status}
type="button"
className="custom-select-option"
onClick={() => {
setStatusFilter(status);
setStatusOpen(false);
}}
>
{status === "ALL" ? "Todos os Estados" : label(status)}
</button>
))}
</div>
)}
</div>
<div className="custom-select toolbar-select">
<button
type="button"
className="custom-select-button"
onClick={() => {
setActionOpen(!actionOpen);
setStatusOpen(false);
}}
>
<span>
{actionFilter === "ALL"
? "Todas as Ações"
: label(actionFilter)}
</span>
<ChevronDown
size={16}
className={actionOpen ? "chevron open" : "chevron"}
/>
</button>
{actionOpen && (
<div className="custom-select-menu">
{ACTION_OPTIONS.map((action) => (
<button
key={action}
type="button"
className="custom-select-option"
onClick={() => {
setActionFilter(action);
setActionOpen(false);
}}
>
{action === "ALL" ? "Todas as Ações" : label(action)}
</button>
))}
</div>
)}
</div>
</div>
</div>
{loading ? (
<p className="panel-empty">A carregar deployments...</p>
) : filteredDeployments.length === 0 ? (
<p className="panel-empty">Nenhum deployment encontrado</p>
) : (
<table>
<thead>
<tr>
<th>Router</th>
<th>Ação</th>
<th>Estado</th>
<th>Iniciado</th>
<th>Terminado</th>
<th>Detalhes</th>
</tr>
</thead>
<tbody>
{filteredDeployments.map((deployment) => (
<tr key={deployment.id}>
<td>{deployment.routerName || "-"}</td>
<td>{label(deployment.action)}</td>
<td>
<span className={`badge ${deployment.status.toLowerCase()}`}>
{label(deployment.status)}
</span>
</td>
<td>{formatDate(deployment.startedAt || deployment.createdAt)}</td>
<td>{formatDate(deployment.finishedAt)}</td>
<td>
<button
className="small-action"
onClick={() => onOpenDeployment(deployment)}
>
<Activity size={14} />
Ver Logs
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
+33 -24
View File
@@ -14,6 +14,21 @@ type Props = {
onDownloadBundle: (router: RouterItem) => void; onDownloadBundle: (router: RouterItem) => void;
}; };
const STATUS_LABELS: Record<string, string> = {
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({ export function RoutersPage({
routers, routers,
loading, loading,
@@ -46,21 +61,18 @@ export function RoutersPage({
}); });
}, [routers, search, statusFilter]); }, [routers, search, statusFilter]);
const selectedStatusLabel = const selectedStatusLabel = statusLabel(statusFilter);
statusFilter === "ALL"
? "All Statuses"
: statusFilter.charAt(0) + statusFilter.slice(1).toLowerCase();
return ( return (
<> <>
<div className="page-header"> <div className="page-header">
<div> <div>
<h1>Routers</h1> <h1>Routers</h1>
<p>Manage routers and OpenVPN provisioning</p> <p>Gerir routers e provisionamento OpenVPN</p>
</div> </div>
<button className="primary" onClick={onCreateClick}> <button className="primary" onClick={onCreateClick}>
+ New Router + Novo Router
</button> </button>
</div> </div>
@@ -71,7 +83,7 @@ export function RoutersPage({
<input <input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Search routers..." placeholder="Pesquisar routers..."
/> />
</div> </div>
@@ -109,10 +121,7 @@ export function RoutersPage({
setStatusOpen(false); setStatusOpen(false);
}} }}
> >
{status === "ALL" {statusLabel(status)}
? "All Statuses"
: status.charAt(0) +
status.slice(1).toLowerCase()}
</button> </button>
))} ))}
</div> </div>
@@ -121,20 +130,20 @@ export function RoutersPage({
</div> </div>
{loading ? ( {loading ? (
<p className="panel-empty">Loading routers...</p> <p className="panel-empty">A carregar routers...</p>
) : filteredRouters.length === 0 ? ( ) : filteredRouters.length === 0 ? (
<p className="panel-empty">No routers found</p> <p className="panel-empty">Nenhum router encontrado</p>
) : ( ) : (
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Nome</th>
<th>Serial</th> <th>Série</th>
<th>LAN IP</th> <th>IP LAN</th>
<th>LAN Subnet</th> <th>Subnet LAN</th>
<th>VPN IP</th> <th>IP VPN</th>
<th>Status</th> <th>Estado</th>
<th>Actions</th> <th>Ações</th>
</tr> </tr>
</thead> </thead>
@@ -160,7 +169,7 @@ export function RoutersPage({
<td>{router.vpnIp || "-"}</td> <td>{router.vpnIp || "-"}</td>
<td> <td>
<span className={`badge ${router.status.toLowerCase()}`}> <span className={`badge ${router.status.toLowerCase()}`}>
{isBusy ? "WORKING" : router.status} {isBusy ? statusLabel("WORKING") : statusLabel(router.status)}
</span> </span>
</td> </td>
@@ -173,7 +182,7 @@ export function RoutersPage({
disabled={isBusy} disabled={isBusy}
> >
<Play size={14} /> <Play size={14} />
{isBusy ? "Working..." : "Provision"} {isBusy ? "A processar..." : "Provisionar"}
</button> </button>
)} )}
@@ -194,7 +203,7 @@ export function RoutersPage({
disabled={isBusy} disabled={isBusy}
> >
<RotateCcw size={14} /> <RotateCcw size={14} />
Remove Remover
</button> </button>
</> </>
)} )}
@@ -204,7 +213,7 @@ export function RoutersPage({
className="table-action danger" className="table-action danger"
onClick={() => onDelete(router)} onClick={() => onDelete(router)}
disabled={isBusy} disabled={isBusy}
title="Delete router" title="Apagar router"
> >
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
+176
View File
@@ -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 (
<section className="page-stack">
<div className="page-header">
<div>
<h1>Definições</h1>
<p>Visão geral da aplicação, VPS e configuração OpenVPN</p>
</div>
<button className="primary" onClick={onRefreshHealth}>
<RefreshCw size={16} />
Testar Ligação
</button>
</div>
<div className="settings-grid">
<SettingsCard
icon={<Database size={22} />}
title="Ligação Backend"
description="Ligação da aplicação desktop ao backend Spring Boot"
>
<SettingRow
label="URL Backend"
value={import.meta.env.VITE_API_BASE || "Não configurado"}
/>
<SettingRow
label="Estado API"
value={statusLabel}
status={openVpnStatus}
/>
<SettingRow
label="API Key"
value={import.meta.env.VITE_API_KEY ? "Configurada" : "Em falta"}
/>
</SettingsCard>
<SettingsCard
icon={<Server size={22} />}
title="Configuração VPS"
description="Servidor remoto configurado para operações OpenVPN"
>
<SettingRow label="Tipo de Ligação" value="SSH" />
<SettingRow label="Host VPS" value="146.59.230.190" />
<SettingRow label="Porta SSH" value="22" />
<SettingRow label="Utilizador SSH" value="lr-openvpn" />
<div className="settings-actions">
<button onClick={onRefreshHealth}>
<Wifi size={16} />
Testar SSH
</button>
<button onClick={onRestartOpenVpn}>
<Activity size={16} />
Reiniciar OpenVPN
</button>
</div>
</SettingsCard>
<SettingsCard
icon={<Shield size={22} />}
title="Configuração OpenVPN"
description="Configuração base do serviço OpenVPN e túneis"
>
<SettingRow
label="Nome do Serviço"
value="openvpn-server@server"
/>
<SettingRow label="Porta VPN" value="443" />
<SettingRow label="Protocolo" value="TCP" />
<SettingRow label="Formato Bundle" value="TAR.GZ" />
</SettingsCard>
<SettingsCard
icon={<MonitorCog size={22} />}
title="Aplicação"
description="Comportamento da aplicação desktop e preferências"
>
<SettingRow label="Página Inicial" value="Dashboard" />
<SettingRow
label="Atualização de Estado"
value="A cada 30 segundos"
/>
<SettingRow label="Tema" value="Operations Light" />
<SettingRow
label="Confirmar Ações Destrutivas"
value="Ativado"
/>
</SettingsCard>
</div>
</section>
);
}
function SettingsCard({
icon,
title,
description,
children,
}: {
icon: React.ReactNode;
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="settings-card">
<div className="settings-card-header">
<div className="settings-card-icon">{icon}</div>
<div>
<h2>{title}</h2>
<p>{description}</p>
</div>
</div>
<div className="settings-list">{children}</div>
</div>
);
}
function SettingRow({
label,
value,
status,
}: {
label: string;
value: string;
status?: OpenVpnStatus;
}) {
return (
<div className="setting-row">
<span>{label}</span>
<strong>
{status && <span className={`dot ${status}`} />}
{value}
</strong>
</div>
);
}
+298
View File
@@ -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 (
<section className="page-stack">
<div className="page-header">
<div>
<h1>Servidor VPS</h1>
<p>Monitorize o estado da VPS configurada e do serviço OpenVPN</p>
</div>
<button className="primary" onClick={onRefreshHealth}>
<RefreshCw size={16} />
Atualizar Estado
</button>
</div>
<div className="vps-hero-card">
<div className="vps-hero-main">
<div className={`vps-server-icon ${openVpnStatus}`}>
<Server size={32} />
</div>
<div>
<div className="vps-title-row">
<h2>VPS Configurada</h2>
<span className={`badge ${statusClass(openVpnStatus)}`}>
{statusLabel}
</span>
</div>
<p>
Este servidor é usado para provisionamento de clientes OpenVPN,
geração de bundles e gestão de túneis.
</p>
</div>
</div>
<div className="vps-health-summary">
<HealthMiniCard
icon={<Wifi size={18} />}
label="Acesso SSH"
value={isOffline ? "Indisponível" : isChecking ? "A verificar" : "Acessível"}
status={isOffline ? "offline" : isChecking ? "checking" : "online"}
/>
<HealthMiniCard
icon={<Shield size={18} />}
label="Serviço OpenVPN"
value={statusLabel}
status={openVpnStatus}
/>
<HealthMiniCard
icon={<Activity size={18} />}
label="Estado dos Túneis"
value={isOnline ? "Operacional" : isDegraded ? "Requer atenção" : "Desconhecido"}
status={openVpnStatus}
/>
</div>
</div>
<div className="vps-grid">
<div className="dashboard-card">
<div className="card-header">
<div>
<h2>Verificações de Serviço</h2>
<p>Verificações em tempo real a partir da VPS configurada</p>
</div>
</div>
<div className="service-check-list">
<ServiceCheck
label="Ligação SSH"
description="O backend consegue ligar-se à VPS via SSH"
status={isOffline ? "offline" : isChecking ? "checking" : "online"}
/>
<ServiceCheck
label="Serviço OpenVPN"
description="O serviço systemd do OpenVPN está ativo"
status={openVpnStatus}
/>
<ServiceCheck
label="Acesso de provisionamento"
description="O servidor consegue executar comandos de provisionamento"
status={isOffline ? "offline" : isChecking ? "checking" : "online"}
/>
<ServiceCheck
label="Acesso aos bundles"
description="O backend consegue descarregar bundles gerados"
status={isOffline ? "offline" : isChecking ? "checking" : "online"}
/>
</div>
</div>
<div className="dashboard-card">
<div className="card-header">
<div>
<h2>Detalhes do Servidor</h2>
<p>Destino configurado no backend</p>
</div>
</div>
<div className="detail-list">
<DetailRow label="Host" value="146.59.230.190" />
<DetailRow label="Utilizador SSH" value="lr-openvpn" />
<DetailRow label="Porta SSH" value="22" />
<DetailRow label="Porta OpenVPN" value="443 / TCP" />
<DetailRow label="Verificações de Estado" value="A cada 30 segundos" />
</div>
</div>
<div className="dashboard-card wide-panel">
<div className="card-header">
<div>
<h2>Ações Operacionais</h2>
<p>Ações comuns de manutenção para esta VPS</p>
</div>
</div>
<div className="vps-actions-grid">
<button className="vps-action-card" onClick={onRefreshHealth}>
<RefreshCw size={20} />
<div>
<strong>Atualizar Estado</strong>
<span>Executar novamente as verificações SSH e OpenVPN</span>
</div>
</button>
<button className="vps-action-card" onClick={onViewLogs}>
<Terminal size={20} />
<div>
<strong>Ver Logs Recentes</strong>
<span>Abrir logs recentes do serviço OpenVPN</span>
</div>
</button>
<button className="vps-action-card" onClick={onRestartOpenVpn}>
<Activity size={20} />
<div>
<strong>Reiniciar OpenVPN</strong>
<span>Reiniciar o serviço OpenVPN na VPS</span>
</div>
</button>
</div>
</div>
<div className="dashboard-card">
<div className="card-header">
<div>
<h2>Notas de Estado</h2>
<p>O que significa este estado</p>
</div>
</div>
<div className="status-note">
{isOnline && (
<>
<CheckCircle2 size={24} />
<p>
A VPS está acessível e o serviço OpenVPN aparenta estar em execução.
</p>
</>
)}
{isDegraded && (
<>
<Clock size={24} />
<p>
A VPS está acessível, mas uma ou mais verificações OpenVPN requerem atenção.
</p>
</>
)}
{isOffline && (
<>
<XCircle size={24} />
<p>
O backend não conseguiu verificar a VPS/serviço OpenVPN. Confirme o SSH,
credenciais, acesso de rede ou o serviço OpenVPN.
</p>
</>
)}
{isChecking && (
<>
<RefreshCw size={24} />
<p>A verificar estado da VPS e do OpenVPN...</p>
</>
)}
</div>
</div>
</div>
</section>
);
}
function HealthMiniCard({
icon,
label,
value,
status,
}: {
icon: React.ReactNode;
label: string;
value: string;
status: OpenVpnStatus;
}) {
return (
<div className="health-mini-card">
<div className={`health-mini-icon ${status}`}>{icon}</div>
<div>
<span>{label}</span>
<strong>{value}</strong>
</div>
</div>
);
}
function ServiceCheck({
label,
description,
status,
}: {
label: string;
description: string;
status: OpenVpnStatus;
}) {
const healthy = status === "online";
return (
<div className="service-check">
<div className={`service-check-icon ${status}`}>
{healthy ? <CheckCircle2 size={18} /> : <XCircle size={18} />}
</div>
<div>
<strong>{label}</strong>
<span>{description}</span>
</div>
</div>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<div className="detail-row">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function statusClass(status: OpenVpnStatus) {
if (status === "online") return "success";
if (status === "degraded") return "warning";
if (status === "offline") return "failed";
return "removed";
}
+3
View File
@@ -0,0 +1,3 @@
export type OpenVpnHealthResponse = {
status: "ONLINE" | "DEGRADED" | "OFFLINE";
};
+1
View File
@@ -0,0 +1 @@
export type OpenVpnStatus = "online" | "degraded" | "offline" | "checking";