10 Commits

Author SHA1 Message Date
Home
743ca72965 feat: create certificate page 2025-10-18 18:53:38 +03:00
Home
f572b28711 feat: added template innbounding feature 2025-10-18 17:38:41 +03:00
Home
781d7439af feat: added server workflow 2025-10-11 19:24:30 +03:00
Home
894dd4da95 feat: added create server 2025-10-11 01:32:59 +03:00
Home
45c21cca82 feat: added redux & ducks 2025-10-11 00:20:28 +03:00
Home
de6f4bc6f9 fix: key in map nav-menu 2025-10-10 23:09:19 +03:00
Home
d264968cc8 fix: dependencies 2025-10-07 14:11:20 +03:00
Boris Cherepanov
1a42dc9d4c feat: fetch dashboard data 2025-10-03 01:57:47 +03:00
Boris Cherepanov
bfa2878109 feat: added nav 2025-10-03 01:10:06 +03:00
Boris Cherepanov
8472e21955 feat: added vite+react+ts 2025-10-03 00:05:32 +03:00
201 changed files with 14642 additions and 10318 deletions

View File

@@ -1,61 +0,0 @@
name: Rust Docker Build
on:
push:
branches:
- 'RUST'
env:
REGISTRY: docker.io
IMAGE_NAME: ultradesu/outfleet
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Extract version from Cargo.toml
id: extract_version
run: |
VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "cargo_version=$VERSION" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION"
- name: Set build variables
id: vars
run: |
echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
echo "sha_full=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +'%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_OUTPUT
echo "branch_name=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
GIT_COMMIT=${{ steps.vars.outputs.sha_full }}
GIT_COMMIT_SHORT=${{ steps.vars.outputs.sha_short }}
BUILD_DATE=${{ steps.vars.outputs.build_date }}
BRANCH_NAME=${{ steps.vars.outputs.branch_name }}
CARGO_VERSION=${{ steps.extract_version.outputs.cargo_version }}
tags: |
${{ env.IMAGE_NAME }}:rs-${{ steps.extract_version.outputs.cargo_version }}
${{ env.IMAGE_NAME }}:rs-${{ steps.extract_version.outputs.cargo_version }}-${{ steps.vars.outputs.sha_short }}
${{ env.IMAGE_NAME }}:rust-latest

174
API.md
View File

@@ -19,34 +19,6 @@ Complete API documentation for OutFleet - a web admin panel for managing xray-co
}
```
### User Subscription
- `GET /sub/{user_id}` - Get all user configuration links (subscription endpoint)
**Description:** Returns all VPN configuration links for a specific user, one per line. This endpoint is designed for VPN clients that support subscription URLs for automatic configuration updates.
**Path Parameters:**
- `user_id` (UUID) - The user's unique identifier
**Response:**
- **Content-Type:** `text/plain; charset=utf-8`
- **Success (200):** Base64 encoded string containing configuration URIs (one per line when decoded)
- **Not Found (404):** User doesn't exist
- **No Content:** Returns base64 encoded comment if no configurations available
**Example Response:**
```
dm1lc3M6Ly9leUoySWpvaU1pSXNJbkJ6SWpvaVUyVnlkbVZ5TVNJc0ltRmtaQ0k2SWpFeU55NHdMakF1TVM0eElpd2ljRzl5ZENJNklqUTBNeUlzSWxsa0lqb2lNVEl6TkRVMk56Z3RNVEl6TkMwMU5qYzRMVGxoWW1NdE1USXpORFUyTnpnNVlXSmpJaXdpWVdsa0lqb2lNQ0lzSW5Oamj0SWpvaVlYVjBieUlzSW01bGRDSTZJblJqY0NJc0luUjVjR1VpT2lKdWIyNWxJaXdpYUc5emRDSTZJaUlzSW5CaGRHZ2lPaUlpTEhKMGJITWlPaUowYkhNaUxGTnVhU0k2SWlKOQ0Kdmxlc3M6Ly91dWlkQGhvc3RuYW1lOnBvcnQ/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PXRscyZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUjU2VydmVyTmFtZQ0Kc3M6Ly9ZV1Z6TFRJMk5TMW5ZMjFBY0dGemMzZHZjbVE2TVRJNExqQXVNQzR5T2pnd09EQT0jU2VydmVyMg0K
```
**Decoded Example:**
```
vmess://eyJ2IjoiMiIsInBzIjoiU2VydmVyMSIsImFkZCI6IjEyNy4wLjAuMSIsInBvcnQiOiI0NDMiLCJpZCI6IjEyMzQ1Njc4LTEyMzQtNTY3OC05YWJjLTEyMzQ1Njc4OWFiYyIsImFpZCI6IjAiLCJzY3kiOiJhdXRvIiwibmV0IjoidGNwIiwidHlwZSI6Im5vbmUiLCJob3N0IjoiIiwicGF0aCI6IiIsInRscyI6InRscyIsInNuaSI6IiJ9
vless://uuid@hostname:port?encryption=none&security=tls&type=tcp&headerType=none#ServerName
ss://YWVzLTI1Ni1nY21AcGFzc3dvcmQ6MTI3LjAuMC4xOjgwODA=#Server2
```
**Usage:** This endpoint is intended for VPN client applications that support subscription URLs. Users can add this URL to their VPN client to automatically receive all their configurations and get updates when configurations change.
## API Endpoints
All API endpoints are prefixed with `/api`.
@@ -532,148 +504,4 @@ All API endpoints are prefixed with `/api`.
"code": "ERROR_CODE",
"details": "Additional error details"
}
```
## Telegram Bot Integration
OutFleet includes a Telegram bot for user management and configuration access.
### User Management Endpoints
#### List User Requests
- `GET /api/user-requests` - Get all user access requests
- `GET /api/user-requests?status=pending` - Get pending requests only
**Response:**
```json
{
"items": [
{
"id": "uuid",
"user_id": "uuid|null",
"telegram_id": 123456789,
"telegram_username": "username",
"telegram_first_name": "John",
"telegram_last_name": "Doe",
"full_name": "John Doe",
"telegram_link": "@username",
"status": "pending|approved|declined",
"request_message": "Access request message",
"response_message": "Admin response",
"processed_by_user_id": "uuid|null",
"processed_at": "timestamp|null",
"created_at": "timestamp",
"updated_at": "timestamp"
}
],
"total": 50,
"page": 1,
"per_page": 20
}
```
#### Get User Request
- `GET /api/user-requests/{id}` - Get specific user request
#### Approve User Request
- `POST /api/user-requests/{id}/approve` - Approve user access request
**Request:**
```json
{
"response_message": "Welcome! Your access has been approved."
}
```
**Response:** Updated user request object
**Side Effects:**
- Creates a new user account
- Sends Telegram notification with main menu to the user
#### Decline User Request
- `POST /api/user-requests/{id}/decline` - Decline user access request
**Request:**
```json
{
"response_message": "Sorry, your request has been declined."
}
```
**Response:** Updated user request object
**Side Effects:**
- Sends Telegram notification to the user
#### Delete User Request
- `DELETE /api/user-requests/{id}` - Delete user request
### Telegram Bot Configuration
#### Get Telegram Status
- `GET /api/telegram/status` - Get bot status and configuration
**Response:**
```json
{
"is_running": true,
"config": {
"id": "uuid",
"name": "Bot Name",
"bot_token": "masked",
"is_active": true,
"created_at": "timestamp",
"updated_at": "timestamp"
}
}
```
#### Create/Update Telegram Config
- `POST /api/telegram/config` - Create new bot configuration
- `PUT /api/telegram/config/{id}` - Update bot configuration
**Request:**
```json
{
"name": "OutFleet Bot",
"bot_token": "bot_token_from_botfather",
"is_active": true
}
```
#### Telegram Admin Management
- `GET /api/telegram/admins` - Get all Telegram admins
- `POST /api/telegram/admins/{user_id}` - Add user as Telegram admin
- `DELETE /api/telegram/admins/{user_id}` - Remove user from Telegram admins
### Telegram Bot Features
#### User Flow
1. **Request Access**: Users send `/start` to the bot and request VPN access
2. **Admin Approval**: Admins receive notifications and can approve/decline via Telegram or web interface
3. **Configuration Access**: Approved users get access to:
- **🔗 Subscription Link**: Personal subscription URL (`/sub/{user_id}`)
- **⚙️ My Configs**: Individual configuration management
- **💬 Support**: Contact support
#### Admin Features
- **📋 User Requests**: View and manage pending access requests
- **📊 Statistics**: View system statistics
- **📢 Broadcast**: Send messages to all users
- **Approval Workflow**: Approve/decline requests with server selection
#### Subscription Link Integration
When users click "🔗 Subscription Link" in the Telegram bot, they receive:
- Personal subscription URL: `{BASE_URL}/sub/{user_id}`
- Instructions in their preferred language (Russian/English)
- Automatic updates when configurations change
**Environment Variables:**
- `BASE_URL` - Base URL for subscription links (default: `http://localhost:8080`)
### Bot Commands
- `/start` - Start bot and show main menu
- `/requests` - [Admin] View pending user requests
- `/stats` - [Admin] Show system statistics
- `/broadcast <message>` - [Admin] Send message to all users
```

593
Cargo.lock generated
View File

@@ -126,20 +126,6 @@ version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
[[package]]
name = "aquamarine"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21cc1548309245035eb18aa7f0967da6bc65587005170c56e6ef2788a4cf3f4e"
dependencies = [
"include_dir",
"itertools 0.10.5",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "arraydeque"
version = "0.5.1"
@@ -152,16 +138,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "async-stream"
version = "0.3.6"
@@ -210,12 +186,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-future"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373"
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -313,35 +283,6 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "axum-test"
version = "14.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167294800740b4b6bc7bfbccbf3a1d50a6c6e097342580ec4c11d1672e456292"
dependencies = [
"anyhow",
"async-trait",
"auto-future",
"axum",
"bytes",
"cookie",
"http 1.3.1",
"http-body-util",
"hyper 1.7.0",
"hyper-util",
"mime",
"pretty_assertions",
"reserve-port",
"rust-multipart-rfc7578_2",
"serde",
"serde_json",
"serde_urlencoded",
"smallvec",
"tokio",
"tower 0.4.13",
"url",
]
[[package]]
name = "backtrace"
version = "0.3.75"
@@ -591,7 +532,7 @@ dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim 0.11.1",
"strsim",
]
[[package]]
@@ -653,7 +594,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf"
dependencies = [
"async-trait",
"convert_case 0.6.0",
"convert_case",
"json5",
"nom",
"pathdiff",
@@ -691,12 +632,6 @@ dependencies = [
"tiny-keccak",
]
[[package]]
name = "convert_case"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]]
name = "convert_case"
version = "0.6.0"
@@ -706,16 +641,6 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"time",
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -808,38 +733,14 @@ dependencies = [
"typenum",
]
[[package]]
name = "darling"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
dependencies = [
"darling_core 0.13.4",
"darling_macro 0.13.4",
]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling_core"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim 0.10.0",
"syn 1.0.109",
"darling_core",
"darling_macro",
]
[[package]]
@@ -852,50 +753,21 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim 0.11.1",
"strsim",
"syn 2.0.106",
]
[[package]]
name = "darling_macro"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
dependencies = [
"darling_core 0.13.4",
"quote",
"syn 1.0.109",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"darling_core",
"quote",
"syn 2.0.106",
]
[[package]]
name = "deadpool"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
dependencies = [
"deadpool-runtime",
"lazy_static",
"num_cpus",
"tokio",
]
[[package]]
name = "deadpool-runtime"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
[[package]]
name = "der"
version = "0.7.10"
@@ -917,25 +789,6 @@ dependencies = [
"serde",
]
[[package]]
name = "derive_more"
version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
dependencies = [
"convert_case 0.4.0",
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.106",
]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "digest"
version = "0.10.7"
@@ -974,21 +827,6 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1"
[[package]]
name = "dptree"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d81175dab5ec79c30e0576df2ed2c244e1721720c302000bb321b107e82e265c"
dependencies = [
"futures",
]
[[package]]
name = "dunce"
version = "1.0.5"
@@ -1019,16 +857,6 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erasable"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "437cfb75878119ed8265685c41a115724eae43fb7cc5a0bf0e4ecc3b803af1c4"
dependencies = [
"autocfg",
"scopeguard",
]
[[package]]
name = "errno"
version = "0.3.14"
@@ -1036,7 +864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -1126,12 +954,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fragile"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619"
[[package]]
name = "fs_extra"
version = "1.3.0"
@@ -1152,7 +974,6 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
@@ -1203,17 +1024,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
@@ -1232,10 +1042,8 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
@@ -1387,12 +1195,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@@ -1773,25 +1575,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -1872,15 +1655,6 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
@@ -2111,33 +1885,6 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "mockall"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48"
dependencies = [
"cfg-if",
"downcast",
"fragile",
"lazy_static",
"mockall_derive",
"predicates",
"predicates-tree",
]
[[package]]
name = "mockall_derive"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2"
dependencies = [
"cfg-if",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "multimap"
version = "0.10.1"
@@ -2254,16 +2001,6 @@ dependencies = [
"libm",
]
[[package]]
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "object"
version = "0.36.7"
@@ -2578,42 +2315,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "predicates"
version = "3.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
dependencies = [
"anstyle",
"predicates-core",
]
[[package]]
name = "predicates-core"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
[[package]]
name = "predicates-tree"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
dependencies = [
"predicates-core",
"termtree",
]
[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
dependencies = [
"diff",
"yansi",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -2824,15 +2525,6 @@ dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "rc-box"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897fecc9fac6febd4408f9e935e86df739b0023b625e610e0357535b9c8adad0"
dependencies = [
"erasable",
]
[[package]]
name = "rcgen"
version = "0.12.1"
@@ -2926,7 +2618,6 @@ dependencies = [
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"once_cell",
"percent-encoding",
@@ -2941,26 +2632,15 @@ dependencies = [
"tokio",
"tokio-native-tls",
"tokio-rustls 0.24.1",
"tokio-util",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots 0.25.4",
"winreg",
]
[[package]]
name = "reserve-port"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356"
dependencies = [
"thiserror 2.0.16",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -3046,22 +2726,6 @@ dependencies = [
"ordered-multimap",
]
[[package]]
name = "rust-multipart-rfc7578_2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03b748410c0afdef2ebbe3685a6a862e2ee937127cdaae623336a459451c8d57"
dependencies = [
"bytes",
"futures-core",
"futures-util",
"http 0.2.12",
"mime",
"mime_guess",
"rand",
"thiserror 1.0.69",
]
[[package]]
name = "rust_decimal"
version = "1.38.0"
@@ -3090,15 +2754,6 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.2"
@@ -3109,7 +2764,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -3240,15 +2895,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scc"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
dependencies = [
"sdd",
]
[[package]]
name = "schannel"
version = "0.1.28"
@@ -3274,12 +2920,6 @@ dependencies = [
"untrusted 0.9.0",
]
[[package]]
name = "sdd"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]]
name = "sea-bae"
version = "0.2.1"
@@ -3407,7 +3047,7 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab"
dependencies = [
"darling 0.20.11",
"darling",
"heck 0.4.1",
"proc-macro2",
"quote",
@@ -3480,12 +3120,6 @@ dependencies = [
"libc",
]
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.225"
@@ -3561,28 +3195,6 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "678b5a069e50bf00ecd22d0cd8ddf7c236f68581b03db652061ed5eb13a312ff"
dependencies = [
"serde",
"serde_with_macros",
]
[[package]]
name = "serde_with_macros"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082"
dependencies = [
"darling 0.13.4",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
@@ -3596,31 +3208,6 @@ dependencies = [
"unsafe-libyaml",
]
[[package]]
name = "serial_test"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9"
dependencies = [
"futures",
"log",
"once_cell",
"parking_lot",
"scc",
"serial_test_derive",
]
[[package]]
name = "serial_test_derive"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "sha1"
version = "0.10.6"
@@ -3632,12 +3219,6 @@ dependencies = [
"digest",
]
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3975,12 +3556,6 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -4065,92 +3640,12 @@ dependencies = [
"libc",
]
[[package]]
name = "take_mut"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60"
[[package]]
name = "takecell"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20f34339676cdcab560c9a82300c4c2581f68b9369aedf0fae86f2ff9565ff3e"
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "teloxide"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f79dd283eb21b90451c03fa7c7f83b9985130efb876b33bad89a2c208ccbc16"
dependencies = [
"aquamarine",
"bytes",
"derive_more",
"dptree",
"either",
"futures",
"log",
"mime",
"pin-project",
"serde",
"serde_json",
"teloxide-core",
"teloxide-macros",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
"tokio-util",
"url",
]
[[package]]
name = "teloxide-core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1642a7ef10e7af63b8298c8d13c0f986d4fc646d42649ff060359607f62f69"
dependencies = [
"bitflags 1.3.2",
"bytes",
"chrono",
"derive_more",
"either",
"futures",
"log",
"mime",
"once_cell",
"pin-project",
"rc-box",
"reqwest",
"serde",
"serde_json",
"serde_with",
"take_mut",
"takecell",
"thiserror 1.0.69",
"tokio",
"tokio-util",
"url",
"uuid",
]
[[package]]
name = "teloxide-macros"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e2d33d809c3e7161a9ab18bedddf98821245014f0a78fa4d2c9430b2ec018c1"
dependencies = [
"heck 0.4.1",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "tempfile"
version = "3.22.0"
@@ -4161,15 +3656,9 @@ dependencies = [
"getrandom 0.3.3",
"once_cell",
"rustix",
"windows-sys 0.61.0",
"windows-sys 0.60.2",
]
[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -4370,19 +3859,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-test"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7"
dependencies = [
"async-stream",
"bytes",
"futures-core",
"tokio",
"tokio-stream",
]
[[package]]
name = "tokio-util"
version = "0.7.16"
@@ -4760,7 +4236,6 @@ dependencies = [
"getrandom 0.3.3",
"js-sys",
"serde",
"sha1_smol",
"wasm-bindgen",
]
@@ -4786,7 +4261,7 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df0bcf92720c40105ac4b2dda2a4ea3aa717d4d6a862cc217da653a4bd5c6b10"
dependencies = [
"darling 0.20.11",
"darling",
"once_cell",
"proc-macro-error",
"proc-macro2",
@@ -4933,19 +4408,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "web-sys"
version = "0.3.80"
@@ -5005,7 +4467,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -5389,29 +4851,6 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "wiremock"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [
"assert-json-diff",
"base64 0.22.1",
"deadpool",
"futures",
"http 1.3.1",
"http-body-util",
"hyper 1.7.0",
"hyper-util",
"log",
"once_cell",
"regex",
"serde",
"serde_json",
"tokio",
"url",
]
[[package]]
name = "wit-bindgen"
version = "0.46.0"
@@ -5435,12 +4874,11 @@ dependencies = [
[[package]]
name = "xray-admin"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"axum",
"axum-test",
"base64 0.21.7",
"chrono",
"clap",
@@ -5448,7 +4886,6 @@ dependencies = [
"hyper 1.7.0",
"instant-acme",
"log",
"mockall",
"pem",
"prost",
"rand",
@@ -5461,14 +4898,11 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"serial_test",
"teloxide",
"tempfile",
"thiserror 1.0.69",
"time",
"tokio",
"tokio-cron-scheduler",
"tokio-test",
"toml",
"tonic",
"tower 0.4.13",
@@ -5479,7 +4913,6 @@ dependencies = [
"urlencoding",
"uuid",
"validator",
"wiremock",
"xray-core",
]

View File

@@ -1,6 +1,6 @@
[package]
name = "xray-admin"
version = "0.1.3"
version = "0.1.0"
edition = "2021"
[dependencies]
@@ -37,7 +37,7 @@ sea-orm = { version = "1.0", features = ["sqlx-postgres", "runtime-tokio-rustls"
sea-orm-migration = "1.0"
# Additional utilities
uuid = { version = "1.0", features = ["v4", "v5", "serde"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
async-trait = "0.1"
log = "0.4"
@@ -65,13 +65,5 @@ rustls = { version = "0.23", features = ["aws-lc-rs"] } # TLS library with aws-
ring = "0.17" # Crypto for ACME
pem = "3.0" # PEM format support
# Telegram bot support
teloxide = { version = "0.13", features = ["macros"] }
[dev-dependencies]
tempfile = "3.0"
tokio-test = "0.4"
wiremock = "0.6"
axum-test = "14.0"
serial_test = "3.0"
mockall = "0.12"
tempfile = "3.0"

View File

@@ -1,70 +1,35 @@
# Use cargo-chef for dependency caching
FROM lukemathwalker/cargo-chef:0.1.68-rust-1.90-slim AS chef
# Build stage
FROM rust:latest as builder
WORKDIR /app
# Install system dependencies needed for building
# Install system dependencies
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
protobuf-compiler \
&& rm -rf /var/lib/apt/lists/*
# Recipe preparation stage
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
# Copy dependency files
COPY Cargo.toml Cargo.lock ./
# Dependency building stage
FROM chef AS builder
# Copy source code
COPY src ./src
COPY static ./static
# Build arguments
ARG GIT_COMMIT="development"
ARG GIT_COMMIT_SHORT="dev"
ARG BUILD_DATE="unknown"
ARG BRANCH_NAME="unknown"
ARG CARGO_VERSION="0.1.0"
# Build the application
RUN cargo build --release
# Environment variables from build args
ENV GIT_COMMIT=${GIT_COMMIT}
ENV GIT_COMMIT_SHORT=${GIT_COMMIT_SHORT}
ENV BUILD_DATE=${BUILD_DATE}
ENV BRANCH_NAME=${BRANCH_NAME}
ENV CARGO_VERSION=${CARGO_VERSION}
# Copy recipe and build dependencies (cached layer)
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
# Copy source and build application
COPY . .
RUN cargo build --release --locked
# Runtime stage - minimal Ubuntu image for glibc compatibility
FROM ubuntu:24.04 AS runtime
# Build arguments (needed for runtime stage)
ARG GIT_COMMIT="development"
ARG GIT_COMMIT_SHORT="dev"
ARG BUILD_DATE="unknown"
ARG BRANCH_NAME="unknown"
ARG CARGO_VERSION="0.1.0"
# Environment variables from build args
ENV GIT_COMMIT=${GIT_COMMIT}
ENV GIT_COMMIT_SHORT=${GIT_COMMIT_SHORT}
ENV BUILD_DATE=${BUILD_DATE}
ENV BRANCH_NAME=${BRANCH_NAME}
ENV CARGO_VERSION=${CARGO_VERSION}
# Runtime stage
FROM ubuntu:24.04
WORKDIR /app
# Install minimal runtime dependencies
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
ca-certificates \
libssl3 \
libprotobuf32 \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
&& rm -rf /var/lib/apt/lists/*
# Copy the binary from builder
COPY --from=builder /app/target/release/xray-admin /app/xray-admin
@@ -75,11 +40,6 @@ COPY --from=builder /app/static ./static
# Copy config file
COPY config.docker.toml ./config.toml
# Create non-root user for security
RUN groupadd -r outfleet && useradd -r -g outfleet -s /bin/false outfleet
RUN chown -R outfleet:outfleet /app
USER outfleet
EXPOSE 8081
CMD ["/app/xray-admin", "--host", "0.0.0.0"]
CMD ["/app/xray-admin", "--host", "0.0.0.0"]

3
client/.env Normal file
View File

@@ -0,0 +1,3 @@
VITE_API_BASE=/api
VITE_API_HOST=http://localhost
VITE_API_PORT=8081

3
client/.env.example Normal file
View File

@@ -0,0 +1,3 @@
VITE_API_BASE=/api
VITE_API_HOST=https://localhost
VITE_API_PORT=8081

24
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
client/.npmrc Normal file
View File

@@ -0,0 +1 @@
public-hoist-pattern[]=*@heroui/*

23
client/.prettierrc Normal file
View File

@@ -0,0 +1,23 @@
{
"arrowParens": "always",
"bracketSameLine": false,
"objectWrap": "preserve",
"bracketSpacing": true,
"semi": true,
"experimentalOperatorPosition": "end",
"experimentalTernaries": false,
"singleQuote": true,
"jsxSingleQuote": false,
"quoteProps": "as-needed",
"trailingComma": "all",
"singleAttributePerLine": false,
"htmlWhitespaceSensitivity": "css",
"vueIndentScriptAndStyle": false,
"proseWrap": "preserve",
"insertPragma": false,
"printWidth": 80,
"requirePragma": false,
"tabWidth": 2,
"useTabs": false,
"embeddedLanguageFormatting": "auto"
}

73
client/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
client/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

14
client/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<link href="/src/style.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

8160
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
client/package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": "^20"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@heroui/react": "^2.8.5",
"@reduxjs/toolkit": "^2.9.0",
"@tailwindcss/vite": "^4.1.14",
"axios": "^1.12.2",
"clsx": "^2.1.1",
"framer-motion": "^12.23.24",
"motion": "^12.23.24",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.64.0",
"react-redux": "^9.2.0",
"react-router": "^7.9.3",
"tailwindcss": "^4.1.14"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"lint-staged": "^16.2.3",
"prettier": "^3.6.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
}
}

1
client/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

9
client/src/api/api.ts Normal file
View File

@@ -0,0 +1,9 @@
import axios from 'axios';
const VITE_API_BASE = import.meta.env.VITE_API_BASE;
const VITE_API_HOST = import.meta.env.VITE_API_HOST;
const VITE_API_PORT = import.meta.env.VITE_API_PORT;
export const api = axios.create({
baseURL: `${VITE_API_HOST}:${VITE_API_PORT}${VITE_API_BASE}`,
});

1
client/src/api/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './api'

View File

@@ -0,0 +1,33 @@
import {addToast, type ToastProps} from "@heroui/toast";
import { useEffect } from 'react';
import {
appNotificator,
type Notice,
type NoticeType,
} from '../../../utils/notification/app-notificator';
const colorMap = new Map<NoticeType, string>([
['success', 'Success'],
['error', 'Danger'],
['warn', 'Warning'],
]);
const paramsMappers = (notice: Notice): Partial<ToastProps> => {
const { type, message } = notice;
const color = colorMap.get(type);
return {
description: message,
color: color?.toLowerCase() as ToastProps['color'],
};
};
export const ApplyNotificator = () => {
useEffect(() => {
appNotificator.applyProvider({
paramsMappers,
show: (params: Partial<ToastProps>) => addToast(params),
});
}, []);
return <></>;
};

View File

@@ -0,0 +1 @@
export * from './apply-notificator'

View File

@@ -0,0 +1,2 @@
export * from './use-app-dispatch'
export * from './use-app-selector'

View File

@@ -0,0 +1,4 @@
import { useDispatch } from 'react-redux'
import type { AppDispatch } from '../../store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()

View File

@@ -0,0 +1,4 @@
import { useSelector } from 'react-redux'
import type { RootState } from '../../store'
export const useAppSelector = useSelector.withTypes<RootState>()

View File

@@ -0,0 +1 @@
export * from './nav-menu';

View File

@@ -0,0 +1 @@
export * from './nav-menu';

View File

@@ -0,0 +1,32 @@
import { Link, useLocation } from 'react-router';
import { clsx } from 'clsx';
interface NavMenuItems {
href: string;
label: string;
}
interface NavMenuProps {
items: NavMenuItems[];
}
export const NavMenu = (props: NavMenuProps) => {
const { items } = props;
const { pathname } = useLocation();
return (
<div className="tabs">
{items.map(({ href, label }) => (
<Link
key={label}
className={clsx('tab', {
active: href === pathname,
})}
to={href}
>
{label}
</Link>
))}
</div>
);
};

View File

@@ -0,0 +1,76 @@
import type { FC } from 'react';
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
} from '@heroui/react';
import type { CertificateDTO } from '../../duck';
export interface CertificateDetailProps {
cetificate: CertificateDTO;
isOpen: boolean;
onOpenChange: () => void;
}
export const CertificateDetail: FC<CertificateDetailProps> = (props) => {
const { cetificate, isOpen, onOpenChange } = props;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
Modal Title
</ModalHeader>
<ModalBody>
<div>
<h4>Basic Information</h4>
<p>
<strong>Name:</strong> ${cetificate.name}
</p>
<p>
<strong>Domain:</strong> ${cetificate.domain}
</p>
<p>
<strong>Type:</strong> ${cetificate.cert_type}
</p>
<p>
<strong>Auto Renew:</strong>
{cetificate.auto_renew ? 'Yes' : 'No'}
</p>
<p>
<strong>Created:</strong>
{new Date(cetificate.created_at).toLocaleString()}
</p>
<p>
<strong>Expires:</strong>
{new Date(cetificate.expires_at).toLocaleString()}
</p>
<h4>Certificate PEM</h4>
<div className="cert-details">
{cetificate.certificate_pem || 'Not available'}
</div>
<h4>Private Key</h4>
<div className="cert-details">
{cetificate.has_private_key
? '[Hidden for security]'
: 'Not available'}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,93 @@
import { useEffect, type FC } from 'react';
import { useForm } from 'react-hook-form';
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
} from '@heroui/react';
import { useAppDispatch } from '../../../../common/hooks';
import { getCertificate } from '../../duck/api';
import { updateCertificate, type EditCertificateDTO } from '../../duck';
export interface CertificateEditProps {
certificateId: string;
isOpen: boolean;
onOpenChange: () => void;
}
export const CertificateEdit: FC<CertificateEditProps> = (props) => {
const dispatch = useAppDispatch();
const { certificateId, isOpen, onOpenChange } = props;
const { register, handleSubmit, reset } = useForm<EditCertificateDTO>();
useEffect(() => {
getCertificate(certificateId).then((response) => {
const { data } = response;
reset({
...data,
});
});
}, [certificateId]);
const onSubmit = (values: EditCertificateDTO) => {
dispatch(
updateCertificate({
id: certificateId,
certificate: values
}),
).then(() => {
onOpenChange();
});
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<form onSubmit={handleSubmit(onSubmit)}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
Modal Title
</ModalHeader>
<ModalBody>
<div>
<div className="form-group">
<label>Name:</label>
<input
type="text"
{...register('name', { required: true })}
/>
</div>
<div className="form-group">
<label>Domain:</label>
<input
type="text"
{...register('domain', { required: true })}
/>
</div>
<div className="form-group">
<label>
<input type="checkbox" {...register('auto_renew')} /> Auto
Renew
</label>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button color="primary" type="submit">
Save
</Button>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,24 @@
import type { FC } from 'react';
import type { CertificateDTO } from '../../duck';
import { CertificateView } from './certificate-view';
export interface CertificateList {
certificates: CertificateDTO[];
}
export const CertificateList: FC<CertificateList> = ({ certificates }) => {
return (
<table>
<tr>
<th>Name</th>
<th>Domain</th>
<th>Type</th>
<th>Expires</th>
<th>Auto Renew</th>
<th>Actions</th>
</tr>
{certificates
.map((certificate)=><CertificateView certificate={certificate} key={certificate.id}/>)}
</table>
);
};

View File

@@ -0,0 +1,56 @@
import type { FC } from 'react';
import { deleteCertificateAction, type CertificateDTO } from '../../duck';
import { useDisclosure } from '@heroui/react';
import { CertificateDetail } from './certificate-details';
import { CertificateEdit } from './certificate-edit';
import { useAppDispatch } from '../../../../common/hooks';
export interface CertificateViewProps {
certificate: CertificateDTO;
}
export const CertificateView: FC<CertificateViewProps> = ({ certificate }) => {
const dispatch = useAppDispatch()
const detailDisclosure = useDisclosure();
const editDisclosure = useDisclosure();
const handleDeleteCertificate = () => {
if (confirm('Delete certificate?')) {
dispatch(deleteCertificateAction(certificate.id));
}
};
return (
<>
<tr>
<td>{certificate.name}</td>
<td>{certificate.domain}</td>
<td>{certificate.cert_type}</td>
<td>{new Date(certificate.expires_at).toLocaleDateString()}</td>
<td>{certificate.auto_renew ? 'Yes' : 'No'}</td>
<td>
<button
className="btn btn-secondary"
onClick={detailDisclosure.onOpenChange}
>
View
</button>
<button
className="btn btn-primary"
onClick={editDisclosure.onOpenChange}
>
Edit
</button>
<button
className="btn btn-danger"
onClick={handleDeleteCertificate}
>
Delete
</button>
</td>
</tr>
<CertificateDetail cetificate={certificate} {...detailDisclosure} />
<CertificateEdit {...editDisclosure} certificateId={certificate.id} />
</>
);
};

View File

@@ -0,0 +1 @@
export * from './certificate-list'

View File

@@ -0,0 +1,51 @@
import type { FC } from 'react';
import { useForm } from 'react-hook-form';
import { createCertificateAction, type CreateCertificateDTO } from '../../duck';
import { useAppDispatch } from '../../../../common/hooks';
export const CreateCertificate: FC = () => {
const { handleSubmit, register, reset } = useForm<CreateCertificateDTO>();
const dispatch = useAppDispatch();
const onSubmit = (values: CreateCertificateDTO) => {
dispatch(createCertificateAction(values)).then(() => {
reset();
});
};
return (
<form id="certificateForm" onSubmit={handleSubmit(onSubmit)}>
<div className="form-group">
<label>Name:</label>
<input type="text" {...register('name', { required: true })} />
</div>
<div className="form-group">
<label>Domain:</label>
<input
type="text"
placeholder="example.com"
{...register('domain', { required: true })}
/>
</div>
<div className="form-group">
<label>Certificate Type:</label>
<select id="certType" {...register('cert_type', { required: true })}>
<option value="self_signed">Self-Signed</option>
</select>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
id="certAutoRenew"
{...register('auto_renew')}
/>{' '}
Auto Renew
</label>
</div>
<button type="submit" className="btn btn-primary">
Generate Certificate
</button>
</form>
);
};

View File

@@ -0,0 +1 @@
export * from './create-certificate'

View File

@@ -0,0 +1 @@
export * from './create-certificate'

View File

@@ -0,0 +1,99 @@
import { certificateSlice } from './slice';
import { createCertificate, deleteCertificate, getCertificates, patchCertificate } from './api';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getCertificatesState } from './selectors';
import type { RootState } from '../../../store';
import { appNotificator } from '../../../utils/notification/app-notificator';
import type { CreateCertificateDTO, EditCertificateDTO } from './dto';
const PREFFIX = 'certificates';
export const fetchCertificates = createAsyncThunk(
`${PREFFIX}/fetchAll`,
async (_, { dispatch, getState }) => {
const { loading } = getCertificatesState(getState() as RootState);
try {
if (loading) {
return;
}
dispatch(certificateSlice.actions.setLoading(true));
const response = await getCertificates().then(({ data }) => data);
dispatch(certificateSlice.actions.setUsers(response));
} catch (e) {
const message =
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
dispatch(certificateSlice.actions.setError(message));
} finally {
dispatch(certificateSlice.actions.setLoading(false));
}
},
);
export const createCertificateAction = createAsyncThunk(
`${PREFFIX}/createCertificates`,
async (params: CreateCertificateDTO, { dispatch }) => {
try {
await createCertificate(params);
dispatch(fetchCertificates());
} catch (e) {
appNotificator.add({
message:
e instanceof Error
? e.message
: `Unknown error in ${PREFFIX}/createCertificates`,
type: 'error',
});
}
},
);
export const updateCertificate = createAsyncThunk(
`${PREFFIX}/updateCertificate`,
async (
params: {
id: string;
certificate: EditCertificateDTO;
},
{ dispatch },
) => {
try {
await patchCertificate(params.id, params.certificate);
dispatch(fetchCertificates());
appNotificator.add({
message: 'Template updated',
type: 'success',
});
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Error updating: ${e.message}`
: `Unknown error in ${PREFFIX}/updateTemplate`,
});
}
},
);
export const deleteCertificateAction = createAsyncThunk(
`${PREFFIX}/deleteCertificate`,
async (id: string, { dispatch }) => {
try {
await deleteCertificate(id);
appNotificator.add({
message: 'Certificate deleted',
type: 'success',
});
dispatch(fetchCertificates());
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Delete error: ${e.message}`
: `Unknown error in ${PREFFIX}/deleteCertificate`,
});
}
},
);

View File

@@ -0,0 +1,20 @@
import type { AxiosResponse } from 'axios';
import { api } from '../../../api/api';
import type { CertificateDTO, CreateCertificateDTO, EditCertificateDTO } from './dto';
export const getCertificates = () =>
api.get<never, AxiosResponse<CertificateDTO[]>>('/certificates');
export const createCertificate = (params: CreateCertificateDTO) =>
api.post<AxiosResponse>('/certificates', params, {
headers: { 'Content-Type': 'application/json' },
});
export const getCertificate = (id: string) => api.get<never, AxiosResponse<CertificateDTO>>(`/certificates/${id}`)
export const patchCertificate = (id: string, certificate: EditCertificateDTO) =>
api.put(`/certificates/${id}`, certificate, {
headers: { 'Content-Type': 'application/json' },
});
export const deleteCertificate = (id: string) => api.delete(`/certificates/${id}`);

View File

@@ -0,0 +1,24 @@
export interface CertificateDTO {
name: string;
domain: string;
cert_type: string;
expires_at: string;
auto_renew: boolean;
id: string
created_at: string
certificate_pem: string
has_private_key: boolean
}
export interface CreateCertificateDTO {
name: string;
domain: string;
cert_type: string;
auto_renew: boolean;
}
export interface EditCertificateDTO {
name: string
domain: string
auto_renew: boolean
}

View File

@@ -0,0 +1,4 @@
export * from './actions'
export * from './dto'
export * from './slice'
export * from './selectors'

View File

@@ -0,0 +1,3 @@
import type { RootState } from '../../../store';
export const getCertificatesState = (state: RootState) => state.certificates;

View File

@@ -0,0 +1,32 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { CertificateDTO } from './dto';
export interface CertificatesState {
loading: boolean;
certificates: CertificateDTO[]
error: null | string;
}
const initialState: CertificatesState = {
loading: false,
certificates: [],
error: null,
};
export const certificateSlice = createSlice({
name: 'certificates',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setUsers: (state, action: PayloadAction<CertificateDTO[]>) => {
state.certificates = action.payload
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload
}
},
});

View File

@@ -0,0 +1,2 @@
export * from './duck'
export * from './components'

View File

@@ -0,0 +1,4 @@
export * from './servers'
export * from './templates'
export * from './users'
export * from './certificates'

View File

@@ -0,0 +1,44 @@
import { useForm, type SubmitHandler } from 'react-hook-form';
import { createServerAction } from '../../duck';
import { useAppDispatch } from '../../../../common/hooks';
import type { CreateServerForm } from '../../types';
export const AddServer = () => {
const dispatch = useAppDispatch();
const { register, handleSubmit, reset } = useForm<CreateServerForm>();
const onSubmit: SubmitHandler<CreateServerForm> = (values) => {
const data = {
...values,
grpc_port: parseInt(values.grpc_port)
}
dispatch(createServerAction(data)).then(() => {
reset();
});
};
return (
<div className="section">
<h2>Add Server</h2>
<form onSubmit={handleSubmit(onSubmit)} id="serverForm">
<div className="form-group">
<label>Name:</label>
<input {...register('name', { required: true })} />
</div>
<div className="form-group">
<label>Hostname:</label>
<input {...register('hostname', { required: true })} />
</div>
<div className="form-group">
<label>gRPC Port:</label>
<input {...register('grpc_port', { required: true })} />
</div>
<button type="submit" className="btn btn-primary">
Add Server
</button>
</form>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from './servers-list'

View File

@@ -0,0 +1,94 @@
import { useEffect, type FC } from 'react';
import { useForm } from 'react-hook-form';
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
} from '@heroui/react';
import type { CreateServerForm } from '../../types';
import { getServer } from '../../duck/api';
import { useAppDispatch } from '../../../../common/hooks';
import { updateServer } from '../../duck';
export interface ServerEditProps {
serverId: string;
isOpen: boolean;
onOpenChange: () => void;
}
export const ServerEdit: FC<ServerEditProps> = (props) => {
const dispatch = useAppDispatch();
const { serverId, isOpen, onOpenChange } = props;
const { register, handleSubmit, reset } = useForm<CreateServerForm>();
useEffect(() => {
getServer(serverId).then((response) => {
const { data } = response;
reset({
...data,
grpc_port: String(data.grpc_port),
});
});
}, [serverId]);
const onSubmit = (values: CreateServerForm) => {
const data = {
...values,
grpc_port: parseInt(values.grpc_port),
};
dispatch(
updateServer({
id: serverId,
server: data,
}),
).then(() => {
onOpenChange();
});
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<form onSubmit={handleSubmit(onSubmit)}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
Modal Title
</ModalHeader>
<ModalBody>
<div className="form-group">
<label>Name:</label>
<input {...register('name', { required: true })} />
</div>
<div className="form-group">
<label>Hostname:</label>
<input {...register('hostname', { required: true })} />
</div>
<div className="form-group">
<label>gRPC Port:</label>
<input
type="number"
{...register('grpc_port', { required: true })}
/>
</div>
</ModalBody>
<ModalFooter>
<Button color="primary" type="submit">
Save
</Button>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,60 @@
import type { FC } from 'react';
import { useDisclosure } from '@heroui/react';
import { deleteServerAction, type ServerDTO } from '../../duck';
import { testServer } from '../../duck/api';
import { appNotificator } from '../../../../utils/notification/app-notificator';
import { useAppDispatch } from '../../../../common/hooks';
import { ServerEdit } from './server-edit';
export interface ServerViewProps {
server: ServerDTO;
}
export const ServerView: FC<ServerViewProps> = ({ server }) => {
const dispatch = useAppDispatch();
const handleTestServer = () => {
testServer(server.id).then((result) => {
const { connected } = result.data;
appNotificator.add({
message: connected ? 'Connection OK' : 'Connection failed',
type: connected ? 'success' : 'error',
});
});
};
const handleDeleteServer = () => {
if (confirm('Delete server?')) {
dispatch(deleteServerAction(server.id));
}
};
const { isOpen, onOpen, onOpenChange } = useDisclosure();
return (
<>
<tr>
<td>{server.name}</td>
<td>{server.hostname}</td>
<td>{server.grpc_port}</td>
<td>{server.status}</td>
<td>
<button className="btn btn-success" onClick={handleTestServer}>
Test
</button>
<button className="btn btn-primary" onClick={onOpen}>
Edit
</button>
<button className="btn btn-danger" onClick={handleDeleteServer}>
Delete
</button>
</td>
</tr>
<ServerEdit
serverId={server.id}
isOpen={isOpen}
onOpenChange={onOpenChange}
/>
</>
);
};

View File

@@ -0,0 +1,30 @@
import type { FC } from 'react';
import type { ServerDTO } from '../../duck';
import { ServerView } from './server-view';
export interface ServersListProps {
servers: ServerDTO[];
}
export const ServersList: FC<ServersListProps> = (props) => {
const { servers } = props;
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Hostname</th>
<th>Port</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{servers.map((server) => (
<ServerView key={server.id} server={server} />
))}
</tbody>
</table>
);
};

View File

@@ -0,0 +1,99 @@
import { serversSlice } from './slice';
import { createServer, deleteServer, getServers, patchServer } from './api';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getServersState } from './selectors';
import type { RootState } from '../../../store';
import type { CreateServerDTO } from './dto';
import { appNotificator } from '../../../utils/notification/app-notificator';
const PREFFIX = 'servers';
export const fetchServers = createAsyncThunk(
`${PREFFIX}/fetchAll`,
async (_, { dispatch, getState }) => {
const { loading } = getServersState(getState() as RootState);
try {
if (loading) {
return;
}
dispatch(serversSlice.actions.setLoading(true));
const response = await getServers().then(({ data }) => data);
dispatch(serversSlice.actions.setServers(response));
} catch (e) {
const message =
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
dispatch(serversSlice.actions.setError(message));
} finally {
dispatch(serversSlice.actions.setLoading(false));
}
},
);
export const createServerAction = createAsyncThunk(
`${PREFFIX}/createServer`,
async (params: CreateServerDTO, { dispatch }) => {
try {
await createServer(params);
dispatch(fetchServers());
} catch (e) {
appNotificator.add({
message:
e instanceof Error
? e.message
: `Unknown error in ${PREFFIX}/createServer`,
type: 'error',
});
}
},
);
export const deleteServerAction = createAsyncThunk(
`${PREFFIX}/deleteServer`,
async (id: string, { dispatch }) => {
try {
await deleteServer(id);
appNotificator.add({
message: 'Server deleted',
type: 'success',
});
dispatch(fetchServers());
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Delete error: ${e.message}`
: `Unknown error in ${PREFFIX}/deleteServer`,
});
}
},
);
export const updateServer = createAsyncThunk(
`${PREFFIX}/updateServer`,
async (
params: {
id: string;
server: CreateServerDTO;
},
{ dispatch },
) => {
try {
await patchServer(params.id, params.server);
dispatch(fetchServers());
appNotificator.add({
message: 'Server updated',
type: 'success',
});
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Error updating: ${e.message}`
: `Unknown error in ${PREFFIX}/deleteServer`,
});
}
},
);

View File

@@ -0,0 +1,28 @@
import type { AxiosResponse } from 'axios';
import { api } from '../../../api/api';
import type { ServerDTO, CreateServerDTO, TestServerDTO } from './dto';
export const getServers = () =>
api.get<never, AxiosResponse<ServerDTO[]>>('/servers');
export const createServer = (params: CreateServerDTO) =>
api.post<AxiosResponse>('servers', params, {
headers: {
'Content-Type': 'application/json',
},
});
export const testServer = (id: string) =>
api.post<TestServerDTO>(`/servers/${id}/test`);
export const deleteServer = (id: string) => api.delete(`/servers/${id}`);
export const getServer = (id: string) =>
api.get<string, AxiosResponse<ServerDTO>>(`/servers/${id}`);
export const patchServer = (id: string, server: CreateServerDTO) =>
api.put(`/servers/${id}`, server, {
headers: { 'Content-Type': 'application/json' },
});

View File

@@ -0,0 +1,18 @@
export interface ServerDTO {
id: string
name: string
hostname: string
grpc_port: number
status: string
}
export interface CreateServerDTO {
name: string;
hostname: string;
grpc_port: number;
}
export interface TestServerDTO {
connected: boolean,
endpoint: string
}

View File

@@ -0,0 +1,4 @@
export * from './actions'
export * from './dto'
export * from './slice'
export * from './selectors'

View File

@@ -0,0 +1,3 @@
import type { RootState } from '../../../store';
export const getServersState = (state: RootState) => state.servers;

View File

@@ -0,0 +1,32 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { ServerDTO } from './dto';
export interface ServersState {
loading: boolean;
servers: ServerDTO[];
error: null | string;
}
const initialState: ServersState = {
loading: false,
servers: [],
error: null,
};
export const serversSlice = createSlice({
name: 'servers',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setServers: (state, action: PayloadAction<ServerDTO[]>) => {
state.servers = action.payload
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload
}
},
});

View File

@@ -0,0 +1 @@
export * from './duck'

View File

@@ -0,0 +1,5 @@
export interface CreateServerForm {
name: string;
hostname: string;
grpc_port: string;
}

View File

@@ -0,0 +1 @@
export * from './form'

View File

@@ -0,0 +1,57 @@
import { useForm, type SubmitHandler } from 'react-hook-form'
import type { CreateTemplateForm } from '../../types';
import { useAppDispatch } from '../../../../common/hooks';
import { protocolOptions } from './util'
import type { CreateTemplateDTO } from '../../duck/dto';
import { createTemplateAction } from '../../duck';
export const AddTemplate = () => {
const dispatch = useAppDispatch()
const { register, handleSubmit, reset } = useForm<CreateTemplateForm>({
defaultValues: {
default_port: '443'
}
});
const onSubmit: SubmitHandler<CreateTemplateForm> = (values) => {
const data: CreateTemplateDTO = {
...values,
default_port: parseInt(values.default_port),
config_template: ''
}
dispatch(createTemplateAction(data)).then(() => {
reset();
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} id="templateForm">
<div className="form-group">
<label>Name:</label>
<input type="text" {...register('name', { required: true })} />
</div>
<div className="form-group">
<label>Protocol:</label>
<select {...register('protocol', {required: true})}>
{Object.entries(protocolOptions).map((protocolTupple)=> (
<option value={protocolTupple[0]}>{protocolTupple[1]}</option>
))}
</select>
</div>
<div className="form-group">
<label>Default Port:</label>
<input type="number" {...register('default_port', {required: true})} />
</div>
<div className="form-group">
<label>
<input type="checkbox" {...register('requires_tls')}/> Requires TLS
</label>
</div>
<button type="submit" className="btn btn-primary">
Add Template
</button>
</form>
)
}

View File

@@ -0,0 +1 @@
export * from './add-template'

View File

@@ -0,0 +1,9 @@
import type { Protocol } from '../../duck/dto'
export const protocolOptions: Record<Protocol, string> = {
vless: 'VLESS',
vmess: 'VMess',
trojan: 'Trojan',
shadowsocks: 'Shadowsocks'
}

View File

@@ -0,0 +1,2 @@
export * from './add-template'
export * from './template-list'

View File

@@ -0,0 +1 @@
export * from './template-list'

View File

@@ -0,0 +1,120 @@
import { useEffect, type FC } from 'react';
import { useForm } from 'react-hook-form';
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
} from '@heroui/react';
import type { EditTemplateForm } from '../../types';
import { useAppDispatch } from '../../../../common/hooks';
import { getTemplateById } from '../../duck/api';
import { protocolOptions } from '../add-template/util';
import { updateTemplate } from '../../duck';
export interface TemplateEditProps {
templateId: string;
isOpen: boolean;
onOpenChange: () => void;
}
export const TemplateEdit: FC<TemplateEditProps> = (props) => {
const dispatch = useAppDispatch();
const { templateId, isOpen, onOpenChange } = props;
const { register, handleSubmit, reset } = useForm<EditTemplateForm>();
useEffect(() => {
getTemplateById(templateId).then((response) => {
const { data } = response;
reset({
...data,
default_port: String(data.default_port),
});
});
}, [templateId]);
const onSubmit = (values: EditTemplateForm) => {
const data = {
...values,
default_port: parseInt(values.default_port),
};
dispatch(
updateTemplate({
id: templateId,
template: data,
}),
).then(() => {
onOpenChange();
});
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<form onSubmit={handleSubmit(onSubmit)}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
Modal Title
</ModalHeader>
<ModalBody>
<div>
<div className="form-group">
<label>Name:</label>
<input
type="text"
{...register('name', { required: true })}
/>
</div>
<div className="form-group">
<label>Protocol:</label>
<select
id="editProtocol"
{...register('protocol', { required: true })}
>
{Object.entries(protocolOptions).map((protocolTupple) => (
<option value={protocolTupple[0]}>
{protocolTupple[1]}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Default Port:</label>
<input
type="number"
{...register('default_port', {required: true})}
/>
</div>
<div className="form-group">
<label>
<input type="checkbox" {...register('requires_tls')} />{' '}
Requires TLS
</label>
</div>
<div className="form-group">
<label>
<input type="checkbox" {...register('is_active')} />{' '}
Active
</label>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button color="primary" type="submit">
Save
</Button>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,29 @@
import type { FC } from 'react';
import type { TemplateDTO } from '../../duck';
import { TemplateView } from './template-view';
export interface TemplateListProps {
templates: TemplateDTO[];
}
export const TemplateList: FC<TemplateListProps> = ({ templates }) => {
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Protocol</th>
<th>Port</th>
<th>TLS</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{templates.map((template) => (
<TemplateView template={template} key={template.id}/>
))}
</tbody>
</table>
);
};

View File

@@ -0,0 +1,48 @@
import type { FC } from 'react';
import { deleteTemplateAction, type TemplateDTO } from '../../duck';
import { useDisclosure } from '@heroui/react';
import { TemplateEdit } from './template-edit';
import { useAppDispatch } from '../../../../common/hooks';
export interface TemplateViewProps {
template: TemplateDTO;
}
export const TemplateView: FC<TemplateViewProps> = ({ template }) => {
const dispatch = useAppDispatch();
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const handleDeleteTemplate = () => {
if (confirm('Delete template?')) {
dispatch(deleteTemplateAction(template.id));
}
};
return (
<>
<tr>
<td>{template.name}</td>
<td>{template.protocol}</td>
<td>{template.default_port}</td>
<td>{template.requires_tls ? 'Yes' : 'No'}</td>
<td>{template.is_active ? 'Yes' : 'No'}</td>
<td>
<button className="btn btn-primary" onClick={onOpen}>
Edit
</button>
<button
className="btn btn-danger"
onClick={handleDeleteTemplate}
>
Delete
</button>
</td>
</tr>
<TemplateEdit
templateId={template.id}
onOpenChange={onOpenChange}
isOpen={isOpen}
/>
</>
);
};

View File

@@ -0,0 +1,102 @@
import { templatesSlice } from './slice';
import { getTemplates, createTemplate, patchTemplate, deleteTemplate } from './api';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getTemplatesState } from './selectors';
import type { RootState } from '../../../store';
import type { CreateTemplateDTO, EditTemplateDTO } from './dto';
import { appNotificator } from '../../../utils/notification/app-notificator';
const PREFFIX = 'templates';
export const fetchTemplates = createAsyncThunk(
`${PREFFIX}/fetchAll`,
async (_, { dispatch, getState }) => {
const { loading } = getTemplatesState(getState() as RootState);
try {
if (loading) {
return;
}
dispatch(templatesSlice.actions.setLoading(true));
const response = await getTemplates().then(({ data }) => data);
dispatch(templatesSlice.actions.setTemplates(response));
} catch (e) {
const message =
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
dispatch(templatesSlice.actions.setError(message));
} finally {
dispatch(templatesSlice.actions.setLoading(false));
}
},
);
export const createTemplateAction = createAsyncThunk(
`${PREFFIX}/createTemplate`,
async (params: CreateTemplateDTO, { dispatch }) => {
try {
await createTemplate(params);
dispatch(fetchTemplates());
} catch (e) {
appNotificator.add({
message:
e instanceof Error
? e.message
: `Unknown error in ${PREFFIX}/createTemplate`,
type: 'error',
});
}
},
);
export const updateTemplate = createAsyncThunk(
`${PREFFIX}/updateTemplate`,
async (
params: {
id: string;
template: EditTemplateDTO;
},
{ dispatch },
) => {
try {
await patchTemplate(params.id, params.template);
dispatch(fetchTemplates());
appNotificator.add({
message: 'Template updated',
type: 'success',
});
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Error updating: ${e.message}`
: `Unknown error in ${PREFFIX}/updateTemplate`,
});
}
},
);
export const deleteTemplateAction = createAsyncThunk(
`${PREFFIX}/deleteTemplate`,
async (id: string, { dispatch }) => {
try {
await deleteTemplate(id);
appNotificator.add({
message: 'Template deleted',
type: 'success',
});
dispatch(fetchTemplates());
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Delete error: ${e.message}`
: `Unknown error in ${PREFFIX}/deleteTemplate`,
});
}
},
);

View File

@@ -0,0 +1,23 @@
import type { AxiosResponse } from 'axios';
import { api } from '../../../api/api';
import type { TemplateDTO, CreateTemplateDTO, EditTemplateDTO } from './dto';
export const getTemplates = () =>
api.get<never, AxiosResponse<TemplateDTO[]>>('/templates');
export const createTemplate = (params: CreateTemplateDTO) =>
api.post<AxiosResponse>('templates', params, {
headers: {
'Content-Type': 'application/json',
},
});
export const getTemplateById = (id: string) =>
api.get<string, AxiosResponse<TemplateDTO>>(`/templates/${id}`);
export const patchTemplate = (id: string, template: EditTemplateDTO) =>
api.put(`/templates/${id}`, template, {
headers: { 'Content-Type': 'application/json' },
});
export const deleteTemplate = (id: string) => api.delete(`/templates/${id}`);

View File

@@ -0,0 +1,33 @@
export type Protocol = 'vless' | 'vmess' | 'trojan' | 'shadowsocks';
export interface TemplateDTO {
base_settings: Record<string, unknown>; // TODO define unknown
created_at: string;
default_port: number;
description: string;
id: string;
is_active: boolean;
name: string;
protocol: Protocol;
requires_domain: boolean;
requires_tls: boolean;
stream_settings: Record<string, unknown>; // TOD define unknown
updated_at: string;
variables: unknown[]; // TOD define unknown
}
export interface CreateTemplateDTO {
name: string;
protocol: Protocol;
default_port: number;
requires_tls: boolean;
config_template: '';
}
export interface EditTemplateDTO {
name: string,
protocol: Protocol
default_port: number
requires_tls: boolean
is_active: boolean
}

View File

@@ -0,0 +1,4 @@
export * from './actions'
export * from './dto'
export * from './slice'
export * from './selectors'

View File

@@ -0,0 +1,3 @@
import type { RootState } from '../../../store';
export const getTemplatesState = (state: RootState) => state.templates;

View File

@@ -0,0 +1,32 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { TemplateDTO } from './dto';
export interface TemplateState {
loading: boolean;
templates: TemplateDTO[];
error: null | string;
}
const initialState: TemplateState = {
loading: false,
templates: [],
error: null,
};
export const templatesSlice = createSlice({
name: 'templates',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setTemplates: (state, action: PayloadAction<TemplateDTO[]>) => {
state.templates = action.payload
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload
}
},
});

View File

@@ -0,0 +1,2 @@
export * from './duck'
export * from './components'

View File

@@ -0,0 +1,16 @@
import type { Protocol } from "../duck/dto"
export interface CreateTemplateForm {
name: string,
protocol: Protocol
default_port: string
requires_tls: boolean
}
export interface EditTemplateForm {
name: string,
protocol: Protocol
default_port: string
requires_tls: boolean
is_active: boolean
}

View File

@@ -0,0 +1 @@
export * from './form'

View File

@@ -0,0 +1,30 @@
import { usersSlice } from './slice';
import { getUsers } from './api';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getUsersState } from './selectors';
import type { RootState } from '../../../store';
const PREFFIX = 'users'
export const fetchUsers = createAsyncThunk(
`${PREFFIX}/fetchAll`,
async (_, { dispatch, getState }) => {
const { loading } = getUsersState(getState() as RootState);
try {
if (loading) {
return;
}
dispatch(usersSlice.actions.setLoading(true));
const response = await getUsers().then(({ data }) => data);
dispatch(usersSlice.actions.setUsers(response));
} catch (e) {
const message =
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
dispatch(usersSlice.actions.setError(message));
} finally {
dispatch(usersSlice.actions.setLoading(false));
}
},
);

View File

@@ -0,0 +1,5 @@
import type { AxiosResponse } from 'axios';
import { api } from '../../../api/api';
import type { UserDTO } from './dto';
export const getUsers = () => api.get<never, AxiosResponse<UserDTO[]>>('/users');

View File

@@ -0,0 +1,8 @@
export interface User {}
export interface UserDTO {
page: number
per_page: number
total: number
users: User[]
}

View File

@@ -0,0 +1,4 @@
export * from './actions'
export * from './dto'
export * from './slice'
export * from './selectors'

View File

@@ -0,0 +1,3 @@
import type { RootState } from '../../../store';
export const getUsersState = (state: RootState) => state.users;

View File

@@ -0,0 +1,32 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { UserDTO, User } from './dto';
export interface UsersState {
loading: boolean;
users: User[]
error: null | string;
}
const initialState: UsersState = {
loading: false,
users: [],
error: null,
};
export const usersSlice = createSlice({
name: 'certificate',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload
},
setUsers: (state, action: PayloadAction<UserDTO[]>) => {
state.users = action.payload.users
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload
}
},
});

View File

@@ -0,0 +1 @@
export * from './duck'

2
client/src/hero.ts Normal file
View File

@@ -0,0 +1,2 @@
import { heroui } from "@heroui/react";
export default heroui();

5
client/src/index.css Normal file
View File

@@ -0,0 +1,5 @@
@import "tailwindcss";
@plugin './hero.ts';
@source '../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
@custom-variant dark (&:is(.dark *));

22
client/src/main.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router/dom';
import { store } from './store/store';
import { Provider } from 'react-redux';
import { HeroUIProvider } from '@heroui/react';
import {ToastProvider} from "@heroui/toast";
import { router } from './router';
import './index.css';
import { ApplyNotificator } from './common/components/apply-notificator';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<HeroUIProvider>
<RouterProvider router={router} />
<ToastProvider/>
<ApplyNotificator/>
</HeroUIProvider>
</Provider>
</StrictMode>,
);

View File

@@ -0,0 +1,38 @@
import type { RouteObject } from 'react-router';
import { useAppDispatch, useAppSelector } from '../../common/hooks';
import { useEffect } from 'react';
import { fetchCertificates, getCertificatesState } from '../../features';
import { CreateCertificate } from '../../features/certificates';
import { CertificateList } from '../../features/certificates/components/certificate-list';
export const Certificates = () => {
const dispatch = useAppDispatch()
const { loading, certificates } = useAppSelector(getCertificatesState)
useEffect(()=>{
dispatch(fetchCertificates())
}, [dispatch])
return (
<div id="certificates" className="tab-content active">
<div className="section">
<h2>Add Certificate</h2>
<CreateCertificate/>
</div>
<div className="section">
<h2>Certificates List</h2>
<div id="certificatesList" className="loading">
{ loading && 'Loading...' }
{ certificates.length ? <CertificateList certificates={certificates}/> : <p>No certificates found</p> }
</div>
</div>
</div>
);
};
export const CertificatesRoute: RouteObject = {
path: '/certificates',
Component: Certificates,
};

View File

@@ -0,0 +1 @@
export * from './certificates';

View File

@@ -0,0 +1,72 @@
import type { RouteObject } from 'react-router';
import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../common/hooks';
import {
fetchServers,
getServersState,
fetchTemplates,
getTemplatesState,
fetchUsers,
getUsersState,
getCertificatesState,
fetchCertificates,
} from '../../features';
export const Dashboard = () => {
const dispatch = useAppDispatch();
const { loading: serverLoading, servers } = useAppSelector(getServersState);
const { loading: usersLoading, users } = useAppSelector(getUsersState);
const { loading: certificatesLoading, certificates } =
useAppSelector(getCertificatesState);
const { loading: templatesLoading, templates } =
useAppSelector(getTemplatesState);
useEffect(() => {
dispatch(fetchServers());
dispatch(fetchTemplates());
dispatch(fetchUsers());
dispatch(fetchCertificates());
}, [dispatch]);
return (
<div id="dashboard" className="tab-content active">
<div className="section">
<h2>Statistics</h2>
<p>
Servers:{' '}
<span id="serverCount">
{serverLoading === true && 'Loading...'}
{servers && String(servers.length)}
</span>
</p>
<p>
Templates:{' '}
<span id="templateCount">
{templatesLoading && 'Loading...'}
{templates && String(templates.length)}
</span>
</p>
<p>
Certificates:{' '}
<span id="certCount">
{certificatesLoading && 'Loading...'}
{certificates && String(certificates.length)}
</span>
</p>
<p>
Users:{' '}
<span id="userCount">
{usersLoading && 'Loading...'}
{users && String(users.length)}
</span>
</p>
</div>
</div>
);
};
export const DashboardRoute: RouteObject = {
index: true,
path: '/',
Component: Dashboard,
};

View File

@@ -0,0 +1 @@
export * from './dashboard';

View File

@@ -0,0 +1,238 @@
body {
font-family: Arial, sans-serif;
margin: 20px;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.section {
background: white;
margin: 20px 0;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h1 {
color: #333;
}
h2 {
color: #666;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
th,
td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
th {
background: #f9f9f9;
}
.btn {
padding: 6px 12px;
margin: 2px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.form-group {
margin: 10px 0;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input,
.form-group select {
width: 300px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
/* Toast notifications */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
pointer-events: none;
}
.toast {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
margin-bottom: 10px;
padding: 16px 20px;
min-width: 300px;
max-width: 400px;
position: relative;
transform: translateX(100%);
transition:
transform 0.3s ease-in-out,
opacity 0.3s ease-in-out;
opacity: 0;
pointer-events: auto;
border-left: 4px solid #007bff;
}
.toast.show {
transform: translateX(0);
opacity: 1;
}
.toast.success {
border-left-color: #28a745;
}
.toast.error {
border-left-color: #dc3545;
}
.toast-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.toast-title {
font-weight: bold;
color: #333;
}
.toast-close {
background: none;
border: none;
font-size: 18px;
color: #999;
cursor: pointer;
padding: 0;
margin-left: 10px;
}
.toast-close:hover {
color: #666;
}
.toast-body {
color: #666;
line-height: 1.4;
}
.toast.success .toast-title {
color: #155724;
}
.toast.error .toast-title {
color: #721c24;
}
.tabs {
border-bottom: 1px solid #ddd;
margin-bottom: 20px;
}
.tab {
display: inline-block;
padding: 10px 20px;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tab.active {
border-bottom-color: #007bff;
background: #f8f9fa;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
/* Modal styles */
.modal {
display: none;
position: fixed;
z-index: 10000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal.show {
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 8px;
padding: 20px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.modal-title {
font-size: 18px;
font-weight: bold;
color: #333;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
color: #999;
cursor: pointer;
}
.modal-close:hover {
color: #666;
}
.modal-body {
margin-bottom: 20px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid #eee;
padding-top: 15px;
}
.cert-details {
font-family: monospace;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 15px;
white-space: pre-wrap;
overflow-x: auto;
max-height: 300px;
}

View File

@@ -0,0 +1,82 @@
import { Outlet } from 'react-router';
import './home.css';
import { NavMenu } from '../../components/nav-menu/nav-menu';
import { navItems } from './utils';
export const Home = () => {
return (
<div>
<div className="container">
<h1 className="text-3xl font-bold underline">Xray Admin Panel - Test Interface</h1>
{/* <!-- Toast notifications container --> */}
<div className="toast-container" id="toastContainer"></div>
<NavMenu items={navItems} />
<Outlet />
</div>
{/* <!-- Modal dialogs --> */}
<div id="editModal" className="modal">
<div className="modal-content">
<div className="modal-header">
<div className="modal-title" id="editModalTitle">
Edit Item
</div>
<button
className="modal-close"
// onClick="closeModal('editModal')"
>
&times;
</button>
</div>
<div className="modal-body" id="editModalBody">
{/* <!-- Content will be dynamically loaded --> */}
</div>
<div className="modal-footer">
<button
className="btn btn-secondary"
// onClick="closeModal('editModal')"
>
Cancel
</button>
<button
className="btn btn-primary"
id="saveEditBtn"
// onClick="saveEdit()"
>
Save
</button>
</div>
</div>
</div>
<div id="viewModal" className="modal">
<div className="modal-content">
<div className="modal-header">
<div className="modal-title" id="viewModalTitle">
View Details
</div>
<button
className="modal-close"
//onClick="closeModal('viewModal')"
>
&times;
</button>
</div>
<div className="modal-body" id="viewModalBody">
{/* <!-- Content will be dynamically loaded --> */}
</div>
<div className="modal-footer">
<button
className="btn btn-secondary"
// onClick="closeModal('viewModal')"
>
Close
</button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from './home';

View File

@@ -0,0 +1,26 @@
export const navItems = [
{
href: '/',
label: 'Dashboard',
},
{
href: '/servers',
label: 'Servers',
},
{
href: '/inbound-templates',
label: 'Inbound Templates',
},
{
href: '/certificates',
label: 'Certificates',
},
{
href: '/inbound-binding',
label: 'Inbound Binding',
},
{
href: '/users',
label: 'Users',
},
];

View File

@@ -0,0 +1,55 @@
import type { RouteObject } from 'react-router';
export const InboundBinding = () => {
return (
<div id="inbounds" className="tab-content active">
<div className="section">
<h2>Bind Template to Server</h2>
<form id="inboundForm">
<div className="form-group">
<label>Server:</label>
<select id="inboundServer" required>
<option value="">Select Server...</option>
</select>
</div>
<div className="form-group">
<label>Template:</label>
<select id="inboundTemplate" required>
<option value="">Select Template...</option>
</select>
</div>
<div className="form-group">
<label>Port:</label>
<input type="number" id="inboundPort" value="443" required />
</div>
<div className="form-group">
<label>Certificate:</label>
<select id="inboundCertificate">
<option value="">No Certificate</option>
</select>
</div>
<div className="form-group">
<label>
<input type="checkbox" id="inboundActive" checked /> Active
</label>
</div>
<button type="submit" className="btn btn-primary">
Bind Template
</button>
</form>
</div>
<div className="section">
<h2>Server Inbounds</h2>
<div id="inboundsList" className="loading">
Loading...
</div>
</div>
</div>
);
};
export const InboundBindingRoute: RouteObject = {
path: '/inbound-binding',
Component: InboundBinding,
};

View File

@@ -0,0 +1 @@
export * from './inbound-binding';

View File

@@ -0,0 +1,39 @@
import type { RouteObject } from 'react-router';
import { AddTemplate, fetchTemplates, getTemplatesState, TemplateList } from '../../features/templates';
import { useAppDispatch, useAppSelector } from '../../common/hooks';
import { useEffect } from 'react';
export const InboundTemplates = () => {
const dispatch = useAppDispatch()
const { loading, templates } = useAppSelector(getTemplatesState);
useEffect(()=>{
dispatch(fetchTemplates())
}, [dispatch])
return (
<div id="templates" className="tab-content active">
<div className="section">
<h2>Add Template</h2>
<AddTemplate />
</div>
<div className="section">
<h2>Templates List</h2>
<div id="templatesList" className="loading">
{loading && 'Loading...'}
{templates.length ? (
<TemplateList templates={templates}/>
) : (
<p>No templates found</p>
)}
</div>
</div>
</div>
);
};
export const InboundTemplatesRoute: RouteObject = {
path: '/inbound-templates',
Component: InboundTemplates,
};

View File

@@ -0,0 +1 @@
export * from './inboud-templates';

View File

@@ -0,0 +1,7 @@
export * from './home';
export * from './certificates';
export * from './dashboard';
export * from './inbound-binding';
export * from './inbound-templates';
export * from './servers';
export * from './users';

View File

@@ -0,0 +1 @@
export * from './servers';

View File

@@ -0,0 +1,39 @@
import { useEffect } from 'react';
import type { RouteObject } from 'react-router';
import { AddServer } from '../../features/servers/components/add-server/add-server';
import { fetchServers, getServersState } from '../../features';
import { useAppDispatch, useAppSelector } from '../../common/hooks';
import clsx from 'clsx';
import { ServersList } from '../../features/servers/components/servers-list';
export const Servers = () => {
const dispatch = useAppDispatch();
const { loading, servers } = useAppSelector(getServersState);
useEffect(() => {
dispatch(fetchServers());
}, [dispatch]);
return (
<div id="servers" className="tab-content active">
<AddServer />
<div className="section">
<h2>Servers List</h2>
<div id="serversList" className={clsx({ loading: loading })}>
{loading && 'Loading...'}
{servers.length ? (
<ServersList servers={servers} />
) : (
<p>No servers found</p>
)}
</div>
</div>
</div>
);
};
export const ServersRoute: RouteObject = {
path: '/servers',
Component: Servers,
};

View File

@@ -0,0 +1 @@
export * from './users';

Some files were not shown because too many files have changed in this diff Show More