feat: build OpenVPN operations dashboard and VPS management UI
This commit is contained in:
Generated
+410
-3
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "LR OpenVPN Tool",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"resizable": true
|
||||
"width": 1600,
|
||||
"height": 1000,
|
||||
"resizable": true,
|
||||
"maximized": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+743
-45
@@ -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);
|
||||
}
|
||||
+160
-33
@@ -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<RouterItem | null>(null);
|
||||
const [routerToRemove, setRouterToRemove] = useState<RouterItem | 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>({
|
||||
name: "",
|
||||
@@ -46,12 +55,32 @@ function App() {
|
||||
const data = await apiGet<RouterItem[]>("/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<OpenVpnHealthResponse>(
|
||||
"/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<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(() => {
|
||||
loadRouters();
|
||||
async function loadInitialData() {
|
||||
await loadRouters();
|
||||
await loadDeployments();
|
||||
}
|
||||
|
||||
loadInitialData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadOpenVpnStatus();
|
||||
|
||||
const interval = window.setInterval(loadOpenVpnStatus, 30_000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar page={page} onPageChange={setPage} />
|
||||
<Sidebar
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
openVpnStatus={openVpnStatus}
|
||||
/>
|
||||
|
||||
<main className="content">
|
||||
{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" && (
|
||||
<RoutersPage
|
||||
@@ -289,6 +380,31 @@ function App() {
|
||||
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>
|
||||
|
||||
{createOpen && (
|
||||
@@ -304,9 +420,9 @@ function App() {
|
||||
|
||||
{routerToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete Router"
|
||||
message={`Are you sure you want to permanently delete "${routerToDelete.name}"?`}
|
||||
confirmLabel="Delete"
|
||||
title="Apagar Router"
|
||||
message={`Tem a certeza que pretende apagar permanentemente "${routerToDelete.name}"?`}
|
||||
confirmLabel="Apagar"
|
||||
danger
|
||||
onCancel={() => setRouterToDelete(null)}
|
||||
onConfirm={() => deleteRouter(routerToDelete)}
|
||||
@@ -315,9 +431,9 @@ function App() {
|
||||
|
||||
{routerToRemove && (
|
||||
<ConfirmDialog
|
||||
title="Remove OpenVPN Client"
|
||||
message={`Remove the OpenVPN client configuration for "${routerToRemove.name}"?`}
|
||||
confirmLabel="Remove"
|
||||
title="Remover Cliente OpenVPN"
|
||||
message={`Remover a configuração de cliente OpenVPN para "${routerToRemove.name}"?`}
|
||||
confirmLabel="Remover"
|
||||
danger
|
||||
onCancel={() => setRouterToRemove(null)}
|
||||
onConfirm={() => removeRouter(routerToRemove)}
|
||||
@@ -330,6 +446,17 @@ function App() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,14 +21,14 @@ export function CreateRouterModal({
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const [subnetOpen, setSubnetOpen] = useState(false);
|
||||
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2>New Router</h2>
|
||||
<p>Create a router before allocating VPN details.</p>
|
||||
<h2>Novo Router</h2>
|
||||
<p>Crie um router antes de atribuir os detalhes VPN.</p>
|
||||
</div>
|
||||
|
||||
<button className="icon-button" onClick={onClose}>
|
||||
@@ -38,7 +38,7 @@ export function CreateRouterModal({
|
||||
|
||||
<form onSubmit={onSubmit} className="router-form">
|
||||
<label>
|
||||
Router Name
|
||||
Nome do Router
|
||||
<input
|
||||
required
|
||||
value={form.name}
|
||||
@@ -48,16 +48,18 @@ export function CreateRouterModal({
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Serial Number
|
||||
Número de Série
|
||||
<input
|
||||
value={form.serialNumber}
|
||||
onChange={(e) => onChange({ ...form, serialNumber: e.target.value })}
|
||||
onChange={(e) =>
|
||||
onChange({ ...form, serialNumber: e.target.value })
|
||||
}
|
||||
placeholder="Ex: LR-001"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
LAN Subnet
|
||||
Subnet LAN
|
||||
|
||||
<div className="custom-select">
|
||||
<button
|
||||
@@ -65,7 +67,7 @@ export function CreateRouterModal({
|
||||
className="custom-select-button"
|
||||
onClick={() => setSubnetOpen(!subnetOpen)}
|
||||
>
|
||||
<span>{form.lanSubnet || "Select LAN subnet"}</span>
|
||||
<span>{form.lanSubnet || "Selecionar subnet LAN"}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
|
||||
@@ -95,7 +97,7 @@ export function CreateRouterModal({
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Gateway IP
|
||||
IP Gateway
|
||||
<input
|
||||
required
|
||||
value={form.lanIp}
|
||||
@@ -106,11 +108,11 @@ export function CreateRouterModal({
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
<button type="submit" className="primary" disabled={saving}>
|
||||
{saving ? "Saving..." : "Save Router"}
|
||||
{saving ? "A guardar..." : "Guardar Router"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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 (
|
||||
<div className="modal-backdrop">
|
||||
<div className="deployment-modal">
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2>Deployment Result</h2>
|
||||
<h2>Resultado do Deployment</h2>
|
||||
|
||||
<p>
|
||||
{deployment.action} · {deployment.routerName || "Router"}
|
||||
</p>
|
||||
@@ -34,13 +38,20 @@ export function DeploymentResultModal({ deployment, onClose }: Props) {
|
||||
|
||||
<div className="deployment-summary">
|
||||
<div className="deployment-status">
|
||||
{successful ? <CheckCircle2 size={20} /> : <XCircle size={20} />}
|
||||
{successful ? (
|
||||
<CheckCircle2 size={20} />
|
||||
) : (
|
||||
<XCircle size={20} />
|
||||
)}
|
||||
|
||||
<span className={`badge ${successful ? "success" : "failed"}`}>
|
||||
{deployment.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span>{deployment.finishedAt || deployment.createdAt || "-"}</span>
|
||||
<span>
|
||||
{deployment.finishedAt || deployment.createdAt || "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="log-box">
|
||||
@@ -49,7 +60,7 @@ export function DeploymentResultModal({ deployment, onClose }: Props) {
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="primary" onClick={onClose}>
|
||||
Done
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+60
-16
@@ -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 (
|
||||
<aside className="sidebar">
|
||||
<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>
|
||||
<strong>OpenVPN Tool</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<button className={page === "dashboard" ? "active" : ""} onClick={() => onPageChange("dashboard")}>
|
||||
<LayoutDashboard size={18} /> Dashboard
|
||||
<button
|
||||
className={page === "dashboard" ? "active" : ""}
|
||||
onClick={() => onPageChange("dashboard")}
|
||||
>
|
||||
<LayoutDashboard size={18} />
|
||||
Dashboard
|
||||
</button>
|
||||
|
||||
<button className={page === "routers" ? "active" : ""} onClick={() => onPageChange("routers")}>
|
||||
<Router size={18} /> Routers
|
||||
<button
|
||||
className={page === "routers" ? "active" : ""}
|
||||
onClick={() => onPageChange("routers")}
|
||||
>
|
||||
<Router size={18} />
|
||||
Routers
|
||||
</button>
|
||||
|
||||
<button className={page === "clients" ? "active" : ""} onClick={() => onPageChange("clients")}>
|
||||
<Shield size={18} /> OpenVPN Clients
|
||||
<button
|
||||
className={page === "clients" ? "active" : ""}
|
||||
onClick={() => onPageChange("clients")}
|
||||
>
|
||||
<Shield size={18} />
|
||||
Clientes OpenVPN
|
||||
</button>
|
||||
|
||||
<button className={page === "deployments" ? "active" : ""} onClick={() => onPageChange("deployments")}>
|
||||
<Activity size={18} /> Deployments
|
||||
<button
|
||||
className={page === "deployments" ? "active" : ""}
|
||||
onClick={() => onPageChange("deployments")}
|
||||
>
|
||||
<Activity size={18} />
|
||||
Deployments
|
||||
</button>
|
||||
|
||||
<button className={page === "servers" ? "active" : ""} onClick={() => onPageChange("servers")}>
|
||||
<Server size={18} /> VPS Servers
|
||||
<button
|
||||
className={page === "servers" ? "active" : ""}
|
||||
onClick={() => onPageChange("servers")}
|
||||
>
|
||||
<Server size={18} />
|
||||
Servidor VPS
|
||||
</button>
|
||||
|
||||
<button className={page === "settings" ? "active" : ""} onClick={() => onPageChange("settings")}>
|
||||
<Settings size={18} /> Settings
|
||||
<button
|
||||
className={page === "settings" ? "active" : ""}
|
||||
onClick={() => onPageChange("settings")}
|
||||
>
|
||||
<Settings size={18} />
|
||||
Definições
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="status-card">
|
||||
<strong>OpenVPN Status</strong>
|
||||
<strong>Estado OpenVPN</strong>
|
||||
|
||||
<p>
|
||||
<span className="dot" /> Online
|
||||
<span className={`dot ${openVpnStatus}`} /> {statusLabel}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
+325
-12
@@ -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<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 (
|
||||
<>
|
||||
<section className="dashboard-page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Overview of your OpenVPN infrastructure</p>
|
||||
<p>Visão geral da infraestrutura OpenVPN</p>
|
||||
</div>
|
||||
|
||||
<div className={`system-pill ${openVpnStatus}`}>
|
||||
<span className={`dot ${openVpnStatus}`} />
|
||||
{statusLabel(openVpnStatus)}
|
||||
</div>
|
||||
<button className="health">System Healthy</button>
|
||||
</div>
|
||||
|
||||
<div className="cards">
|
||||
<Metric title="Routers" value={routers.length} />
|
||||
<Metric title="OpenVPN Clients" value="-" />
|
||||
<Metric title="VPS Servers" value="1" />
|
||||
<Metric title="Deployments" value="-" />
|
||||
<div 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+34
-25
@@ -14,6 +14,21 @@ type Props = {
|
||||
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({
|
||||
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 (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Routers</h1>
|
||||
<p>Manage routers and OpenVPN provisioning</p>
|
||||
<p>Gerir routers e provisionamento OpenVPN</p>
|
||||
</div>
|
||||
|
||||
<button className="primary" onClick={onCreateClick}>
|
||||
+ New Router
|
||||
+ Novo Router
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +83,7 @@ export function RoutersPage({
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search routers..."
|
||||
placeholder="Pesquisar routers..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -109,10 +121,7 @@ export function RoutersPage({
|
||||
setStatusOpen(false);
|
||||
}}
|
||||
>
|
||||
{status === "ALL"
|
||||
? "All Statuses"
|
||||
: status.charAt(0) +
|
||||
status.slice(1).toLowerCase()}
|
||||
{statusLabel(status)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -121,20 +130,20 @@ export function RoutersPage({
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="panel-empty">Loading routers...</p>
|
||||
<p className="panel-empty">A carregar routers...</p>
|
||||
) : filteredRouters.length === 0 ? (
|
||||
<p className="panel-empty">No routers found</p>
|
||||
<p className="panel-empty">Nenhum router encontrado</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Serial</th>
|
||||
<th>LAN IP</th>
|
||||
<th>LAN Subnet</th>
|
||||
<th>VPN IP</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
<th>Nome</th>
|
||||
<th>Série</th>
|
||||
<th>IP LAN</th>
|
||||
<th>Subnet LAN</th>
|
||||
<th>IP VPN</th>
|
||||
<th>Estado</th>
|
||||
<th>Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -160,7 +169,7 @@ export function RoutersPage({
|
||||
<td>{router.vpnIp || "-"}</td>
|
||||
<td>
|
||||
<span className={`badge ${router.status.toLowerCase()}`}>
|
||||
{isBusy ? "WORKING" : router.status}
|
||||
{isBusy ? statusLabel("WORKING") : statusLabel(router.status)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -173,7 +182,7 @@ export function RoutersPage({
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Play size={14} />
|
||||
{isBusy ? "Working..." : "Provision"}
|
||||
{isBusy ? "A processar..." : "Provisionar"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -194,7 +203,7 @@ export function RoutersPage({
|
||||
disabled={isBusy}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
Remove
|
||||
Remover
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -204,7 +213,7 @@ export function RoutersPage({
|
||||
className="table-action danger"
|
||||
onClick={() => onDelete(router)}
|
||||
disabled={isBusy}
|
||||
title="Delete router"
|
||||
title="Apagar router"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type OpenVpnHealthResponse = {
|
||||
status: "ONLINE" | "DEGRADED" | "OFFLINE";
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export type OpenVpnStatus = "online" | "degraded" | "offline" | "checking";
|
||||
Reference in New Issue
Block a user