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",
|
"@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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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