feat(app): add WGRplane web UI and backend features

- Add Vue 3 frontend with glassmorphism design (Tailwind CSS)
- Add Go backend handlers: auth, webhooks, stats, scheduler, validation
- Add i18n support (EN, ID, ZH)
- Add Swagger docs and API handlers
- Add nftables integration and plugins support
- Remove deprecated go.mod (migrated to wgrplane)
This commit is contained in:
datadunia
2026-05-03 23:20:34 +07:00
parent 3eae539a56
commit 6d8899a078
54 changed files with 7395 additions and 434 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>WGRplane - WireGuard Control</title>
</head>
<body>
<div id="app"></div>
+675 -22
View File
@@ -11,19 +11,38 @@
"@headlessui/vue": "^1.7.23",
"@vueuse/core": "^14.3.0",
"chart.js": "^4.5.1",
"vue": "^3.5.33",
"vue-chartjs": "^5.3.3",
"vue-i18n": "^9.14.5",
"vue-router": "^4.6.4",
"vue-sonner": "^2.0.9"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.4",
"@vitejs/plugin-vue": "^6.0.6",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.13",
"tailwindcss": "^4.2.4",
"typescript": "~6.0.2",
"vite": "^8.0.10"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -31,7 +50,6 @@
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -39,7 +57,6 @@
"node_modules/@babel/parser": {
"version": "7.29.3",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.29.0"
},
@@ -53,7 +70,6 @@
"node_modules/@babel/types": {
"version": "7.29.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
@@ -111,10 +127,96 @@
"vue": "^3.2.0"
}
},
"node_modules/@intlify/core-base": {
"version": "9.14.5",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz",
"integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "9.14.5",
"@intlify/shared": "9.14.5"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "9.14.5",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz",
"integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "9.14.5",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "9.14.5",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz",
"integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"peer": true
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
@@ -427,6 +529,289 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
"integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.19.0",
"jiti": "^2.6.1",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.2.4"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz",
"integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.2.4",
"@tailwindcss/oxide-darwin-arm64": "4.2.4",
"@tailwindcss/oxide-darwin-x64": "4.2.4",
"@tailwindcss/oxide-freebsd-x64": "4.2.4",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.4",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.4",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.4",
"@tailwindcss/oxide-linux-x64-musl": "4.2.4",
"@tailwindcss/oxide-wasm32-wasi": "4.2.4",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.4",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.4"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz",
"integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz",
"integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz",
"integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz",
"integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz",
"integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz",
"integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz",
"integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz",
"integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz",
"integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz",
"integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.8.1",
"@emnapi/runtime": "^1.8.1",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.1.1",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
"integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz",
"integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz",
"integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.2.4",
"@tailwindcss/oxide": "4.2.4",
"postcss": "^8.5.6",
"tailwindcss": "4.2.4"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
@@ -470,10 +855,33 @@
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.6",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
"integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-rc.13"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@vitejs/plugin-vue/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
"dev": true,
"license": "MIT"
},
"node_modules/@vue/compiler-core": {
"version": "3.5.33",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/shared": "3.5.33",
@@ -485,7 +893,6 @@
"node_modules/@vue/compiler-dom": {
"version": "3.5.33",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-core": "3.5.33",
"@vue/shared": "3.5.33"
@@ -494,7 +901,6 @@
"node_modules/@vue/compiler-sfc": {
"version": "3.5.33",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/compiler-core": "3.5.33",
@@ -510,7 +916,6 @@
"node_modules/@vue/compiler-ssr": {
"version": "3.5.33",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.33",
"@vue/shared": "3.5.33"
@@ -525,7 +930,6 @@
"node_modules/@vue/reactivity": {
"version": "3.5.33",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.33"
}
@@ -533,7 +937,6 @@
"node_modules/@vue/runtime-core": {
"version": "3.5.33",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.33",
"@vue/shared": "3.5.33"
@@ -542,7 +945,6 @@
"node_modules/@vue/runtime-dom": {
"version": "3.5.33",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.33",
"@vue/runtime-core": "3.5.33",
@@ -553,7 +955,6 @@
"node_modules/@vue/server-renderer": {
"version": "3.5.33",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.33",
"@vue/shared": "3.5.33"
@@ -564,8 +965,7 @@
},
"node_modules/@vue/shared": {
"version": "3.5.33",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@vueuse/core": {
"version": "14.3.0",
@@ -605,6 +1005,111 @@
"vue": "^3.5.0"
}
},
"node_modules/autoprefixer": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
"integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.28.2",
"caniuse-lite": "^1.0.30001787",
"fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
"bin": {
"autoprefixer": "bin/autoprefixer"
},
"engines": {
"node": "^10 || ^12 || >=14"
},
"peerDependencies": {
"postcss": "^8.1.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.25",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz",
"integrity": "sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
"electron-to-chromium": "^1.5.328",
"node-releases": "^2.0.36",
"update-browserslist-db": "^1.2.3"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001791",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
@@ -619,8 +1124,7 @@
},
"node_modules/csstype": {
"version": "3.2.3",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
@@ -630,10 +1134,30 @@
"node": ">=8"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.349",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
"integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==",
"dev": true,
"license": "ISC"
},
"node_modules/enhanced-resolve": {
"version": "5.21.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
"integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=0.12"
},
@@ -641,10 +1165,19 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
@@ -662,6 +1195,20 @@
}
}
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -677,6 +1224,23 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"dev": true,
@@ -949,7 +1513,6 @@
"node_modules/magic-string": {
"version": "0.30.21",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
@@ -970,6 +1533,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-releases": {
"version": "2.0.38",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"license": "ISC"
@@ -987,6 +1557,8 @@
},
"node_modules/postcss": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"funding": [
{
"type": "opencollective",
@@ -1011,6 +1583,13 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.0.0-rc.17",
"dev": true,
@@ -1050,6 +1629,27 @@
"node": ">=0.10.0"
}
},
"node_modules/tailwindcss": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
"integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"dev": true,
@@ -1085,6 +1685,37 @@
"node": ">=14.17"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
},
"peerDependencies": {
"browserslist": ">= 4.21.0"
}
},
"node_modules/vite": {
"version": "8.0.10",
"dev": true,
@@ -1163,8 +1794,9 @@
},
"node_modules/vue": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.33",
"@vue/compiler-sfc": "3.5.33",
@@ -1191,6 +1823,27 @@
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-i18n": {
"version": "9.14.5",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz",
"integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==",
"deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "9.14.5",
"@intlify/shared": "9.14.5",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
+7
View File
@@ -9,6 +9,11 @@
"preview": "vite preview"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.4",
"@vitejs/plugin-vue": "^6.0.6",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.13",
"tailwindcss": "^4.2.4",
"typescript": "~6.0.2",
"vite": "^8.0.10"
},
@@ -16,7 +21,9 @@
"@headlessui/vue": "^1.7.23",
"@vueuse/core": "^14.3.0",
"chart.js": "^4.5.1",
"vue": "^3.5.33",
"vue-chartjs": "^5.3.3",
"vue-i18n": "^9.14.5",
"vue-router": "^4.6.4",
"vue-sonner": "^2.0.9"
}
+1 -1
View File
@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
+161
View File
@@ -0,0 +1,161 @@
<template>
<div class="app-container">
<!-- Mobile Top Navbar -->
<header class="top-navbar lg:hidden">
<div class="flex items-center justify-between p-4">
<button @click="toggleSidebar" class="text-white/80 hover:text-cyan-400 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<h1 class="text-xl font-bold text-cyan-400">WGRplane</h1>
<div class="w-6"></div>
</div>
</header>
<!-- Sidebar -->
<aside
class="sidebar"
:class="{ '-translate-x-full': !sidebarOpen, 'translate-x-0': sidebarOpen, 'lg:translate-x-0': true }"
>
<div class="sidebar-header">
<h1 class="text-2xl font-bold text-cyan-400">WGRplane</h1>
<p class="text-sm text-white/50">WireGuard Control</p>
</div>
<nav class="sidebar-nav">
<router-link
to="/"
class="nav-item"
:class="{ 'active': $route.path === '/' }"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
<span>Dashboard</span>
</router-link>
<router-link
to="/settings"
class="nav-item"
:class="{ 'active': $route.path === '/settings' }"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Settings</span>
</router-link>
</nav>
</aside>
<!-- Overlay for mobile sidebar -->
<div
v-if="sidebarOpen"
class="sidebar-overlay lg:hidden"
@click="toggleSidebar"
></div>
<!-- Main Content -->
<main class="main-content" :class="{ 'lg:ml-64': true }">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const sidebarOpen = ref(false)
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
</script>
<style scoped>
.app-container {
min-height: 100vh;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
}
.top-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 40;
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 16rem;
z-index: 50;
background: rgba(15, 23, 42, 0.9);
backdrop-filter: blur(16px);
border-right: 1px solid rgba(255, 255, 255, 0.1);
transition: transform 0.3s ease-in-out;
display: flex;
flex-direction: column;
padding: 1.5rem;
}
.sidebar-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
color: rgba(255, 255, 255, 0.7);
text-decoration: none;
transition: all 0.2s ease;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.1);
color: #22d3ee;
}
.nav-item.active {
background: rgba(6, 182, 212, 0.2);
color: #22d3ee;
border: 1px solid rgba(34, 211, 238, 0.3);
}
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 45;
}
.main-content {
padding-top: 4rem;
padding: 1.5rem;
min-height: 100vh;
}
@media (min-width: 1024px) {
.main-content {
padding-top: 1.5rem;
margin-left: 16rem;
}
}
</style>
@@ -0,0 +1,61 @@
<template>
<div class="space-y-2">
<label class="block text-sm font-medium text-white/70 mb-1">Subscribed Actions</label>
<div class="grid grid-cols-2 gap-2">
<label
v-for="action in availableActions"
:key="action.value"
class="flex items-center gap-2 p-2 bg-white/5 rounded-lg border border-white/10 hover:border-cyan-400/50 transition-colors cursor-pointer"
>
<input
type="checkbox"
:value="action.value"
:checked="modelValue.includes(action.value)"
@change="toggleAction(action.value)"
class="w-4 h-4 rounded border-white/20 bg-white/5 text-cyan-500 focus:ring-cyan-500 focus:ring-offset-0"
/>
<span class="text-sm text-white">{{ action.label }}</span>
</label>
</div>
</div>
</template>
<script setup lang="ts">
interface Action {
label: string
value: string
}
const props = defineProps<{
modelValue: string[]
availableActions?: Action[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const defaultActions: Action[] = [
{ label: 'Peer Connected', value: 'peer.connected' },
{ label: 'Peer Disconnected', value: 'peer.disconnected' },
{ label: 'Peer Added', value: 'peer.added' },
{ label: 'Peer Removed', value: 'peer.removed' },
{ label: 'Policy Updated', value: 'policy.updated' },
{ label: 'Server Started', value: 'server.started' },
{ label: 'Server Stopped', value: 'server.stopped' },
{ label: 'Login Failed', value: 'auth.failed' }
]
const availableActions = props.availableActions || defaultActions
const toggleAction = (value: string) => {
const current = [...props.modelValue]
const index = current.indexOf(value)
if (index === -1) {
current.push(value)
} else {
current.splice(index, 1)
}
emit('update:modelValue', current)
}
</script>
@@ -0,0 +1,93 @@
<template>
<div class="space-y-2">
<div class="flex gap-2">
<input
v-model="inputValue"
type="text"
:placeholder="placeholder || 'Add CIDR (e.g., 192.168.1.0/24)'"
class="flex-1 bg-white/5 backdrop-blur-sm border border-white/10 rounded-lg px-4 py-2 text-white placeholder-white/30 focus:outline-none focus:border-cyan-400/50 transition-colors"
@keydown.enter="addCIDR"
/>
<button
@click="addCIDR"
class="px-4 py-2 bg-white/10 backdrop-blur-md rounded-lg border border-white/20 shadow-xl hover:bg-white/20 hover:border-cyan-400/50 transition-all duration-300 text-cyan-400"
>
Add
</button>
</div>
<p v-if="error" class="text-red-400 text-sm">{{ error }}</p>
<div class="flex flex-wrap gap-2">
<div
v-for="(cidr, index) in modelValue"
:key="index"
class="flex items-center gap-1 px-3 py-1 bg-white/10 backdrop-blur-md border border-white/20 rounded-full text-white/90 text-sm hover:border-cyan-400/30 transition-colors"
>
<span>{{ cidr }}</span>
<button
@click="removeCIDR(index)"
class="text-white/50 hover:text-red-400 transition-colors ml-1"
>
&times;
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
modelValue: string[]
placeholder?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const inputValue = ref('')
const error = ref('')
const validateCIDR = (cidr: string): boolean => {
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/(\d{1,2})$/
if (!cidrRegex.test(cidr)) {
error.value = 'Invalid CIDR format. Use e.g., 192.168.1.0/24'
return false
}
const [ip, prefixStr] = cidr.split('/')
const octets = ip.split('.').map(Number)
const prefix = Number(prefixStr)
if (octets.some(octet => octet < 0 || octet > 255)) {
error.value = 'Invalid IP octet (must be 0-255)'
return false
}
if (prefix < 0 || prefix > 32) {
error.value = 'Invalid prefix (must be 0-32)'
return false
}
error.value = ''
return true
}
const addCIDR = () => {
const trimmed = inputValue.value.trim()
if (!trimmed) return
if (props.modelValue.includes(trimmed)) {
error.value = 'CIDR already exists'
return
}
if (validateCIDR(trimmed)) {
emit('update:modelValue', [...props.modelValue, trimmed])
inputValue.value = ''
error.value = ''
}
}
const removeCIDR = (index: number) => {
const newList = [...props.modelValue]
newList.splice(index, 1)
emit('update:modelValue', newList)
}
</script>
@@ -0,0 +1,43 @@
<template>
<div class="space-y-2">
<label class="block text-sm font-medium text-white/70 mb-1">Custom Body (JSON)</label>
<textarea
:value="modelValue"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
rows="6"
placeholder='{ "text": "Webhook triggered", "event": "{{event}}" }'
class="w-full bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-white/50 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-cyan-500 resize-y"
></textarea>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<p class="text-xs text-white/50">
Use {{event}}, {{timestamp}}, {{peer}} as placeholders for dynamic values.
</p>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const error = ref('')
watch(() => props.modelValue, (val) => {
if (!val.trim()) {
error.value = ''
return
}
try {
JSON.parse(val)
error.value = ''
} catch (e) {
error.value = 'Invalid JSON format'
}
}, { immediate: true })
</script>
@@ -0,0 +1,76 @@
<template>
<div class="space-y-2">
<label class="block text-sm font-medium text-white/70 mb-1">Headers</label>
<div
v-for="(header, index) in headers"
:key="index"
class="flex items-center gap-2"
>
<input
type="text"
placeholder="Key"
:value="header.key"
@input="updateHeader(index, 'key', ($event.target as HTMLInputElement).value)"
class="flex-1 bg-white/5 border border-white/20 rounded-lg px-3 py-1.5 text-white placeholder-white/50 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
<input
type="text"
placeholder="Value"
:value="header.value"
@input="updateHeader(index, 'value', ($event.target as HTMLInputElement).value)"
class="flex-1 bg-white/5 border border-white/20 rounded-lg px-3 py-1.5 text-white placeholder-white/50 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
<button
@click="removeHeader(index)"
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors"
title="Remove header"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button
@click="addHeader"
class="text-sm text-cyan-400 hover:text-cyan-300 flex items-center gap-1"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Header
</button>
</div>
</template>
<script setup lang="ts">
interface Header {
key: string
value: string
}
const props = defineProps<{
modelValue: Header[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: Header[]): void
}>()
const headers = props.modelValue.length ? props.modelValue : [{ key: '', value: '' }]
const addHeader = () => {
emit('update:modelValue', [...props.modelValue, { key: '', value: '' }])
}
const removeHeader = (index: number) => {
const newHeaders = [...props.modelValue]
newHeaders.splice(index, 1)
emit('update:modelValue', newHeaders.length ? newHeaders : [{ key: '', value: '' }])
}
const updateHeader = (index: number, field: keyof Header, value: string) => {
const newHeaders = [...props.modelValue]
newHeaders[index] = { ...newHeaders[index], [field]: value }
emit('update:modelValue', newHeaders)
}
</script>
@@ -0,0 +1,29 @@
<template>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
class="sr-only peer"
:checked="modelValue"
@change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
/>
<div
class="w-12 h-6 rounded-full transition-colors duration-300 backdrop-blur-sm border"
:class="modelValue ? 'bg-cyan-500/30 border-cyan-400/50' : 'bg-gray-700/50 border-white/10'"
></div>
<div
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-md transition-transform duration-300"
:class="modelValue ? 'translate-x-6 bg-cyan-400' : 'translate-x-0 bg-gray-300'"
></div>
<span class="ml-3 text-white/90 text-sm">{{ modelValue ? 'Allowed' : 'Blocked' }}</span>
</label>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
</script>
+402
View File
@@ -0,0 +1,402 @@
<template>
<GlassCard class="p-6 overflow-x-auto">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-cyan-400">Peer Management</h2>
<GlassButton @click="isAddPeerModalOpen = true" class="from-cyan-500 to-blue-500">
+ Add Peer
</GlassButton>
</div>
<table class="w-full text-left text-white/90">
<thead>
<tr class="border-b border-white/10">
<th class="p-3 text-white/70">Peer Name</th>
<th class="p-3 text-white/70">Public Key</th>
<th class="p-3 text-white/70">IP Address</th>
<th class="p-3 text-white/70">Allow Access (CIDRs)</th>
<th class="p-3 text-white/70">Allow Internet</th>
<th class="p-3 text-white/70">Expiry Date</th>
<th class="p-3 text-white/70">Data Usage</th>
<th class="p-3 text-white/70">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="peer in peers" :key="peer.id" class="border-b border-white/5 hover:bg-white/5 transition-colors">
<td class="p-3">{{ peer.name }}</td>
<td class="p-3 font-mono text-sm text-white/70 truncate max-w-[200px]" :title="peer.publicKey">{{ peer.publicKey }}</td>
<td class="p-3 font-mono text-sm text-white/70">{{ peer.ip }}</td>
<td class="p-3">
<CIDRTagInput v-model="peer.allowedAccess" placeholder="Add CIDR" />
</td>
<td class="p-3">
<InternetToggle v-model="peer.allowInternet" />
</td>
<td class="p-3 text-white/70">
{{ peer.expiresAt ? new Date(peer.expiresAt).toLocaleDateString() : 'Never' }}
</td>
<td class="p-3 text-white/70">
{{ peer.currentDataUsageBytes ? formatBytes(peer.currentDataUsageBytes) : '0 B' }}
</td>
<td class="p-3 flex gap-2">
<button @click="openEditPeer(peer)" class="px-3 py-1 bg-white/10 backdrop-blur-md rounded-lg border border-white/20 hover:bg-white/20 hover:border-yellow-400/50 transition-all text-yellow-400 text-sm">
Edit
</button>
<button @click="showQRCode(peer)" class="px-3 py-1 bg-white/10 backdrop-blur-md rounded-lg border border-white/20 hover:bg-white/20 hover:border-cyan-400/50 transition-all text-cyan-400 text-sm">
QR Code
</button>
<button @click="downloadConfig(peer.id)" class="px-3 py-1 bg-white/10 backdrop-blur-md rounded-lg border border-white/20 hover:bg-white/20 hover:border-green-400/50 transition-all text-green-400 text-sm">
Config
</button>
<button @click="deletePeer(peer.id)" class="px-3 py-1 bg-white/10 backdrop-blur-md rounded-lg border border-white/20 hover:bg-white/20 hover:border-red-400/50 transition-all text-red-400 text-sm">
Delete
</button>
</td>
</tr>
</tbody>
</table>
<!-- Add Peer Modal -->
<TransitionRoot appear :show="isAddPeerModalOpen" as="template">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-black/70 backdrop-blur-sm" @click="isAddPeerModalOpen = false" />
</TransitionChild>
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg rounded-2xl bg-gradient-to-br from-gray-900/90 to-gray-800/90 backdrop-blur-xl p-6 border border-white/20 shadow-xl">
<DialogTitle class="text-lg font-medium text-cyan-400 mb-4">Add New Peer</DialogTitle>
<div class="space-y-4">
<div>
<label class="block text-white/70 mb-1">Public Key</label>
<GlassInput v-model="newPeer.publicKey" placeholder="Paste Public Key (or auto-generate)" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">IP Address</label>
<GlassInput v-model="newPeer.ip" placeholder="e.g., 10.0.0.5/32" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">Allow Access (CIDRs)</label>
<CIDRTagInput v-model="newPeer.allowedAccess" placeholder="Add CIDR (e.g., 192.168.1.0/24)" />
</div>
<div class="flex items-center justify-between">
<span class="text-white/70">Allow Internet</span>
<InternetToggle v-model="newPeer.allowInternet" />
</div>
<div>
<label class="block text-white/70 mb-1">Expiry Date</label>
<input
type="date"
v-model="newPeer.expiresAt"
class="w-full px-4 py-2 bg-white/5 backdrop-blur-md rounded-lg border border-white/20 text-white/90 focus:outline-none focus:border-cyan-400/50 transition-all"
/>
</div>
<div>
<label class="block text-white/70 mb-1">Data Limit (GB)</label>
<GlassInput
v-model.number="newPeer.dataLimitGB"
type="number"
min="0"
step="0.1"
placeholder="Leave empty for no limit"
class="w-full"
/>
</div>
</div>
<div class="flex gap-4 mt-6 justify-end">
<GlassButton @click="isAddPeerModalOpen = false" class="from-gray-500 to-gray-600">Cancel</GlassButton>
<GlassButton @click="addPeer" class="from-cyan-500 to-blue-500">Add Peer</GlassButton>
</div>
</DialogPanel>
</TransitionChild>
</TransitionRoot>
<!-- Edit Peer Modal -->
<TransitionRoot appear :show="isEditPeerModalOpen" as="template">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-black/70 backdrop-blur-sm" @click="isEditPeerModalOpen = false" />
</TransitionChild>
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg rounded-2xl bg-gradient-to-br from-gray-900/90 to-gray-800/90 backdrop-blur-xl p-6 border border-white/20 shadow-xl">
<DialogTitle class="text-lg font-medium text-cyan-400 mb-4">Edit Peer</DialogTitle>
<div class="space-y-4" v-if="selectedPeerForEdit">
<div>
<label class="block text-white/70 mb-1">Public Key</label>
<GlassInput v-model="selectedPeerForEdit.publicKey" placeholder="Public Key" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">IP Address</label>
<GlassInput v-model="selectedPeerForEdit.ip" placeholder="e.g., 10.0.0.5/32" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">Allow Access (CIDRs)</label>
<CIDRTagInput v-model="selectedPeerForEdit.allowedAccess" placeholder="Add CIDR (e.g., 192.168.1.0/24)" />
</div>
<div class="flex items-center justify-between">
<span class="text-white/70">Allow Internet</span>
<InternetToggle v-model="selectedPeerForEdit.allowInternet" />
</div>
<div>
<label class="block text-white/70 mb-1">Expiry Date</label>
<input
type="date"
v-model="selectedPeerForEdit.expiresAt"
class="w-full px-4 py-2 bg-white/5 backdrop-blur-md rounded-lg border border-white/20 text-white/90 focus:outline-none focus:border-cyan-400/50 transition-all"
/>
</div>
<div>
<label class="block text-white/70 mb-1">Data Limit (GB)</label>
<GlassInput
v-model.number="selectedPeerForEdit.dataLimitGB"
type="number"
min="0"
step="0.1"
placeholder="Leave empty for no limit"
class="w-full"
/>
</div>
</div>
<div class="flex gap-4 mt-6 justify-end">
<GlassButton @click="isEditPeerModalOpen = false" class="from-gray-500 to-gray-600">Cancel</GlassButton>
<GlassButton @click="updatePeer" class="from-cyan-500 to-blue-500">Save Changes</GlassButton>
</div>
</DialogPanel>
</TransitionChild>
</TransitionRoot>
<!-- QR Code Modal -->
<TransitionRoot appear :show="isQRCodeModalOpen" as="template">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-black/70 backdrop-blur-sm" @click="isQRCodeModalOpen = false" />
</TransitionChild>
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md rounded-2xl bg-gradient-to-br from-gray-900/90 to-gray-800/90 backdrop-blur-xl p-6 border border-white/20 shadow-xl">
<DialogTitle class="text-lg font-medium text-cyan-400 mb-4">Peer QR Code</DialogTitle>
<div class="flex flex-col items-center gap-4">
<img
v-if="selectedPeerForQR"
:src="`/api/peers/${selectedPeerForQR.id}/qrcode`"
alt="Peer QR Code"
class="w-64 h-64 bg-white p-4 rounded-lg"
/>
<p v-if="selectedPeerForQR" class="text-white/70 text-sm text-center">{{ selectedPeerForQR.name }}</p>
</div>
<div class="flex justify-end mt-6">
<GlassButton @click="isQRCodeModalOpen = false" class="from-gray-500 to-gray-600">Close</GlassButton>
</div>
</DialogPanel>
</TransitionChild>
</TransitionRoot>
</GlassCard>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { TransitionRoot, TransitionChild, Dialog, DialogPanel, DialogTitle } from '@headlessui/vue'
import GlassCard from './glass/GlassCard.vue'
import GlassInput from './glass/GlassInput.vue'
import GlassButton from './glass/GlassButton.vue'
import CIDRTagInput from './CIDRTagInput.vue'
import InternetToggle from './InternetToggle.vue'
interface Peer {
id: string
name: string
publicKey: string
ip: string
allowedAccess: string[]
allowInternet: boolean
expiresAt?: string | null
dataLimitGB?: number | null
currentDataUsageBytes?: number | null
}
const props = defineProps<{
serverId: string
}>()
const peers = ref<Peer[]>([
{
id: '1',
name: 'Client 1',
publicKey: 'xTIBA5rboUvnH3h2+JQ0qf0pI0n6KJ7g5Uc5gG0q0=',
ip: '10.0.0.2/32',
allowedAccess: ['192.168.1.0/24', '10.0.0.1/32'],
allowInternet: true
},
{
id: '2',
name: 'Client 2',
publicKey: 'yUIBA6scpVwoH4h3+KR1qf1qJ1o7KJ8h6Vd6hH1r1=',
ip: '10.0.0.3/32',
allowedAccess: [],
allowInternet: false
},
{
id: '3',
name: 'Client 3',
publicKey: 'zVJCB7tdqWxpI5h4+LS2rg2rK2p8LK9i7We7iI2s2=',
ip: '10.0.0.4/32',
allowedAccess: ['172.16.0.0/16'],
allowInternet: true
}
])
const isAddPeerModalOpen = ref(false)
const isQRCodeModalOpen = ref(false)
const isEditPeerModalOpen = ref(false)
const selectedPeerForQR = ref<Peer | null>(null)
const selectedPeerForEdit = ref<Peer | null>(null)
const newPeer = ref({
publicKey: '',
ip: '',
allowedAccess: [] as string[],
allowInternet: false,
expiresAt: null as string | null,
dataLimitGB: null as number | null
})
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const openEditPeer = (peer: Peer) => {
selectedPeerForEdit.value = { ...peer }
isEditPeerModalOpen.value = true
}
const updatePeer = async () => {
if (!selectedPeerForEdit.value) return
try {
const peer = selectedPeerForEdit.value
const response = await fetch(`/api/peers/${peer.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
publicKey: peer.publicKey,
ip: peer.ip,
allowedAccess: peer.allowedAccess,
allowInternet: peer.allowInternet,
expiresAt: peer.expiresAt,
dataLimitGB: peer.dataLimitGB
})
})
if (!response.ok) throw new Error('Failed to update peer')
const updatedPeer = await response.json()
const index = peers.value.findIndex(p => p.id === updatedPeer.id)
if (index !== -1) {
peers.value[index] = updatedPeer
}
isEditPeerModalOpen.value = false
selectedPeerForEdit.value = null
} catch (error) {
console.error('Update peer error:', error)
alert('Failed to update peer. Check console for details.')
}
}
const addPeer = async () => {
try {
const response = await fetch(`/api/servers/${props.serverId}/peers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPeer.value)
})
if (!response.ok) throw new Error('Failed to add peer')
const createdPeer = await response.json()
peers.value.push({ ...createdPeer, name: `Client ${peers.value.length + 1}` })
isAddPeerModalOpen.value = false
newPeer.value = { publicKey: '', ip: '', allowedAccess: [], allowInternet: false, expiresAt: null, dataLimitGB: null }
} catch (error) {
console.error('Add peer error:', error)
alert('Failed to add peer. Check console for details.')
}
}
const deletePeer = async (id: string) => {
if (!confirm('Are you sure you want to delete this peer?')) return
try {
const response = await fetch(`/api/peers/${id}`, { method: 'DELETE' })
if (!response.ok) throw new Error('Failed to delete peer')
peers.value = peers.value.filter(peer => peer.id !== id)
} catch (error) {
console.error('Delete peer error:', error)
alert('Failed to delete peer. Check console for details.')
}
}
const showQRCode = (peer: Peer) => {
selectedPeerForQR.value = peer
isQRCodeModalOpen.value = true
}
const downloadConfig = async (id: string) => {
try {
const response = await fetch(`/api/peers/${id}/config`)
if (!response.ok) throw new Error('Failed to download config')
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `peer-${id}.conf`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error) {
console.error('Download config error:', error)
alert('Failed to download config. Check console for details.')
}
}
</script>
@@ -0,0 +1,26 @@
<template>
<div class="space-y-1">
<label class="block text-sm font-medium text-white/70 mb-1">Template</label>
<select
:value="modelValue"
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
class="w-full bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-cyan-500 appearance-none bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22%23ffffff%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20d%3D%22M5.23%207.21a.75.75%200%20011.06.02L10%2011.168l3.71-3.938a.75.75%200%20111.08%201.04l-4.25%204.5a.75.75%200%2001-1.08%200l-4.25-4.5a.75.75%200%20011.06-1.06z%22%20clip-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E')] bg-[length:20px] bg-[right_8px_center] bg-no-repeat"
>
<option value="" class="bg-gray-900">No Template (Custom Body)</option>
<option value="slack" class="bg-gray-900">Slack Message</option>
<option value="discord" class="bg-gray-900">Discord Embed</option>
<option value="telegram" class="bg-gray-900">Telegram Message</option>
<option value="generic" class="bg-gray-900">Generic JSON</option>
</select>
</div>
</template>
<script setup lang="ts">
defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
</script>
+127
View File
@@ -0,0 +1,127 @@
<template>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/70 mb-1">Name</label>
<input
v-model="form.name"
type="text"
required
placeholder="My Webhook"
class="w-full bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/70 mb-1">URL</label>
<input
v-model="form.url"
type="url"
required
placeholder="https://example.com/webhook"
class="w-full bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
<TemplateDropdown v-model="form.template" />
<CustomBodyEditor
v-if="!form.template"
v-model="form.customBody"
/>
<HeaderKeyValue v-model="form.headers" />
<ActionCheckboxes v-model="form.subscribedActions" />
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-white/70 text-sm">
<input
v-model="form.verifySSL"
type="checkbox"
class="w-4 h-4 rounded border-white/20 bg-white/5 text-cyan-500 focus:ring-cyan-500 focus:ring-offset-0"
/>
Verify SSL
</label>
<label class="flex items-center gap-2 text-white/70 text-sm">
<input
v-model="form.enabled"
type="checkbox"
class="w-4 h-4 rounded border-white/20 bg-white/5 text-cyan-500 focus:ring-cyan-500 focus:ring-offset-0"
/>
Enabled
</label>
</div>
<div class="flex gap-3 pt-2">
<button
type="submit"
class="px-6 py-2 bg-gradient-to-r from-cyan-500 to-blue-500 text-white rounded-lg hover:shadow-cyan-500/50 hover:scale-105 transition-all duration-300"
>
{{ submitLabel }}
</button>
<button
v-if="showCancel"
type="button"
@click="$emit('cancel')"
class="px-6 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors"
>
Cancel
</button>
</div>
</form>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
import type { Webhook, WebhookHeader } from '../types/webhook'
import TemplateDropdown from './TemplateDropdown.vue'
import CustomBodyEditor from './CustomBodyEditor.vue'
import HeaderKeyValue from './HeaderKeyValue.vue'
import ActionCheckboxes from './ActionCheckboxes.vue'
const props = defineProps<{
webhook?: Webhook
submitLabel?: string
showCancel?: boolean
}>()
const emit = defineEmits<{
(e: 'submit', webhook: Omit<Webhook, 'id' | 'createdAt' | 'updatedAt'>): void
(e: 'cancel'): void
}>()
const getDefaultForm = (): Omit<Webhook, 'id' | 'createdAt' | 'updatedAt'> => ({
name: '',
url: '',
template: '',
customBody: '',
headers: [{ key: '', value: '' }],
subscribedActions: [],
verifySSL: true,
enabled: true
})
const form = reactive<Omit<Webhook, 'id' | 'createdAt' | 'updatedAt'>>(getDefaultForm())
watch(() => props.webhook, (newWebhook) => {
if (newWebhook) {
Object.assign(form, {
name: newWebhook.name,
url: newWebhook.url,
template: newWebhook.template,
customBody: newWebhook.customBody,
headers: [...newWebhook.headers],
subscribedActions: [...newWebhook.subscribedActions],
verifySSL: newWebhook.verifySSL,
enabled: newWebhook.enabled
})
} else {
Object.assign(form, getDefaultForm())
}
}, { immediate: true })
const handleSubmit = () => {
emit('submit', { ...form })
}
</script>
@@ -0,0 +1,92 @@
<template>
<div class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold text-white">Configured Webhooks</h3>
<button
@click="$emit('add')"
class="px-4 py-2 bg-gradient-to-r from-cyan-500 to-blue-500 text-white rounded-lg hover:shadow-cyan-500/50 hover:scale-105 transition-all duration-300 flex items-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Webhook
</button>
</div>
<div v-if="webhooks.length === 0" class="text-center py-8 text-white/50">
No webhooks configured yet. Click "Add Webhook" to create one.
</div>
<div v-else class="space-y-3">
<div
v-for="webhook in webhooks"
:key="webhook.id"
class="bg-white/5 backdrop-blur-md border border-white/10 rounded-xl p-4 hover:border-cyan-400/50 transition-all duration-300"
>
<div class="flex justify-between items-start">
<div class="space-y-1">
<div class="flex items-center gap-2">
<h4 class="font-medium text-white">{{ webhook.name }}</h4>
<span
v-if="webhook.enabled"
class="px-2 py-0.5 text-xs bg-green-500/20 text-green-400 rounded-full"
>
Enabled
</span>
<span
v-else
class="px-2 py-0.5 text-xs bg-red-500/20 text-red-400 rounded-full"
>
Disabled
</span>
<span
v-if="webhook.template"
class="px-2 py-0.5 text-xs bg-blue-500/20 text-blue-400 rounded-full"
>
{{ webhook.template }}
</span>
</div>
<p class="text-sm text-white/70">{{ webhook.url }}</p>
<p class="text-xs text-white/50">
Subscribed to: {{ webhook.subscribedActions.join(', ') || 'None' }}
</p>
</div>
<div class="flex gap-2">
<button
@click="$emit('edit', webhook)"
class="p-2 text-cyan-400 hover:text-cyan-300 hover:bg-cyan-500/10 rounded-lg transition-colors"
title="Edit webhook"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
@click="$emit('delete', webhook.id)"
class="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors"
title="Delete webhook"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Webhook } from '../types/webhook'
defineProps<{
webhooks: Webhook[]
}>()
defineEmits<{
(e: 'add'): void
(e: 'edit', webhook: Webhook): void
(e: 'delete', id: string): void
}>()
</script>
@@ -1,8 +1,8 @@
<template>
<button
class="px-6 py-2 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-lg hover:opacity-90 transition-opacity"
v-bind="$attrs"
>
<button
class="px-6 py-2 bg-gradient-to-r dark:from-blue-500 dark:to-purple-600 from-blue-600 to-purple-700 text-white rounded-lg hover:opacity-90 transition-opacity"
v-bind="$attrs"
>
<slot></slot>
</button>
</template>
@@ -1,5 +1,5 @@
<template>
<div class="bg-white/10 backdrop-blur-md border border-white/20 rounded-xl shadow-glass p-6">
<div class="dark:bg-white/10 dark:border-white/20 bg-gray-100/80 border-gray-200/50 backdrop-blur-md rounded-xl shadow-glass p-6">
<slot></slot>
</div>
</template>
@@ -1,6 +1,6 @@
<template>
<input
class="w-full bg-white/5 border border-white/20 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500"
v-bind="$attrs"
/>
<input
class="w-full dark:bg-white/5 dark:border-white/20 bg-gray-100 border-gray-300 rounded-lg px-4 py-2 dark:text-white text-gray-900 dark:placeholder-white/50 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
v-bind="$attrs"
/>
</template>
@@ -1,9 +1,9 @@
<template>
<button
class="relative w-12 h-6 rounded-full transition-colors"
:class="modelValue ? 'bg-blue-500' : 'bg-gray-600'"
@click="$emit('update:modelValue', !modelValue)"
>
<button
class="relative w-12 h-6 rounded-full transition-colors"
:class="modelValue ? 'bg-blue-500' : 'dark:bg-gray-600 bg-gray-300'"
@click="$emit('update:modelValue', !modelValue)"
>
<span
class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform"
:class="modelValue ? 'translate-x-6' : ''"
+36
View File
@@ -0,0 +1,36 @@
import { watch } from 'vue'
import { useDark, useStorage } from '@vueuse/core'
export type Theme = 'dark' | 'light' | 'auto'
const theme = useStorage<Theme>('wgrplane-theme', 'auto')
const isDark = useDark({
selector: 'html',
attribute: 'class',
valueDark: 'dark',
valueLight: 'light'
})
watch(theme, (newTheme) => {
if (newTheme === 'auto') {
localStorage.removeItem('vueuse-color-scheme')
} else {
localStorage.setItem('vueuse-color-scheme', newTheme)
}
if (newTheme === 'dark') {
isDark.value = true
} else if (newTheme === 'light') {
isDark.value = false
}
})
export function useTheme() {
return {
theme,
isDark,
toggleTheme: (newTheme: Theme) => {
theme.value = newTheme
}
}
}
@@ -0,0 +1,144 @@
import { ref, onUnmounted, type Ref } from 'vue'
export interface PeerStats {
publicKey: string
ip: string
rxBytes: number
txBytes: number
lastHandshake: string
isOnline: boolean
}
export interface TrafficDataPoint {
timestamp: number
rxBytes: number
txBytes: number
}
export interface WebSocketStats {
totalPeers: number
activePeers: number
totalRules: number
trafficHistory: TrafficDataPoint[]
peers: PeerStats[]
}
export interface UseWebSocketReturn {
isConnected: Ref<boolean>
stats: Ref<WebSocketStats>
error: Ref<string | null>
connect: () => void
disconnect: () => void
}
const DEFAULT_STATS: WebSocketStats = {
totalPeers: 0,
activePeers: 0,
totalRules: 0,
trafficHistory: [],
peers: []
}
export function useWebSocket(url: string = 'ws://localhost:8080/ws/stats'): UseWebSocketReturn {
const isConnected = ref(false)
const stats = ref<WebSocketStats>({ ...DEFAULT_STATS })
const error = ref<string | null>(null)
let ws: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
const reconnectDelay = 3000
let trafficBuffer: TrafficDataPoint[] = []
const connect = () => {
if (ws && ws.readyState === WebSocket.OPEN) return
error.value = null
try {
ws = new WebSocket(url)
ws.onopen = () => {
isConnected.value = true
error.value = null
}
ws.onmessage = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data)
if (data.peers !== undefined) {
stats.value.peers = data.peers
stats.value.totalPeers = data.peers.length
stats.value.activePeers = data.peers.filter((p: PeerStats) => p.isOnline).length
}
if (data.totalRules !== undefined) {
stats.value.totalRules = data.totalRules
}
if (data.traffic) {
const point: TrafficDataPoint = {
timestamp: Date.now(),
rxBytes: data.traffic.rxBytes || 0,
txBytes: data.traffic.txBytes || 0
}
trafficBuffer.push(point)
if (trafficBuffer.length > 60) trafficBuffer.shift()
stats.value.trafficHistory = [...trafficBuffer]
}
if (data.stats) {
stats.value = { ...stats.value, ...data.stats }
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e)
}
}
ws.onerror = () => {
error.value = 'WebSocket connection error'
}
ws.onclose = () => {
isConnected.value = false
ws = null
scheduleReconnect()
}
} catch (e) {
error.value = `Failed to connect: ${e}`
scheduleReconnect()
}
}
const scheduleReconnect = () => {
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(() => {
connect()
}, reconnectDelay)
}
const disconnect = () => {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (ws) {
ws.close()
ws = null
}
isConnected.value = false
}
onUnmounted(() => {
disconnect()
})
return {
isConnected,
stats,
error,
connect,
disconnect
}
}
+15
View File
@@ -0,0 +1,15 @@
import { createI18n } from 'vue-i18n'
import en from './locales/en.json'
import id from './locales/id.json'
import zh from './locales/zh.json'
export const i18n = createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
messages: {
en,
id,
zh
}
})
+29
View File
@@ -0,0 +1,29 @@
{
"settings": "Settings",
"language": "Language",
"theme": "Theme",
"dark": "Dark",
"light": "Light",
"auto": "Auto",
"english": "English",
"indonesian": "Indonesian",
"chinese": "Chinese",
"dashboard": "Dashboard",
"clients": "Clients",
"logs": "Logs",
"save": "Save",
"cancel": "Cancel",
"dashboardSettings": "Dashboard Settings",
"totpSetup": "TOTP Setup",
"smtpSettings": "SMTP Settings",
"enableSMTP": "Enable SMTP",
"server": "Server",
"port": "Port",
"useTLS": "Use TLS",
"username": "Username",
"password": "Password",
"fromEmail": "From Email",
"fromName": "From Name",
"testEmail": "Test Email",
"saveSettings": "Save Settings"
}
+29
View File
@@ -0,0 +1,29 @@
{
"settings": "Pengaturan",
"language": "Bahasa",
"theme": "Tema",
"dark": "Gelap",
"light": "Terang",
"auto": "Otomatis",
"english": "Inggris",
"indonesian": "Indonesia",
"chinese": "Mandarin",
"dashboard": "Dasbor",
"clients": "Klien",
"logs": "Log",
"save": "Simpan",
"cancel": "Batal",
"dashboardSettings": "Pengaturan Dasbor",
"totpSetup": "Pengaturan TOTP",
"smtpSettings": "Pengaturan SMTP",
"enableSMTP": "Aktifkan SMTP",
"server": "Server",
"port": "Port",
"useTLS": "Gunakan TLS",
"username": "Nama Pengguna",
"password": "Kata Sandi",
"fromEmail": "Email Pengirim",
"fromName": "Nama Pengirim",
"testEmail": "Tes Email",
"saveSettings": "Simpan Pengaturan"
}
+29
View File
@@ -0,0 +1,29 @@
{
"settings": "设置",
"language": "语言",
"theme": "主题",
"dark": "深色",
"light": "浅色",
"auto": "自动",
"english": "英语",
"indonesian": "印尼语",
"chinese": "中文",
"dashboard": "仪表盘",
"clients": "客户端",
"logs": "日志",
"save": "保存",
"cancel": "取消",
"dashboardSettings": "仪表盘设置",
"totpSetup": "TOTP 设置",
"smtpSettings": "SMTP 设置",
"enableSMTP": "启用 SMTP",
"server": "服务器",
"port": "端口",
"useTLS": "使用 TLS",
"username": "用户名",
"password": "密码",
"fromEmail": "发件人邮箱",
"fromName": "发件人名称",
"testEmail": "测试邮件",
"saveSettings": "保存设置"
}
+8 -58
View File
@@ -1,60 +1,10 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { i18n } from './i18n'
import './style.css'
import typescriptLogo from './assets/typescript.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import { setupCounter } from './counter.ts'
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<section id="center">
<div class="hero">
<img src="${heroImg}" class="base" width="170" height="179">
<img src="${typescriptLogo}" class="framework" alt="TypeScript logo"/>
<img src="${viteLogo}" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/main.ts</code> and save to test <code>HMR</code></p>
</div>
<button id="counter" type="button" class="counter"></button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#documentation-icon"></use></svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" src="${viteLogo}" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://www.typescriptlang.org" target="_blank">
<img class="button-icon" src="${typescriptLogo}" alt="">
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#social-icon"></use></svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li><a href="https://github.com/vitejs/vite" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#github-icon"></use></svg>GitHub</a></li>
<li><a href="https://chat.vite.dev/" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#discord-icon"></use></svg>Discord</a></li>
<li><a href="https://x.com/vite_js" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#x-icon"></use></svg>X.com</a></li>
<li><a href="https://bsky.app/profile/vite.dev" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#bluesky-icon"></use></svg>Bluesky</a></li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
`
setupCounter(document.querySelector<HTMLButtonElement>('#counter')!)
const app = createApp(App)
app.use(router)
app.use(i18n)
app.mount('#app')
+45
View File
@@ -0,0 +1,45 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('../views/HomeView.vue'),
meta: { title: 'Dashboard' }
},
{
path: '/server/:id',
name: 'server-detail',
component: () => import('../views/ServerDetailView.vue'),
meta: { title: 'Server Detail' }
},
{
path: '/server/:id/peers',
name: 'peers',
component: () => import('../views/PeersView.vue'),
meta: { title: 'Peer Management' }
},
{
path: '/server/:id/webhooks',
name: 'webhooks',
component: () => import('../views/WebhooksView.vue'),
meta: { title: 'Webhook Management' }
},
{
path: '/settings',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
meta: { title: 'Settings' }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to) => {
document.title = `${to.meta.title || 'WGRplane'} - WGRplane`
})
export default router
+5
View File
@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+6 -289
View File
@@ -1,296 +1,13 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
background: #0f172a;
color: #fff;
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
min-height: 100vh;
}
+47
View File
@@ -0,0 +1,47 @@
export interface WebhookHeader {
key: string
value: string
}
export interface Webhook {
id: string
name: string
url: string
template: string
customBody: string
headers: WebhookHeader[]
subscribedActions: string[]
verifySSL: boolean
enabled: boolean
createdAt?: string
updatedAt?: string
}
export const mockWebhooks: Webhook[] = [
{
id: '1',
name: 'Slack Alerts',
url: 'https://hooks.slack.com/services/xxx/yyy/zzz',
template: 'slack',
customBody: '',
headers: [{ key: 'Content-Type', value: 'application/json' }],
subscribedActions: ['peer.connected', 'peer.disconnected'],
verifySSL: true,
enabled: true,
createdAt: '2026-04-15T08:30:00Z',
updatedAt: '2026-05-01T14:20:00Z'
},
{
id: '2',
name: 'Discord Notifications',
url: 'https://discord.com/api/webhooks/xxx/yyy',
template: 'discord',
customBody: '',
headers: [{ key: 'Content-Type', value: 'application/json' }],
subscribedActions: ['policy.updated', 'server.started'],
verifySSL: true,
enabled: false,
createdAt: '2026-04-20T10:15:00Z',
updatedAt: '2026-04-28T09:45:00Z'
}
]
+188
View File
@@ -0,0 +1,188 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold text-white">Dashboard</h1>
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded-full"
:class="isConnected ? 'bg-green-400 animate-pulse' : 'bg-red-400'"
></div>
<span class="text-sm text-white/70">
{{ isConnected ? 'Live' : 'Disconnected' }}
</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<GlassCard class="p-6">
<div class="flex items-center justify-between mb-2">
<p class="text-white/50 text-sm">Total Peers</p>
<svg class="w-5 h-5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<p class="text-3xl font-bold text-white">{{ stats.totalPeers }}</p>
</GlassCard>
<GlassCard class="p-6">
<div class="flex items-center justify-between mb-2">
<p class="text-white/50 text-sm">Active Peers</p>
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p class="text-3xl font-bold text-green-400">{{ stats.activePeers }}</p>
</GlassCard>
<GlassCard class="p-6">
<div class="flex items-center justify-between mb-2">
<p class="text-white/50 text-sm">Active Rules</p>
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<p class="text-3xl font-bold text-purple-400">{{ stats.totalRules }}</p>
</GlassCard>
</div>
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">Traffic Overview</h2>
<div class="h-64">
<Line v-if="chartData.datasets[0].data.length > 0" :data="chartData" :options="chartOptions" />
<div v-else class="flex items-center justify-center h-full text-white/30">
Waiting for data...
</div>
</div>
</GlassCard>
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">Peer Status</h2>
<div v-if="stats.peers.length > 0" class="space-y-2 max-h-64 overflow-y-auto">
<div
v-for="peer in stats.peers"
:key="peer.publicKey"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
>
<div class="flex items-center gap-3">
<div
class="w-2 h-2 rounded-full"
:class="peer.isOnline ? 'bg-green-400' : 'bg-gray-500'"
></div>
<span class="text-white font-mono text-sm">{{ peer.ip }}</span>
</div>
<div class="text-white/50 text-xs">
RX: {{ formatBytes(peer.rxBytes) }} | TX: {{ formatBytes(peer.txBytes) }}
</div>
</div>
</div>
<p v-else class="text-white/30">No peer data available</p>
</GlassCard>
<div v-if="error" class="p-4 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400">
{{ error }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
import GlassCard from '../components/glass/GlassCard.vue'
import { useWebSocket } from '../composables/useWebSocket'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
)
const { isConnected, stats, error, connect } = useWebSocket('ws://localhost:8080/ws/stats')
onMounted(() => {
connect()
})
const chartData = computed(() => {
const history = stats.value.trafficHistory || []
return {
labels: history.map((_, i) => `${i}s`),
datasets: [
{
label: 'Download (RX)',
data: history.map(p => p.rxBytes),
borderColor: 'rgb(34, 211, 238)',
backgroundColor: 'rgba(34, 211, 238, 0.1)',
fill: true,
tension: 0.4
},
{
label: 'Upload (TX)',
data: history.map(p => p.txBytes),
borderColor: 'rgb(168, 85, 247)',
backgroundColor: 'rgba(168, 85, 247, 0.1)',
fill: true,
tension: 0.4
}
]
}
})
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: 'rgba(255, 255, 255, 0.7)'
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
callbacks: {
label: (context: any) => {
return `${context.dataset.label}: ${formatBytes(context.parsed.y)}`
}
}
}
},
scales: {
x: {
ticks: { color: 'rgba(255, 255, 255, 0.5)' },
grid: { color: 'rgba(255, 255, 255, 0.1)' }
},
y: {
ticks: {
color: 'rgba(255, 255, 255, 0.5)',
callback: (value: number) => formatBytes(value)
},
grid: { color: 'rgba(255, 255, 255, 0.1)' }
}
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>
+20
View File
@@ -0,0 +1,20 @@
<template>
<div class="space-y-6">
<div class="flex items-center gap-4">
<GlassButton @click="$router.back()" class="!px-3 !py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</GlassButton>
<h1 class="text-3xl font-bold text-white">Peer Management</h1>
</div>
<p class="text-white/70">Server ID: {{ $route.params.id }}</p>
<PeerTable :server-id="$route.params.id" />
</div>
</template>
<script setup lang="ts">
import GlassButton from '../components/glass/GlassButton.vue'
import PeerTable from '../components/PeerTable.vue'
</script>
@@ -0,0 +1,28 @@
<template>
<div class="space-y-6">
<div class="flex items-center gap-4">
<GlassButton @click="$router.back()" class="!px-3 !py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</GlassButton>
<h1 class="text-3xl font-bold text-white">Server Detail</h1>
</div>
<p class="text-white/70">Server ID: {{ $route.params.id }}</p>
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">Server Information</h2>
<p class="text-white/50">Server details will be displayed here.</p>
</GlassCard>
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">Peer Table</h2>
<p class="text-white/50">Peer list for this server will be displayed here.</p>
</GlassCard>
</div>
</template>
<script setup lang="ts">
import GlassCard from '../components/glass/GlassCard.vue'
import GlassButton from '../components/glass/GlassButton.vue'
</script>
+160
View File
@@ -0,0 +1,160 @@
<template>
<div class="space-y-6">
<h1 class="text-3xl font-bold text-white">{{ t('settings') }}</h1>
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">{{ t('dashboardSettings') }}</h2>
<p class="text-white/50">Dashboard configuration options will be displayed here.</p>
</GlassCard>
<!-- Language Settings -->
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">{{ t('language') }}</h2>
<div class="flex gap-4">
<GlassButton
@click="switchLanguage('en')"
:class="locale === 'en' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
class="px-4 py-2"
>
{{ t('english') }}
</GlassButton>
<GlassButton
@click="switchLanguage('id')"
:class="locale === 'id' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
class="px-4 py-2"
>
{{ t('indonesian') }}
</GlassButton>
<GlassButton
@click="switchLanguage('zh')"
:class="locale === 'zh' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
class="px-4 py-2"
>
{{ t('chinese') }}
</GlassButton>
</div>
</GlassCard>
<!-- Theme Settings -->
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">{{ t('theme') }}</h2>
<div class="flex gap-4">
<GlassButton
@click="toggleTheme('dark')"
:class="theme === 'dark' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
class="px-4 py-2"
>
{{ t('dark') }}
</GlassButton>
<GlassButton
@click="toggleTheme('light')"
:class="theme === 'light' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
class="px-4 py-2"
>
{{ t('light') }}
</GlassButton>
<GlassButton
@click="toggleTheme('auto')"
:class="theme === 'auto' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
class="px-4 py-2"
>
{{ t('auto') }}
</GlassButton>
</div>
</GlassCard>
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">{{ t('totpSetup') }}</h2>
<p class="text-white/50">Two-factor authentication setup will be displayed here.</p>
</GlassCard>
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">{{ t('smtpSettings') }}</h2>
<div class="flex items-center justify-between mb-6">
<span class="text-white">{{ t('enableSMTP') }}</span>
<GlassToggle v-model="smtpSettings.Enabled" />
</div>
<div class="space-y-4">
<div>
<label class="block text-white/70 mb-1">{{ t('server') }}</label>
<GlassInput v-model="smtpSettings.Server" placeholder="smtp.example.com" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">{{ t('port') }}</label>
<GlassInput v-model="smtpSettings.Port" type="number" placeholder="587" class="w-full" />
</div>
<div class="flex items-center justify-between">
<span class="text-white/70">{{ t('useTLS') }}</span>
<GlassToggle v-model="smtpSettings.UseTLS" />
</div>
<div>
<label class="block text-white/70 mb-1">{{ t('username') }}</label>
<GlassInput v-model="smtpSettings.Username" placeholder="user@example.com" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">{{ t('password') }}</label>
<GlassInput v-model="smtpSettings.Password" type="password" placeholder="SMTP Password" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">{{ t('fromEmail') }}</label>
<GlassInput v-model="smtpSettings.FromEmail" type="email" placeholder="noreply@example.com" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">{{ t('fromName') }}</label>
<GlassInput v-model="smtpSettings.FromName" placeholder="WireGuard VPN" class="w-full" />
</div>
</div>
<div class="flex gap-4 mt-6">
<GlassButton @click="testEmail" class="from-cyan-500 to-blue-500">{{ t('testEmail') }}</GlassButton>
<GlassButton @click="saveSettings" class="from-green-500 to-teal-500">{{ t('saveSettings') }}</GlassButton>
</div>
</GlassCard>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import GlassCard from '../components/glass/GlassCard.vue'
import GlassInput from '../components/glass/GlassInput.vue'
import GlassToggle from '../components/glass/GlassToggle.vue'
import GlassButton from '../components/glass/GlassButton.vue'
import { useTheme } from '../composables/useTheme'
const { t, locale } = useI18n()
const { theme, toggleTheme } = useTheme()
const smtpSettings = reactive({
Enabled: false,
Server: '',
Port: 587,
UseTLS: true,
Username: '',
Password: '',
FromEmail: '',
FromName: ''
})
const switchLanguage = (lang: string) => {
locale.value = lang
}
const testEmail = () => {
console.log('Test Email with settings:', JSON.parse(JSON.stringify(smtpSettings)))
alert('Test email functionality (mock) - check console for settings')
}
const saveSettings = () => {
console.log('Saving SMTP settings (mock):', JSON.parse(JSON.stringify(smtpSettings)))
alert('SMTP settings saved (mock)')
}
</script>
+125
View File
@@ -0,0 +1,125 @@
<template>
<div class="space-y-6">
<div class="flex items-center gap-4">
<GlassButton @click="$router.back()" class="!px-3 !py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</GlassButton>
<h1 class="text-3xl font-bold text-white">Webhook Management</h1>
</div>
<p class="text-white/70">Server ID: {{ $route.params.id }}</p>
<GlassCard class="p-6">
<WebhookList
:webhooks="webhooks"
@add="showAddForm"
@edit="showEditForm"
@delete="deleteWebhook"
/>
</GlassCard>
<!-- Add/Edit Form Modal -->
<Teleport to="body">
<Transition name="modal">
<div
v-if="showForm"
class="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4 z-50"
@click.self="closeForm"
>
<div class="bg-gradient-to-br from-gray-900/90 to-gray-800/90 backdrop-blur-xl rounded-2xl p-6 border border-white/20 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-cyan-400">
{{ editingWebhook ? 'Edit Webhook' : 'Add New Webhook' }}
</h2>
<button
@click="closeForm"
class="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<WebhookForm
:webhook="editingWebhook"
:submit-label="editingWebhook ? 'Update Webhook' : 'Create Webhook'"
:show-cancel="true"
@submit="saveWebhook"
@cancel="closeForm"
/>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import GlassCard from '../components/glass/GlassCard.vue'
import GlassButton from '../components/glass/GlassButton.vue'
import WebhookList from '../components/WebhookList.vue'
import WebhookForm from '../components/WebhookForm.vue'
import { mockWebhooks, type Webhook } from '../types/webhook'
const webhooks = ref<Webhook[]>([...mockWebhooks])
const showForm = ref(false)
const editingWebhook = ref<Webhook | undefined>(undefined)
const showAddForm = () => {
editingWebhook.value = undefined
showForm.value = true
}
const showEditForm = (webhook: Webhook) => {
editingWebhook.value = webhook
showForm.value = true
}
const closeForm = () => {
showForm.value = false
editingWebhook.value = undefined
}
const saveWebhook = (webhookData: Omit<Webhook, 'id' | 'createdAt' | 'updatedAt'>) => {
if (editingWebhook.value) {
// Update existing webhook
const index = webhooks.value.findIndex(w => w.id === editingWebhook.value!.id)
if (index !== -1) {
webhooks.value[index] = {
...webhooks.value[index],
...webhookData,
updatedAt: new Date().toISOString()
}
}
} else {
// Add new webhook
const newWebhook: Webhook = {
...webhookData,
id: Date.now().toString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
webhooks.value.push(newWebhook)
}
closeForm()
}
const deleteWebhook = (id: string) => {
if (confirm('Are you sure you want to delete this webhook?')) {
webhooks.value = webhooks.value.filter(w => w.id !== id)
}
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
+1
View File
@@ -3,6 +3,7 @@ export default {
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
backdropBlur: {
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})