new calculation approach with unique sessions, new API endpoint to get list of active sessions, fix for UNDEF user, UI and Back to support certificate management still under development
This commit is contained in:
291
UI/client/package-lock.json
generated
291
UI/client/package-lock.json
generated
@@ -9,9 +9,11 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"sass": "^1.97.2",
|
"sass": "^1.97.2",
|
||||||
|
"sweetalert2": "^11.26.17",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
@@ -1323,6 +1325,23 @@
|
|||||||
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
|
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.13.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||||
|
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.6",
|
||||||
|
"form-data": "^4.0.4",
|
||||||
|
"proxy-from-env": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bootstrap": {
|
"node_modules/bootstrap": {
|
||||||
"version": "5.3.8",
|
"version": "5.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
||||||
@@ -1355,6 +1374,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chart.js": {
|
"node_modules/chart.js": {
|
||||||
"version": "4.5.1",
|
"version": "4.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
@@ -1382,12 +1414,33 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"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==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||||
@@ -1401,6 +1454,20 @@
|
|||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
|
||||||
@@ -1413,6 +1480,51 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||||
@@ -1492,6 +1604,42 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1507,6 +1655,103 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
"version": "5.1.4",
|
"version": "5.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
|
||||||
@@ -1555,6 +1800,15 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/micromatch": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
@@ -1582,6 +1836,27 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -1655,6 +1930,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
@@ -1743,6 +2024,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sweetalert2": {
|
||||||
|
"version": "11.26.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.26.17.tgz",
|
||||||
|
"integrity": "sha512-kkaySn1IRfwNlf9AkZVqDmBINDWw9NRR6Ij0O5dBRBOD1+mbtZJWxxR9/pA90nce9E5tIIkJ7SWij4rMWXtA1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/limonte"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"sass": "^1.97.2",
|
"sass": "^1.97.2",
|
||||||
|
"sweetalert2": "^11.26.17",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,26 +5,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="main-content-wrapper">
|
<div v-else class="app-wrapper" :class="{ 'mobile-nav-active': isSidebarOpen }">
|
||||||
<div class="container">
|
<!-- Mobile Overlay -->
|
||||||
<div class="header">
|
<div class="mobile-overlay" @click="isSidebarOpen = false" v-if="isSidebarOpen"></div>
|
||||||
<div class="d-flex justify-content-between align-items-start flex-wrap">
|
|
||||||
<div class="mb-3 mb-md-0">
|
|
||||||
<h1 class="h3 mb-1">OpenVPN Monitor</h1>
|
|
||||||
<p class="text-muted mb-0">Real-time traffic & connection statistics</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
|
||||||
<router-link to="/" class="btn-nav" active-class="active">
|
|
||||||
<i class="fas fa-list me-2"></i>Clients
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/certificates" class="btn-nav" active-class="active">
|
|
||||||
<i class="fas fa-certificate me-2"></i>Certificates
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/analytics" class="btn-nav" active-class="active">
|
|
||||||
<i class="fas fa-chart-pie me-2"></i>Analytics
|
|
||||||
</router-link>
|
|
||||||
|
|
||||||
<span class="header-timezone">
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<i class="fas fa-shield-alt sidebar-brand-icon"></i>
|
||||||
|
<span>OpenVPN Monitor</span>
|
||||||
|
<button class="btn btn-link text-muted d-md-none ms-auto" @click="isSidebarOpen = false">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-menu">
|
||||||
|
<router-link to="/" class="nav-link" active-class="active">
|
||||||
|
<i class="fas fa-list"></i> Clients
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/certificates" class="nav-link" active-class="active">
|
||||||
|
<i class="fas fa-certificate"></i> Certificates
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/analytics" class="nav-link" active-class="active">
|
||||||
|
<i class="fas fa-chart-pie"></i> Analytics
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/settings" class="nav-link" active-class="active">
|
||||||
|
<i class="fas fa-cog"></i> Settings
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="p-3 text-center text-muted small border-top" style="border-color: var(--border-color) !important;">
|
||||||
|
v3.0.1
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<!-- Top Navbar -->
|
||||||
|
<header class="top-navbar">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<button class="btn-header d-md-none" @click="isSidebarOpen = !isSidebarOpen">
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
<div class="page-title">
|
||||||
|
<!-- Dynamic Title Could Go Here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="navbar-actions">
|
||||||
|
<span class="header-timezone d-none d-md-inline-flex">
|
||||||
<i class="fas fa-globe me-1 text-muted"></i>{{ timezoneAbbr }}
|
<i class="fas fa-globe me-1 text-muted"></i>{{ timezoneAbbr }}
|
||||||
</span>
|
</span>
|
||||||
<button class="btn-header" @click="toggleTheme" title="Toggle Theme">
|
<button class="btn-header" @click="toggleTheme" title="Toggle Theme">
|
||||||
@@ -33,23 +62,28 @@
|
|||||||
<button class="btn-header" @click="refreshPage" title="Refresh">
|
<button class="btn-header" @click="refreshPage" title="Refresh">
|
||||||
<i class="fas fa-sync-alt" id="refreshIcon"></i>
|
<i class="fas fa-sync-alt" id="refreshIcon"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<router-view :key="$route.fullPath + '-' + refreshKey"></router-view>
|
<!-- Content Area -->
|
||||||
</div>
|
<div class="content-wrapper">
|
||||||
|
<router-view :key="$route.fullPath + '-' + refreshKey"></router-view>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted, watch } from 'vue';
|
||||||
import { useAppConfig } from './composables/useAppConfig';
|
import { useAppConfig } from './composables/useAppConfig';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
const { loadConfig, isLoaded } = useAppConfig();
|
const { loadConfig, isLoaded } = useAppConfig();
|
||||||
const timezoneAbbr = ref(new Date().toLocaleTimeString('en-us',{timeZoneName:'short'}).split(' ')[2] || 'UTC');
|
const timezoneAbbr = ref(new Date().toLocaleTimeString('en-us',{timeZoneName:'short'}).split(' ')[2] || 'UTC');
|
||||||
const isDark = ref(false);
|
const isDark = ref(false);
|
||||||
const refreshKey = ref(0);
|
const refreshKey = ref(0);
|
||||||
|
const isSidebarOpen = ref(false);
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
isDark.value = !isDark.value;
|
isDark.value = !isDark.value;
|
||||||
@@ -62,6 +96,11 @@ const refreshPage = () => {
|
|||||||
refreshKey.value++;
|
refreshKey.value++;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Close sidebar on route change
|
||||||
|
watch(() => route.path, () => {
|
||||||
|
isSidebarOpen.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadConfig();
|
await loadConfig();
|
||||||
|
|
||||||
|
|||||||
@@ -37,15 +37,15 @@
|
|||||||
--bg-element: #21262d;
|
--bg-element: #21262d;
|
||||||
|
|
||||||
/* Используем прозрачность для hover, чтобы текст не сливался */
|
/* Используем прозрачность для hover, чтобы текст не сливался */
|
||||||
--bg-element-hover: rgba(255, 255, 255, 0.03);
|
--bg-element-hover: rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
--bg-input: #0d1117;
|
--bg-input: #0d1117;
|
||||||
|
|
||||||
--text-heading: #e6edf3;
|
--text-heading: #f0f6fc;
|
||||||
/* Светлее для заголовков */
|
/* Светлее для заголовков */
|
||||||
--text-main: #8b949e;
|
--text-main: #c9d1d9;
|
||||||
/* Мягкий серый для текста */
|
/* Мягкий серый для текста */
|
||||||
--text-muted: #6e7681;
|
--text-muted: #8b949e;
|
||||||
|
|
||||||
/* ОЧЕНЬ мягкие границы (8% прозрачности белого) */
|
/* ОЧЕНЬ мягкие границы (8% прозрачности белого) */
|
||||||
--border-color: rgba(240, 246, 252, 0.1);
|
--border-color: rgba(240, 246, 252, 0.1);
|
||||||
@@ -73,23 +73,145 @@ body {
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
padding: 20px 0;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
/* --- NEW APP LAYOUT --- */
|
||||||
max-width: 95%;
|
.app-wrapper {
|
||||||
margin: 0 auto;
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1400px) {
|
/* Sidebar */
|
||||||
.container {
|
.sidebar {
|
||||||
max-width: 75%;
|
width: 260px;
|
||||||
}
|
background-color: var(--bg-card);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: fixed;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 1000;
|
||||||
|
transition: width 0.3s ease, background-color 0.3s ease, border-color 0.3s ease;
|
||||||
|
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.04);
|
||||||
|
/* Shading added */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand-icon {
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 15px;
|
||||||
|
color: var(--text-main);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background-color: var(--bg-element-hover);
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link i {
|
||||||
|
width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
margin-right: 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active i {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content Area */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 260px;
|
||||||
|
/* Width of sidebar */
|
||||||
|
width: calc(100% - 260px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top Navigation Bar (Glassmorphism) */
|
||||||
|
.top-navbar {
|
||||||
|
height: 64px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.6);
|
||||||
|
/* Semi-transparent Light */
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 30px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 900;
|
||||||
|
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .top-navbar {
|
||||||
|
background-color: rgba(22, 27, 34, 0.6);
|
||||||
|
/* Semi-transparent Dark */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.page-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Container */
|
||||||
|
.content-wrapper {
|
||||||
|
padding: 30px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adapt existing container */
|
||||||
|
.container {
|
||||||
|
max-width: 100% !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Layout Elements */
|
/* Layout Elements */
|
||||||
.header,
|
|
||||||
.card {
|
.card {
|
||||||
background-color: var(--bg-card);
|
background-color: var(--bg-card);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -99,20 +221,12 @@ body {
|
|||||||
transition: border-color 0.3s ease;
|
transition: border-color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-bottom {
|
|
||||||
border-bottom: 1px solid var(--border-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
padding: 15px 20px;
|
padding: 15px 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-heading);
|
color: var(--text-heading) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@@ -129,20 +243,11 @@ h6 {
|
|||||||
color: var(--text-muted) !important;
|
color: var(--text-muted) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Blur Effect */
|
/* Navbar Actions */
|
||||||
body.modal-open .main-content-wrapper {
|
.navbar-actions {
|
||||||
filter: blur(8px) grayscale(20%);
|
display: flex;
|
||||||
transform: scale(0.99);
|
align-items: center;
|
||||||
opacity: 0.6;
|
gap: 10px;
|
||||||
transition: all 0.4s ease;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content-wrapper {
|
|
||||||
transition: all 0.4s ease;
|
|
||||||
transform: scale(1);
|
|
||||||
filter: blur(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons & Controls */
|
/* Buttons & Controls */
|
||||||
@@ -320,29 +425,51 @@ body.modal-open .main-content-wrapper {
|
|||||||
.status-badge {
|
.status-badge {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.75rem;
|
display: inline-block;
|
||||||
|
line-height: normal;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-active,
|
/* Using !important to ensure override of any bootstrap or generic styles */
|
||||||
.status-valid {
|
.status-valid,
|
||||||
background-color: var(--success-bg);
|
.status-active {
|
||||||
color: var(--success-text);
|
background-color: rgba(40, 167, 69, 0.1) !important;
|
||||||
border-color: var(--badge-border-active);
|
color: #28a745 !important;
|
||||||
|
border-color: rgba(40, 167, 69, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-disconnected,
|
.status-expired,
|
||||||
.status-expired {
|
.status-disconnected {
|
||||||
background-color: var(--danger-bg);
|
background-color: rgba(220, 53, 69, 0.1) !important;
|
||||||
color: var(--danger-text);
|
color: #dc3545 !important;
|
||||||
border-color: var(--badge-border-disconnected);
|
border-color: rgba(220, 53, 69, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-warning,
|
||||||
.status-expiring {
|
.status-expiring {
|
||||||
background-color: var(--warning-bg);
|
background-color: rgba(255, 193, 7, 0.1) !important;
|
||||||
color: var(--warning-text);
|
color: #856404 !important;
|
||||||
border: 1px solid transparent;
|
border-color: rgba(255, 193, 7, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-server {
|
||||||
|
background-color: rgba(13, 202, 240, 0.1) !important;
|
||||||
|
color: #0c5460 !important;
|
||||||
|
border-color: rgba(13, 202, 240, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-client {
|
||||||
|
background-color: rgba(108, 117, 125, 0.1) !important;
|
||||||
|
color: #373b3e !important;
|
||||||
|
border-color: rgba(108, 117, 125, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-secondary {
|
||||||
|
background-color: rgba(108, 117, 125, 0.1) !important;
|
||||||
|
color: #373b3e !important;
|
||||||
|
border-color: rgba(108, 117, 125, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-soft-warning {
|
.badge-soft-warning {
|
||||||
@@ -400,6 +527,11 @@ body.modal-open .main-content-wrapper {
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
color: var(--text-heading);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.input-group-text {
|
.input-group-text {
|
||||||
background-color: var(--bg-element);
|
background-color: var(--bg-element);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -610,6 +742,22 @@ body.modal-open .main-content-wrapper {
|
|||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
width: 260px;
|
||||||
|
/* Keep fixed width when open */
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-wrapper.mobile-nav-active .sidebar {
|
||||||
|
transform: translateX(0);
|
||||||
|
box-shadow: 0 0 50px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.header-controls {
|
.header-controls {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -636,4 +784,16 @@ body.modal-open .main-content-wrapper {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Overlay for mobile sidebar */
|
||||||
|
.mobile-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 999;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,38 @@
|
|||||||
|
import axios from 'axios';
|
||||||
import { useAppConfig } from './useAppConfig';
|
import { useAppConfig } from './useAppConfig';
|
||||||
|
|
||||||
export function useApi() {
|
export function useApi() {
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
|
|
||||||
const getBaseUrl = () => {
|
const getBaseUrl = () => {
|
||||||
// Ensure config is loaded or use default/fallback
|
|
||||||
return config.value?.api_base_url || '/api/v1';
|
return config.value?.api_base_url || '/api/v1';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Create axios instance
|
||||||
|
// Note: We create it dynamically or return a getter if config changes dynamically,
|
||||||
|
// but usually base URL is static or set once.
|
||||||
|
// For simplicity in this logical scope, we'll create a simple wrapper or instance.
|
||||||
|
|
||||||
|
// However, if config.value.api_base_url changes, a static instance won't update.
|
||||||
|
// For now, let's assume standard /api path proxies or defined base.
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: getBaseUrl() // Initial base
|
||||||
|
});
|
||||||
|
|
||||||
|
// Interceptor to update base URL if it changes (optional, but good practice if config is async)
|
||||||
|
apiClient.interceptors.request.use((reqConfig) => {
|
||||||
|
reqConfig.baseURL = getBaseUrl();
|
||||||
|
return reqConfig;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrapper methods to match existing interface where used,
|
||||||
|
// but also exposing apiClient for new components.
|
||||||
|
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${getBaseUrl()}/stats`);
|
const res = await apiClient.get('/stats');
|
||||||
return await res.json();
|
return res.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Fetch Stats Error:', e);
|
console.error('Fetch Stats Error:', e);
|
||||||
throw e;
|
throw e;
|
||||||
@@ -20,8 +41,8 @@ export function useApi() {
|
|||||||
|
|
||||||
const fetchClientHistory = async (clientId, range) => {
|
const fetchClientHistory = async (clientId, range) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${getBaseUrl()}/stats/${clientId}?range=${range}`);
|
const res = await apiClient.get(`/stats/${clientId}`, { params: { range } });
|
||||||
return await res.json();
|
return res.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Fetch History Error:', e);
|
console.error('Fetch History Error:', e);
|
||||||
throw e;
|
throw e;
|
||||||
@@ -30,8 +51,8 @@ export function useApi() {
|
|||||||
|
|
||||||
const fetchAnalytics = async (range) => {
|
const fetchAnalytics = async (range) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${getBaseUrl()}/analytics?range=${range}`);
|
const res = await apiClient.get('/analytics', { params: { range } });
|
||||||
return await res.json();
|
return res.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Fetch Analytics Error:', e);
|
console.error('Fetch Analytics Error:', e);
|
||||||
throw e;
|
throw e;
|
||||||
@@ -40,8 +61,8 @@ export function useApi() {
|
|||||||
|
|
||||||
const fetchCertificates = async () => {
|
const fetchCertificates = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${getBaseUrl()}/certificates`);
|
const res = await apiClient.get('/certificates');
|
||||||
return await res.json();
|
return res.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Fetch Certificates Error:', e);
|
console.error('Fetch Certificates Error:', e);
|
||||||
throw e;
|
throw e;
|
||||||
@@ -49,6 +70,7 @@ export function useApi() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
apiClient, // Export the axios instance
|
||||||
fetchStats,
|
fetchStats,
|
||||||
fetchClientHistory,
|
fetchClientHistory,
|
||||||
fetchAnalytics,
|
fetchAnalytics,
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ const routes = [
|
|||||||
path: '/certificates',
|
path: '/certificates',
|
||||||
name: 'Certificates',
|
name: 'Certificates',
|
||||||
component: () => import('../views/Certificates.vue')
|
component: () => import('../views/Certificates.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'Settings',
|
||||||
|
component: () => import('../views/Settings.vue')
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||||||
<input type="text" class="form-control" placeholder="Search by client name..." v-model="searchQuery">
|
<input type="text" class="form-control" placeholder="Search by client name..." v-model="searchQuery">
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary" @click="showNewClientModal">
|
||||||
|
<i class="fas fa-plus me-1"></i> New Client
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
@@ -33,8 +36,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="certificatesCard">
|
<div class="card" id="certificatesCard">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center bg-transparent border-bottom">
|
<div class="card-header d-flex justify-content-between align-items-center bg-transparent">
|
||||||
<span><i class="fas fa-certificate me-2"></i>Certificates List</span>
|
<span><i class="fas fa-certificate me-2"></i>Certificates List</span>
|
||||||
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<span class="status-badge status-valid me-1">
|
<span class="status-badge status-valid me-1">
|
||||||
<i class="fas fa-check-circle me-1"></i><span>{{ activeCerts.length }}</span> Active
|
<i class="fas fa-check-circle me-1"></i><span>{{ activeCerts.length }}</span> Active
|
||||||
@@ -43,20 +47,23 @@
|
|||||||
<i class="fas fa-times-circle me-1"></i><span>{{ expiredCerts.length }}</span> Expired
|
<i class="fas fa-times-circle me-1"></i><span>{{ expiredCerts.length }}</span> Expired
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Client Name</th>
|
<th>Client Name</th>
|
||||||
|
<th>Type</th> <!-- Preserved Key Info -->
|
||||||
<th>Validity Not After</th>
|
<th>Validity Not After</th>
|
||||||
<th>Days Remaining</th>
|
<th>Days Remaining</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Actions</th> <!-- Preserved Actions -->
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="loading">
|
<tr v-if="loading">
|
||||||
<td colspan="4" class="text-center py-4">
|
<td colspan="6" class="text-center py-4">
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<span class="visually-hidden">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +72,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr v-else-if="activeCerts.length === 0 && expiredCerts.length === 0">
|
<tr v-else-if="activeCerts.length === 0 && expiredCerts.length === 0">
|
||||||
<td colspan="4" class="empty-state text-center py-5">
|
<td colspan="6" class="empty-state text-center py-5">
|
||||||
<i class="fas fa-certificate fa-2x mb-3 text-muted"></i>
|
<i class="fas fa-certificate fa-2x mb-3 text-muted"></i>
|
||||||
<p class="text-muted">No certificates found</p>
|
<p class="text-muted">No certificates found</p>
|
||||||
</td>
|
</td>
|
||||||
@@ -74,37 +81,52 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Active Section -->
|
<!-- Active Section -->
|
||||||
<tr v-if="activeCerts.length > 0" class="section-divider">
|
<tr v-if="activeCerts.length > 0" class="section-divider">
|
||||||
<td colspan="4">Active Certificates ({{ activeCerts.length }})</td>
|
<td colspan="6">Active Certificates ({{ activeCerts.length }})</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="cert in activeCerts" :key="cert.common_name + '_active'">
|
<tr v-for="cert in activeCerts" :key="cert.common_name + '_active'">
|
||||||
<td>
|
<td>
|
||||||
<div class="fw-semibold" style="color: var(--text-heading);">{{ getClientName(cert) }}</div>
|
<div class="fw-semibold" style="color: var(--text-heading);">{{ getClientName(cert) }}</div>
|
||||||
<div class="certificate-file text-muted small">{{ cert.file || 'N/A' }}</div>
|
<div class="certificate-file text-muted small">{{ cert.file || 'N/A' }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ formatDate(cert.not_after || cert.expiration_date) }}</td>
|
<td><span :class="getBadgeClass(cert.type)">{{ cert.type }}</span></td>
|
||||||
|
<td>{{ formatDate(cert.not_after || cert.expires_iso) }}</td>
|
||||||
<td class="fw-semibold" :class="getDaysClass(cert.days_remaining)">
|
<td class="fw-semibold" :class="getDaysClass(cert.days_remaining)">
|
||||||
{{ cert.days_remaining || 'N/A' }}
|
{{ cert.days_remaining || 'N/A' }}
|
||||||
</td>
|
</td>
|
||||||
<td v-html="getStatusBadgeHTML(cert.days_remaining)"></td>
|
<td v-html="getStatusBadgeHTML(cert.days_remaining)"></td>
|
||||||
|
<td>
|
||||||
|
<button v-if="cert.type === 'Client'" class="btn btn-sm btn-link text-primary p-0 me-2" title="Download Config" @click="downloadConfig(cert.common_name || cert.name)">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</button>
|
||||||
|
<button v-if="cert.type === 'Client'" class="btn btn-sm btn-link text-danger p-0" title="Revoke" @click="revokeClient(cert.common_name || cert.name)">
|
||||||
|
<i class="fas fa-ban"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Expired Section -->
|
<!-- Expired Section -->
|
||||||
<template v-if="!hideExpired && expiredCerts.length > 0">
|
<template v-if="!hideExpired && expiredCerts.length > 0">
|
||||||
<tr class="section-divider">
|
<tr class="section-divider">
|
||||||
<td colspan="4">Expired Certificates ({{ expiredCerts.length }})</td>
|
<td colspan="6">Expired Certificates ({{ expiredCerts.length }})</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="cert in expiredCerts" :key="cert.common_name + '_expired'">
|
<tr v-for="cert in expiredCerts" :key="cert.common_name + '_expired'">
|
||||||
<td>
|
<td>
|
||||||
<div class="fw-semibold" style="color: var(--text-heading);">{{ getClientName(cert) }}</div>
|
<div class="fw-semibold" style="color: var(--text-heading);">{{ getClientName(cert) }}</div>
|
||||||
<div class="certificate-file text-muted small">{{ cert.file || 'N/A' }}</div>
|
<div class="certificate-file text-muted small">{{ cert.file || 'N/A' }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ formatDate(cert.not_after || cert.expiration_date) }}</td>
|
<td><span :class="getBadgeClass(cert.type)">{{ cert.type }}</span></td>
|
||||||
|
<td>{{ formatDate(cert.not_after || cert.expires_iso) }}</td>
|
||||||
<td class="fw-semibold text-danger">
|
<td class="fw-semibold text-danger">
|
||||||
{{ formatExpiredDays(cert.days_remaining) }}
|
{{ formatExpiredDays(cert.days_remaining) }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="status-badge status-expired"><i class="fas fa-times-circle me-1"></i>Expired</span>
|
<span class="status-badge status-expired"><i class="fas fa-times-circle me-1"></i>Expired</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<button v-if="cert.type === 'Client'" class="btn btn-sm btn-link text-danger p-0" title="Revoke" @click="revokeClient(cert.common_name || cert.name)">
|
||||||
|
<i class="fas fa-ban"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -117,11 +139,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useApi } from '../composables/useApi';
|
import { useApi } from '../composables/useApi';
|
||||||
import { useFormatters } from '../composables/useFormatters';
|
import Swal from 'sweetalert2';
|
||||||
|
|
||||||
const { fetchCertificates } = useApi();
|
const { apiClient } = useApi();
|
||||||
// use locally defined format logic to match legacy specificities if needed, but simple date string is likely fine
|
|
||||||
const { formatDate: _formatDate } = useFormatters();
|
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const allCertificates = ref([]);
|
const allCertificates = ref([]);
|
||||||
@@ -129,8 +149,10 @@ const searchQuery = ref('');
|
|||||||
const hideExpired = ref(false);
|
const hideExpired = ref(false);
|
||||||
|
|
||||||
const getClientName = (cert) => {
|
const getClientName = (cert) => {
|
||||||
const cn = cert.common_name || cert.subject || 'N/A';
|
// Backend now provides common_name, or we parse subject. Fallback to name.
|
||||||
return cn.replace('CN=', '').trim();
|
if (cert.common_name) return cert.common_name;
|
||||||
|
const cn = cert.subject || 'N/A';
|
||||||
|
return cn.replace('CN=', '').trim() || cert.name || 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
@@ -138,15 +160,13 @@ const formatDate = (dateStr) => {
|
|||||||
try {
|
try {
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
if(isNaN(d)) return dateStr;
|
if(isNaN(d)) return dateStr;
|
||||||
// Legacy: 'May 16, 2033 11:32:06 AM' format roughly
|
|
||||||
// Using standard locale string which is close enough and better
|
|
||||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
|
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
|
||||||
} catch(e) { return dateStr; }
|
} catch(e) { return dateStr; }
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDaysClass = (daysText) => {
|
const getDaysClass = (daysText) => {
|
||||||
if (!daysText || daysText === 'N/A') return 'text-success'; // default valid
|
if (!daysText || daysText === 'N/A') return 'text-success'; // default valid
|
||||||
if (daysText.includes('Expired')) return 'text-danger';
|
if (daysText.toString().includes('Expired')) return 'text-danger';
|
||||||
|
|
||||||
const days = parseInt(daysText);
|
const days = parseInt(daysText);
|
||||||
if (!isNaN(days) && days <= 30) return 'text-warning';
|
if (!isNaN(days) && days <= 30) return 'text-warning';
|
||||||
@@ -154,34 +174,41 @@ const getDaysClass = (daysText) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadgeHTML = (daysText) => {
|
const getStatusBadgeHTML = (daysText) => {
|
||||||
if (!daysText || daysText === 'N/A') return '<span class="status-badge text-muted">Unknown</span>';
|
if (!daysText || daysText === 'N/A') return '<span class="status-badge status-secondary">Unknown</span>';
|
||||||
|
|
||||||
const days = parseInt(daysText);
|
const days = parseInt(daysText);
|
||||||
if (!isNaN(days)) {
|
if (!isNaN(days)) {
|
||||||
if (days <= 30) {
|
if (days <= 30) {
|
||||||
return '<span class="status-badge status-expiring"><i class="fas fa-exclamation-triangle me-1"></i>Expiring Soon</span>';
|
return '<span class="status-badge status-warning"><i class="fas fa-exclamation-triangle me-1"></i>Expiring Soon</span>';
|
||||||
} else {
|
} else {
|
||||||
return '<span class="status-badge status-valid"><i class="fas fa-check-circle me-1"></i>Valid</span>';
|
return '<span class="status-badge status-valid"><i class="fas fa-check-circle me-1"></i>Valid</span>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '<span class="status-badge text-muted">Unknown</span>';
|
return '<span class="status-badge status-secondary">Unknown</span>';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatExpiredDays = (daysText) => {
|
const formatExpiredDays = (daysText) => {
|
||||||
if(!daysText) return 'N/A';
|
if(!daysText) return 'N/A';
|
||||||
if (daysText.includes('Expired')) {
|
// Clean up "Expired (X days ago)" logic if present
|
||||||
|
if (daysText.toString().includes('Expired')) {
|
||||||
return daysText.replace('Expired (', '').replace(' days ago)', '') + ' days ago';
|
return daysText.replace('Expired (', '').replace(' days ago)', '') + ' days ago';
|
||||||
}
|
}
|
||||||
return daysText;
|
return daysText;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getBadgeClass = (type) => {
|
||||||
|
if (type === 'CA') return 'status-badge status-warning';
|
||||||
|
if (type === 'Server') return 'status-badge status-server';
|
||||||
|
return 'status-badge status-client';
|
||||||
|
};
|
||||||
|
|
||||||
// Data Processing
|
// Data Processing
|
||||||
const filteredData = computed(() => {
|
const filteredData = computed(() => {
|
||||||
let data = allCertificates.value;
|
let data = allCertificates.value;
|
||||||
if (searchQuery.value) {
|
if (searchQuery.value) {
|
||||||
const term = searchQuery.value.toLowerCase();
|
const term = searchQuery.value.toLowerCase();
|
||||||
data = data.filter(c => {
|
data = data.filter(c => {
|
||||||
const commonName = (c.common_name || c.subject || '').toLowerCase();
|
const commonName = (c.common_name || c.subject || c.name || '').toLowerCase();
|
||||||
const fileName = (c.file || '').toLowerCase();
|
const fileName = (c.file || '').toLowerCase();
|
||||||
return commonName.includes(term) || fileName.includes(term);
|
return commonName.includes(term) || fileName.includes(term);
|
||||||
});
|
});
|
||||||
@@ -195,8 +222,11 @@ const categorized = computed(() => {
|
|||||||
|
|
||||||
filteredData.value.forEach(cert => {
|
filteredData.value.forEach(cert => {
|
||||||
let isExpired = false;
|
let isExpired = false;
|
||||||
|
// Backend now returns 'state' too, but we stick to days logic as per snippet
|
||||||
if (cert.days_remaining && typeof cert.days_remaining === 'string' && cert.days_remaining.includes('Expired')) {
|
if (cert.days_remaining && typeof cert.days_remaining === 'string' && cert.days_remaining.includes('Expired')) {
|
||||||
isExpired = true;
|
isExpired = true;
|
||||||
|
} else if (cert.state === 'Expired') {
|
||||||
|
isExpired = true;
|
||||||
} else if ((!cert.days_remaining || cert.days_remaining === 'N/A') && cert.not_after) {
|
} else if ((!cert.days_remaining || cert.days_remaining === 'N/A') && cert.not_after) {
|
||||||
const expDate = new Date(cert.not_after);
|
const expDate = new Date(cert.not_after);
|
||||||
if (expDate < new Date()) isExpired = true;
|
if (expDate < new Date()) isExpired = true;
|
||||||
@@ -234,22 +264,95 @@ const expiringCount = computed(() => {
|
|||||||
}).length;
|
}).length;
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadCerts = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetchCertificates();
|
const response = await apiClient.get('/certificates');
|
||||||
if(res.success) {
|
if (response.data.success) {
|
||||||
allCertificates.value = res.data;
|
let data = response.data.certificates || response.data.data;
|
||||||
|
// Handle Object-based response (index keys)
|
||||||
|
if (data && !Array.isArray(data) && typeof data === 'object') {
|
||||||
|
allCertificates.value = Object.values(data);
|
||||||
|
} else {
|
||||||
|
allCertificates.value = data || [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
Swal.fire('Error', 'Failed to load certificates', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showNewClientModal = async () => {
|
||||||
|
const { value: name } = await Swal.fire({
|
||||||
|
title: 'New Client Name',
|
||||||
|
input: 'text',
|
||||||
|
inputLabel: 'Enter client name (CN)',
|
||||||
|
showCancelButton: true,
|
||||||
|
inputValidator: (value) => {
|
||||||
|
if (!value) return 'You need to write something!'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
const res = await apiClient.post('/pki/client', { name });
|
||||||
|
if (res.data.success) {
|
||||||
|
Swal.fire('Success', 'Client created successfully. You can download the config from the list.', 'success');
|
||||||
|
loadCerts();
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
Swal.fire('Error', e.response?.data?.error || e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadConfig = async (name) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/pki/client/${name}/config`);
|
||||||
|
if(res.data.success) {
|
||||||
|
const blob = new Blob([res.data.config], { type: 'text/plain' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = res.data.filename;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
Swal.fire('Error', 'Could not download config: ' + (e.response?.data?.error || e.message), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeClient = async (name) => {
|
||||||
|
const result = await Swal.fire({
|
||||||
|
title: 'Revoke Certificate?',
|
||||||
|
text: `Are you sure you want to revoke access for ${name}?`,
|
||||||
|
icon: 'warning',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonColor: '#d33',
|
||||||
|
confirmButtonText: 'Yes, revoke it!'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
await apiClient.delete(`/pki/client/${name}`);
|
||||||
|
Swal.fire('Revoked!', `${name} has been revoked.`, 'success');
|
||||||
|
loadCerts();
|
||||||
|
} catch(e) {
|
||||||
|
Swal.fire('Error', e.response?.data?.error || e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData();
|
loadCerts();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -286,4 +389,8 @@ onMounted(() => {
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center bg-transparent border-bottom">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span><i class="fas fa-network-wired me-2"></i>Clients List</span>
|
<span><i class="fas fa-network-wired me-2"></i>Clients List</span>
|
||||||
<small class="text-muted">Updated: {{ lastUpdated }}</small>
|
<small class="text-muted">Updated: {{ lastUpdated }}</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,9 +121,9 @@ import { useFormatters } from '../composables/useFormatters';
|
|||||||
import HistoryModal from '../components/HistoryModal.vue';
|
import HistoryModal from '../components/HistoryModal.vue';
|
||||||
import { useAppConfig } from '../composables/useAppConfig';
|
import { useAppConfig } from '../composables/useAppConfig';
|
||||||
|
|
||||||
const { fetchStats } = useApi();
|
const { fetchStats, apiClient } = useApi();
|
||||||
const { formatBytes, formatRate, parseServerDate } = useFormatters();
|
const { formatBytes, formatRate, parseServerDate } = useFormatters();
|
||||||
const { config } = useAppConfig(); // To get refresh interval
|
const { config } = useAppConfig();
|
||||||
|
|
||||||
const clients = ref([]);
|
const clients = ref([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|||||||
603
UI/client/src/views/Settings.vue
Normal file
603
UI/client/src/views/Settings.vue
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
<template>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2 class="mb-4">System Settings</h2>
|
||||||
|
|
||||||
|
<div class="card card-body border-0 shadow-sm p-0 mb-4" style="overflow: hidden;">
|
||||||
|
<ul class="nav nav-tabs nav-fill" id="settingsTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active py-3" id="pki-tab" data-bs-toggle="tab" data-bs-target="#pki" type="button" role="tab" aria-selected="true">
|
||||||
|
<i class="fas fa-lock me-2"></i>PKI & Certificates
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link py-3" id="server-tab" data-bs-toggle="tab" data-bs-target="#server" type="button" role="tab" aria-selected="false">
|
||||||
|
<i class="fas fa-server me-2"></i>Server Configuration
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link py-3" id="service-tab" data-bs-toggle="tab" data-bs-target="#service" type="button" role="tab" aria-selected="false">
|
||||||
|
<i class="fas fa-cogs me-2"></i>Service Control
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="settingsTabsContent">
|
||||||
|
|
||||||
|
<!-- PKI Tab -->
|
||||||
|
<div class="tab-pane fade show active" id="pki" role="tabpanel" aria-labelledby="pki-tab">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">PKI Environment</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label fw-bold">PKI Location</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" v-model="pkiPath" placeholder="/etc/openvpn/pki">
|
||||||
|
<button class="btn btn-outline-secondary" @click="validatePkiPath" :disabled="loading">
|
||||||
|
<i class="fas fa-check me-1"></i> Connect/Validate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Point to an existing PKI directory to use it, or define where to initialize a new one.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 mt-2">
|
||||||
|
<label class="form-label">EasyRSA Location (Optional)</label>
|
||||||
|
<input type="text" class="form-control" v-model="easyrsaPath" placeholder="Auto-detected or /usr/share/easy-rsa/easyrsa">
|
||||||
|
<small class="text-muted">Path to the <code>easyrsa</code> script. Leave empty to auto-detect based on PKI location.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12"><hr></div>
|
||||||
|
|
||||||
|
<!-- PKI Variables -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">CA Common Name (CN)</label>
|
||||||
|
<input type="text" class="form-control" v-model="pkiConfig.EASYRSA_REQ_CN" placeholder="OpenVPN-CA">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Key Size</label>
|
||||||
|
<select class="form-select" v-model="pkiConfig.EASYRSA_KEY_SIZE">
|
||||||
|
<option value="2048">2048 bits</option>
|
||||||
|
<option value="4096">4096 bits</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">CA Expiration (Days)</label>
|
||||||
|
<input type="number" class="form-control" v-model="pkiConfig.EASYRSA_CA_EXPIRE">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Cert Expiration (Days)</label>
|
||||||
|
<input type="number" class="form-control" v-model="pkiConfig.EASYRSA_CERT_EXPIRE">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">CRL Expiration (Days)</label>
|
||||||
|
<input type="number" class="form-control" v-model="pkiConfig.EASYRSA_CRL_DAYS">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Country (2 letters)</label>
|
||||||
|
<input type="text" class="form-control" v-model="pkiConfig.EASYRSA_REQ_COUNTRY" maxlength="2">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Organization</label>
|
||||||
|
<input type="text" class="form-control" v-model="pkiConfig.EASYRSA_REQ_ORG">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Province / State</label>
|
||||||
|
<input type="text" class="form-control" v-model="pkiConfig.EASYRSA_REQ_PROVINCE">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">City</label>
|
||||||
|
<input type="text" class="form-control" v-model="pkiConfig.EASYRSA_REQ_CITY">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" v-model="pkiConfig.EASYRSA_REQ_EMAIL">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Organizational Unit</label>
|
||||||
|
<input type="text" class="form-control" v-model="pkiConfig.EASYRSA_REQ_OU">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 mt-4">
|
||||||
|
<h6>Initialization</h6>
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="forceInit" v-model="forceInitPki">
|
||||||
|
<label class="form-check-label text-danger" for="forceInit">
|
||||||
|
Force Initialize (Wipe existing PKI including CA)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-warning" @click="initPki" :disabled="loading">
|
||||||
|
<i class="fas fa-cogs me-2"></i> Initialize / Update PKI
|
||||||
|
</button>
|
||||||
|
<small class="d-block text-muted mt-2">
|
||||||
|
Updates 'vars' and generates CA/Keys if missing.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Config Tab -->
|
||||||
|
<div class="tab-pane fade" id="server" role="tabpanel" aria-labelledby="server-tab">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">OpenVPN Server Settings</h5>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm me-2" @click="loadServerConfig" title="Reload from file">
|
||||||
|
<i class="fas fa-sync"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="saveServerConfig" :disabled="loading">
|
||||||
|
<i class="fas fa-save me-1"></i> Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Basic Settings -->
|
||||||
|
<h6 class="border-bottom pb-2 mb-3">General Configuration</h6>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Server Config File Path</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" v-model="serverConfig.config_path" placeholder="/etc/openvpn/server.conf">
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Path to reading/writing the server configuration file.</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Public Hostname / IP</label>
|
||||||
|
<input type="text" class="form-control" v-model="serverConfig.public_ip" placeholder="e.g. vpn.example.com or 203.0.113.1">
|
||||||
|
<small class="text-muted">Address clients use to connect (<code>remote</code> directive).</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="border-bottom pb-2 mb-3">Network & Transport</h6>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Protocol</label>
|
||||||
|
<select class="form-select" v-model="serverConfig.proto">
|
||||||
|
<option value="udp">UDP</option>
|
||||||
|
<option value="tcp">TCP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Port</label>
|
||||||
|
<input type="number" class="form-control" v-model="serverConfig.port">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Topology</label>
|
||||||
|
<select class="form-select" v-model="serverConfig.topology">
|
||||||
|
<option value="subnet">Subnet (Recommended)</option>
|
||||||
|
<option value="p2p">Point-to-Point</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Device</label>
|
||||||
|
<input type="text" class="form-control" value="tun" readonly disabled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">VPN Network</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" placeholder="10.8.0.0" v-model="serverConfig.server_network">
|
||||||
|
<span class="input-group-text">/</span>
|
||||||
|
<input type="text" class="form-control" placeholder="255.255.255.0" v-model="serverConfig.server_netmask">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">TUN MTU</label>
|
||||||
|
<input type="number" class="form-control" v-model="serverConfig.tun_mtu" placeholder="1500">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">MSS-FIX</label>
|
||||||
|
<input type="number" class="form-control" v-model="serverConfig.mssfix" placeholder="0 = default">
|
||||||
|
<small class="text-muted" style="font-size: 0.7rem;">Helps with fragmentation</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Routing -->
|
||||||
|
<h6 class="border-bottom pb-2 mb-3">Routing & Tunneling</h6>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label mb-2 fw-bold">Tunnel Mode</label>
|
||||||
|
<div class="d-flex gap-4">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" id="tunnelFull" value="full" v-model="tunnelMode">
|
||||||
|
<label class="form-check-label" for="tunnelFull">
|
||||||
|
Full Tunnel (Redirect Gateway)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" id="tunnelSplit" value="split" v-model="tunnelMode">
|
||||||
|
<label class="form-check-label" for="tunnelSplit">
|
||||||
|
Split Tunnel
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12" v-if="tunnelMode === 'split'">
|
||||||
|
<label class="form-label">Split Tunnel Routes (CIDR)</label>
|
||||||
|
<p class="text-muted small mb-2">Traffic to these networks will be routed through the VPN.</p>
|
||||||
|
<div class="input-group mb-2" v-for="(route, index) in serverConfig.routes" :key="'route-'+index">
|
||||||
|
<input type="text" class="form-control" placeholder="e.g. 192.168.1.0 255.255.255.0" v-model="serverConfig.routes[index]">
|
||||||
|
<button class="btn btn-outline-danger" @click="serverConfig.routes.splice(index, 1)"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-secondary" @click="serverConfig.routes.push('')">Add Route</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="clientToClient" v-model="serverConfig.client_to_client">
|
||||||
|
<label class="form-check-label" for="clientToClient">Allow Client-to-Client Communication</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">DNS Servers</label>
|
||||||
|
<div class="input-group mb-2" v-for="(dns, index) in serverConfig.dns_servers" :key="'dns-'+index">
|
||||||
|
<input type="text" class="form-control" v-model="serverConfig.dns_servers[index]">
|
||||||
|
<button class="btn btn-outline-danger" @click="serverConfig.dns_servers.splice(index, 1)"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-secondary" @click="serverConfig.dns_servers.push('')">Add DNS Server</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced -->
|
||||||
|
<h6 class="border-bottom pb-2 mb-3">Encryption, Security & Logging</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Cipher</label>
|
||||||
|
<select class="form-select" v-model="serverConfig.cipher">
|
||||||
|
<option value="AES-256-GCM">AES-256-GCM (Recommended)</option>
|
||||||
|
<option value="AES-128-GCM">AES-128-GCM</option>
|
||||||
|
<option value="AES-256-CBC">AES-256-CBC</option>
|
||||||
|
<option value="CHACHA20-POLY1305">CHACHA20-POLY1305</option>
|
||||||
|
<option value="CHACHA20-POLY1305:AES-256-GCM:AES-256-CBC">CHACHA20-POLY1305:AES-256-GCM:AES-256-CBC (Fallback)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Auth Algorithm</label>
|
||||||
|
<select class="form-select" v-model="serverConfig.auth_algo">
|
||||||
|
<option value="SHA256">SHA256 (Default)</option>
|
||||||
|
<option value="SHA512">SHA512</option>
|
||||||
|
<option value="SHA1">SHA1 (Legacy - Not Recommended)</option>
|
||||||
|
</select>
|
||||||
|
<small class="text-muted">HMAC algorithm for packet authentication</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Data Ciphers (NCP)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" v-model="serverConfig.data_ciphers" placeholder="AES-256-GCM:AES-128-GCM">
|
||||||
|
<button class="btn btn-outline-info" @click="applyModernSecurity" title="Apply Modern Security Preset">
|
||||||
|
<i class="fas fa-shield-alt me-1"></i> Use Modern Preset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Colon-separated list of allowed ciphers for negotiation.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mt-3">
|
||||||
|
<label class="form-label">Data Ciphers Fallback</label>
|
||||||
|
<input type="text" class="form-control" v-model="serverConfig.data_ciphers_fallback" placeholder="AES-256-CBC">
|
||||||
|
<small class="text-muted">Cipher used for older clients that don't support NCP (OpenVPN < 2.4).</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check form-switch mt-4">
|
||||||
|
<input class="form-check-input" type="checkbox" id="crlVerify" v-model="serverConfig.crl_verify">
|
||||||
|
<label class="form-check-label" for="crlVerify">Use CRL (Certificate Revocation List)</label>
|
||||||
|
<small class="d-block text-muted">Reject connections from revoked clients.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">IPP Path (ifconfig-pool-persist)</label>
|
||||||
|
<input type="text" class="form-control" v-model="serverConfig.ipp_path">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Status Log Path</label>
|
||||||
|
<input type="text" class="form-control" v-model="serverConfig.status_log">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Main Log Path</label>
|
||||||
|
<input type="text" class="form-control" v-model="serverConfig.log_file">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Service Control Tab -->
|
||||||
|
<div class="tab-pane fade" id="service" role="tabpanel" aria-labelledby="service-tab">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Service Management</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="display-6 me-2">Status:</span>
|
||||||
|
<span class="display-6 fw-bold" :class="serviceStatus === 'active' ? 'text-success' : 'text-danger'">
|
||||||
|
{{ serviceStatus.toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group btn-group-lg">
|
||||||
|
<button class="btn btn-success" @click="serviceAction('start')" :disabled="serviceStatus === 'active' || loading">
|
||||||
|
<i class="fas fa-play me-2"></i> Start
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-warning text-white" @click="serviceAction('restart')" :disabled="loading">
|
||||||
|
<i class="fas fa-sync me-2"></i> Restart
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger" @click="serviceAction('stop')" :disabled="serviceStatus === 'inactive' || loading">
|
||||||
|
<i class="fas fa-stop me-2"></i> Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import { useApi } from '../composables/useApi';
|
||||||
|
import Swal from 'sweetalert2';
|
||||||
|
|
||||||
|
const { apiClient } = useApi();
|
||||||
|
const loading = ref(false);
|
||||||
|
const serviceStatus = ref('unknown');
|
||||||
|
const forceInitPki = ref(false);
|
||||||
|
|
||||||
|
// PKI Path and EasyRSA Path
|
||||||
|
const pkiPath = ref('/etc/openvpn/pki');
|
||||||
|
const easyrsaPath = ref(''); // Optional, autodected usually
|
||||||
|
|
||||||
|
// PKI Config Model
|
||||||
|
// ... (rest same)
|
||||||
|
|
||||||
|
const validatePkiPath = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
const payload = {
|
||||||
|
path: pkiPath.value,
|
||||||
|
easyrsa_path: easyrsaPath.value
|
||||||
|
};
|
||||||
|
const res = await apiClient.post('/pki/validate', payload);
|
||||||
|
if (res.data.success) {
|
||||||
|
// If valid, save it as config
|
||||||
|
const saveRes = await apiClient.post('/pki/config', payload);
|
||||||
|
if (saveRes.data.success) {
|
||||||
|
// If the backend detected a path we didn't have, maybe update UI?
|
||||||
|
// The save response has details
|
||||||
|
if (saveRes.data.details && saveRes.data.details.easyrsa_path) {
|
||||||
|
easyrsaPath.value = saveRes.data.details.easyrsa_path;
|
||||||
|
}
|
||||||
|
Swal.fire('Success', 'PKI Path validated and configuration saved.', 'success');
|
||||||
|
} else {
|
||||||
|
Swal.fire('Warning', 'Path valid but failed to save config: ' + saveRes.data.error, 'warning');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Swal.fire('Warning', res.data.message || 'Invalid PKI path', 'warning');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
Swal.fire('Error', e.response?.data?.error || e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPkiConfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/pki/config');
|
||||||
|
if (res.data.success && res.data.data) {
|
||||||
|
const data = res.data.data;
|
||||||
|
if (data.pki_path) pkiPath.value = data.pki_path;
|
||||||
|
if (data.easyrsa_path) easyrsaPath.value = data.easyrsa_path;
|
||||||
|
}
|
||||||
|
} catch(e) { }
|
||||||
|
};
|
||||||
|
const pkiConfig = ref({
|
||||||
|
EASYRSA_REQ_CN: 'OpenVPN-CA',
|
||||||
|
EASYRSA_REQ_COUNTRY: 'RU',
|
||||||
|
EASYRSA_REQ_PROVINCE: 'Moscow',
|
||||||
|
EASYRSA_REQ_CITY: 'Moscow',
|
||||||
|
EASYRSA_REQ_ORG: 'MyOrg',
|
||||||
|
EASYRSA_REQ_EMAIL: 'admin@example.com',
|
||||||
|
EASYRSA_REQ_OU: 'IT',
|
||||||
|
EASYRSA_KEY_SIZE: '2048',
|
||||||
|
EASYRSA_CA_EXPIRE: '3650',
|
||||||
|
EASYRSA_CERT_EXPIRE: '3650',
|
||||||
|
EASYRSA_CRL_DAYS: '180'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server Config Model
|
||||||
|
const tunnelMode = ref('full');
|
||||||
|
const serverConfig = ref({
|
||||||
|
proto: 'udp',
|
||||||
|
config_path: '/etc/openvpn/server.conf',
|
||||||
|
public_ip: '',
|
||||||
|
port: 1194,
|
||||||
|
server_network: '10.8.0.0',
|
||||||
|
server_netmask: '255.255.255.0',
|
||||||
|
topology: 'subnet',
|
||||||
|
dns_servers: ['8.8.8.8', '8.8.4.4'],
|
||||||
|
client_to_client: false,
|
||||||
|
redirect_gateway: true,
|
||||||
|
routes: [],
|
||||||
|
cipher: 'AES-256-GCM',
|
||||||
|
data_ciphers: 'AES-256-GCM:AES-128-GCM',
|
||||||
|
auth_algo: 'SHA256',
|
||||||
|
tun_mtu: 1500,
|
||||||
|
mssfix: 0,
|
||||||
|
crl_verify: true,
|
||||||
|
status_log: '/var/log/openvpn/openvpn-status.log',
|
||||||
|
log_file: '/var/log/openvpn/openvpn.log',
|
||||||
|
ipp_path: 'ipp.txt',
|
||||||
|
data_ciphers_fallback: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch Mode to update config model
|
||||||
|
watch(tunnelMode, (newMode) => {
|
||||||
|
if (newMode === 'full') {
|
||||||
|
serverConfig.value.redirect_gateway = true;
|
||||||
|
} else {
|
||||||
|
serverConfig.value.redirect_gateway = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyModernSecurity = () => {
|
||||||
|
serverConfig.value.cipher = 'AES-256-GCM';
|
||||||
|
serverConfig.value.data_ciphers = 'CHACHA20-POLY1305:AES-256-GCM:AES-256-CBC';
|
||||||
|
serverConfig.value.data_ciphers_fallback = 'AES-256-CBC';
|
||||||
|
serverConfig.value.auth_algo = 'SHA256';
|
||||||
|
Swal.fire({
|
||||||
|
toast: true,
|
||||||
|
position: 'top-end',
|
||||||
|
icon: 'success',
|
||||||
|
title: 'Modern security settings applied',
|
||||||
|
showConfirmButton: false,
|
||||||
|
timer: 3000
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const loadServiceStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/server/status');
|
||||||
|
if (res.data.success) {
|
||||||
|
serviceStatus.value = res.data.status;
|
||||||
|
}
|
||||||
|
} catch(e) { console.error("Service status check failed", e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadServerConfig = async (manualReload = false) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
// Send current path selection ONLY if manually reloading
|
||||||
|
const params = {};
|
||||||
|
if (manualReload && serverConfig.value && serverConfig.value.config_path) {
|
||||||
|
params.path = serverConfig.value.config_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiClient.get('/server/config', { params });
|
||||||
|
if (res.data.success) {
|
||||||
|
// Merge with defaults
|
||||||
|
const data = res.data.data;
|
||||||
|
serverConfig.value = { ...serverConfig.value, ...data };
|
||||||
|
|
||||||
|
// Handle conversions
|
||||||
|
serverConfig.value.crl_verify = !!data.crl_verify;
|
||||||
|
if(data.tun_mtu) serverConfig.value.tun_mtu = parseInt(data.tun_mtu);
|
||||||
|
if(data.mssfix) serverConfig.value.mssfix = parseInt(data.mssfix);
|
||||||
|
|
||||||
|
// Infer mode
|
||||||
|
tunnelMode.value = serverConfig.value.redirect_gateway ? 'full' : 'split';
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error("Failed to load config", e);
|
||||||
|
Swal.fire('Error', 'Failed to load config: ' + (e.response?.data?.error || e.message), 'error');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initPki = async () => {
|
||||||
|
const result = await Swal.fire({
|
||||||
|
title: 'Initialize PKI?',
|
||||||
|
text: forceInitPki.value
|
||||||
|
? "WARNING: THIS WILL WIPE ALL CERTIFICATES AND CA!"
|
||||||
|
: "This will update configuration and ensure directory structure.",
|
||||||
|
icon: forceInitPki.value ? 'warning' : 'info',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: 'Proceed'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
await apiClient.post('/pki/init', {
|
||||||
|
force: forceInitPki.value,
|
||||||
|
vars: pkiConfig.value
|
||||||
|
});
|
||||||
|
Swal.fire('Success', 'PKI Environment Updated', 'success');
|
||||||
|
} catch(e) {
|
||||||
|
Swal.fire('Error', e.response?.data?.error || e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const saveServerConfig = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
// Clean up empty array items
|
||||||
|
serverConfig.value.routes = serverConfig.value.routes.filter(r => r.trim());
|
||||||
|
serverConfig.value.dns_servers = serverConfig.value.dns_servers.filter(d => d.trim());
|
||||||
|
|
||||||
|
await apiClient.post('/server/config', serverConfig.value);
|
||||||
|
Swal.fire('Saved', 'Configuration saved. Restart service to apply changes.', 'success');
|
||||||
|
} catch(e) {
|
||||||
|
Swal.fire('Error', e.response?.data?.error || e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const serviceAction = async (action) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
await apiClient.post(`/server/action`, { action });
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
await loadServiceStatus();
|
||||||
|
Swal.fire('Success', `Service ${action}ed command sent.`, 'success');
|
||||||
|
} catch(e) {
|
||||||
|
Swal.fire('Error', e.response?.data?.error || e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadServiceStatus();
|
||||||
|
loadServerConfig();
|
||||||
|
loadPkiConfig();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nav-tabs .nav-link {
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.nav-tabs .nav-link.active {
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.nav-tabs .nav-link:hover {
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user