mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-14 16:46:07 +00:00
Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 867a145979 | |||
| 6185db586a | |||
| 4c71669815 | |||
| c6123f9628 | |||
| 2ed85aadda | |||
| b71ed1e3ee | |||
| 95aebf1d83 | |||
| 09df07ddf5 | |||
| c5b71041d3 | |||
| 9c60ed7ea8 | |||
| edf0f36940 | |||
| 237b7c898d | |||
| 7368359924 | |||
| f2f5d584b3 | |||
| 3d1d75d65a | |||
| 6e2816d035 | |||
| 7fc7c14ac1 | |||
| 5f318f3b16 | |||
| 9f80cfedab | |||
| 1b436bb3e0 | |||
| 5b5ac3f04b | |||
| 3827d7d061 | |||
| d7f47d8b6a | |||
| fd3770c8c9 | |||
| 758e1ad050 | |||
| 121b6e0bd0 | |||
| bb5ea3af05 | |||
| b36e5e0869 | |||
| 788c979ad1 | |||
| 66f946ee54 | |||
| 6000bc7134 | |||
| 85e2ded0e1 | |||
| f9ae0347c6 | |||
| 2928b52b04 | |||
| 07cdb82027 | |||
| f00f82b392 | |||
| 5a1019534f | |||
| 78f1719c6d | |||
| 5cf8a08540 | |||
| 79a9be7b22 | |||
| 19d50bd16c | |||
| 3af45c1462 | |||
| 6badd829df | |||
| b79abc8bc9 | |||
| 05b68c3b13 | |||
| f3c7660f84 | |||
| 9b0fd047cb | |||
| e4218a1029 | |||
| 7065d41be6 | |||
| 1284756f8a | |||
| 1f052c0e8f | |||
| ae6f13b533 | |||
| 1cf2582e6d | |||
| eacb9f63b0 | |||
| e7035b56fe | |||
| 5f526e5201 | |||
| bd8d33980f | |||
| 5dc02a9af3 | |||
| 033c5993e0 | |||
| 2204c8231d | |||
| 01a7dc807b | |||
| 6bf4a2c4f0 | |||
| 21058eb63c | |||
| 194de8869e | |||
| 26accfd8f7 | |||
| 2551a673c3 | |||
| ce4c42e09c | |||
| 18614bd6ea | |||
| e564c9283d | |||
| 933567d423 | |||
| 771bc7c8ef | |||
| 61ab602887 | |||
| adc262a238 | |||
| 67b098dfd3 | |||
| 5543466fcc | |||
| b10a9f1de7 | |||
| 4399fe2a85 | |||
| 6c6b40e063 | |||
| b97ff40ad6 | |||
| 46b6f8c66c | |||
| 102df7a290 | |||
| f29c8a5e29 | |||
| ad81649c16 | |||
| b47f794ed0 | |||
| 4e1b597914 | |||
| bbefe91011 | |||
| e40554a7d5 | |||
| 3569b1be73 | |||
| 38da210ded | |||
| 9fc47b3d41 | |||
| 210c25cf13 | |||
| 5dd7e44594 | |||
| 4e4a8e9ff7 | |||
| 23970e72a7 | |||
| 8bdb093d6e | |||
| 3b0bcb910e | |||
| 428f1333ac | |||
| 406cb6dbc0 | |||
| 5fb36d34c9 | |||
| 4884a2972a | |||
| 6e12329d9d | |||
| 9f7e8178d4 | |||
| 60e6b12f4c | |||
| 0dbadf82c0 | |||
| 48e90bba51 | |||
| 6de9b24229 | |||
| 07bc74a521 | |||
| f570b991e7 | |||
| 80031e67cc | |||
| fdaa65ad7e | |||
| d86e87ed30 | |||
| 89a8f549f2 | |||
| 355bb4c9c0 | |||
| 9feeccffc0 | |||
| cb962175c2 | |||
| 8f3202f431 | |||
| 0cb6568fd5 | |||
| 6a90f98412 | |||
| 9318c2105f | |||
| e642f7324e | |||
| 7214ffafc5 | |||
| 88061bac10 | |||
| b5479f3f30 | |||
| d8aedcdde4 | |||
| 5f3e9ed0ea | |||
| 3e8a0eb93e | |||
| 4c2915586c | |||
| 9f06bffbea | |||
| e20d73ba7e | |||
| 8834e5fbbe | |||
| 6d732d8d32 | |||
| e4900f1bd4 | |||
| 04828246fc | |||
| c1efc48694 | |||
| f1760b0a28 | |||
| 745e394c74 | |||
| 737300b14b | |||
| 30469fcd10 | |||
| 887fca86ec |
@@ -9,3 +9,11 @@ updates:
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "**.html"
|
||||
- "**.css"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- ".nvmrc"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "**.html"
|
||||
- "**.css"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- ".nvmrc"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
go-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Stub web/dist for go:embed
|
||||
run: mkdir -p web/dist && touch web/dist/.gitkeep
|
||||
- name: Test
|
||||
run: |
|
||||
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
|
||||
go test $(cat /tmp/go-packages.txt)
|
||||
|
||||
govulncheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Stub web/dist for go:embed
|
||||
run: mkdir -p web/dist && touch web/dist/.gitkeep
|
||||
- name: Install govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
- name: Run govulncheck
|
||||
run: govulncheck ./...
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: frontend
|
||||
- name: Audit
|
||||
run: npm audit --audit-level=high
|
||||
working-directory: frontend
|
||||
@@ -2,9 +2,29 @@ name: "CodeQL Advanced"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags-ignore:
|
||||
- "v*"
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "frontend/package-lock.json"
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "frontend/package-lock.json"
|
||||
schedule:
|
||||
- cron: "18 2 * * 2"
|
||||
|
||||
@@ -35,9 +55,6 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# The Go binary embeds web/dist/ via //go:embed all:dist (web/web.go).
|
||||
# web/dist/ is .gitignored, so CodeQL's autobuild for Go will fail with
|
||||
# "pattern all:dist: no matching files found" unless vite emits it first.
|
||||
- name: Setup Node.js
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -19,6 +19,17 @@ on:
|
||||
- "x-ui.service.arch"
|
||||
- "x-ui.service.rhel"
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.js"
|
||||
- "**.css"
|
||||
- "**.html"
|
||||
- "**.sh"
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "x-ui.service.debian"
|
||||
- "x-ui.service.arch"
|
||||
- "x-ui.service.rhel"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -105,7 +116,7 @@ jobs:
|
||||
cd x-ui/bin
|
||||
|
||||
# Download dependencies
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.5.9/"
|
||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-64.zip
|
||||
unzip Xray-linux-64.zip
|
||||
@@ -239,7 +250,7 @@ jobs:
|
||||
cd x-ui\bin
|
||||
|
||||
# Download Xray for Windows
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.5.9/"
|
||||
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
||||
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
||||
Remove-Item "Xray-windows-64.zip"
|
||||
|
||||
@@ -16,6 +16,9 @@ tmp/
|
||||
backup/
|
||||
bin/
|
||||
dist/
|
||||
!web/dist/
|
||||
web/dist/*
|
||||
!web/dist/.gitkeep
|
||||
release/
|
||||
node_modules/
|
||||
|
||||
|
||||
Vendored
+5
-19
@@ -10,26 +10,12 @@
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"XUI_DEBUG": "true"
|
||||
"XUI_DEBUG": "true",
|
||||
"XUI_DB_FOLDER": "x-ui",
|
||||
"XUI_LOG_FOLDER": "x-ui",
|
||||
"XUI_BIN_FOLDER": "x-ui"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Run 3x-ui (Debug, custom env)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
// Set to true to serve assets/templates directly from disk for development
|
||||
"XUI_DEBUG": "true",
|
||||
// Uncomment to override DB folder location (by default uses working dir on Windows when debug)
|
||||
// "XUI_DB_FOLDER": "${workspaceFolder}",
|
||||
// Example: override log level (debug|info|notice|warn|error)
|
||||
// "XUI_LOG_LEVEL": "debug"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+14
@@ -70,6 +70,20 @@
|
||||
"problemMatcher": [
|
||||
"$go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "go: fmt",
|
||||
"type": "shell",
|
||||
"command": "gofmt",
|
||||
"args": [
|
||||
"-l",
|
||||
"-w",
|
||||
"."
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
+225
-4
@@ -1,5 +1,226 @@
|
||||
## Local Development Setup
|
||||
# Contributing
|
||||
|
||||
- Create a directory named `x-ui` in the project root
|
||||
- Rename `.env.example` to `.env `
|
||||
- Run `main.go`
|
||||
Thanks for taking the time to contribute to 3x-ui. This guide gets a development panel running locally and explains the conventions the project follows so changes land cleanly.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.26+** (the version pinned in `go.mod`)
|
||||
- **Node.js 22+** and npm 10+ (for the React frontend)
|
||||
- **Git**
|
||||
- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.
|
||||
|
||||
### Windows: MinGW-w64
|
||||
|
||||
`go build` on Windows fails with `cgo: C compiler "gcc" not found` until a GCC toolchain is installed. Two options — pick whichever fits.
|
||||
|
||||
**Option A — standalone zip (fastest, no package manager)**
|
||||
|
||||
1. Download the latest build from <https://github.com/niXman/mingw-builds-binaries/releases>. For most setups, pick a release named:
|
||||
```
|
||||
x86_64-<version>-release-posix-seh-ucrt-rt_<n>-rev<m>.7z
|
||||
```
|
||||
(64-bit, POSIX threads, SEH exceptions, UCRT runtime — matches modern Windows defaults.)
|
||||
2. Extract it somewhere stable, e.g. `C:\mingw64\`.
|
||||
3. Add `C:\mingw64\bin` to the **Windows** `PATH` (System Properties → Environment Variables → Path → New).
|
||||
4. Open a fresh terminal and confirm:
|
||||
```powershell
|
||||
gcc --version
|
||||
```
|
||||
|
||||
**Option B — MSYS2 (when a Unix shell is also useful)**
|
||||
|
||||
1. Install MSYS2 from <https://www.msys2.org/>.
|
||||
2. Open the **MSYS2 UCRT64** shell from the Start menu and update once:
|
||||
```bash
|
||||
pacman -Syu
|
||||
```
|
||||
3. Install the UCRT64 toolchain:
|
||||
```bash
|
||||
pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkg-config
|
||||
```
|
||||
4. Add `C:\msys64\ucrt64\bin` to the Windows `PATH`.
|
||||
5. Verify with `gcc --version` in a fresh terminal.
|
||||
|
||||
After either path, `go build ./...` and `go run .` work normally.
|
||||
|
||||
> **Why MinGW-w64 over MSVC:** `mattn/go-sqlite3` officially supports GCC, builds are faster on Windows, and the toolchain does not require a Visual Studio install. If Visual Studio Build Tools are already present that works too — just make sure `CC=cl` is **not** set in the environment.
|
||||
|
||||
Cross-building the Linux SQLite target from Windows (or vice versa) requires a separate cross-compiler and is out of scope here; build natively on the target OS.
|
||||
|
||||
## First-time setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MHSanaei/3x-ui.git
|
||||
cd 3x-ui
|
||||
|
||||
cp .env.example .env
|
||||
|
||||
mkdir x-ui
|
||||
|
||||
go mod download
|
||||
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
```
|
||||
|
||||
`.env.example` ships with defaults that keep the database, logs, and xray binary inside the local `x-ui/` folder so nothing escapes the project directory:
|
||||
|
||||
```
|
||||
XUI_DEBUG=true
|
||||
XUI_DB_FOLDER=x-ui
|
||||
XUI_LOG_FOLDER=x-ui
|
||||
XUI_BIN_FOLDER=x-ui
|
||||
```
|
||||
|
||||
Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` and `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows, `wintun.dll` is also required for testing TUN inbounds.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `admin`. Credentials must be changed on first login.
|
||||
|
||||
### Inside VS Code
|
||||
|
||||
The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy from the snippet below if absent):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run 3x-ui (Debug)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"XUI_DEBUG": "true",
|
||||
"XUI_DB_FOLDER": "x-ui",
|
||||
"XUI_LOG_FOLDER": "x-ui",
|
||||
"XUI_BIN_FOLDER": "x-ui"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Working on the frontend
|
||||
|
||||
The panel UI is a **React 19 + Ant Design 6 + TypeScript** app under `frontend/`, built with Vite 8. The sections below cover the architecture, the conventions, and the two dev workflows.
|
||||
|
||||
### Architecture
|
||||
|
||||
The frontend is a **multi-page application**, not a SPA. Every panel route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/xray`, `/panel/settings`, `/panel/nodes`, `/panel/api-docs`, `/panel/sub`, plus `login`) has its own HTML entry in `frontend/*.html` and its own bootstrap in `src/entries/<page>.tsx`. Vite emits each entry into `web/dist/`, and the Go binary embeds that directory at compile time via `embed.FS`. Each panel navigation is a real document load, but every per-page bundle is small enough to keep the experience responsive. There is no React Router and no global store; the surface area does not justify either.
|
||||
|
||||
### State and data flow
|
||||
|
||||
- **No global store.** State lives in the page that owns it. Cross-page data (settings, current user, theme) is re-fetched on each page load — the backend is local and responses are inexpensive.
|
||||
- **Hooks** in `src/hooks/` encapsulate reactive logic worth sharing inside a page (`useTheme`, `useStatus`, `useNodes`, `useWebSocket`, `useDatepicker`, …). Prefer extending an existing hook over introducing a new global.
|
||||
- **Domain models** in `src/models/` (`Inbound`, `DBInbound`, `Outbound`, `Status`, …) own the protocol-specific logic — link generation, settings JSON shape, TLS/Reality stream handling. React components stay declarative; they ask the model "what is my link?" and render the answer.
|
||||
- **HTTP** goes through `src/utils/index.js`'s `HttpUtil`, a thin Axios wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in `src/api/axios-init.js`.
|
||||
|
||||
### i18n
|
||||
|
||||
Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/`. The Go binary embeds the same JSON and serves it to both backend templates and `react-i18next` (initialized in `src/i18n/react.ts`). When a new English key is added it must also land in **every** non-English locale — missing keys do not break the build, they just render the raw key in the UI.
|
||||
|
||||
### Two dev workflows
|
||||
|
||||
| Goal | Command |
|
||||
|------|---------|
|
||||
| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and `/api/*` to the Go panel on `:2053`). Start the Go panel first. |
|
||||
| Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
|
||||
|
||||
The Vite dev proxy rewrites the sidebar's production-style links (`/panel`, `/panel/inbounds`, `/panel/clients`, …) to the matching Vite-served HTML, so navigation behaves identically to production without round-tripping through Go. The allowlist lives in `MIGRATED_ROUTES` in `vite.config.js` — register every new page there.
|
||||
|
||||
> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
|
||||
|
||||
### Adding a new page
|
||||
|
||||
1. Create `frontend/<page>.html` (copy an existing entry and adjust the title and the imported `<script type="module" src="/src/entries/<page>.tsx">`).
|
||||
2. Create `src/entries/<page>.tsx` — mount the page with `createRoot(document.getElementById('app')!).render(...)`, wrapped in the shared `ConfigProvider` for AntD theming and i18n.
|
||||
3. Create the page component under `src/pages/<page>/<Page>.tsx` (kebab-case folder, PascalCase component).
|
||||
4. Register the entry in `rollupOptions.input` inside `vite.config.js`.
|
||||
5. If the page is reachable from the sidebar at `/panel/<route>`, add `<route>` to `MIGRATED_ROUTES` so dev-mode navigation works.
|
||||
6. Wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in production.
|
||||
|
||||
### Conventions
|
||||
|
||||
- **TypeScript strict mode** — all new code in `.ts` / `.tsx`. Run `npm run typecheck` (`tsc --noEmit`) before pushing. The path alias `@/*` resolves to `src/*`.
|
||||
- **Ant Design 6** is the only UI kit — no Tailwind, no shadcn. A previous attempt to migrate was rolled back. Small, targeted UX tweaks beat sweeping rewrites; raise broader visual changes for discussion before implementing.
|
||||
- **Function components + hooks** everywhere. No class components.
|
||||
- **No `//` line comments** in committed JS/TS/Vue/Go. HTML `<!-- ... -->` is fine for template structure. Names should carry the meaning; rename rather than annotate. Comments are reserved for the *why*, and only when the reason is surprising.
|
||||
- **RTL is a first-class concern.** Persian and Arabic users matter — RTL is enabled through AntD's `ConfigProvider direction="rtl"`. When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows.
|
||||
- **Do not break link generation.** Share-link generation has two paths: the **inbounds page** (`InboundsPage.tsx` → `checkFallback()`) and the **clients page** (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`). Exercise both whenever URL generation, fallback projection, or TLS handling changes.
|
||||
- **Vite is pinned** to `8.0.13`. Do not bump to `8.0.14+` — the esbuild dep-optimizer in those builds breaks i18n loading in dev mode.
|
||||
|
||||
### Project layout
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── *.html — Vite entry HTML, one per panel route
|
||||
├── tsconfig.json — strict, jsx: "react-jsx", paths "@/*" → "src/*"
|
||||
├── eslint.config.js — ESLint 10 flat config (@eslint/js + typescript-eslint + react-hooks)
|
||||
├── vite.config.js
|
||||
└── src/
|
||||
├── entries/ — per-page bootstrap (createRoot + render)
|
||||
├── pages/ — one folder per route (index, login, inbounds, clients, xray, nodes, settings, api-docs, sub)
|
||||
├── components/ — cross-page React components (AppSidebar, DateTimePicker, FinalMaskForm, JsonEditor, …)
|
||||
├── hooks/ — reusable hooks (useTheme, useStatus, useNodes, useWebSocket, useDatepicker, …)
|
||||
├── api/ — Axios setup + CSRF interceptor + WebSocket client
|
||||
├── i18n/ — react-i18next bootstrap (JSON lives in web/translation/)
|
||||
├── models/ — Inbound, DBInbound, Outbound, Status, reality-targets, …
|
||||
├── styles/ — shared CSS (page-cards, …)
|
||||
└── utils/ — HttpUtil, ObjectUtil, LanguageManager, RandomUtil, SizeFormatter, …
|
||||
```
|
||||
|
||||
For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/README.md).
|
||||
|
||||
## Project layout
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `main.go` | Process entry point, CLI subcommands, signal handling |
|
||||
| `web/` | Gin HTTP server, controllers, services, embedded frontend assets |
|
||||
| `frontend/` | React + Ant Design 6 + TypeScript source for the panel UI |
|
||||
| `database/` | GORM models, migrations, seeders (SQLite / PostgreSQL) |
|
||||
| `xray/` | Xray-core process lifecycle and gRPC API client |
|
||||
| `sub/` | Subscription endpoints (raw, JSON, Clash) |
|
||||
| `config/` | Environment-variable helpers, paths, defaults |
|
||||
| `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
|
||||
|
||||
## Sending a pull request
|
||||
|
||||
1. Branch off `main` (e.g. `feat/short-description`).
|
||||
2. Keep the diff focused — separate refactors from feature work.
|
||||
3. Run the relevant checks before pushing:
|
||||
- `go build ./...`
|
||||
- `go test ./...` (when Go code changed)
|
||||
- `cd frontend && npm run typecheck && npm run lint && npm run build` (when the frontend changed)
|
||||
4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged.
|
||||
5. Open the PR against `main` with a brief description of what changed and how to test it.
|
||||
|
||||
## Useful environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `XUI_DEBUG` | `false` | Verbose logs + Gin debug mode + serve `/assets` from disk |
|
||||
| `XUI_LOG_LEVEL` | `info` | `debug` / `info` / `notice` / `warning` / `error` |
|
||||
| `XUI_DB_FOLDER` | platform default | Where `x-ui.db` lives |
|
||||
| `XUI_LOG_FOLDER` | platform default | Where `3xui.log` lives |
|
||||
| `XUI_BIN_FOLDER` | `bin` | Where the xray binary, geo files, and xray `config.json` live |
|
||||
| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
|
||||
| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
|
||||
|
||||
## Issues and discussion
|
||||
|
||||
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
|
||||
- General questions and ideas: [GitHub Discussions](https://github.com/MHSanaei/3x-ui/discussions)
|
||||
|
||||
Before filing a bug, include the OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.
|
||||
|
||||
+3
-3
@@ -22,7 +22,7 @@ EOF
|
||||
|
||||
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
|
||||
[Definition]
|
||||
datepattern = ^%Y/%m/%d %H:%M:%S
|
||||
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
|
||||
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||||
ignoreregex =
|
||||
EOF
|
||||
@@ -43,10 +43,10 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
||||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%Y/%m/%d %H:%M:%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%Y/%m/%d %H:%M:%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
[Init]
|
||||
name = default
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ case $1 in
|
||||
esac
|
||||
mkdir -p build/bin
|
||||
cd build/bin
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/Xray-linux-${ARCH}.zip"
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.5.9/Xray-linux-${ARCH}.zip"
|
||||
unzip "Xray-linux-${ARCH}.zip"
|
||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
||||
mv xray "xray-linux-${FNAME}"
|
||||
|
||||
@@ -64,6 +64,10 @@ RUN chmod +x \
|
||||
/usr/bin/x-ui
|
||||
|
||||
ENV XUI_ENABLE_FAIL2BAN="true"
|
||||
# Database backend: set XUI_DB_TYPE=postgres and XUI_DB_DSN=postgres://... to use PostgreSQL.
|
||||
# Default (unset) is SQLite stored under /etc/x-ui.
|
||||
ENV XUI_DB_TYPE=""
|
||||
ENV XUI_DB_DSN=""
|
||||
EXPOSE 2053
|
||||
VOLUME [ "/etc/x-ui" ]
|
||||
CMD [ "./x-ui" ]
|
||||
|
||||
@@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (الترخيص: **GPL-3.0**): _قواعد توجيه v2ray/xray و v2ray/xray-clients المحسنة مع النطاقات الإيرانية المدمجة وتركيز على الأمان وحظر الإعلانات._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (الترخيص: **GPL-3.0**): _يحتوي هذا المستودع على قواعد توجيه V2Ray محدثة تلقائيًا بناءً على بيانات النطاقات والعناوين المحظورة في روسيا._
|
||||
|
||||
## أدوات المجتمع
|
||||
|
||||
أدوات وتكاملات بناها المجتمع حول 3x-ui.
|
||||
|
||||
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (الترخيص: **MIT**): _إدارة الاتصالات الواردة والعملاء وإعدادات اللوحة وتكوين Xray كرمز باستخدام Terraform / OpenTofu._
|
||||
|
||||
## دعم المشروع
|
||||
|
||||
**إذا كان هذا المشروع مفيدًا لك، فقد ترغب في إعطائه**:star2:
|
||||
|
||||
@@ -39,6 +39,12 @@ Para documentación completa, visita la [Wiki del proyecto](https://github.com/M
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Licencia: **GPL-3.0**): _Reglas de enrutamiento mejoradas para v2ray/xray y v2ray/xray-clients con dominios iraníes incorporados y un enfoque en seguridad y bloqueo de anuncios._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Licencia: **GPL-3.0**): _Este repositorio contiene reglas de enrutamiento V2Ray actualizadas automáticamente basadas en datos de dominios y direcciones bloqueadas en Rusia._
|
||||
|
||||
## Herramientas de la Comunidad
|
||||
|
||||
Herramientas e integraciones construidas por la comunidad alrededor de 3x-ui.
|
||||
|
||||
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Licencia: **MIT**): _Gestiona inbounds, clientes, configuración del panel y configuración de Xray como código con Terraform / OpenTofu._
|
||||
|
||||
## Apoyar el Proyecto
|
||||
|
||||
**Si este proyecto te es útil, puedes darle una**:star2:
|
||||
|
||||
@@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (مجوز: **GPL-3.0**): _قوانین مسیریابی بهبود یافته v2ray/xray و v2ray/xray-clients با دامنههای ایرانی داخلی و تمرکز بر امنیت و مسدود کردن تبلیغات._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (مجوز: **GPL-3.0**): _این مخزن شامل قوانین مسیریابی V2Ray بهروزرسانی شده خودکار بر اساس دادههای دامنهها و آدرسهای مسدود شده در روسیه است._
|
||||
|
||||
## ابزارهای جامعه
|
||||
|
||||
ابزارها و یکپارچهسازیهایی که توسط جامعه پیرامون 3x-ui ساخته شدهاند.
|
||||
|
||||
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (مجوز: **MIT**): _مدیریت اینباندها، کلاینتها، تنظیمات پنل و پیکربندی Xray بهصورت کد با Terraform / OpenTofu._
|
||||
|
||||
## پشتیبانی از پروژه
|
||||
|
||||
**اگر این پروژه برای شما مفید است، میتوانید به آن یک**:star2: بدهید
|
||||
|
||||
@@ -30,6 +30,38 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
||||
|
||||
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
## Database Options
|
||||
|
||||
3X-UI supports two backends, chosen during the install:
|
||||
|
||||
- **SQLite** (default) — a single file at `/etc/x-ui/x-ui.db`. Zero setup, ideal for small/medium deployments.
|
||||
- **PostgreSQL** — recommended for high client counts or multi-node setups. The installer can install PostgreSQL locally for you, or accept a DSN to an existing server.
|
||||
|
||||
At runtime the backend is selected via env vars (the installer writes these to `/etc/default/x-ui` for you):
|
||||
|
||||
```
|
||||
XUI_DB_TYPE=postgres
|
||||
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
|
||||
```
|
||||
|
||||
### Migrating an existing SQLite install to PostgreSQL
|
||||
|
||||
```bash
|
||||
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
|
||||
# then set XUI_DB_TYPE and XUI_DB_DSN in /etc/default/x-ui and restart:
|
||||
systemctl restart x-ui
|
||||
```
|
||||
|
||||
The source SQLite file is left untouched; remove it manually once you have verified the new backend.
|
||||
|
||||
### Docker
|
||||
|
||||
The default `docker compose up -d` keeps using SQLite. To run with the bundled PostgreSQL service, uncomment the two `XUI_DB_*` env lines in `docker-compose.yml` and start with the profile:
|
||||
|
||||
```bash
|
||||
docker compose --profile postgres up -d
|
||||
```
|
||||
|
||||
## A Special Thanks to
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
@@ -39,6 +71,12 @@ For full documentation, please visit the [project Wiki](https://github.com/MHSan
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _This repository contains automatically updated V2Ray routing rules based on data on blocked domains and addresses in Russia._
|
||||
|
||||
## Community Tools
|
||||
|
||||
Tools and integrations built by the community around 3x-ui.
|
||||
|
||||
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (License: **MIT**): _Manage inbounds, clients, panel settings, and Xray configuration as code with Terraform / OpenTofu._
|
||||
|
||||
## Support project
|
||||
|
||||
**If this project is helpful to you, you may wish to give it a**:star2:
|
||||
|
||||
@@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Лицензия: **GPL-3.0**): _Улучшенные правила маршрутизации для v2ray/xray и v2ray/xray-clients со встроенными иранскими доменами и фокусом на безопасность и блокировку рекламы._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Лицензия: **GPL-3.0**): _Этот репозиторий содержит автоматически обновляемые правила маршрутизации V2Ray на основе данных о заблокированных доменах и адресах в России._
|
||||
|
||||
## Инструменты сообщества
|
||||
|
||||
Инструменты и интеграции, созданные сообществом вокруг 3x-ui.
|
||||
|
||||
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Лицензия: **MIT**): _Управление входящими, клиентами, настройками панели и конфигурацией Xray через код с помощью Terraform / OpenTofu._
|
||||
|
||||
## Поддержка проекта
|
||||
|
||||
**Если этот проект полезен для вас, вы можете поставить ему**:star2:
|
||||
|
||||
@@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (许可证: **GPL-3.0**): _增强的 v2ray/xray 和 v2ray/xray-clients 路由规则,内置伊朗域名,专注于安全性和广告拦截。_
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (许可证: **GPL-3.0**): _此仓库包含基于俄罗斯被阻止域名和地址数据自动更新的 V2Ray 路由规则。_
|
||||
|
||||
## 社区工具
|
||||
|
||||
社区围绕 3x-ui 构建的工具和集成。
|
||||
|
||||
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (许可证: **MIT**): _使用 Terraform / OpenTofu 通过代码管理入站、客户端、面板设置和 Xray 配置。_
|
||||
|
||||
## 支持项目
|
||||
|
||||
**如果这个项目对您有帮助,您可以给它一个**:star2:
|
||||
|
||||
@@ -57,6 +57,11 @@ func IsDebug() bool {
|
||||
return os.Getenv("XUI_DEBUG") == "true"
|
||||
}
|
||||
|
||||
// IsSkipHSTS returns true if skipping HSTS mode is enabled via the XUI_SKIP_HSTS environment variable.
|
||||
func IsSkipHSTS() bool {
|
||||
return os.Getenv("XUI_SKIP_HSTS") == "true"
|
||||
}
|
||||
|
||||
// GetBinFolderPath returns the path to the binary folder, defaulting to "bin" if not set via XUI_BIN_FOLDER.
|
||||
func GetBinFolderPath() string {
|
||||
binFolderPath := os.Getenv("XUI_BIN_FOLDER")
|
||||
@@ -100,6 +105,22 @@ func GetDBPath() string {
|
||||
return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
|
||||
}
|
||||
|
||||
// GetDBKind returns the configured database backend: "sqlite" (default) or "postgres".
|
||||
func GetDBKind() string {
|
||||
v := strings.ToLower(strings.TrimSpace(os.Getenv("XUI_DB_TYPE")))
|
||||
switch v {
|
||||
case "postgres", "postgresql", "pg":
|
||||
return "postgres"
|
||||
default:
|
||||
return "sqlite"
|
||||
}
|
||||
}
|
||||
|
||||
// GetDBDSN returns the PostgreSQL DSN from XUI_DB_DSN. Empty for sqlite.
|
||||
func GetDBDSN() string {
|
||||
return strings.TrimSpace(os.Getenv("XUI_DB_DSN"))
|
||||
}
|
||||
|
||||
// GetLogFolder returns the path to the log folder based on environment variables or platform defaults.
|
||||
func GetLogFolder() string {
|
||||
logFolderPath := os.Getenv("XUI_LOG_FOLDER")
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.0.0
|
||||
3.1.0
|
||||
+287
-56
@@ -1,15 +1,18 @@
|
||||
// Package database provides database initialization, migration, and management utilities
|
||||
// for the 3x-ui panel using GORM with SQLite.
|
||||
// for the 3x-ui panel using GORM with SQLite or PostgreSQL.
|
||||
package database
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/config"
|
||||
@@ -17,6 +20,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
@@ -24,6 +28,27 @@ import (
|
||||
|
||||
var db *gorm.DB
|
||||
|
||||
const (
|
||||
DialectSQLite = "sqlite"
|
||||
DialectPostgres = "postgres"
|
||||
)
|
||||
|
||||
// IsPostgres reports whether the active connection is a PostgreSQL backend.
|
||||
func IsPostgres() bool {
|
||||
if db == nil {
|
||||
return config.GetDBKind() == "postgres"
|
||||
}
|
||||
return db.Dialector.Name() == "postgres"
|
||||
}
|
||||
|
||||
// Dialect returns the active GORM dialect name, or "" if the DB is not open.
|
||||
func Dialect() string {
|
||||
if db == nil {
|
||||
return ""
|
||||
}
|
||||
return db.Dialector.Name()
|
||||
}
|
||||
|
||||
const (
|
||||
defaultUsername = "admin"
|
||||
defaultPassword = "admin"
|
||||
@@ -40,9 +65,17 @@ func initModels() error {
|
||||
&model.HistoryOfSeeders{},
|
||||
&model.CustomGeoResource{},
|
||||
&model.Node{},
|
||||
&model.ApiToken{},
|
||||
&model.ClientRecord{},
|
||||
&model.ClientInbound{},
|
||||
&model.InboundFallback{},
|
||||
}
|
||||
for _, model := range models {
|
||||
if err := db.AutoMigrate(model); err != nil {
|
||||
for _, mdl := range models {
|
||||
if err := db.AutoMigrate(mdl); err != nil {
|
||||
if isIgnorableDuplicateColumnErr(err, mdl) {
|
||||
log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("Error auto migrating model: %v", err)
|
||||
return err
|
||||
}
|
||||
@@ -50,6 +83,32 @@ func initModels() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
// SQLite: "duplicate column name: foo"
|
||||
// Postgres: `pq: column "foo" of relation "bar" already exists` / `sqlstate 42701`
|
||||
const sqlitePrefix = "duplicate column name:"
|
||||
if _, after, ok := strings.Cut(errMsg, sqlitePrefix); ok {
|
||||
col := strings.TrimSpace(after)
|
||||
col = strings.Trim(col, "`\"[]")
|
||||
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
|
||||
}
|
||||
if strings.Contains(errMsg, "already exists") && strings.Contains(errMsg, "column ") {
|
||||
// Best effort: extract the column name between the first pair of double quotes.
|
||||
if _, after, ok := strings.Cut(errMsg, "column \""); ok {
|
||||
rest := after
|
||||
if e := strings.Index(rest, "\""); e > 0 {
|
||||
col := rest[:e]
|
||||
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// initUser creates a default admin user if the users table is empty.
|
||||
func initUser() error {
|
||||
empty, err := isTableEmpty("users")
|
||||
@@ -86,43 +145,195 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
return db.Create(hashSeeder).Error
|
||||
} else {
|
||||
var seedersHistory []string
|
||||
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
|
||||
log.Printf("Error fetching seeder history: %v", err)
|
||||
if err := db.Create(hashSeeder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return seedApiTokens()
|
||||
}
|
||||
|
||||
var seedersHistory []string
|
||||
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
|
||||
log.Printf("Error fetching seeder history: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
|
||||
var users []model.User
|
||||
if err := db.Find(&users).Error; err != nil {
|
||||
log.Printf("Error fetching users for password migration: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
|
||||
var users []model.User
|
||||
if err := db.Find(&users).Error; err != nil {
|
||||
log.Printf("Error fetching users for password migration: %v", err)
|
||||
for _, user := range users {
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
|
||||
log.Printf("Error updating password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
|
||||
log.Printf("Error updating password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
return db.Create(hashSeeder).Error
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
if err := db.Create(hashSeeder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "ApiTokensTable") {
|
||||
if err := seedApiTokens(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "ClientsTable") {
|
||||
if err := seedClientsFromInboundJSON(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeClientJSONFields coerces loosely-typed numeric fields in a raw
|
||||
// settings.clients entry so json.Unmarshal into model.Client doesn't fail
|
||||
// when older rows wrote tgId/limitIp/totalGB/etc. as strings. Empty strings
|
||||
// drop the key so the field falls back to its zero value.
|
||||
func normalizeClientJSONFields(obj map[string]any) {
|
||||
normalizeInt := func(key string) {
|
||||
raw, exists := obj[key]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
s, ok := raw.(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
trimmed := strings.ReplaceAll(strings.TrimSpace(s), " ", "")
|
||||
if trimmed == "" {
|
||||
delete(obj, key)
|
||||
return
|
||||
}
|
||||
if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
|
||||
obj[key] = n
|
||||
} else {
|
||||
delete(obj, key)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"tgId", "limitIp", "totalGB", "expiryTime", "reset", "created_at", "updated_at"} {
|
||||
normalizeInt(k)
|
||||
}
|
||||
}
|
||||
|
||||
func seedClientsFromInboundJSON() error {
|
||||
var inbounds []model.Inbound
|
||||
if err := db.Find(&inbounds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
byEmail := map[string]*model.ClientRecord{}
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
if strings.TrimSpace(inbound.Settings) == "" {
|
||||
continue
|
||||
}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
log.Printf("ClientsTable seed: skip inbound %d (invalid settings json): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
rawList, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, raw := range rawList {
|
||||
obj, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
normalizeClientJSONFields(obj)
|
||||
blob, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var c model.Client
|
||||
if err := json.Unmarshal(blob, &c); err != nil {
|
||||
log.Printf("ClientsTable seed: skip client in inbound %d (unmarshal failed): %v; payload=%s",
|
||||
inbound.Id, err, string(blob))
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(c.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
incoming := c.ToRecord()
|
||||
|
||||
row, dup := byEmail[email]
|
||||
if !dup {
|
||||
if err := tx.Create(incoming).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
byEmail[email] = incoming
|
||||
row = incoming
|
||||
} else {
|
||||
conflicts := model.MergeClientRecord(row, incoming)
|
||||
for _, x := range conflicts {
|
||||
log.Printf("client merge: email=%s conflict on %s old=%v new=%v kept=%v",
|
||||
email, x.Field, x.Old, x.New, x.Kept)
|
||||
}
|
||||
if err := tx.Save(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
link := model.ClientInbound{
|
||||
ClientId: row.Id,
|
||||
InboundId: inbound.Id,
|
||||
FlowOverride: c.Flow,
|
||||
}
|
||||
if err := tx.Where("client_id = ? AND inbound_id = ?", row.Id, inbound.Id).
|
||||
FirstOrCreate(&link).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Create(&model.HistoryOfSeeders{SeederName: "ClientsTable"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// seedApiTokens copies the legacy `apiToken` setting into the new
|
||||
// api_tokens table as a row named "default" so existing central panels
|
||||
// keep working after the upgrade. Idempotent — records itself in
|
||||
// history_of_seeders and only runs when api_tokens is empty.
|
||||
func seedApiTokens() error {
|
||||
empty, err := isTableEmpty("api_tokens")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if empty {
|
||||
var legacy model.Setting
|
||||
err := db.Model(model.Setting{}).Where("key = ?", "apiToken").First(&legacy).Error
|
||||
if err == nil && legacy.Value != "" {
|
||||
row := &model.ApiToken{
|
||||
Name: "default",
|
||||
Token: legacy.Value,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
log.Printf("Error migrating legacy apiToken: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
|
||||
}
|
||||
|
||||
// isTableEmpty returns true if the named table contains zero rows.
|
||||
func isTableEmpty(tableName string) (bool, error) {
|
||||
var count int64
|
||||
@@ -131,43 +342,64 @@ func isTableEmpty(tableName string) (bool, error) {
|
||||
}
|
||||
|
||||
// InitDB sets up the database connection, migrates models, and runs seeders.
|
||||
// When XUI_DB_TYPE=postgres, dbPath is ignored and XUI_DB_DSN is used instead.
|
||||
func InitDB(dbPath string) error {
|
||||
dir := path.Dir(dbPath)
|
||||
err := os.MkdirAll(dir, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var gormLogger logger.Interface
|
||||
|
||||
if config.IsDebug() {
|
||||
gormLogger = logger.Default
|
||||
gormLogger = logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Info,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: true,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
gormLogger = logger.Discard
|
||||
}
|
||||
c := &gorm.Config{Logger: gormLogger}
|
||||
|
||||
c := &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
}
|
||||
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
|
||||
db, err = gorm.Open(sqlite.Open(dsn), c)
|
||||
if err != nil {
|
||||
return err
|
||||
var err error
|
||||
switch config.GetDBKind() {
|
||||
case "postgres":
|
||||
dsn := config.GetDBDSN()
|
||||
if dsn == "" {
|
||||
return errors.New("XUI_DB_TYPE=postgres but XUI_DB_DSN is empty")
|
||||
}
|
||||
db, err = gorm.Open(postgres.Open(dsn), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
dir := path.Dir(dbPath)
|
||||
if err = os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
|
||||
db, err = gorm.Open(sqlite.Open(dsn), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(8)
|
||||
sqlDB.SetMaxIdleConns(4)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
@@ -220,13 +452,12 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
|
||||
}
|
||||
|
||||
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
|
||||
// No-op on PostgreSQL (WAL there is managed by the server).
|
||||
func Checkpoint() error {
|
||||
// Update WAL
|
||||
err := db.Exec("PRAGMA wal_checkpoint;").Error
|
||||
if err != nil {
|
||||
return err
|
||||
if IsPostgres() {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
return db.Exec("PRAGMA wal_checkpoint;").Error
|
||||
}
|
||||
|
||||
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package database
|
||||
|
||||
import "fmt"
|
||||
|
||||
// JSONClientsFromInbound returns the FROM clause that yields one row per element
|
||||
// of inbounds.settings -> clients, with a column named `client.value` whose text
|
||||
// fields can be read with JSONFieldText("client.value", "<key>").
|
||||
func JSONClientsFromInbound() string {
|
||||
if IsPostgres() {
|
||||
return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"
|
||||
}
|
||||
return "FROM inbounds, JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client"
|
||||
}
|
||||
|
||||
// JSONFieldText returns a SQL expression that extracts the textual value of <key>
|
||||
// from a JSON expression. On both backends the result is the raw (unquoted) string,
|
||||
// so callers do NOT need to trim surrounding quotes.
|
||||
func JSONFieldText(expr, key string) string {
|
||||
if IsPostgres() {
|
||||
return fmt.Sprintf("(%s ->> '%s')", expr, key)
|
||||
}
|
||||
// SQLite's JSON_EXTRACT on a text value returns the JSON-encoded form
|
||||
// (with surrounding quotes). Wrap it in json_extract(json_quote(...)) trick
|
||||
// is fragile; simpler: unwrap quotes with TRIM(BOTH '"').
|
||||
return fmt.Sprintf("TRIM(JSON_EXTRACT(%s, '$.%s'), '\"')", expr, key)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// migrationModels is the FK-aware order in which tables are created and copied.
|
||||
// Parents come before their children so foreign-key constraints stay satisfied
|
||||
// even when checks are not explicitly disabled.
|
||||
func migrationModels() []any {
|
||||
return []any{
|
||||
&model.User{},
|
||||
&model.Setting{},
|
||||
&model.HistoryOfSeeders{},
|
||||
&model.CustomGeoResource{},
|
||||
&model.Node{},
|
||||
&model.ApiToken{},
|
||||
&model.Inbound{},
|
||||
&xray.ClientTraffic{},
|
||||
&model.OutboundTraffics{},
|
||||
&model.InboundClientIps{},
|
||||
&model.ClientRecord{},
|
||||
&model.ClientInbound{},
|
||||
&model.InboundFallback{},
|
||||
}
|
||||
}
|
||||
|
||||
// MigrateData copies every row from the configured SQLite file at srcPath into
|
||||
// a fresh PostgreSQL database described by dstDSN. The destination tables are
|
||||
// (re)created with AutoMigrate before the copy. Source data is left untouched.
|
||||
func MigrateData(srcPath, dstDSN string) error {
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
return fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
|
||||
}
|
||||
if dstDSN == "" {
|
||||
return errors.New("destination DSN is required")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(path.Dir(srcPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcDSN := srcPath + "?_journal_mode=WAL&_busy_timeout=10000"
|
||||
src, err := gorm.Open(sqlite.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open sqlite source: %w", err)
|
||||
}
|
||||
srcSQL, err := src.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcSQL.Close()
|
||||
|
||||
dst, err := gorm.Open(postgres.Open(dstDSN), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open postgres destination: %w", err)
|
||||
}
|
||||
dstSQL, err := dst.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstSQL.Close()
|
||||
dstSQL.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
log.Println("Creating destination schema...")
|
||||
for _, m := range migrationModels() {
|
||||
if err := dst.AutoMigrate(m); err != nil {
|
||||
return fmt.Errorf("AutoMigrate %T: %w", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
totalRows := 0
|
||||
for _, m := range migrationModels() {
|
||||
n, err := copyTable(src, dst, m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy %T: %w", m, err)
|
||||
}
|
||||
totalRows += n
|
||||
log.Printf(" %-32s %d rows", reflect.TypeOf(m).Elem().Name(), n)
|
||||
}
|
||||
|
||||
if err := resetPostgresSequences(dst); err != nil {
|
||||
log.Printf("warning: failed to reset some postgres sequences: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Migration complete: %d rows across %d tables.", totalRows, len(migrationModels()))
|
||||
log.Println("Set XUI_DB_TYPE=postgres and XUI_DB_DSN=... in /etc/default/x-ui, then restart x-ui.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyTable streams every row of `mdl` from src to dst in batches.
|
||||
func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
|
||||
sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem()))
|
||||
batchPtr := reflect.New(sliceType)
|
||||
batchPtr.Elem().Set(reflect.MakeSlice(sliceType, 0, 0))
|
||||
|
||||
total := 0
|
||||
err := src.Model(mdl).FindInBatches(batchPtr.Interface(), 500, func(tx *gorm.DB, _ int) error {
|
||||
batch := batchPtr.Elem()
|
||||
if batch.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := dst.CreateInBatches(batchPtr.Interface(), 200).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
total += batch.Len()
|
||||
return nil
|
||||
}).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// resetPostgresSequences advances each table's id sequence past MAX(id),
|
||||
// otherwise the next INSERT-without-id would clash with copied rows.
|
||||
func resetPostgresSequences(dst *gorm.DB) error {
|
||||
tables := []string{
|
||||
"users", "inbounds", "outbound_traffics", "settings", "inbound_client_ips",
|
||||
"client_traffics", "history_of_seeders", "custom_geo_resources", "nodes",
|
||||
"api_tokens", "client_records", "client_inbounds", "inbound_fallback_children",
|
||||
}
|
||||
for _, t := range tables {
|
||||
// setval is a no-op if the table or its id sequence doesn't exist; we ignore errors per-table.
|
||||
_ = dst.Exec(fmt.Sprintf(
|
||||
`SELECT setval(pg_get_serial_sequence('%s','id'), COALESCE((SELECT MAX(id) FROM "%s"), 1), true)
|
||||
WHERE pg_get_serial_sequence('%s','id') IS NOT NULL`,
|
||||
t, t, t,
|
||||
)).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+419
-31
@@ -2,7 +2,10 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
@@ -21,12 +24,8 @@ const (
|
||||
Shadowsocks Protocol = "shadowsocks"
|
||||
Mixed Protocol = "mixed"
|
||||
WireGuard Protocol = "wireguard"
|
||||
// UI stores Hysteria v1 and v2 both as "hysteria" and uses
|
||||
// settings.version to discriminate. Imports from outside the panel
|
||||
// can carry the literal "hysteria2" string, so IsHysteria below
|
||||
// accepts both.
|
||||
Hysteria Protocol = "hysteria"
|
||||
Hysteria2 Protocol = "hysteria2"
|
||||
Hysteria Protocol = "hysteria"
|
||||
Hysteria2 Protocol = "hysteria2"
|
||||
)
|
||||
|
||||
// IsHysteria returns true for both "hysteria" and "hysteria2".
|
||||
@@ -38,9 +37,10 @@ func IsHysteria(p Protocol) bool {
|
||||
|
||||
// User represents a user account in the 3x-ui panel.
|
||||
type User struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
LoginEpoch int64 `json:"-" gorm:"default:0"`
|
||||
}
|
||||
|
||||
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
||||
@@ -50,7 +50,6 @@ type Inbound struct {
|
||||
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
|
||||
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
||||
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
||||
AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"` // All-time traffic usage
|
||||
Remark string `json:"remark" form:"remark"` // Human-readable remark
|
||||
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
@@ -66,12 +65,24 @@ type Inbound struct {
|
||||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||
Sniffing string `json:"sniffing" form:"sniffing"`
|
||||
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
||||
|
||||
// NodeID points at the remote panel (Node) where this inbound's xray
|
||||
// actually runs. NULL means the inbound runs on the local xray (the
|
||||
// pre-multi-node behaviour). Existing rows migrate to NULL with no
|
||||
// backfill.
|
||||
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
||||
// FallbackParent is populated by the API layer when this inbound is
|
||||
// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
|
||||
// The frontend uses it to rewrite client-share links so they advertise
|
||||
// the master's externally reachable endpoint instead of the child's
|
||||
// loopback listen. Not persisted.
|
||||
FallbackParent *FallbackParentInfo `json:"fallbackParent,omitempty" gorm:"-"`
|
||||
}
|
||||
|
||||
// FallbackParentInfo carries everything the frontend needs to rewrite a
|
||||
// child inbound's client link: where to connect (the master's address
|
||||
// and port) and which path matched on the master's fallbacks array.
|
||||
// The frontend already has the master inbound in its dbInbounds list,
|
||||
// so we only ship identifiers + the match path here.
|
||||
type FallbackParentInfo struct {
|
||||
MasterId int `json:"masterId"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
||||
@@ -90,25 +101,132 @@ type InboundClientIps struct {
|
||||
Ips string `json:"ips" form:"ips"`
|
||||
}
|
||||
|
||||
// MarshalJSON emits the Ips column as a real JSON array instead of an escaped
|
||||
// JSON-text string. Empty or unparseable storage renders as null so API
|
||||
// consumers don't have to special-case the legacy double-encoded shape.
|
||||
func (ic InboundClientIps) MarshalJSON() ([]byte, error) {
|
||||
type alias InboundClientIps
|
||||
return json.Marshal(struct {
|
||||
alias
|
||||
Ips json.RawMessage `json:"ips"`
|
||||
}{
|
||||
alias: alias(ic),
|
||||
Ips: jsonStringFieldToRaw(ic.Ips),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts ips as either a JSON array (modern shape) or a
|
||||
// JSON-encoded string (legacy shape), normalising back to the JSON-text the
|
||||
// column stores.
|
||||
func (ic *InboundClientIps) UnmarshalJSON(data []byte) error {
|
||||
type alias InboundClientIps
|
||||
aux := struct {
|
||||
*alias
|
||||
Ips json.RawMessage `json:"ips"`
|
||||
}{
|
||||
alias: (*alias)(ic),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
ic.Ips = jsonStringFieldFromRaw(aux.Ips)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.
|
||||
type HistoryOfSeeders struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
SeederName string `json:"seederName"`
|
||||
}
|
||||
|
||||
type ApiToken struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||
Token string `json:"token" gorm:"not null"`
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
}
|
||||
|
||||
// MarshalJSON emits settings, streamSettings, and sniffing as nested JSON
|
||||
// objects rather than escaped strings, so API consumers don't need to JSON.parse
|
||||
// a string inside a string. Empty fields render as null; fields whose stored
|
||||
// text isn't valid JSON fall back to a JSON-encoded string so no data is lost.
|
||||
func (i Inbound) MarshalJSON() ([]byte, error) {
|
||||
type alias Inbound
|
||||
return json.Marshal(struct {
|
||||
alias
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
StreamSettings json.RawMessage `json:"streamSettings"`
|
||||
Sniffing json.RawMessage `json:"sniffing"`
|
||||
}{
|
||||
alias: alias(i),
|
||||
Settings: jsonStringFieldToRaw(i.Settings),
|
||||
StreamSettings: jsonStringFieldToRaw(i.StreamSettings),
|
||||
Sniffing: jsonStringFieldToRaw(i.Sniffing),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts settings, streamSettings, and sniffing as either a raw
|
||||
// JSON object/array (the modern shape MarshalJSON emits) or a JSON-encoded
|
||||
// string (the legacy shape). Either form is normalised back to the JSON-text
|
||||
// string the DB column stores.
|
||||
func (i *Inbound) UnmarshalJSON(data []byte) error {
|
||||
type alias Inbound
|
||||
aux := struct {
|
||||
*alias
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
StreamSettings json.RawMessage `json:"streamSettings"`
|
||||
Sniffing json.RawMessage `json:"sniffing"`
|
||||
}{
|
||||
alias: (*alias)(i),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
i.Settings = jsonStringFieldFromRaw(aux.Settings)
|
||||
i.StreamSettings = jsonStringFieldFromRaw(aux.StreamSettings)
|
||||
i.Sniffing = jsonStringFieldFromRaw(aux.Sniffing)
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonStringFieldToRaw(s string) json.RawMessage {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
if trimmed == "" {
|
||||
return json.RawMessage("null")
|
||||
}
|
||||
if json.Valid([]byte(trimmed)) {
|
||||
return json.RawMessage(trimmed)
|
||||
}
|
||||
b, _ := json.Marshal(s)
|
||||
return b
|
||||
}
|
||||
|
||||
func jsonStringFieldFromRaw(r json.RawMessage) string {
|
||||
trimmed := bytes.TrimSpace(r)
|
||||
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
return ""
|
||||
}
|
||||
if trimmed[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(trimmed, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return string(trimmed)
|
||||
}
|
||||
|
||||
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
|
||||
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
||||
listen := i.Listen
|
||||
// Default to 0.0.0.0 (all interfaces) when listen is empty
|
||||
// This ensures proper dual-stack IPv4/IPv6 binding in systems where bindv6only=0
|
||||
if listen == "" {
|
||||
listen = "0.0.0.0"
|
||||
}
|
||||
listen = fmt.Sprintf("\"%v\"", listen)
|
||||
protocol := string(i.Protocol)
|
||||
return &xray.InboundConfig{
|
||||
Listen: json_util.RawMessage(listen),
|
||||
Port: i.Port,
|
||||
Protocol: string(i.Protocol),
|
||||
Protocol: protocol,
|
||||
Settings: json_util.RawMessage(i.Settings),
|
||||
StreamSettings: json_util.RawMessage(i.StreamSettings),
|
||||
Tag: i.Tag,
|
||||
@@ -128,15 +246,16 @@ type Setting struct {
|
||||
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
||||
// status fields below.
|
||||
type Node struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Scheme string `json:"scheme" form:"scheme"`
|
||||
Address string `json:"address" form:"address"`
|
||||
Port int `json:"port" form:"port"`
|
||||
BasePath string `json:"basePath" form:"basePath"`
|
||||
ApiToken string `json:"apiToken" form:"apiToken"`
|
||||
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Scheme string `json:"scheme" form:"scheme"`
|
||||
Address string `json:"address" form:"address"`
|
||||
Port int `json:"port" form:"port"`
|
||||
BasePath string `json:"basePath" form:"basePath"`
|
||||
ApiToken string `json:"apiToken" form:"apiToken"`
|
||||
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
||||
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
||||
|
||||
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
|
||||
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
||||
@@ -145,13 +264,19 @@ type Node struct {
|
||||
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never
|
||||
LatencyMs int `json:"latencyMs"`
|
||||
XrayVersion string `json:"xrayVersion"`
|
||||
PanelVersion string `json:"panelVersion" gorm:"column:panel_version"`
|
||||
CpuPct float64 `json:"cpuPct"`
|
||||
MemPct float64 `json:"memPct"`
|
||||
UptimeSecs uint64 `json:"uptimeSecs"`
|
||||
LastError string `json:"lastError"`
|
||||
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
|
||||
InboundCount int `json:"inboundCount" gorm:"-"`
|
||||
ClientCount int `json:"clientCount" gorm:"-"`
|
||||
OnlineCount int `json:"onlineCount" gorm:"-"`
|
||||
DepletedCount int `json:"depletedCount" gorm:"-"`
|
||||
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
}
|
||||
|
||||
type CustomGeoResource struct {
|
||||
@@ -162,8 +287,8 @@ type CustomGeoResource struct {
|
||||
LocalPath string `json:"localPath" gorm:"column:local_path"`
|
||||
LastUpdatedAt int64 `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
|
||||
LastModified string `json:"lastModified" gorm:"column:last_modified"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime;column:created_at"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli;column:created_at"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli;column:updated_at"`
|
||||
}
|
||||
|
||||
type ClientReverse struct {
|
||||
@@ -190,3 +315,266 @@ type Client struct {
|
||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||
}
|
||||
|
||||
type ClientRecord struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||
SubID string `json:"subId" gorm:"index;column:sub_id"`
|
||||
UUID string `json:"uuid" gorm:"column:uuid"`
|
||||
Password string `json:"password"`
|
||||
Auth string `json:"auth"`
|
||||
Flow string `json:"flow"`
|
||||
Security string `json:"security"`
|
||||
Reverse string `json:"reverse" gorm:"column:reverse"`
|
||||
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
||||
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||
Enable bool `json:"enable" gorm:"default:true"`
|
||||
TgID int64 `json:"tgId" gorm:"column:tg_id"`
|
||||
Comment string `json:"comment"`
|
||||
Reset int `json:"reset" gorm:"default:0"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
}
|
||||
|
||||
func (ClientRecord) TableName() string { return "clients" }
|
||||
|
||||
// MarshalJSON emits the reverse column as a nested JSON object rather than an
|
||||
// escaped JSON-text string, matching the same convention Inbound uses for its
|
||||
// JSON-text columns. Empty storage renders as null.
|
||||
func (r ClientRecord) MarshalJSON() ([]byte, error) {
|
||||
type alias ClientRecord
|
||||
return json.Marshal(struct {
|
||||
alias
|
||||
Reverse json.RawMessage `json:"reverse"`
|
||||
}{
|
||||
alias: alias(r),
|
||||
Reverse: jsonStringFieldToRaw(r.Reverse),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts reverse as either a JSON object (modern shape) or a
|
||||
// JSON-encoded string (legacy shape).
|
||||
func (r *ClientRecord) UnmarshalJSON(data []byte) error {
|
||||
type alias ClientRecord
|
||||
aux := struct {
|
||||
*alias
|
||||
Reverse json.RawMessage `json:"reverse"`
|
||||
}{
|
||||
alias: (*alias)(r),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
r.Reverse = jsonStringFieldFromRaw(aux.Reverse)
|
||||
return nil
|
||||
}
|
||||
|
||||
type ClientInbound struct {
|
||||
ClientId int `json:"clientId" gorm:"primaryKey;column:client_id;index"`
|
||||
InboundId int `json:"inboundId" gorm:"primaryKey;column:inbound_id;index"`
|
||||
FlowOverride string `json:"flowOverride" gorm:"column:flow_override"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
}
|
||||
|
||||
func (ClientInbound) TableName() string { return "client_inbounds" }
|
||||
|
||||
// InboundFallback is one routing rule on a master inbound's
|
||||
// settings.fallbacks array. The master is always a VLESS or Trojan
|
||||
// inbound on TCP transport with TLS or Reality. The child is any other
|
||||
// inbound — its listen+port becomes the fallback dest, with optional
|
||||
// SNI/ALPN/path match criteria pulled from the same row.
|
||||
type InboundFallback struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
MasterId int `json:"masterId" gorm:"index;not null;column:master_id"`
|
||||
ChildId int `json:"childId" gorm:"index;not null;column:child_id"`
|
||||
Name string `json:"name"`
|
||||
Alpn string `json:"alpn"`
|
||||
Path string `json:"path"`
|
||||
Xver int `json:"xver"`
|
||||
SortOrder int `json:"sortOrder" gorm:"default:0;column:sort_order"`
|
||||
}
|
||||
|
||||
func (InboundFallback) TableName() string { return "inbound_fallbacks" }
|
||||
|
||||
func (c *Client) ToRecord() *ClientRecord {
|
||||
rec := &ClientRecord{
|
||||
Email: c.Email,
|
||||
SubID: c.SubID,
|
||||
UUID: c.ID,
|
||||
Password: c.Password,
|
||||
Auth: c.Auth,
|
||||
Flow: c.Flow,
|
||||
Security: c.Security,
|
||||
LimitIP: c.LimitIP,
|
||||
TotalGB: c.TotalGB,
|
||||
ExpiryTime: c.ExpiryTime,
|
||||
Enable: c.Enable,
|
||||
TgID: c.TgID,
|
||||
Comment: c.Comment,
|
||||
Reset: c.Reset,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
}
|
||||
if c.Reverse != nil {
|
||||
if b, err := json.Marshal(c.Reverse); err == nil {
|
||||
rec.Reverse = string(b)
|
||||
}
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func (r *ClientRecord) ToClient() *Client {
|
||||
c := &Client{
|
||||
ID: r.UUID,
|
||||
Email: r.Email,
|
||||
SubID: r.SubID,
|
||||
Password: r.Password,
|
||||
Auth: r.Auth,
|
||||
Flow: r.Flow,
|
||||
Security: r.Security,
|
||||
LimitIP: r.LimitIP,
|
||||
TotalGB: r.TotalGB,
|
||||
ExpiryTime: r.ExpiryTime,
|
||||
Enable: r.Enable,
|
||||
TgID: r.TgID,
|
||||
Comment: r.Comment,
|
||||
Reset: r.Reset,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
if r.Reverse != "" {
|
||||
var rev ClientReverse
|
||||
if err := json.Unmarshal([]byte(r.Reverse), &rev); err == nil {
|
||||
c.Reverse = &rev
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
type ClientMergeConflict struct {
|
||||
Field string
|
||||
Old any
|
||||
New any
|
||||
Kept any
|
||||
}
|
||||
|
||||
func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
|
||||
var conflicts []ClientMergeConflict
|
||||
keep := func(field string, oldV, newV, kept any) {
|
||||
conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: oldV, New: newV, Kept: kept})
|
||||
}
|
||||
const redacted = "<redacted>"
|
||||
keepSecret := func(field string) {
|
||||
conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: redacted, New: redacted, Kept: redacted})
|
||||
}
|
||||
|
||||
incomingNewer := incoming.UpdatedAt > existing.UpdatedAt ||
|
||||
(incoming.UpdatedAt == existing.UpdatedAt && incoming.CreatedAt > existing.CreatedAt)
|
||||
|
||||
if existing.UUID != incoming.UUID && incoming.UUID != "" {
|
||||
if incomingNewer || existing.UUID == "" {
|
||||
existing.UUID = incoming.UUID
|
||||
}
|
||||
keepSecret("uuid")
|
||||
}
|
||||
if existing.Password != incoming.Password && incoming.Password != "" {
|
||||
if incomingNewer || existing.Password == "" {
|
||||
existing.Password = incoming.Password
|
||||
keepSecret("password")
|
||||
}
|
||||
}
|
||||
if existing.Auth != incoming.Auth && incoming.Auth != "" {
|
||||
if incomingNewer || existing.Auth == "" {
|
||||
existing.Auth = incoming.Auth
|
||||
keepSecret("auth")
|
||||
}
|
||||
}
|
||||
if existing.Flow != incoming.Flow && incoming.Flow != "" {
|
||||
if incomingNewer || existing.Flow == "" {
|
||||
keep("flow", existing.Flow, incoming.Flow, incoming.Flow)
|
||||
existing.Flow = incoming.Flow
|
||||
}
|
||||
}
|
||||
if existing.Security != incoming.Security && incoming.Security != "" {
|
||||
if incomingNewer || existing.Security == "" {
|
||||
keep("security", existing.Security, incoming.Security, incoming.Security)
|
||||
existing.Security = incoming.Security
|
||||
}
|
||||
}
|
||||
if existing.SubID != incoming.SubID && incoming.SubID != "" {
|
||||
if incomingNewer || existing.SubID == "" {
|
||||
existing.SubID = incoming.SubID
|
||||
keepSecret("subId")
|
||||
}
|
||||
}
|
||||
if existing.TotalGB != incoming.TotalGB {
|
||||
picked := existing.TotalGB
|
||||
if existing.TotalGB == 0 || (incoming.TotalGB != 0 && incoming.TotalGB > existing.TotalGB) {
|
||||
picked = incoming.TotalGB
|
||||
}
|
||||
if picked != existing.TotalGB {
|
||||
keep("totalGB", existing.TotalGB, incoming.TotalGB, picked)
|
||||
existing.TotalGB = picked
|
||||
}
|
||||
}
|
||||
if existing.ExpiryTime != incoming.ExpiryTime {
|
||||
picked := existing.ExpiryTime
|
||||
if existing.ExpiryTime == 0 || (incoming.ExpiryTime != 0 && incoming.ExpiryTime > existing.ExpiryTime) {
|
||||
picked = incoming.ExpiryTime
|
||||
}
|
||||
if picked != existing.ExpiryTime {
|
||||
keep("expiryTime", existing.ExpiryTime, incoming.ExpiryTime, picked)
|
||||
existing.ExpiryTime = picked
|
||||
}
|
||||
}
|
||||
if existing.LimitIP != incoming.LimitIP && incoming.LimitIP != 0 {
|
||||
picked := existing.LimitIP
|
||||
if existing.LimitIP == 0 || incoming.LimitIP > existing.LimitIP {
|
||||
picked = incoming.LimitIP
|
||||
}
|
||||
if picked != existing.LimitIP {
|
||||
keep("limitIp", existing.LimitIP, incoming.LimitIP, picked)
|
||||
existing.LimitIP = picked
|
||||
}
|
||||
}
|
||||
if existing.TgID != incoming.TgID && incoming.TgID != 0 {
|
||||
if incomingNewer || existing.TgID == 0 {
|
||||
keep("tgId", existing.TgID, incoming.TgID, incoming.TgID)
|
||||
existing.TgID = incoming.TgID
|
||||
}
|
||||
}
|
||||
if existing.Reset != incoming.Reset && incoming.Reset != 0 {
|
||||
if incomingNewer || existing.Reset == 0 {
|
||||
keep("reset", existing.Reset, incoming.Reset, incoming.Reset)
|
||||
existing.Reset = incoming.Reset
|
||||
}
|
||||
}
|
||||
if existing.Reverse != incoming.Reverse && incoming.Reverse != "" {
|
||||
if incomingNewer || existing.Reverse == "" {
|
||||
keep("reverse", existing.Reverse, incoming.Reverse, incoming.Reverse)
|
||||
existing.Reverse = incoming.Reverse
|
||||
}
|
||||
}
|
||||
if existing.Comment != incoming.Comment && incoming.Comment != "" {
|
||||
if incomingNewer || existing.Comment == "" {
|
||||
keep("comment", existing.Comment, incoming.Comment, incoming.Comment)
|
||||
existing.Comment = incoming.Comment
|
||||
}
|
||||
}
|
||||
if existing.Enable != incoming.Enable {
|
||||
if incoming.Enable {
|
||||
if !existing.Enable {
|
||||
keep("enable", existing.Enable, incoming.Enable, true)
|
||||
existing.Enable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if incoming.CreatedAt != 0 && (existing.CreatedAt == 0 || incoming.CreatedAt < existing.CreatedAt) {
|
||||
existing.CreatedAt = incoming.CreatedAt
|
||||
}
|
||||
if incoming.UpdatedAt > existing.UpdatedAt {
|
||||
existing.UpdatedAt = incoming.UpdatedAt
|
||||
}
|
||||
return conflicts
|
||||
}
|
||||
|
||||
@@ -1,6 +1,193 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInboundMarshalJSONNestsObjectFields(t *testing.T) {
|
||||
in := Inbound{
|
||||
Id: 7,
|
||||
Protocol: VLESS,
|
||||
Port: 443,
|
||||
Settings: `{"clients":[],"decryption":"none"}`,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
Sniffing: `{"enabled":true}`,
|
||||
}
|
||||
out, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
for _, field := range []string{"settings", "streamSettings", "sniffing"} {
|
||||
if _, ok := parsed[field].(map[string]any); !ok {
|
||||
t.Errorf("expected %s to marshal as a JSON object, got %T", field, parsed[field])
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(out), `"settings":"`) {
|
||||
t.Errorf("settings should not be emitted as a JSON string: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundMarshalJSONEmptyFieldsBecomeNull(t *testing.T) {
|
||||
in := Inbound{Id: 1, Protocol: VLESS}
|
||||
out, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
for _, field := range []string{"settings", "streamSettings", "sniffing"} {
|
||||
if parsed[field] != nil {
|
||||
t.Errorf("expected %s to be null, got %v", field, parsed[field])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "nested objects (modern)",
|
||||
body: `{"id":1,"settings":{"clients":[],"decryption":"none"},"streamSettings":{"network":"tcp"},"sniffing":{"enabled":true}}`,
|
||||
},
|
||||
{
|
||||
name: "JSON-encoded strings (legacy)",
|
||||
body: `{"id":1,"settings":"{\"clients\":[],\"decryption\":\"none\"}","streamSettings":"{\"network\":\"tcp\"}","sniffing":"{\"enabled\":true}"}`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var in Inbound
|
||||
if err := json.Unmarshal([]byte(tc.body), &in); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(in.Settings, `"decryption":"none"`) {
|
||||
t.Errorf("Settings not normalised: %q", in.Settings)
|
||||
}
|
||||
if !strings.Contains(in.StreamSettings, `"network":"tcp"`) {
|
||||
t.Errorf("StreamSettings not normalised: %q", in.StreamSettings)
|
||||
}
|
||||
if !strings.Contains(in.Sniffing, `"enabled":true`) {
|
||||
t.Errorf("Sniffing not normalised: %q", in.Sniffing)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundMarshalJSONInvalidTextFallsBackToString(t *testing.T) {
|
||||
in := Inbound{Id: 1, Settings: "not json at all"}
|
||||
out, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), `"settings":"not json at all"`) {
|
||||
t.Errorf("expected invalid settings text to be wrapped as a JSON string, got %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRecordMarshalJSONNestsReverse(t *testing.T) {
|
||||
rec := ClientRecord{Id: 1, Email: "alice@example.com", Reverse: `{"tag":"vless-in"}`}
|
||||
out, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
obj, ok := parsed["reverse"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected reverse to marshal as a JSON object, got %T", parsed["reverse"])
|
||||
}
|
||||
if obj["tag"] != "vless-in" {
|
||||
t.Errorf("expected tag to be preserved, got %v", obj["tag"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRecordMarshalJSONEmptyReverseIsNull(t *testing.T) {
|
||||
rec := ClientRecord{Id: 1, Email: "alice@example.com"}
|
||||
out, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
if parsed["reverse"] != nil {
|
||||
t.Errorf("expected reverse to be null, got %v", parsed["reverse"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRecordUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "nested object", body: `{"id":1,"reverse":{"tag":"vless-in"}}`},
|
||||
{name: "legacy string", body: `{"id":1,"reverse":"{\"tag\":\"vless-in\"}"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var rec ClientRecord
|
||||
if err := json.Unmarshal([]byte(tc.body), &rec); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(rec.Reverse, `"tag":"vless-in"`) {
|
||||
t.Errorf("Reverse not normalised: %q", rec.Reverse)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundClientIpsMarshalJSONNestsArray(t *testing.T) {
|
||||
row := InboundClientIps{Id: 1, ClientEmail: "alice@example.com", Ips: `[{"ip":"1.2.3.4","timestamp":1700000000}]`}
|
||||
out, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
arr, ok := parsed["ips"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected ips to marshal as a JSON array, got %T", parsed["ips"])
|
||||
}
|
||||
if len(arr) != 1 {
|
||||
t.Errorf("expected 1 entry, got %d", len(arr))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "nested array", body: `{"id":1,"ips":[{"ip":"1.2.3.4","timestamp":1}]}`},
|
||||
{name: "legacy string", body: `{"id":1,"ips":"[{\"ip\":\"1.2.3.4\",\"timestamp\":1}]"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var row InboundClientIps
|
||||
if err := json.Unmarshal([]byte(tc.body), &row); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(row.Ips, `"ip":"1.2.3.4"`) {
|
||||
t.Errorf("Ips not normalised: %q", row.Ips)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHysteria(t *testing.T) {
|
||||
cases := []struct {
|
||||
|
||||
+19
-1
@@ -11,6 +11,24 @@ services:
|
||||
environment:
|
||||
XRAY_VMESS_AEAD_FORCED: "false"
|
||||
XUI_ENABLE_FAIL2BAN: "true"
|
||||
# To use PostgreSQL instead of the default SQLite, run:
|
||||
# docker compose --profile postgres up -d
|
||||
# and uncomment the two lines below.
|
||||
# XUI_DB_TYPE: "postgres"
|
||||
# XUI_DB_DSN: "postgres://xui:xui@postgres:5432/xui?sslmode=disable"
|
||||
tty: true
|
||||
network_mode: host
|
||||
ports:
|
||||
- "2053:2053"
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: 3xui_postgres
|
||||
profiles: ["postgres"]
|
||||
environment:
|
||||
POSTGRES_USER: xui
|
||||
POSTGRES_PASSWORD: xui
|
||||
POSTGRES_DB: xui
|
||||
volumes:
|
||||
- $PWD/pgdata/:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
+22
-14
@@ -1,8 +1,8 @@
|
||||
# 3x-ui frontend
|
||||
|
||||
Vue 3 + Ant Design Vue 4 + Vite. Multi-page app — one HTML entry per
|
||||
panel route — built into `../web/dist/` and embedded into the Go binary
|
||||
via `embed.FS`.
|
||||
React 19 + Ant Design 6 + TypeScript + Vite 8. Multi-page app — one HTML
|
||||
entry per panel route — built into `../web/dist/` and embedded into the
|
||||
Go binary via `embed.FS`.
|
||||
|
||||
## Dev
|
||||
|
||||
@@ -30,44 +30,52 @@ Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
|
||||
`assets/`). The Go binary embeds this directory at compile time and
|
||||
`web/controller/dist.go` serves the per-page HTML.
|
||||
|
||||
## Lint
|
||||
## Type check and lint
|
||||
|
||||
```sh
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
ESLint 10 with `eslint.config.js` (flat config) — `vue3-recommended`
|
||||
plus a few rule overrides for the project's formatting style.
|
||||
`tsc --noEmit` against `tsconfig.json` (strict mode, `jsx: "react-jsx"`,
|
||||
`@/*` → `src/*` alias). ESLint 10 with `eslint.config.js` (flat config)
|
||||
— `@eslint/js` recommended plus `typescript-eslint` and
|
||||
`eslint-plugin-react-hooks` rules.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── *.html # Vite entry HTML, one per panel route
|
||||
├── tsconfig.json
|
||||
├── eslint.config.js
|
||||
├── vite.config.js
|
||||
└── src/
|
||||
├── entries/ # Per-page bootstrap (createApp + mount)
|
||||
├── entries/ # Per-page bootstrap (createRoot + render)
|
||||
├── pages/ # One folder per route, each with the page
|
||||
│ ├── index/ # component + helpers + sub-components
|
||||
│ ├── login/
|
||||
│ ├── inbounds/
|
||||
│ ├── clients/
|
||||
│ ├── xray/
|
||||
│ ├── nodes/
|
||||
│ ├── settings/
|
||||
│ ├── api-docs/
|
||||
│ └── sub/
|
||||
├── components/ # Cross-page Vue components
|
||||
├── composables/ # Reusable reactive logic (useTheme, …)
|
||||
├── api/ # Axios setup, CSRF interceptor
|
||||
├── i18n/ # vue-i18n init (locales live in web/translation/)
|
||||
├── components/ # Cross-page React components
|
||||
├── hooks/ # Reusable hooks (useTheme, useWebSocket, …)
|
||||
├── api/ # Axios setup, CSRF interceptor, WebSocket
|
||||
├── i18n/ # react-i18next init (locales live in web/translation/)
|
||||
├── models/ # Inbound, Outbound, Status, … domain classes
|
||||
├── styles/ # Shared CSS modules (page-cards, …)
|
||||
└── utils/ # HttpUtil, ObjectUtil, LanguageManager, …
|
||||
```
|
||||
|
||||
## Adding a new page
|
||||
|
||||
1. Add `frontend/<page>.html` referencing `/src/entries/<page>.js`.
|
||||
2. Add `src/entries/<page>.js` that imports the page component and
|
||||
mounts it.
|
||||
1. Add `frontend/<page>.html` referencing `/src/entries/<page>.tsx`.
|
||||
2. Add `src/entries/<page>.tsx` that imports the page component and
|
||||
mounts it with `createRoot(...).render(...)`.
|
||||
3. Add the page component under `src/pages/<page>/`.
|
||||
4. Register the entry in `rollupOptions.input` in `vite.config.js`.
|
||||
5. If the page is reachable from the sidebar at `/panel/<route>`, add
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>API Docs</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/api-docs.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Clients</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/clients.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+44
-34
@@ -1,27 +1,19 @@
|
||||
import js from '@eslint/js';
|
||||
import vue from 'eslint-plugin-vue';
|
||||
import vueParser from 'vue-eslint-parser';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
{ ignores: ['node_modules/**', '../web/dist/**'] },
|
||||
js.configs.recommended,
|
||||
...vue.configs['flat/recommended'],
|
||||
{
|
||||
files: ['**/*.{js,vue}'],
|
||||
files: ['**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
parser: vueParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: false },
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
// Legacy script tags inject a couple of helpers on window before
|
||||
// the SPA boots; declared here so no-undef stops flagging them.
|
||||
getRandomRealityTarget: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
@@ -32,30 +24,48 @@ export default [
|
||||
}],
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
'no-case-declarations': 'off',
|
||||
},
|
||||
},
|
||||
...tseslint.configs.recommended.map((config) => ({
|
||||
...config,
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
})),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'@typescript-eslint/no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
|
||||
// Stylistic rules from vue/recommended that don't match the
|
||||
// existing codebase formatting. Disable rather than churn the
|
||||
// whole tree to satisfy them.
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-v-html': 'off',
|
||||
'vue/html-self-closing': 'off',
|
||||
'vue/max-attributes-per-line': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/multiline-html-element-content-newline': 'off',
|
||||
'vue/html-indent': 'off',
|
||||
'vue/html-closing-bracket-newline': 'off',
|
||||
'vue/attributes-order': 'off',
|
||||
'vue/first-attribute-linebreak': 'off',
|
||||
'vue/one-component-per-file': 'off',
|
||||
'vue/order-in-components': 'off',
|
||||
'vue/attribute-hyphenation': 'off',
|
||||
'vue/v-on-event-hyphenation': 'off',
|
||||
|
||||
// Pervasive in form components ported from the Vue 2 codebase
|
||||
// (parent passes a reactive object; child mutates it in place).
|
||||
// Properly fixing this means rewiring those components to emit
|
||||
// updates — a meaningful architectural change, separate task.
|
||||
'vue/no-mutating-props': 'off',
|
||||
// react-hooks v7 introduces several new rules driven by the React
|
||||
// Compiler. The migration uses several legitimate patterns those
|
||||
// rules flag (initial-fetch in useEffect, dirty-check derived
|
||||
// state, `Date.now()` inside derive helpers, inline arrow event
|
||||
// handlers, in-place mutation of imported Outbound class
|
||||
// instances in the OutboundFormModal). We're not running the
|
||||
// compiler, so the memoization-preservation warnings have no
|
||||
// effect on runtime — turning them off until the codebase
|
||||
// stabilises.
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/purity': 'off',
|
||||
'react-hooks/react-compiler': 'off',
|
||||
'react-hooks/preserve-manual-memoization': 'off',
|
||||
'react-hooks/immutability': 'off',
|
||||
'react-hooks/refs': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · Inbounds</title>
|
||||
<title>Inbounds</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/inbounds.js"></script>
|
||||
<script type="module" src="/src/entries/inbounds.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-2
@@ -3,11 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui</title>
|
||||
<title>Overview</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/index.js"></script>
|
||||
<script type="module" src="/src/entries/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-2
@@ -4,11 +4,11 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<title>3x-ui — Sign in</title>
|
||||
<title>Sign in</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/login.js"></script>
|
||||
<script type="module" src="/src/entries/login.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-2
@@ -3,11 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · Nodes</title>
|
||||
<title>Nodes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/nodes.js"></script>
|
||||
<script type="module" src="/src/entries/nodes.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+2353
-725
File diff suppressed because it is too large
Load Diff
+28
-20
@@ -1,38 +1,46 @@
|
||||
{
|
||||
"name": "3x-ui-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
|
||||
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src"
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.7.9",
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"antd": "^6.4.3",
|
||||
"axios": "^1.16.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"moment": "^2.30.1",
|
||||
"i18next": "^26.2.0",
|
||||
"otpauth": "^9.5.1",
|
||||
"qrious": "^4.0.2",
|
||||
"qs": "^6.13.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.1.4",
|
||||
"vue3-persian-datetime-picker": "^1.2.2"
|
||||
"persian-calendar-suite": "^1.5.5",
|
||||
"qs": "^6.15.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-vue": "^10.9.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.6.0",
|
||||
"vite": "^8.0.11",
|
||||
"vue-eslint-parser": "^10.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
"moment-jalaali": "^0.10.4"
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"vite": "8.0.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · Settings</title>
|
||||
<title>Settings</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/settings.js"></script>
|
||||
<script type="module" src="/src/entries/settings.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,27 +2,19 @@ import axios from 'axios';
|
||||
import qs from 'qs';
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
||||
// Public CSRF endpoint — works pre-login (the panel-scoped
|
||||
// /panel/csrf-token sits behind checkLogin and would 401 a fresh
|
||||
// login page that hasn't authenticated yet).
|
||||
const CSRF_TOKEN_PATH = '/csrf-token';
|
||||
|
||||
// Cached session CSRF token. The legacy panel injects it via a
|
||||
// <meta name="csrf-token"> tag rendered by Go; the new SPA pages
|
||||
// fetch it once from /panel/csrf-token instead. Module-level so
|
||||
// every axios POST sees the latest value.
|
||||
let csrfToken = null;
|
||||
let csrfFetchPromise = null;
|
||||
let sessionExpired = false;
|
||||
|
||||
function readMetaToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
|
||||
}
|
||||
|
||||
// Fetch the token via a bare fetch() (not axios) so the call doesn't
|
||||
// recurse through this same interceptor.
|
||||
async function fetchCsrfToken() {
|
||||
try {
|
||||
const basePath = window.__X_UI_BASE_PATH__;
|
||||
const basePath = window.X_UI_BASE_PATH;
|
||||
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
||||
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
|
||||
: CSRF_TOKEN_PATH);
|
||||
@@ -59,7 +51,12 @@ export function setupAxios() {
|
||||
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
||||
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
const basePath = window.__X_UI_BASE_PATH__;
|
||||
// Read base path from window object or fallback to meta tag (for Cloudflare Rocket Loader compatibility)
|
||||
let basePath = window.X_UI_BASE_PATH;
|
||||
if (!basePath) {
|
||||
const metaTag = document.querySelector('meta[name="base-path"]');
|
||||
basePath = metaTag ? metaTag.getAttribute('content') : null;
|
||||
}
|
||||
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
|
||||
axios.defaults.baseURL = basePath;
|
||||
}
|
||||
@@ -79,7 +76,14 @@ export function setupAxios() {
|
||||
if (config.data instanceof FormData) {
|
||||
config.headers['Content-Type'] = 'multipart/form-data';
|
||||
} else {
|
||||
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
|
||||
const declaredType = String(config.headers['Content-Type'] || config.headers['content-type'] || '');
|
||||
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||
if (config.data !== undefined && typeof config.data !== 'string') {
|
||||
config.data = JSON.stringify(config.data);
|
||||
}
|
||||
} else {
|
||||
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
@@ -91,19 +95,12 @@ export function setupAxios() {
|
||||
async (error) => {
|
||||
const status = error.response?.status;
|
||||
if (status === 401) {
|
||||
// 401 → session is gone. In production, the panel routes
|
||||
// are gated by Go's checkLogin which redirects to base_path
|
||||
// serving the login page; a reload is enough. In dev, Vite
|
||||
// serves /index.html directly at "/", so a reload would put
|
||||
// the user right back on the dashboard and the interceptor
|
||||
// would loop. Navigate to the dev login entry instead.
|
||||
if (import.meta.env.DEV) {
|
||||
const basePath = window.__X_UI_BASE_PATH__ || '/';
|
||||
window.location.href = `${basePath}login.html`;
|
||||
} else {
|
||||
window.location.reload();
|
||||
if (!sessionExpired) {
|
||||
sessionExpired = true;
|
||||
const basePath = window.X_UI_BASE_PATH || '/';
|
||||
window.location.replace(basePath);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
return new Promise(() => { });
|
||||
}
|
||||
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
|
||||
const cfg = error.config;
|
||||
@@ -114,9 +111,14 @@ export function setupAxios() {
|
||||
if (token) {
|
||||
cfg.headers = cfg.headers || {};
|
||||
cfg.headers['X-CSRF-Token'] = token;
|
||||
// axios re-stringifies on retry, so unwind our qs.stringify before
|
||||
// letting the same request flow through the interceptor again.
|
||||
if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data);
|
||||
const declaredType = String(cfg.headers['Content-Type'] || cfg.headers['content-type'] || '');
|
||||
if (typeof cfg.data === 'string') {
|
||||
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||
try { cfg.data = JSON.parse(cfg.data); } catch (_e) { /* keep as-is */ }
|
||||
} else {
|
||||
cfg.data = qs.parse(cfg.data);
|
||||
}
|
||||
}
|
||||
return axios(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ export class WebSocketClient {
|
||||
|
||||
#buildUrl() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// basePath comes from window.__X_UI_BASE_PATH__ which is only injected
|
||||
// basePath comes from window.X_UI_BASE_PATH which is only injected
|
||||
// by the Go binary in production. In dev (Vite serves directly) the
|
||||
// global is missing and basePath would be '' — without the fallback to
|
||||
// '/' we'd build `ws://host:portws` (no separator) and the WebSocket
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
.ant-sidebar > .ant-layout-sider {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.sider-brand,
|
||||
.drawer-brand {
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed {
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
padding: 14px 4px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand-version {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.6;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed .brand-block {
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.brand-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-donate {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-donate:hover,
|
||||
.sidebar-donate:focus-visible {
|
||||
background-color: rgba(236, 72, 153, 0.12);
|
||||
color: #ec4899;
|
||||
transform: scale(1.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-donate .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sidebar-theme-cycle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-theme-cycle:hover,
|
||||
.sidebar-theme-cycle:focus-visible {
|
||||
background-color: rgba(64, 150, 255, 0.1);
|
||||
color: #4096ff;
|
||||
transform: scale(1.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-theme-cycle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.drawer-header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drawer-handle {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.drawer-close:hover,
|
||||
.drawer-close:focus-visible {
|
||||
background: rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.drawer-menu .ant-menu-item {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.drawer-menu .ant-menu-item .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.drawer-utility {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sider-nav {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sider-utility {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.drawer-handle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children,
|
||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-trigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-sidebar > .ant-layout-sider {
|
||||
flex: 0 0 0 !important;
|
||||
max-width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
body.dark .drawer-brand,
|
||||
body.dark .sider-brand {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-brand,
|
||||
html[data-theme='ultra-dark'] .sider-brand {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .sidebar-theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .sidebar-theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .sidebar-donate {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .sidebar-donate {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .ant-drawer .ant-drawer-content,
|
||||
body.dark .ant-drawer .ant-drawer-body {
|
||||
background: #252526 !important;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-selected,
|
||||
.sider-utility .ant-menu-item-selected,
|
||||
.drawer-menu .ant-menu-item-selected {
|
||||
background-color: rgba(64, 150, 255, 0.2) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
|
||||
background-color: rgba(64, 150, 255, 0.1) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Drawer, Layout, Menu } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
ClusterOutlined,
|
||||
CloseOutlined,
|
||||
DashboardOutlined,
|
||||
HeartOutlined,
|
||||
LogoutOutlined,
|
||||
MenuOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
ToolOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import './AppSidebar.css';
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||
|
||||
interface AppSidebarProps {
|
||||
basePath?: string;
|
||||
requestUri?: string;
|
||||
}
|
||||
|
||||
type IconName = 'dashboard' | 'user' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
|
||||
|
||||
const iconByName: Record<IconName, ComponentType> = {
|
||||
dashboard: DashboardOutlined,
|
||||
user: UserOutlined,
|
||||
team: TeamOutlined,
|
||||
setting: SettingOutlined,
|
||||
tool: ToolOutlined,
|
||||
cluster: ClusterOutlined,
|
||||
logout: LogoutOutlined,
|
||||
apidocs: ApiOutlined,
|
||||
};
|
||||
|
||||
function readCollapsed(): boolean {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
|
||||
return (
|
||||
<a
|
||||
href={DONATE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sidebar-donate"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
>
|
||||
<HeartOutlined />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
|
||||
id: string;
|
||||
isDark: boolean;
|
||||
isUltra: boolean;
|
||||
onCycle: () => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
className="sidebar-theme-cycle"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
onClick={onCycle}
|
||||
>
|
||||
{!isDark ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
) : !isUltra ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppSidebar({ basePath = '', requestUri = '' }: AppSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const prefix = basePath.startsWith('/') ? basePath : `/${basePath || ''}`;
|
||||
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
|
||||
const panelVersion = window.X_UI_CUR_VER || '';
|
||||
|
||||
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
|
||||
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
||||
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
||||
{ key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') },
|
||||
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: 'logout', icon: 'logout', title: t('logout') },
|
||||
], [prefix, t]);
|
||||
|
||||
const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
|
||||
const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
|
||||
|
||||
const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
|
||||
items.map((tab) => {
|
||||
const Icon = iconByName[tab.icon];
|
||||
return {
|
||||
key: tab.key,
|
||||
icon: <Icon />,
|
||||
label: tab.title,
|
||||
};
|
||||
}),
|
||||
[]);
|
||||
|
||||
const openLink = useCallback(async (key: string) => {
|
||||
if (key === 'logout') {
|
||||
await HttpUtil.post('/logout');
|
||||
window.location.href = basePath || '/';
|
||||
return;
|
||||
}
|
||||
if (key.startsWith('http')) {
|
||||
window.open(key);
|
||||
} else {
|
||||
window.location.href = key;
|
||||
}
|
||||
}, [basePath]);
|
||||
|
||||
const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(({ key }) => {
|
||||
openLink(String(key));
|
||||
}, [openLink]);
|
||||
|
||||
const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
|
||||
if (type === 'clickTrigger') {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
|
||||
setCollapsed(isCollapsed);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cycleTheme = useCallback((id: string) => {
|
||||
pauseAnimationsUntilLeave(id);
|
||||
if (!isDark) {
|
||||
toggleTheme();
|
||||
if (isUltra) toggleUltra();
|
||||
} else if (!isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
}, [isDark, isUltra, toggleTheme, toggleUltra]);
|
||||
|
||||
return (
|
||||
<div className="ant-sidebar">
|
||||
<Layout.Sider
|
||||
theme={currentTheme}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
breakpoint="md"
|
||||
onCollapse={onSiderCollapse}
|
||||
>
|
||||
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
|
||||
<div className="brand-block">
|
||||
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
|
||||
{!collapsed && panelVersion && (
|
||||
<span className="brand-version">v{panelVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="brand-actions">
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
<ThemeCycleButton
|
||||
id="theme-cycle"
|
||||
isDark={isDark}
|
||||
isUltra={isUltra}
|
||||
onCycle={() => cycleTheme('theme-cycle')}
|
||||
ariaLabel={t('menu.theme')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
className="sider-nav"
|
||||
items={toMenuItems(navItems)}
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
className="sider-utility"
|
||||
items={toMenuItems(utilItems)}
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
|
||||
<Drawer
|
||||
placement="left"
|
||||
closable={false}
|
||||
open={drawerOpen}
|
||||
rootClassName={currentTheme}
|
||||
size="min(82vw, 320px)"
|
||||
styles={{
|
||||
wrapper: { padding: 0 },
|
||||
body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' },
|
||||
header: { display: 'none' },
|
||||
}}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
>
|
||||
<div className="drawer-header">
|
||||
<div className="brand-block">
|
||||
<span className="drawer-brand">3X-UI</span>
|
||||
{panelVersion && <span className="brand-version">v{panelVersion}</span>}
|
||||
</div>
|
||||
<div className="drawer-header-actions">
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
<ThemeCycleButton
|
||||
id="theme-cycle-drawer"
|
||||
isDark={isDark}
|
||||
isUltra={isUltra}
|
||||
onCycle={() => cycleTheme('theme-cycle-drawer')}
|
||||
ariaLabel={t('menu.theme')}
|
||||
/>
|
||||
<button
|
||||
className="drawer-close"
|
||||
type="button"
|
||||
aria-label={t('close')}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
className="drawer-menu drawer-nav"
|
||||
items={toMenuItems(navItems)}
|
||||
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
|
||||
/>
|
||||
<Menu
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
selectedKeys={[requestUri]}
|
||||
className="drawer-menu drawer-utility"
|
||||
items={toMenuItems(utilItems)}
|
||||
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
{!drawerOpen && (
|
||||
<button
|
||||
className="drawer-handle"
|
||||
type="button"
|
||||
aria-label={t('menu.dashboard')}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
<MenuOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
ToolOutlined,
|
||||
ClusterOutlined,
|
||||
LogoutOutlined,
|
||||
CloseOutlined,
|
||||
MenuFoldOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { currentTheme } from '@/composables/useTheme.js';
|
||||
import ThemeSwitch from '@/components/ThemeSwitch.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||
|
||||
const props = defineProps({
|
||||
// Path prefix (e.g. /custom-base/) the panel is served under. Defaults
|
||||
// to '' which means tab keys end up as '/panel/...'. Pages pass the
|
||||
// value the Go backend gave them (in production via a meta tag).
|
||||
basePath: { type: String, default: '' },
|
||||
// Current request URI so the matching menu item highlights.
|
||||
requestUri: { type: String, default: '' },
|
||||
});
|
||||
|
||||
// AD-Vue 4 dropped <a-icon :type="x"> in favor of explicit icon
|
||||
// imports — keep a small name-to-component map so tab definitions stay
|
||||
// declarative.
|
||||
const iconByName = {
|
||||
dashboard: DashboardOutlined,
|
||||
user: UserOutlined,
|
||||
setting: SettingOutlined,
|
||||
tool: ToolOutlined,
|
||||
cluster: ClusterOutlined,
|
||||
logout: LogoutOutlined,
|
||||
};
|
||||
|
||||
// basePath comes from Go (`/` by default, `/myprefix/` when configured) so
|
||||
// these concatenations land on absolute paths. In dev we synthesize the prop
|
||||
// from a window global which can be empty — force a leading slash so the
|
||||
// browser doesn't resolve the link relative to the current pathname (which
|
||||
// would turn /panel/settings + 'panel/...' into /panel/panel/...).
|
||||
const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
|
||||
|
||||
// Labels are i18n-driven so the sidebar matches the locale picked
|
||||
// in panel settings without a page reload of the sidebar component.
|
||||
const tabs = computed(() => [
|
||||
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
||||
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
||||
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') },
|
||||
]);
|
||||
|
||||
const activeTab = ref([props.requestUri]);
|
||||
|
||||
const drawerOpen = ref(false);
|
||||
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
|
||||
|
||||
function openLink(key) {
|
||||
if (key.startsWith('http')) {
|
||||
window.open(key);
|
||||
} else {
|
||||
window.location.href = key;
|
||||
}
|
||||
}
|
||||
|
||||
function onCollapse(isCollapsed, type) {
|
||||
// Only persist explicit toggle clicks, not breakpoint-triggered collapses.
|
||||
if (type === 'clickTrigger') {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, isCollapsed);
|
||||
collapsed.value = isCollapsed;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDrawer() {
|
||||
drawerOpen.value = !drawerOpen.value;
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
drawerOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ant-sidebar">
|
||||
<a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
|
||||
<ThemeSwitch />
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in tabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
|
||||
:wrap-style="{ padding: 0 }" :style="{ height: '100%' }" @close="closeDrawer">
|
||||
<ThemeSwitch />
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in tabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-drawer>
|
||||
|
||||
<button class="drawer-handle" type="button" @click="toggleDrawer">
|
||||
<CloseOutlined v-if="drawerOpen" />
|
||||
<MenuFoldOutlined v-else />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ant-sidebar>.ant-layout-sider {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.drawer-handle {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
border: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.drawer-handle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* On mobile the drawer is the menu — hide the inline sider's content
|
||||
* + the collapse trigger so the sider stops taking layout space and
|
||||
* leaves no remnant button next to the page. */
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-sidebar>.ant-layout-sider {
|
||||
flex: 0 0 0 !important;
|
||||
max-width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
width: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
.ant-statistic-content {
|
||||
font-size: 17px !important;
|
||||
line-height: 1.4 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ant-statistic-content-value,
|
||||
.ant-statistic-content-prefix,
|
||||
.ant-statistic-content-suffix {
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.ant-statistic-content-prefix {
|
||||
margin-inline-end: 8px !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ant-statistic-content-prefix .anticon {
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.ant-statistic-content-suffix {
|
||||
font-size: 12px !important;
|
||||
opacity: 0.55;
|
||||
margin-inline-start: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ant-statistic-title {
|
||||
font-size: 11px !important;
|
||||
margin-bottom: 6px !important;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
body.dark .ant-statistic-content {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .ant-statistic-title {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-statistic-content {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-statistic-title {
|
||||
color: rgba(255, 255, 255, 0.70);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Statistic } from 'antd';
|
||||
import './CustomStatistic.css';
|
||||
|
||||
interface CustomStatisticProps {
|
||||
title?: string;
|
||||
value?: string | number;
|
||||
prefix?: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
}
|
||||
|
||||
export default function CustomStatistic({ title = '', value = '', prefix, suffix }: CustomStatisticProps) {
|
||||
return <Statistic title={title} value={value} prefix={prefix} suffix={suffix} />;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
value: { type: [String, Number], default: '' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-statistic :title="title" :value="value">
|
||||
<template #prefix>
|
||||
<slot name="prefix" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<slot name="suffix" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-statistic-content) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:global(body.dark .ant-statistic-content) {
|
||||
color: var(--dark-color-text-primary);
|
||||
}
|
||||
|
||||
:global(body.dark .ant-statistic-title) {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
.jdp-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jdp-wrap > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jdp-wrap input {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
.jdp-dark .jdp-wrap input,
|
||||
.jdp-dark input {
|
||||
color: rgba(255, 255, 255, 0.88) !important;
|
||||
background-color: #23252b !important;
|
||||
}
|
||||
|
||||
.jdp-ultra .jdp-wrap input,
|
||||
.jdp-ultra input {
|
||||
color: rgba(255, 255, 255, 0.88) !important;
|
||||
background-color: #101013 !important;
|
||||
}
|
||||
|
||||
.jdp-dark input::placeholder,
|
||||
.jdp-ultra input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.30) !important;
|
||||
}
|
||||
|
||||
.jdp-disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DatePicker } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { PersianDateTimePicker } from 'persian-calendar-suite';
|
||||
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import './DateTimePicker.css';
|
||||
|
||||
interface DateTimePickerProps {
|
||||
value: Dayjs | null;
|
||||
onChange: (next: Dayjs | null) => void;
|
||||
showTime?: boolean;
|
||||
format?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const LIGHT_THEME = {
|
||||
primaryColor: '#1677ff',
|
||||
backgroundColor: '#ffffff',
|
||||
borderColor: '#d9d9d9',
|
||||
hoverColor: 'rgba(22, 119, 255, 0.10)',
|
||||
selectedTextColor: '#ffffff',
|
||||
textColor: 'rgba(0, 0, 0, 0.88)',
|
||||
};
|
||||
|
||||
const DARK_THEME = {
|
||||
primaryColor: '#1677ff',
|
||||
backgroundColor: '#23252b',
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
hoverColor: 'rgba(22, 119, 255, 0.18)',
|
||||
selectedTextColor: '#ffffff',
|
||||
textColor: 'rgba(255, 255, 255, 0.88)',
|
||||
};
|
||||
|
||||
const ULTRA_DARK_THEME = {
|
||||
primaryColor: '#1677ff',
|
||||
backgroundColor: '#101013',
|
||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||
hoverColor: 'rgba(22, 119, 255, 0.16)',
|
||||
selectedTextColor: '#ffffff',
|
||||
textColor: 'rgba(255, 255, 255, 0.88)',
|
||||
};
|
||||
|
||||
export default function DateTimePicker({
|
||||
value,
|
||||
onChange,
|
||||
showTime = true,
|
||||
format = 'YYYY-MM-DD HH:mm:ss',
|
||||
placeholder = '',
|
||||
disabled = false,
|
||||
}: DateTimePickerProps) {
|
||||
const { datepicker } = useDatepicker();
|
||||
const { isDark, isUltra } = useTheme();
|
||||
|
||||
const persianTheme = useMemo(() => {
|
||||
if (isUltra) return ULTRA_DARK_THEME;
|
||||
if (isDark) return DARK_THEME;
|
||||
return LIGHT_THEME;
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
if (datepicker === 'jalalian') {
|
||||
return (
|
||||
<div className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
|
||||
<PersianDateTimePicker
|
||||
value={value ? value.valueOf() : null}
|
||||
onChange={(next: number | string | null) => {
|
||||
if (next == null || next === '') {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
const ms = typeof next === 'number' ? next : Number(next);
|
||||
if (Number.isFinite(ms)) onChange(dayjs(ms));
|
||||
}}
|
||||
showTime={showTime}
|
||||
outputFormat="timestamp"
|
||||
persianNumbers
|
||||
rtlCalendar
|
||||
theme={persianTheme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
value={value}
|
||||
onChange={(next) => onChange(next || null)}
|
||||
showTime={showTime ? { format: 'HH:mm:ss' } : false}
|
||||
format={format}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import PersianDatePicker from 'vue3-persian-datetime-picker';
|
||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||
|
||||
// Drop-in replacement for <a-date-picker> that swaps to a real Jalali
|
||||
// calendar (vue3-persian-datetime-picker, backed by moment-jalaali)
|
||||
// when the panel's "Calendar Type" setting is `jalalian`.
|
||||
//
|
||||
// The v-model contract matches AD-Vue: the parent works with a dayjs
|
||||
// object (or null). For the persian picker we serialize to/from the
|
||||
// `YYYY-MM-DD HH:mm:ss` string it expects so callers don't need to
|
||||
// know which renderer is active.
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: [Object, null], default: null },
|
||||
showTime: { type: Boolean, default: true },
|
||||
format: { type: String, default: 'YYYY-MM-DD HH:mm:ss' },
|
||||
placeholder: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:value']);
|
||||
|
||||
const { datepicker } = useDatepicker();
|
||||
const isJalali = computed(() => datepicker.value === 'jalalian');
|
||||
|
||||
const ISO_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
// Persian picker's display format — `j…` tokens come from moment-jalaali
|
||||
// and render Jalali year/month/day.
|
||||
const persianDisplayFormat = computed(() =>
|
||||
props.showTime ? 'jYYYY/jMM/jDD HH:mm:ss' : 'jYYYY/jMM/jDD',
|
||||
);
|
||||
|
||||
// Persian picker stores the date as a Gregorian string in the format
|
||||
// it was given via `format`. We normalize on `YYYY-MM-DD HH:mm:ss` so
|
||||
// dayjs(...) round-trips cleanly.
|
||||
const stringValue = computed({
|
||||
get() {
|
||||
const v = props.value;
|
||||
if (!v) return '';
|
||||
return dayjs.isDayjs(v) ? v.format(ISO_FORMAT) : dayjs(v).format(ISO_FORMAT);
|
||||
},
|
||||
set(next) {
|
||||
if (!next) {
|
||||
emit('update:value', null);
|
||||
return;
|
||||
}
|
||||
const parsed = dayjs(next, ISO_FORMAT);
|
||||
emit('update:value', parsed.isValid() ? parsed : null);
|
||||
},
|
||||
});
|
||||
|
||||
function onAntChange(next) {
|
||||
emit('update:value', next || null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PersianDatePicker v-if="isJalali" v-model="stringValue" :format="ISO_FORMAT" :display-format="persianDisplayFormat"
|
||||
:placeholder="placeholder" :disabled="disabled" color="#1677ff" auto-submit append-to="body"
|
||||
input-class="ant-input persian-datepicker-input" class="jalali-datepicker" />
|
||||
<a-date-picker v-else :value="value" :show-time="showTime ? { format: 'HH:mm:ss' } : false" :format="format"
|
||||
:placeholder="placeholder" :disabled="disabled" :style="{ width: '100%' }" @update:value="onAntChange" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.jalali-datepicker {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Theme overrides for the picker. AD-Vue 4 doesn't expose CSS variables
|
||||
by default (its tokens live in JS), so we hardcode hexes per theme
|
||||
class — `body.dark` for the navy theme, `[data-theme="ultra-dark"]`
|
||||
for the neutral ultra-dark variant. The popup stays inside the
|
||||
wrapper's subtree (no teleport) so global selectors reach it cleanly. -->
|
||||
<style>
|
||||
/* ===== Light (default) =================================================== */
|
||||
|
||||
.persian-datepicker-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 11px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.persian-datepicker-input:hover {
|
||||
border-color: #4096ff;
|
||||
}
|
||||
|
||||
.persian-datepicker-input:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Light theme keeps the picker's brand-blue calendar button (set via
|
||||
* inline style on .vpd-icon-btn) — only its border + corner radius are
|
||||
* normalized so it sits flush with the input. Dark/ultra-dark themes
|
||||
* below override the inline blue so the control matches the form. */
|
||||
.vpd-main .vpd-icon-btn {
|
||||
color: #fff;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
/* Match the input's left edge (no rounded left, no double border at the
|
||||
* seam) so it sits flush against the icon-btn. */
|
||||
.persian-datepicker-input {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.vpd-main .vpd-clear-btn {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Width is exactly 316px so the 7-day grid (7 × 40px + 36px padding)
|
||||
* fits flush. Don't add `border` here — box-sizing: border-box would
|
||||
* eat 2px from the content width and the 7th day-cell of each row
|
||||
* wraps. Use box-shadow + a wider radius for the visual edge instead. */
|
||||
.vpd-wrapper .vpd-content {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-header {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-header .vpd-year-label,
|
||||
.vpd-wrapper .vpd-header .vpd-date,
|
||||
.vpd-wrapper .vpd-header .vpd-locales li {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-month-label,
|
||||
.vpd-wrapper .vpd-body .vpd-month-label>span {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-week,
|
||||
.vpd-wrapper .vpd-body .vpd-weekday {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-next,
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
/* The picker's <arrow> component renders an inline SVG with a hardcoded
|
||||
* `fill="#000"` attribute. Override the path fill via CSS so the arrow
|
||||
* is visible in every theme. */
|
||||
.vpd-wrapper .vpd-next svg path,
|
||||
.vpd-wrapper .vpd-prev svg path {
|
||||
fill: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
|
||||
fill: #1677ff;
|
||||
}
|
||||
|
||||
/* The picker paints disabled days as `darken(#fff, 20%)` (~#cccccc) which
|
||||
* is invisible on white and dark themes alike. Reset the day text color
|
||||
* across all states so days are always readable. */
|
||||
.vpd-wrapper .vpd-day,
|
||||
.vpd-wrapper .vpd-day .vpd-day-text {
|
||||
color: rgba(0, 0, 0, 0.88) !important;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-day[disabled='true'],
|
||||
.vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
|
||||
color: rgba(0, 0, 0, 0.25) !important;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-day:not([disabled='true']):hover .vpd-day-text,
|
||||
.vpd-wrapper .vpd-day.vpd-selected .vpd-day-text {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-actions button {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-actions button:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-addon-list,
|
||||
.vpd-wrapper .vpd-addon-list-content {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-addon-list-item {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-addon-list-item.vpd-selected,
|
||||
.vpd-wrapper .vpd-addon-list-item:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-close-addon {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* ===== Dark (navy) ======================================================= */
|
||||
|
||||
body.dark .persian-datepicker-input {
|
||||
background: #142340;
|
||||
border-color: #1f3358;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .persian-datepicker-input:hover {
|
||||
border-color: #4096ff;
|
||||
}
|
||||
|
||||
body.dark .persian-datepicker-input:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.18);
|
||||
}
|
||||
|
||||
body.dark .vpd-main .vpd-icon-btn {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
border: 1px solid #1f3358 !important;
|
||||
border-right: none !important;
|
||||
border-radius: 6px 0 0 6px !important;
|
||||
color: rgba(255, 255, 255, 0.75) !important;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-content {
|
||||
background: #1a2c4d;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.48),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body {
|
||||
background: #1a2c4d;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-month-label,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-month-label>span {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-week,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-weekday {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-next svg path,
|
||||
body.dark .vpd-wrapper .vpd-prev svg path {
|
||||
fill: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
|
||||
fill: #4096ff;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-day,
|
||||
body.dark .vpd-wrapper .vpd-day .vpd-day-text {
|
||||
color: rgba(255, 255, 255, 0.88) !important;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-day[disabled='true'],
|
||||
body.dark .vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
|
||||
color: rgba(255, 255, 255, 0.25) !important;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-actions button {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-actions button:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-addon-list,
|
||||
body.dark .vpd-wrapper .vpd-addon-list-content {
|
||||
background: #1a2c4d;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-addon-list-item {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-addon-list-item.vpd-selected,
|
||||
body.dark .vpd-wrapper .vpd-addon-list-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-close-addon {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ===== Ultra-dark (neutral black) ======================================= */
|
||||
|
||||
html[data-theme='ultra-dark'] .persian-datepicker-input {
|
||||
background: #0a0a0a;
|
||||
border-color: #303030;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-main .vpd-icon-btn {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
border: 1px solid #303030 !important;
|
||||
border-right: none !important;
|
||||
border-radius: 6px 0 0 6px !important;
|
||||
color: rgba(255, 255, 255, 0.75) !important;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-content {
|
||||
background: #141414;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-body {
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list,
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list-content {
|
||||
background: #141414;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,738 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Divider, Form, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { RandomUtil } from '@/utils';
|
||||
import { Protocols } from '@/models/outbound.js';
|
||||
|
||||
interface StreamShape {
|
||||
network?: string;
|
||||
kcp?: { mtu?: number };
|
||||
finalmask: {
|
||||
tcp?: MaskRow[];
|
||||
udp?: MaskRow[];
|
||||
enableQuicParams?: boolean;
|
||||
quicParams?: QuicParams;
|
||||
};
|
||||
addTcpMask: (type?: string) => void;
|
||||
delTcpMask: (index: number) => void;
|
||||
addUdpMask: (type?: string) => void;
|
||||
delUdpMask: (index: number) => void;
|
||||
}
|
||||
|
||||
interface MaskRow {
|
||||
type: string;
|
||||
settings: Record<string, unknown>;
|
||||
_getDefaultSettings: (type: string, settings: Record<string, unknown>) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ItemRow {
|
||||
type: string;
|
||||
packet: string | unknown[];
|
||||
delay?: number | string;
|
||||
rand?: number | string;
|
||||
randRange?: string;
|
||||
}
|
||||
|
||||
interface QuicParams {
|
||||
congestion: string;
|
||||
debug?: boolean;
|
||||
brutalUp?: number | string;
|
||||
brutalDown?: number | string;
|
||||
hasUdpHop?: boolean;
|
||||
udpHop?: { ports: string; interval: string | number };
|
||||
maxIdleTimeout?: number;
|
||||
keepAlivePeriod?: number;
|
||||
disablePathMTUDiscovery?: boolean;
|
||||
maxIncomingStreams?: number;
|
||||
initStreamReceiveWindow?: number;
|
||||
maxStreamReceiveWindow?: number;
|
||||
initConnectionReceiveWindow?: number;
|
||||
maxConnectionReceiveWindow?: number;
|
||||
}
|
||||
|
||||
interface FinalMaskFormProps {
|
||||
stream: StreamShape;
|
||||
protocol: string;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
function changeMaskType(mask: MaskRow, type: string) {
|
||||
mask.type = type;
|
||||
mask.settings = mask._getDefaultSettings(type, {});
|
||||
}
|
||||
|
||||
function changeItemType(item: ItemRow, type: string) {
|
||||
item.type = type;
|
||||
if (type === 'base64') item.packet = RandomUtil.randomBase64();
|
||||
else if (type === 'array') {
|
||||
item.rand = 0;
|
||||
item.packet = [];
|
||||
} else item.packet = '';
|
||||
}
|
||||
|
||||
function newClientServerItem(): ItemRow {
|
||||
return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||
}
|
||||
|
||||
function newUdpClientServerItem(): ItemRow {
|
||||
return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||
}
|
||||
|
||||
function newNoiseItem(): ItemRow {
|
||||
return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20' };
|
||||
}
|
||||
|
||||
export default function FinalMaskForm({ stream, protocol, onChange }: FinalMaskFormProps) {
|
||||
const isHysteria = protocol === Protocols.Hysteria || protocol === 'hysteria';
|
||||
const network = stream?.network || '';
|
||||
|
||||
const showTcp = useMemo(
|
||||
() => ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(network),
|
||||
[network],
|
||||
);
|
||||
const showUdp = isHysteria || network === 'kcp';
|
||||
const showQuic = isHysteria || network === 'xhttp';
|
||||
|
||||
function notify() {
|
||||
onChange();
|
||||
}
|
||||
|
||||
function changeUdpMaskType(mask: MaskRow, type: string) {
|
||||
changeMaskType(mask, type);
|
||||
if (network === 'kcp' && stream.kcp) {
|
||||
stream.kcp.mtu = type === 'xdns' ? 900 : 1350;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
function addUdpMaskWithDefault() {
|
||||
const def = isHysteria ? 'salamander' : 'mkcp-aes128gcm';
|
||||
stream.addUdpMask(def);
|
||||
notify();
|
||||
}
|
||||
|
||||
const tcpMasks = stream.finalmask.tcp || [];
|
||||
const udpMasks = stream.finalmask.udp || [];
|
||||
|
||||
if (!showTcp && !showUdp && !showQuic) return null;
|
||||
|
||||
return (
|
||||
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
|
||||
{showTcp && (
|
||||
<>
|
||||
<Form.Item label="TCP Masks">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
stream.addTcpMask('fragment');
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{tcpMasks.map((mask, mIdx) => (
|
||||
<div key={`tcp-${mIdx}`}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
TCP Mask {mIdx + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
stream.delTcpMask(mIdx);
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
|
||||
<Form.Item label="Type">
|
||||
<Select
|
||||
value={mask.type}
|
||||
onChange={(v) => {
|
||||
changeMaskType(mask, v);
|
||||
notify();
|
||||
}}
|
||||
options={[
|
||||
{ value: 'fragment', label: 'Fragment' },
|
||||
{ value: 'header-custom', label: 'Header Custom' },
|
||||
{ value: 'sudoku', label: 'Sudoku' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{mask.type === 'fragment' && (
|
||||
<>
|
||||
<Form.Item label="Packets">
|
||||
<Select
|
||||
value={mask.settings.packets as string}
|
||||
onChange={(v) => {
|
||||
(mask.settings as Record<string, unknown>).packets = v;
|
||||
notify();
|
||||
}}
|
||||
options={[
|
||||
{ value: 'tlshello', label: 'tlshello' },
|
||||
{ value: '1-3', label: '1-3' },
|
||||
{ value: '1-5', label: '1-5' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{(['length', 'delay', 'maxSplit'] as const).map((field) => (
|
||||
<Form.Item key={field} label={field === 'maxSplit' ? 'Max Split' : field.charAt(0).toUpperCase() + field.slice(1)}>
|
||||
<Input
|
||||
value={(mask.settings[field] as string) || ''}
|
||||
onChange={(e) => {
|
||||
(mask.settings as Record<string, unknown>)[field] = e.target.value;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mask.type === 'sudoku' && (
|
||||
<>
|
||||
{(['password', 'ascii', 'customTable', 'customTables'] as const).map((field) => (
|
||||
<Form.Item key={field} label={field === 'customTable' ? 'Custom Table' : field === 'customTables' ? 'Custom Tables' : field.charAt(0).toUpperCase() + field.slice(1)}>
|
||||
<Input
|
||||
value={(mask.settings[field] as string) || ''}
|
||||
onChange={(e) => {
|
||||
(mask.settings as Record<string, unknown>)[field] = e.target.value;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
{(['paddingMin', 'paddingMax'] as const).map((field) => (
|
||||
<Form.Item key={field} label={field === 'paddingMin' ? 'Padding Min' : 'Padding Max'}>
|
||||
<InputNumber
|
||||
value={(mask.settings[field] as number) || 0}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
(mask.settings as Record<string, unknown>)[field] = Number(v) || 0;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mask.type === 'header-custom' && (
|
||||
<HeaderCustomGroups mask={mask} kind="tcp" onChange={notify} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showUdp && (
|
||||
<>
|
||||
<Form.Item label="UDP Masks">
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={addUdpMaskWithDefault} />
|
||||
</Form.Item>
|
||||
|
||||
{udpMasks.map((mask, mIdx) => (
|
||||
<div key={`udp-${mIdx}`}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
UDP Mask {mIdx + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
stream.delUdpMask(mIdx);
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
|
||||
<Form.Item label="Type">
|
||||
<Select
|
||||
value={mask.type}
|
||||
onChange={(v) => changeUdpMaskType(mask, v)}
|
||||
options={
|
||||
isHysteria
|
||||
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
|
||||
: [
|
||||
{ value: 'mkcp-aes128gcm', label: 'mKCP AES-128-GCM' },
|
||||
{ value: 'header-dns', label: 'Header DNS' },
|
||||
{ value: 'header-dtls', label: 'Header DTLS 1.2' },
|
||||
{ value: 'header-srtp', label: 'Header SRTP' },
|
||||
{ value: 'header-utp', label: 'Header uTP' },
|
||||
{ value: 'header-wechat', label: 'Header WeChat Video' },
|
||||
{ value: 'header-wireguard', label: 'Header WireGuard' },
|
||||
{ value: 'mkcp-original', label: 'mKCP Original' },
|
||||
{ value: 'xdns', label: 'xDNS' },
|
||||
{ value: 'xicmp', label: 'xICMP' },
|
||||
{ value: 'header-custom', label: 'Header Custom' },
|
||||
{ value: 'noise', label: 'Noise' },
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{['mkcp-aes128gcm', 'salamander'].includes(mask.type) && (
|
||||
<Form.Item label="Password">
|
||||
<Input
|
||||
value={(mask.settings.password as string) || ''}
|
||||
placeholder="Obfuscation password"
|
||||
onChange={(e) => {
|
||||
(mask.settings as Record<string, unknown>).password = e.target.value;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{mask.type === 'header-dns' && (
|
||||
<Form.Item label="Domain">
|
||||
<Input
|
||||
value={(mask.settings.domain as string) || ''}
|
||||
placeholder="e.g., www.example.com"
|
||||
onChange={(e) => {
|
||||
(mask.settings as Record<string, unknown>).domain = e.target.value;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{mask.type === 'xdns' && (
|
||||
<Form.Item label="Domains">
|
||||
<Select
|
||||
mode="tags"
|
||||
value={(mask.settings.domains as string[]) || []}
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={[',']}
|
||||
placeholder="e.g., www.example.com"
|
||||
onChange={(v) => {
|
||||
(mask.settings as Record<string, unknown>).domains = v;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{mask.type === 'noise' && (
|
||||
<NoiseItems mask={mask} onChange={notify} />
|
||||
)}
|
||||
|
||||
{mask.type === 'header-custom' && (
|
||||
<UdpHeaderCustom mask={mask} onChange={notify} />
|
||||
)}
|
||||
|
||||
{mask.type === 'xicmp' && (
|
||||
<>
|
||||
<Form.Item label="IP">
|
||||
<Input
|
||||
value={(mask.settings.ip as string) || ''}
|
||||
placeholder="0.0.0.0"
|
||||
onChange={(e) => {
|
||||
(mask.settings as Record<string, unknown>).ip = e.target.value;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="ID">
|
||||
<InputNumber
|
||||
value={(mask.settings.id as number) || 0}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
(mask.settings as Record<string, unknown>).id = Number(v) || 0;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showQuic && (
|
||||
<>
|
||||
<Form.Item label="QUIC Params">
|
||||
<Switch
|
||||
checked={!!stream.finalmask.enableQuicParams}
|
||||
onChange={(v) => {
|
||||
stream.finalmask.enableQuicParams = v;
|
||||
notify();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{stream.finalmask.enableQuicParams && stream.finalmask.quicParams && (
|
||||
<QuicParamsForm params={stream.finalmask.quicParams} onChange={notify} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderCustomGroups({
|
||||
mask,
|
||||
kind: _kind,
|
||||
onChange,
|
||||
}: {
|
||||
mask: MaskRow;
|
||||
kind: 'tcp';
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const settings = mask.settings as { clients?: ItemRow[][]; servers?: ItemRow[][] };
|
||||
if (!settings.clients) settings.clients = [];
|
||||
if (!settings.servers) settings.servers = [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{(['clients', 'servers'] as const).map((groupKey) => (
|
||||
<div key={groupKey}>
|
||||
<Form.Item label={groupKey === 'clients' ? 'Clients' : 'Servers'}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
(settings[groupKey] as ItemRow[][]).push([newClientServerItem()]);
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{(settings[groupKey] as ItemRow[][]).map((group, gi) => (
|
||||
<div key={`${groupKey}-${gi}`}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
{groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
(settings[groupKey] as ItemRow[][]).splice(gi, 1);
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
{group.map((item, _ii) => (
|
||||
<ItemEditor key={_ii} item={item} onChange={onChange} delayAsNumber />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UdpHeaderCustom({ mask, onChange }: { mask: MaskRow; onChange: () => void }) {
|
||||
const settings = mask.settings as { client?: ItemRow[]; server?: ItemRow[] };
|
||||
if (!settings.client) settings.client = [];
|
||||
if (!settings.server) settings.server = [];
|
||||
return (
|
||||
<>
|
||||
{(['client', 'server'] as const).map((groupKey) => (
|
||||
<div key={groupKey}>
|
||||
<Form.Item label={groupKey === 'client' ? 'Client' : 'Server'}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
(settings[groupKey] as ItemRow[]).push(newUdpClientServerItem());
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{(settings[groupKey] as ItemRow[]).map((item, ci) => (
|
||||
<div key={ci}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
{groupKey === 'client' ? 'Client' : 'Server'} {ci + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
(settings[groupKey] as ItemRow[]).splice(ci, 1);
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
<ItemEditor item={item} onChange={onChange} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NoiseItems({ mask, onChange }: { mask: MaskRow; onChange: () => void }) {
|
||||
const settings = mask.settings as { reset?: number; noise?: ItemRow[] };
|
||||
if (!settings.noise) settings.noise = [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Reset">
|
||||
<InputNumber
|
||||
value={settings.reset || 0}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
settings.reset = Number(v) || 0;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Noise">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
(settings.noise as ItemRow[]).push(newNoiseItem());
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{(settings.noise as ItemRow[]).map((n, ni) => (
|
||||
<div key={ni}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
Noise {ni + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
onClick={() => {
|
||||
(settings.noise as ItemRow[]).splice(ni, 1);
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
<ItemEditor item={n} onChange={onChange} delayAsString />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemEditor({
|
||||
item,
|
||||
onChange,
|
||||
delayAsNumber,
|
||||
delayAsString,
|
||||
}: {
|
||||
item: ItemRow;
|
||||
onChange: () => void;
|
||||
delayAsNumber?: boolean;
|
||||
delayAsString?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Type">
|
||||
<Select
|
||||
value={item.type}
|
||||
onChange={(v) => {
|
||||
changeItemType(item, v);
|
||||
onChange();
|
||||
}}
|
||||
options={[
|
||||
{ value: 'array', label: 'Array' },
|
||||
{ value: 'str', label: 'String' },
|
||||
{ value: 'hex', label: 'Hex' },
|
||||
{ value: 'base64', label: 'Base64' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{delayAsNumber && (
|
||||
<Form.Item label="Delay (ms)">
|
||||
<InputNumber
|
||||
value={typeof item.delay === 'number' ? item.delay : 0}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
item.delay = Number(v) || 0;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{item.type === 'array' ? (
|
||||
<>
|
||||
<Form.Item label="Rand">
|
||||
{delayAsString ? (
|
||||
<Input
|
||||
value={String(item.rand ?? '')}
|
||||
onChange={(e) => {
|
||||
item.rand = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
placeholder="0 or 1-8192"
|
||||
/>
|
||||
) : (
|
||||
<InputNumber
|
||||
value={typeof item.rand === 'number' ? item.rand : 0}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
item.rand = Number(v) || 0;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item label="Rand Range">
|
||||
<Input
|
||||
value={item.randRange || ''}
|
||||
placeholder="0-255"
|
||||
onChange={(e) => {
|
||||
item.randRange = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<Form.Item label="Packet">
|
||||
{item.type === 'base64' ? (
|
||||
<Input.Group compact>
|
||||
<Input
|
||||
value={String(item.packet ?? '')}
|
||||
placeholder="binary data"
|
||||
style={{ width: 'calc(100% - 32px)' }}
|
||||
onChange={(e) => {
|
||||
item.packet = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
item.packet = RandomUtil.randomBase64();
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Input.Group>
|
||||
) : (
|
||||
<Input
|
||||
value={String(item.packet ?? '')}
|
||||
placeholder="binary data"
|
||||
onChange={(e) => {
|
||||
item.packet = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
)}
|
||||
{delayAsString && (
|
||||
<Form.Item label="Delay">
|
||||
<Input
|
||||
value={typeof item.delay === 'string' ? item.delay : ''}
|
||||
placeholder="10-20"
|
||||
onChange={(e) => {
|
||||
item.delay = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function QuicParamsForm({ params, onChange }: { params: QuicParams; onChange: () => void }) {
|
||||
function update<K extends keyof QuicParams>(key: K, value: QuicParams[K]) {
|
||||
params[key] = value;
|
||||
onChange();
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Congestion">
|
||||
<Select
|
||||
value={params.congestion}
|
||||
onChange={(v) => update('congestion', v)}
|
||||
options={[
|
||||
{ value: 'reno', label: 'Reno' },
|
||||
{ value: 'bbr', label: 'BBR' },
|
||||
{ value: 'brutal', label: 'Brutal' },
|
||||
{ value: 'force-brutal', label: 'Force Brutal' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Debug">
|
||||
<Switch checked={!!params.debug} onChange={(v) => update('debug', v)} />
|
||||
</Form.Item>
|
||||
{['brutal', 'force-brutal'].includes(params.congestion) && (
|
||||
<>
|
||||
<Form.Item label="Brutal Up">
|
||||
<Input
|
||||
value={String(params.brutalUp ?? '')}
|
||||
placeholder="65537"
|
||||
onChange={(e) => update('brutalUp', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Brutal Down">
|
||||
<Input
|
||||
value={String(params.brutalDown ?? '')}
|
||||
placeholder="65537"
|
||||
onChange={(e) => update('brutalDown', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item label="UDP Hop">
|
||||
<Switch checked={!!params.hasUdpHop} onChange={(v) => update('hasUdpHop', v)} />
|
||||
</Form.Item>
|
||||
{params.hasUdpHop && params.udpHop && (
|
||||
<>
|
||||
<Form.Item label="Hop Ports">
|
||||
<Input
|
||||
value={params.udpHop.ports || ''}
|
||||
placeholder="e.g. 20000-50000"
|
||||
onChange={(e) => {
|
||||
params.udpHop!.ports = e.target.value;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Hop Interval (s)">
|
||||
<InputNumber
|
||||
value={Number(params.udpHop.interval) || 5}
|
||||
min={5}
|
||||
onChange={(v) => {
|
||||
params.udpHop!.interval = Number(v) || 5;
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{(
|
||||
[
|
||||
['maxIdleTimeout', 'Max Idle Timeout (s)', 4, 120],
|
||||
['keepAlivePeriod', 'Keep Alive Period (s)', 2, 60],
|
||||
] as const
|
||||
).map(([key, label, min, max]) => (
|
||||
<Form.Item key={key} label={label}>
|
||||
<InputNumber
|
||||
value={params[key] as number}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={(v) => update(key, Number(v) || min)}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item label="Disable Path MTU Dis">
|
||||
<Switch checked={!!params.disablePathMTUDiscovery} onChange={(v) => update('disablePathMTUDiscovery', v)} />
|
||||
</Form.Item>
|
||||
{(
|
||||
[
|
||||
['maxIncomingStreams', 'Max Incoming Streams', 8, '1024 = default'],
|
||||
['initStreamReceiveWindow', 'Init Stream Window', 16384, '8388608 = default'],
|
||||
['maxStreamReceiveWindow', 'Max Stream Window', 16384, '8388608 = default'],
|
||||
['initConnectionReceiveWindow', 'Init Conn Window', 16384, '20971520 = default'],
|
||||
['maxConnectionReceiveWindow', 'Max Conn Window', 16384, '20971520 = default'],
|
||||
] as const
|
||||
).map(([key, label, min, placeholder]) => (
|
||||
<Form.Item key={key} label={label}>
|
||||
<InputNumber
|
||||
value={params[key] as number}
|
||||
min={min}
|
||||
placeholder={placeholder}
|
||||
onChange={(v) => update(key, Number(v) || 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { RandomUtil } from '@/utils';
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
|
||||
// Mirrors web/html/form/stream/stream_finalmask.html. Used by both the
|
||||
// inbound and outbound modals — they share the same StreamSettings
|
||||
// shape (`stream.finalmask`, `stream.addTcpMask()`, etc.) so a single
|
||||
// component handles both. The host modal passes its protocol through
|
||||
// so we know whether to show only the Hysteria-specific UDP types.
|
||||
const props = defineProps({
|
||||
stream: { type: Object, required: true },
|
||||
protocol: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const isHysteria = computed(() => props.protocol === Protocols.HYSTERIA);
|
||||
const network = computed(() => props.stream?.network || '');
|
||||
|
||||
const showTcp = computed(() => ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(network.value));
|
||||
const showUdp = computed(() => isHysteria.value || network.value === 'kcp');
|
||||
const showQuic = computed(() => isHysteria.value || network.value === 'xhttp');
|
||||
|
||||
// Reset the per-row settings shape when the user picks a different
|
||||
// type — mirrors the legacy `mask._getDefaultSettings(type, {})` call.
|
||||
function changeMaskType(mask, type) {
|
||||
mask.type = type;
|
||||
mask.settings = mask._getDefaultSettings(type, {});
|
||||
}
|
||||
|
||||
// Special case from the legacy form: switching a UDP mask to xdns
|
||||
// shrinks the kcp MTU; everything else needs the default 1350.
|
||||
function changeUdpMaskType(mask, type) {
|
||||
changeMaskType(mask, type);
|
||||
if (network.value === 'kcp' && props.stream.kcp) {
|
||||
props.stream.kcp.mtu = type === 'xdns' ? 900 : 1350;
|
||||
}
|
||||
}
|
||||
|
||||
// header-custom and noise rows share the same per-item shape — the
|
||||
// type select rewires the packet field. Pulled out so the click
|
||||
// handlers in the template stay readable.
|
||||
function changeItemType(item, type) {
|
||||
item.type = type;
|
||||
if (type === 'base64') item.packet = RandomUtil.randomBase64();
|
||||
else if (type === 'array') { item.rand = 0; item.packet = []; }
|
||||
else item.packet = '';
|
||||
}
|
||||
|
||||
function addUdpMaskWithDefault() {
|
||||
const def = isHysteria.value ? 'salamander' : 'mkcp-aes128gcm';
|
||||
props.stream.addUdpMask(def);
|
||||
}
|
||||
|
||||
function newClientServerItem() {
|
||||
return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||
}
|
||||
|
||||
function newUdpClientServerItem() {
|
||||
return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||
}
|
||||
|
||||
function newNoiseItem() {
|
||||
return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-form v-if="showTcp || showUdp || showQuic" :colon="false" :label-col="{ md: { span: 8 } }"
|
||||
:wrapper-col="{ md: { span: 14 } }">
|
||||
<!-- ============================== TCP MASKS ============================== -->
|
||||
<template v-if="showTcp">
|
||||
<a-form-item label="TCP Masks">
|
||||
<a-button type="primary" size="small" @click="stream.addTcpMask('fragment')">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<template v-for="(mask, mIdx) in (stream.finalmask.tcp || [])" :key="`tcp-${mIdx}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
TCP Mask {{ mIdx + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="stream.delTcpMask(mIdx)" />
|
||||
</a-divider>
|
||||
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="mask.type" @change="(t) => changeMaskType(mask, t)">
|
||||
<a-select-option value="fragment">Fragment</a-select-option>
|
||||
<a-select-option value="header-custom">Header Custom</a-select-option>
|
||||
<a-select-option value="sudoku">Sudoku</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Fragment -->
|
||||
<template v-if="mask.type === 'fragment'">
|
||||
<a-form-item label="Packets">
|
||||
<a-select v-model:value="mask.settings.packets">
|
||||
<a-select-option value="tlshello">tlshello</a-select-option>
|
||||
<a-select-option value="1-3">1-3</a-select-option>
|
||||
<a-select-option value="1-5">1-5</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Length">
|
||||
<a-input v-model:value="mask.settings.length" placeholder="e.g. 100-200" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay">
|
||||
<a-input v-model:value="mask.settings.delay" placeholder="e.g. 10-20" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Split">
|
||||
<a-input v-model:value="mask.settings.maxSplit" placeholder="e.g. 3-6" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- Sudoku -->
|
||||
<template v-if="mask.type === 'sudoku'">
|
||||
<a-form-item label="Password">
|
||||
<a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
|
||||
</a-form-item>
|
||||
<a-form-item label="ASCII">
|
||||
<a-input v-model:value="mask.settings.ascii" placeholder="ASCII" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Custom Table">
|
||||
<a-input v-model:value="mask.settings.customTable" placeholder="Custom Table" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Custom Tables">
|
||||
<a-input v-model:value="mask.settings.customTables" placeholder="Custom Tables" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Min">
|
||||
<a-input-number v-model:value="mask.settings.paddingMin" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Max">
|
||||
<a-input-number v-model:value="mask.settings.paddingMax" :min="0" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- Header Custom — clients/servers as 2D groups -->
|
||||
<template v-if="mask.type === 'header-custom'">
|
||||
<!-- Clients -->
|
||||
<a-form-item label="Clients">
|
||||
<a-button type="primary" size="small" @click="mask.settings.clients.push([newClientServerItem()])">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(group, gi) in mask.settings.clients" :key="`tcp-cg-${mIdx}-${gi}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Clients Group {{ gi + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.clients.splice(gi, 1)" />
|
||||
</a-divider>
|
||||
<template v-for="(item, ii) in group" :key="`tcp-ci-${mIdx}-${gi}-${ii}`">
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="item.type" @change="(t) => changeItemType(item, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay (ms)">
|
||||
<a-input-number v-model:value="item.delay" :min="0" />
|
||||
</a-form-item>
|
||||
<template v-if="item.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="item.rand" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="item.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="item.type === 'base64'" compact>
|
||||
<a-input v-model:value="item.packet" placeholder="binary data"
|
||||
:style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="item.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Servers -->
|
||||
<a-form-item label="Servers">
|
||||
<a-button type="primary" size="small" @click="mask.settings.servers.push([newClientServerItem()])">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(group, gi) in mask.settings.servers" :key="`tcp-sg-${mIdx}-${gi}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Servers Group {{ gi + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.servers.splice(gi, 1)" />
|
||||
</a-divider>
|
||||
<template v-for="(item, ii) in group" :key="`tcp-si-${mIdx}-${gi}-${ii}`">
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="item.type" @change="(t) => changeItemType(item, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay (ms)">
|
||||
<a-input-number v-model:value="item.delay" :min="0" />
|
||||
</a-form-item>
|
||||
<template v-if="item.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="item.rand" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="item.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="item.type === 'base64'" compact>
|
||||
<a-input v-model:value="item.packet" placeholder="binary data"
|
||||
:style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="item.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ============================== UDP MASKS ============================== -->
|
||||
<template v-if="showUdp">
|
||||
<a-form-item label="UDP Masks">
|
||||
<a-button type="primary" size="small" @click="addUdpMaskWithDefault">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<template v-for="(mask, mIdx) in (stream.finalmask.udp || [])" :key="`udp-${mIdx}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
UDP Mask {{ mIdx + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="stream.delUdpMask(mIdx)" />
|
||||
</a-divider>
|
||||
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="mask.type" @change="(t) => changeUdpMaskType(mask, t)">
|
||||
<template v-if="isHysteria">
|
||||
<a-select-option value="salamander">Salamander (Hysteria2)</a-select-option>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-select-option value="mkcp-aes128gcm">mKCP AES-128-GCM</a-select-option>
|
||||
<a-select-option value="header-dns">Header DNS</a-select-option>
|
||||
<a-select-option value="header-dtls">Header DTLS 1.2</a-select-option>
|
||||
<a-select-option value="header-srtp">Header SRTP</a-select-option>
|
||||
<a-select-option value="header-utp">Header uTP</a-select-option>
|
||||
<a-select-option value="header-wechat">Header WeChat Video</a-select-option>
|
||||
<a-select-option value="header-wireguard">Header WireGuard</a-select-option>
|
||||
<a-select-option value="mkcp-original">mKCP Original</a-select-option>
|
||||
<a-select-option value="xdns">xDNS</a-select-option>
|
||||
<a-select-option value="xicmp">xICMP</a-select-option>
|
||||
<a-select-option value="header-custom">Header Custom</a-select-option>
|
||||
<a-select-option value="noise">Noise</a-select-option>
|
||||
</template>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="['mkcp-aes128gcm', 'salamander'].includes(mask.type)" label="Password">
|
||||
<a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="mask.type === 'header-dns'" label="Domain">
|
||||
<a-input v-model:value="mask.settings.domain" placeholder="e.g., www.example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="mask.type === 'xdns'" label="Domains">
|
||||
<a-select v-model:value="mask.settings.domains" mode="tags" :style="{ width: '100%' }"
|
||||
:token-separators="[',']" placeholder="e.g., www.example.com" />
|
||||
</a-form-item>
|
||||
|
||||
<!-- Noise -->
|
||||
<template v-if="mask.type === 'noise'">
|
||||
<a-form-item label="Reset">
|
||||
<a-input-number v-model:value="mask.settings.reset" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Noise">
|
||||
<a-button type="primary" size="small" @click="mask.settings.noise.push(newNoiseItem())">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(n, ni) in mask.settings.noise" :key="`udp-noise-${mIdx}-${ni}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Noise {{ ni + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.noise.splice(ni, 1)" />
|
||||
</a-divider>
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="n.type" @change="(t) => changeItemType(n, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<template v-if="n.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input v-model:value="n.rand" placeholder="0 or 1-8192" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="n.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="n.type === 'base64'" compact>
|
||||
<a-input v-model:value="n.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="n.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="n.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay">
|
||||
<a-input v-model:value="n.delay" placeholder="10-20" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Header Custom (UDP) — flat client/server lists -->
|
||||
<template v-if="mask.type === 'header-custom'">
|
||||
<a-form-item label="Client">
|
||||
<a-button type="primary" size="small" @click="mask.settings.client.push(newUdpClientServerItem())">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(c, ci) in mask.settings.client" :key="`udp-c-${mIdx}-${ci}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Client {{ ci + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.client.splice(ci, 1)" />
|
||||
</a-divider>
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="c.type" @change="(t) => changeItemType(c, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<template v-if="c.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="c.rand" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="c.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="c.type === 'base64'" compact>
|
||||
<a-input v-model:value="c.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="c.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="c.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-divider :style="{ margin: '0' }" />
|
||||
<a-form-item label="Server">
|
||||
<a-button type="primary" size="small" @click="mask.settings.server.push(newUdpClientServerItem())">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(s, si) in mask.settings.server" :key="`udp-s-${mIdx}-${si}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Server {{ si + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.server.splice(si, 1)" />
|
||||
</a-divider>
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="s.type" @change="(t) => changeItemType(s, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<template v-if="s.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="s.rand" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="s.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="s.type === 'base64'" compact>
|
||||
<a-input v-model:value="s.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="s.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="s.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- xICMP -->
|
||||
<template v-if="mask.type === 'xicmp'">
|
||||
<a-form-item label="IP">
|
||||
<a-input v-model:value="mask.settings.ip" placeholder="0.0.0.0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="ID">
|
||||
<a-input-number v-model:value="mask.settings.id" :min="0" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ============================== QUIC PARAMS ============================== -->
|
||||
<template v-if="showQuic">
|
||||
<a-form-item label="QUIC Params">
|
||||
<a-switch v-model:checked="stream.finalmask.enableQuicParams" />
|
||||
</a-form-item>
|
||||
<template v-if="stream.finalmask.enableQuicParams && stream.finalmask.quicParams">
|
||||
<a-form-item label="Congestion">
|
||||
<a-select v-model:value="stream.finalmask.quicParams.congestion">
|
||||
<a-select-option value="reno">Reno</a-select-option>
|
||||
<a-select-option value="bbr">BBR</a-select-option>
|
||||
<a-select-option value="brutal">Brutal</a-select-option>
|
||||
<a-select-option value="force-brutal">Force Brutal</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Debug">
|
||||
<a-switch v-model:checked="stream.finalmask.quicParams.debug" />
|
||||
</a-form-item>
|
||||
<template v-if="['brutal', 'force-brutal'].includes(stream.finalmask.quicParams.congestion)">
|
||||
<a-form-item label="Brutal Up">
|
||||
<a-input v-model:value="stream.finalmask.quicParams.brutalUp" placeholder="65537" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Brutal Down">
|
||||
<a-input v-model:value="stream.finalmask.quicParams.brutalDown" placeholder="65537" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item label="UDP Hop">
|
||||
<a-switch v-model:checked="stream.finalmask.quicParams.hasUdpHop" />
|
||||
</a-form-item>
|
||||
<template v-if="stream.finalmask.quicParams.hasUdpHop && stream.finalmask.quicParams.udpHop">
|
||||
<a-form-item label="Hop Ports">
|
||||
<a-input v-model:value="stream.finalmask.quicParams.udpHop.ports" placeholder="e.g. 20000-50000" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Hop Interval (s)">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.udpHop.interval" :min="5" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item label="Max Idle Timeout (s)">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxIdleTimeout" :min="4" :max="120" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Keep Alive Period (s)">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.keepAlivePeriod" :min="2" :max="60" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Disable Path MTU Dis">
|
||||
<a-switch v-model:checked="stream.finalmask.quicParams.disablePathMTUDiscovery" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Incoming Streams">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxIncomingStreams" :min="8"
|
||||
placeholder="1024 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Init Stream Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.initStreamReceiveWindow" :min="16384"
|
||||
placeholder="8388608 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Stream Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxStreamReceiveWindow" :min="16384"
|
||||
placeholder="8388608 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Init Conn Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.initConnectionReceiveWindow" :min="16384"
|
||||
placeholder="20971520 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Conn Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxConnectionReceiveWindow" :min="16384"
|
||||
placeholder="20971520 = default" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</a-form>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
interface InfinityIconProps {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
}
|
||||
|
||||
export default function InfinityIcon({ width = 14, height = 10 }: InfinityIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 640 512"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
style={{ verticalAlign: '-1px', display: 'inline-block' }}
|
||||
>
|
||||
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<script setup>
|
||||
// Inline ∞ SVG. The Unicode infinity character (U+221E) renders as an
|
||||
// "m"-shaped glyph in some system fonts (Windows Segoe UI in particular),
|
||||
// so the inbound list and client row table use this SVG instead. The
|
||||
// path matches what the legacy panel embedded.
|
||||
defineProps({
|
||||
width: { type: [String, Number], default: 14 },
|
||||
height: { type: [String, Number], default: 10 },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="width" :height="height" viewBox="0 0 640 512" fill="currentColor" aria-hidden="true"
|
||||
style="vertical-align: -1px; display: inline-block;">
|
||||
<path
|
||||
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
.input-addon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 11px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
line-height: 30px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
body.dark .input-addon,
|
||||
html[data-theme='ultra-dark'] .input-addon {
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
border-color: #424242;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.ant-space-compact > .input-addon:not(:first-child) {
|
||||
margin-inline-start: -1px;
|
||||
}
|
||||
|
||||
.ant-space-compact > .input-addon:first-child {
|
||||
border-start-end-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
|
||||
.ant-space-compact > .input-addon:last-child {
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
}
|
||||
|
||||
.ant-space-compact > .input-addon:not(:first-child):not(:last-child) {
|
||||
border-radius: 0;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import './InputAddon.css';
|
||||
|
||||
interface InputAddonProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function InputAddon({ children, className = '', style, onClick }: InputAddonProps) {
|
||||
return (
|
||||
<span
|
||||
className={`input-addon ${className}`.trim()}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.json-editor-host {
|
||||
border: 1px solid var(--ant-color-border, #d9d9d9);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.json-editor-host .cm-editor,
|
||||
.json-editor-host .cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.json-editor-host:focus-within {
|
||||
border-color: var(--ant-color-primary, #1677ff);
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
body.dark .json-editor-host {
|
||||
border-color: #3a3a3c;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
html[data-theme="ultra-dark"] .json-editor-host {
|
||||
border-color: #1f1f1f;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import { EditorView, basicSetup } from 'codemirror';
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import { json, jsonParseLinter } from '@codemirror/lang-json';
|
||||
import { lintGutter, linter } from '@codemirror/lint';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
import { syntaxHighlighting } from '@codemirror/language';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import './JsonEditor.css';
|
||||
|
||||
export interface JsonEditorProps {
|
||||
value: string;
|
||||
onChange?: (next: string) => void;
|
||||
minHeight?: string;
|
||||
maxHeight?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface JsonEditorHandle {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
interface DarkPalette {
|
||||
bg: string;
|
||||
panelBg: string;
|
||||
activeBg: string;
|
||||
border: string;
|
||||
selection: string;
|
||||
}
|
||||
|
||||
function buildDarkTheme({ bg, panelBg, activeBg, border, selection }: DarkPalette) {
|
||||
return EditorView.theme(
|
||||
{
|
||||
'&': { color: '#dcdcdc', backgroundColor: bg },
|
||||
'.cm-content': { caretColor: '#dcdcdc' },
|
||||
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
|
||||
'.cm-gutters': {
|
||||
backgroundColor: bg,
|
||||
borderRight: `1px solid ${border}`,
|
||||
color: '#6a6a6a',
|
||||
},
|
||||
'.cm-activeLine': { backgroundColor: activeBg },
|
||||
'.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||
{ backgroundColor: selection },
|
||||
'.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
|
||||
'.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
|
||||
'.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
|
||||
'.cm-tooltip': {
|
||||
backgroundColor: panelBg,
|
||||
border: `1px solid ${border}`,
|
||||
color: '#dcdcdc',
|
||||
},
|
||||
},
|
||||
{ dark: true },
|
||||
);
|
||||
}
|
||||
|
||||
const darkTheme = buildDarkTheme({
|
||||
bg: '#1e1e1e',
|
||||
panelBg: '#2d2d30',
|
||||
activeBg: '#252526',
|
||||
border: '#3a3a3c',
|
||||
selection: '#3a3a3c',
|
||||
});
|
||||
|
||||
const ultraDarkTheme = buildDarkTheme({
|
||||
bg: '#0a0a0a',
|
||||
panelBg: '#141414',
|
||||
activeBg: '#141414',
|
||||
border: '#1f1f1f',
|
||||
selection: '#2a2a2a',
|
||||
});
|
||||
|
||||
function themeExtension(isDark: boolean, isUltra: boolean) {
|
||||
if (!isDark) return [];
|
||||
const chrome = isUltra ? ultraDarkTheme : darkTheme;
|
||||
return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
|
||||
}
|
||||
|
||||
const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEditor(
|
||||
{ value, onChange, minHeight = '320px', maxHeight = '600px', readOnly = false },
|
||||
ref,
|
||||
) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const themeCompartmentRef = useRef<Compartment>(new Compartment());
|
||||
const readonlyCompartmentRef = useRef<Compartment>(new Compartment());
|
||||
const onChangeRef = useRef(onChange);
|
||||
const valueRef = useRef(value);
|
||||
const { isDark, isUltra } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => viewRef.current?.focus(),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return;
|
||||
|
||||
const updateListener = EditorView.updateListener.of((u) => {
|
||||
if (!u.docChanged) return;
|
||||
const next = u.state.doc.toString();
|
||||
if (next === valueRef.current) return;
|
||||
valueRef.current = next;
|
||||
onChangeRef.current?.(next);
|
||||
});
|
||||
|
||||
const view = new EditorView({
|
||||
parent: hostRef.current,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
keymap.of([indentWithTab]),
|
||||
json(),
|
||||
linter(jsonParseLinter()),
|
||||
lintGutter(),
|
||||
EditorView.lineWrapping,
|
||||
updateListener,
|
||||
themeCompartmentRef.current.of(themeExtension(isDark, isUltra)),
|
||||
readonlyCompartmentRef.current.of(EditorState.readOnly.of(readOnly)),
|
||||
EditorView.theme({
|
||||
'&': { height: '100%' },
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: '12px',
|
||||
minHeight,
|
||||
maxHeight,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
viewRef.current = view;
|
||||
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const current = view.state.doc.toString();
|
||||
if (value === current) return;
|
||||
valueRef.current = value;
|
||||
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: themeCompartmentRef.current.reconfigure(themeExtension(isDark, isUltra)),
|
||||
});
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: readonlyCompartmentRef.current.reconfigure(EditorState.readOnly.of(readOnly)),
|
||||
});
|
||||
}, [readOnly]);
|
||||
|
||||
return <div ref={hostRef} className="json-editor-host" />;
|
||||
});
|
||||
|
||||
export default JsonEditor;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Suspense, useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
interface LazyMountProps {
|
||||
when: boolean;
|
||||
fallback?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Mounts children only after `when` first becomes true and keeps them mounted
|
||||
// thereafter, so React.lazy modals get loaded on demand but their close
|
||||
// animations still play out. Pair with `lazy(() => import(...))` modal imports
|
||||
// on heavy list pages to keep the initial bundle small.
|
||||
export default function LazyMount({ when, fallback = null, children }: LazyMountProps) {
|
||||
const [mounted, setMounted] = useState(when);
|
||||
useEffect(() => {
|
||||
if (when && !mounted) setMounted(true);
|
||||
}, [when, mounted]);
|
||||
if (!mounted) return null;
|
||||
return <Suspense fallback={fallback}>{children}</Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Input, Modal } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
|
||||
interface PromptModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
okText?: string;
|
||||
type?: 'input' | 'textarea';
|
||||
initialValue?: string;
|
||||
loading?: boolean;
|
||||
onConfirm: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function PromptModal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
okText = 'OK',
|
||||
type = 'input',
|
||||
initialValue = '',
|
||||
loading = false,
|
||||
onConfirm,
|
||||
}: PromptModalProps) {
|
||||
const [value, setValue] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const inputRef = useRef<InputRef | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValue(initialValue);
|
||||
setTimeout(() => {
|
||||
if (type === 'textarea') textareaRef.current?.focus();
|
||||
else inputRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
}, [open, initialValue, type]);
|
||||
|
||||
function onKeydown(e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
|
||||
if (type !== 'textarea' && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onConfirm(value);
|
||||
return;
|
||||
}
|
||||
if (type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
onConfirm(value);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
okText={okText}
|
||||
cancelText="Cancel"
|
||||
mask={{ closable: false }}
|
||||
confirmLoading={loading}
|
||||
onOk={() => onConfirm(value)}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
>
|
||||
{type === 'textarea' ? (
|
||||
<Input.TextArea
|
||||
ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
autoSize={{ minRows: 10, maxRows: 20 }}
|
||||
onKeyDown={onKeydown}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeydown}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
// Generic prompt modal — used by features like "import inbound" that
|
||||
// need a free-form text/textarea input and a confirm callback. The
|
||||
// parent owns the action; this component only surfaces the value via
|
||||
// the `confirm` event when the user clicks OK.
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
okText: { type: String, default: 'OK' },
|
||||
// 'text' = single-line input; 'textarea' = multi-line.
|
||||
type: { type: String, default: 'text', validator: (v) => ['text', 'textarea'].includes(v) },
|
||||
initialValue: { type: String, default: '' },
|
||||
loading: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'confirm']);
|
||||
|
||||
const value = ref('');
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (next) value.value = props.initialValue;
|
||||
});
|
||||
|
||||
function close() { emit('update:open', false); }
|
||||
function ok() { emit('confirm', value.value); }
|
||||
|
||||
// Enter submits when single-line; ctrl+S submits in textarea mode
|
||||
// (matches legacy keybindings).
|
||||
function onKeydown(e) {
|
||||
if (props.type !== 'textarea' && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
ok();
|
||||
return;
|
||||
}
|
||||
if (props.type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
ok();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :ok-text="okText" cancel-text="Cancel" :mask-closable="false"
|
||||
:confirm-loading="loading" @ok="ok" @cancel="close">
|
||||
<a-textarea v-if="type === 'textarea'" v-model:value="value" :auto-size="{ minRows: 10, maxRows: 20 }" autofocus
|
||||
@keydown="onKeydown" />
|
||||
<a-input v-else v-model:value="value" autofocus @keydown="onKeydown" />
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
.setting-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
||||
}
|
||||
|
||||
.setting-list-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
body.dark .setting-list-item,
|
||||
html[data-theme='ultra-dark'] .setting-list-item {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.setting-list-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.setting-list-title {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-list-description {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1.5715;
|
||||
}
|
||||
|
||||
body.dark .setting-list-title,
|
||||
html[data-theme='ultra-dark'] .setting-list-title {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .setting-list-description,
|
||||
html[data-theme='ultra-dark'] .setting-list-description {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Col, Row } from 'antd';
|
||||
import './SettingListItem.css';
|
||||
|
||||
interface SettingListItemProps {
|
||||
paddings?: 'small' | 'default';
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
control?: ReactNode;
|
||||
}
|
||||
|
||||
export default function SettingListItem({
|
||||
paddings = 'default',
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
control,
|
||||
}: SettingListItemProps) {
|
||||
const padding = paddings === 'small' ? '10px 20px' : '20px';
|
||||
return (
|
||||
<div className="setting-list-item" style={{ padding }}>
|
||||
<Row gutter={[8, 16]} style={{ width: '100%' }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<div className="setting-list-meta">
|
||||
{title && <div className="setting-list-title">{title}</div>}
|
||||
{description && <div className="setting-list-description">{description}</div>}
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
{control ?? children}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
paddings: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['small', 'default'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const padding = computed(() =>
|
||||
props.paddings === 'small' ? '10px 20px !important' : '20px !important',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-list-item :style="{ padding }">
|
||||
<a-row :gutter="[8, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
<slot name="title" />
|
||||
</template>
|
||||
<template #description>
|
||||
<slot name="description" />
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<slot name="control" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-list-item>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
.sparkline-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-y-text,
|
||||
.sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(0, 0, 0, 0.55);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-line {
|
||||
stroke: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-tooltip-text {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-tooltip-pill {
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-y-text,
|
||||
body.dark .sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-line {
|
||||
stroke: rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-tooltip-pill {
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import './Sparkline.css';
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[];
|
||||
labels?: (string | number)[];
|
||||
vbWidth?: number;
|
||||
height?: number;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
maxPoints?: number;
|
||||
showGrid?: boolean;
|
||||
gridColor?: string;
|
||||
fillOpacity?: number;
|
||||
showMarker?: boolean;
|
||||
markerRadius?: number;
|
||||
showAxes?: boolean;
|
||||
yTickStep?: number;
|
||||
tickCountX?: number;
|
||||
paddingLeft?: number;
|
||||
paddingRight?: number;
|
||||
paddingTop?: number;
|
||||
paddingBottom?: number;
|
||||
showTooltip?: boolean;
|
||||
valueMin?: number;
|
||||
valueMax?: number | null;
|
||||
yFormatter?: (v: number) => string;
|
||||
tooltipFormatter?: ((v: number) => string) | null;
|
||||
}
|
||||
|
||||
export default function Sparkline({
|
||||
data,
|
||||
labels = [],
|
||||
vbWidth = 320,
|
||||
height = 80,
|
||||
stroke = '#008771',
|
||||
strokeWidth = 2,
|
||||
maxPoints = 120,
|
||||
showGrid = true,
|
||||
gridColor = 'rgba(0,0,0,0.08)',
|
||||
fillOpacity = 0.22,
|
||||
showMarker = true,
|
||||
markerRadius = 3,
|
||||
showAxes = false,
|
||||
yTickStep = 25,
|
||||
tickCountX = 4,
|
||||
paddingLeft = 56,
|
||||
paddingRight = 6,
|
||||
paddingTop = 6,
|
||||
paddingBottom = 20,
|
||||
showTooltip = false,
|
||||
valueMin = 0,
|
||||
valueMax = 100,
|
||||
yFormatter = (v: number) => `${Math.round(v)}%`,
|
||||
tooltipFormatter = null,
|
||||
}: SparklineProps) {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [measuredWidth, setMeasuredWidth] = useState(0);
|
||||
const [hoverIdx, setHoverIdx] = useState(-1);
|
||||
|
||||
const reactId = useId();
|
||||
const safeId = reactId.replace(/[^a-zA-Z0-9]/g, '');
|
||||
const gradId = `spkGrad-${safeId}`;
|
||||
const shadowId = `spkShadow-${safeId}`;
|
||||
const glowId = `spkGlow-${safeId}`;
|
||||
|
||||
useEffect(() => {
|
||||
const el = svgRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const w = el.getBoundingClientRect?.().width || 0;
|
||||
if (w > 0) setMeasuredWidth(Math.round(w));
|
||||
};
|
||||
measure();
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}, []);
|
||||
|
||||
const effectiveVbWidth = measuredWidth > 0 ? measuredWidth : vbWidth;
|
||||
const drawWidth = Math.max(1, effectiveVbWidth - paddingLeft - paddingRight);
|
||||
const drawHeight = Math.max(1, height - paddingTop - paddingBottom);
|
||||
const nPoints = Math.min(data.length, maxPoints);
|
||||
|
||||
const dataSlice = useMemo(
|
||||
() => (nPoints === 0 ? [] : data.slice(data.length - nPoints)),
|
||||
[data, nPoints],
|
||||
);
|
||||
|
||||
const labelsSlice = useMemo(() => {
|
||||
if (!labels?.length || nPoints === 0) return [] as (string | number)[];
|
||||
const start = Math.max(0, labels.length - nPoints);
|
||||
return labels.slice(start);
|
||||
}, [labels, nPoints]);
|
||||
|
||||
const yDomain = useMemo(() => {
|
||||
const min = valueMin;
|
||||
if (valueMax != null) return { min, max: valueMax };
|
||||
let max = min;
|
||||
for (const v of dataSlice) {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
if (max <= min) max = min + 1;
|
||||
return { min, max: max * 1.1 };
|
||||
}, [dataSlice, valueMin, valueMax]);
|
||||
|
||||
const project = useCallback(
|
||||
(v: number) => {
|
||||
const { min, max } = yDomain;
|
||||
const span = max - min;
|
||||
if (span <= 0) return paddingTop + drawHeight;
|
||||
const clipped = Math.max(min, Math.min(max, Number(v) || 0));
|
||||
const ratio = (clipped - min) / span;
|
||||
return Math.round(paddingTop + (drawHeight - ratio * drawHeight));
|
||||
},
|
||||
[yDomain, paddingTop, drawHeight],
|
||||
);
|
||||
|
||||
const pointsArr = useMemo<[number, number][]>(() => {
|
||||
if (nPoints === 0) return [];
|
||||
const w = drawWidth;
|
||||
const dx = nPoints > 1 ? w / (nPoints - 1) : 0;
|
||||
return dataSlice.map((v, i) => {
|
||||
const x = Math.round(paddingLeft + i * dx);
|
||||
return [x, project(v)];
|
||||
});
|
||||
}, [dataSlice, nPoints, drawWidth, paddingLeft, project]);
|
||||
|
||||
const pointsStr = useMemo(() => pointsArr.map((p) => `${p[0]},${p[1]}`).join(' '), [pointsArr]);
|
||||
|
||||
const areaPath = useMemo(() => {
|
||||
if (pointsArr.length === 0) return '';
|
||||
const first = pointsArr[0];
|
||||
const last = pointsArr[pointsArr.length - 1];
|
||||
const baseY = paddingTop + drawHeight;
|
||||
const line = pointsStr.replace(/ /g, ' L ');
|
||||
return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
|
||||
}, [pointsArr, pointsStr, paddingTop, drawHeight]);
|
||||
|
||||
const gridLines = useMemo(() => {
|
||||
if (!showGrid) return [];
|
||||
const h = drawHeight;
|
||||
const w = drawWidth;
|
||||
return [0, 0.25, 0.5, 0.75, 1].map((r) => {
|
||||
const y = Math.round(paddingTop + h * r);
|
||||
return { x1: paddingLeft, y1: y, x2: paddingLeft + w, y2: y };
|
||||
});
|
||||
}, [showGrid, drawHeight, drawWidth, paddingTop, paddingLeft]);
|
||||
|
||||
const lastPoint = pointsArr.length === 0 ? null : pointsArr[pointsArr.length - 1];
|
||||
|
||||
const yTicks = useMemo(() => {
|
||||
if (!showAxes) return [];
|
||||
const { min, max } = yDomain;
|
||||
const out: { y: number; label: string }[] = [];
|
||||
if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
|
||||
for (let p = min; p <= max; p += yTickStep) {
|
||||
out.push({ y: project(p), label: yFormatter(p) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const ticks = 5;
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
const v = min + ((max - min) * i) / (ticks - 1);
|
||||
out.push({ y: project(v), label: yFormatter(v) });
|
||||
}
|
||||
return out;
|
||||
}, [showAxes, yDomain, valueMax, valueMin, yTickStep, project, yFormatter]);
|
||||
|
||||
const xTicks = useMemo(() => {
|
||||
if (!showAxes) return [];
|
||||
if (nPoints === 0) return [];
|
||||
const m = Math.max(2, tickCountX);
|
||||
const w = drawWidth;
|
||||
const dx = nPoints > 1 ? w / (nPoints - 1) : 0;
|
||||
const out: { x: number; label: string }[] = [];
|
||||
for (let i = 0; i < m; i++) {
|
||||
const idx = Math.round((i * (nPoints - 1)) / (m - 1));
|
||||
const label = labelsSlice[idx] != null ? String(labelsSlice[idx]) : String(idx);
|
||||
const x = Math.round(paddingLeft + idx * dx);
|
||||
out.push({ x, label });
|
||||
}
|
||||
return out;
|
||||
}, [showAxes, labelsSlice, nPoints, tickCountX, drawWidth, paddingLeft]);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(evt: MouseEvent<SVGSVGElement>) => {
|
||||
if (!showTooltip || pointsArr.length === 0) return;
|
||||
const rect = evt.currentTarget.getBoundingClientRect();
|
||||
const px = evt.clientX - rect.left;
|
||||
const x = (px / rect.width) * effectiveVbWidth;
|
||||
const dx = nPoints > 1 ? drawWidth / (nPoints - 1) : 0;
|
||||
const idx = Math.max(0, Math.min(nPoints - 1, Math.round((x - paddingLeft) / (dx || 1))));
|
||||
setHoverIdx(idx);
|
||||
},
|
||||
[showTooltip, pointsArr.length, effectiveVbWidth, nPoints, drawWidth, paddingLeft],
|
||||
);
|
||||
|
||||
const onMouseLeave = useCallback(() => setHoverIdx(-1), []);
|
||||
|
||||
const hoverText = useMemo(() => {
|
||||
const idx = hoverIdx;
|
||||
if (idx < 0 || idx >= dataSlice.length) return '';
|
||||
const raw = Number(dataSlice[idx] || 0);
|
||||
const fmt = tooltipFormatter || yFormatter;
|
||||
const val = fmt(Number.isFinite(raw) ? raw : 0);
|
||||
const lab = labelsSlice[idx] != null ? labelsSlice[idx] : '';
|
||||
return `${val}${lab ? ' • ' + lab : ''}`;
|
||||
}, [hoverIdx, dataSlice, labelsSlice, tooltipFormatter, yFormatter]);
|
||||
|
||||
const tooltipPillWidth = Math.max(48, hoverText.length * 6.2 + 14);
|
||||
const hoverPoint = hoverIdx >= 0 ? pointsArr[hoverIdx] : null;
|
||||
const tooltipX = hoverPoint
|
||||
? Math.max(
|
||||
paddingLeft + 2,
|
||||
Math.min(effectiveVbWidth - paddingRight - tooltipPillWidth - 2, hoverPoint[0] - tooltipPillWidth / 2),
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
height={height}
|
||||
viewBox={`0 0 ${effectiveVbWidth} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className="sparkline-svg"
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity={Math.min(1, fillOpacity * 1.8)} />
|
||||
<stop offset="50%" stopColor={stroke} stopOpacity={fillOpacity * 0.7} />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<filter id={shadowId} x="-10%" y="-50%" width="120%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="2.4" />
|
||||
<feOffset dx="0" dy="2" result="offsetBlur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.45" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<radialGradient id={glowId}>
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.55" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{showGrid && (
|
||||
<g>
|
||||
{gridLines.map((g, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={g.x1}
|
||||
y1={g.y1}
|
||||
x2={g.x2}
|
||||
y2={g.y2}
|
||||
stroke={gridColor}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 5"
|
||||
className="cpu-grid-line"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{showAxes && (
|
||||
<g>
|
||||
{yTicks.map((tk, i) => (
|
||||
<text
|
||||
key={`y${i}`}
|
||||
className="cpu-grid-y-text"
|
||||
x={Math.max(0, paddingLeft - 6)}
|
||||
y={tk.y + 4}
|
||||
textAnchor="end"
|
||||
fontSize={10.5}
|
||||
>
|
||||
{tk.label}
|
||||
</text>
|
||||
))}
|
||||
{xTicks.map((tk, i) => (
|
||||
<text
|
||||
key={`x${i}`}
|
||||
className="cpu-grid-x-text"
|
||||
x={tk.x}
|
||||
y={paddingTop + drawHeight + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={10.5}
|
||||
>
|
||||
{tk.label}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{areaPath && <path d={areaPath} fill={`url(#${gradId})`} stroke="none" />}
|
||||
<polyline
|
||||
points={pointsStr}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter={`url(#${shadowId})`}
|
||||
/>
|
||||
{showMarker && lastPoint && (
|
||||
<>
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius * 3} fill={`url(#${glowId})`}>
|
||||
<animate attributeName="r" values={`${markerRadius * 2.4};${markerRadius * 3.4};${markerRadius * 2.4}`} dur="2.6s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius + 1.5} fill={stroke} fillOpacity={0.25} />
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius} fill={stroke} stroke="#fff" strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx] && (
|
||||
<g>
|
||||
<line
|
||||
className="cpu-grid-h-line"
|
||||
x1={pointsArr[hoverIdx][0]}
|
||||
x2={pointsArr[hoverIdx][0]}
|
||||
y1={paddingTop}
|
||||
y2={paddingTop + drawHeight}
|
||||
stroke={stroke}
|
||||
strokeOpacity={0.45}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 4"
|
||||
/>
|
||||
<circle cx={pointsArr[hoverIdx][0]} cy={pointsArr[hoverIdx][1]} r={5} fill={stroke} fillOpacity={0.25} />
|
||||
<circle cx={pointsArr[hoverIdx][0]} cy={pointsArr[hoverIdx][1]} r={3.5} fill={stroke} stroke="#fff" strokeWidth={1.5} />
|
||||
<rect
|
||||
x={tooltipX}
|
||||
y={paddingTop + 2}
|
||||
width={tooltipPillWidth}
|
||||
height={18}
|
||||
rx={9}
|
||||
ry={9}
|
||||
className="cpu-tooltip-pill"
|
||||
fill={stroke}
|
||||
fillOpacity={0.92}
|
||||
/>
|
||||
<text
|
||||
className="cpu-tooltip-text"
|
||||
x={tooltipX + tooltipPillWidth / 2}
|
||||
y={paddingTop + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fontWeight={600}
|
||||
fill="#fff"
|
||||
>
|
||||
{hoverText}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Array, required: true },
|
||||
labels: { type: Array, default: () => [] },
|
||||
vbWidth: { type: Number, default: 320 },
|
||||
height: { type: Number, default: 80 },
|
||||
stroke: { type: String, default: '#008771' },
|
||||
strokeWidth: { type: Number, default: 2 },
|
||||
maxPoints: { type: Number, default: 120 },
|
||||
showGrid: { type: Boolean, default: true },
|
||||
gridColor: { type: String, default: 'rgba(0,0,0,0.1)' },
|
||||
fillOpacity: { type: Number, default: 0.15 },
|
||||
showMarker: { type: Boolean, default: true },
|
||||
markerRadius: { type: Number, default: 2.8 },
|
||||
showAxes: { type: Boolean, default: false },
|
||||
yTickStep: { type: Number, default: 25 },
|
||||
tickCountX: { type: Number, default: 4 },
|
||||
paddingLeft: { type: Number, default: 32 },
|
||||
paddingRight: { type: Number, default: 6 },
|
||||
paddingTop: { type: Number, default: 6 },
|
||||
paddingBottom: { type: Number, default: 20 },
|
||||
showTooltip: { type: Boolean, default: false },
|
||||
// Value-range customization. When valueMax is null the chart auto-scales
|
||||
// to the running max of the data (useful for unbounded series like
|
||||
// network throughput or online clients). Defaults preserve the legacy
|
||||
// 0..100 percent behavior so existing callers don't need to change.
|
||||
valueMin: { type: Number, default: 0 },
|
||||
valueMax: { type: [Number, null], default: 100 },
|
||||
// Y-axis tick formatter. Receives the raw value, returns the label.
|
||||
// tooltipFormatter formats the hover-readout; falls back to yFormatter.
|
||||
yFormatter: { type: Function, default: (v) => `${Math.round(v)}%` },
|
||||
tooltipFormatter: { type: Function, default: null },
|
||||
});
|
||||
|
||||
const hoverIdx = ref(-1);
|
||||
|
||||
// Measured CSS width of the SVG. Drives the viewBox so SVG units stay
|
||||
// 1:1 with rendered pixels — otherwise `preserveAspectRatio="none"`
|
||||
// stretches the X axis and squashes axis text horizontally on narrow
|
||||
// containers (mobile). Falls back to the prop until the first measure.
|
||||
const svgRef = ref(null);
|
||||
const measuredWidth = ref(0);
|
||||
const effectiveVbWidth = computed(() => measuredWidth.value > 0 ? measuredWidth.value : props.vbWidth);
|
||||
|
||||
let resizeObserver = null;
|
||||
function measure() {
|
||||
const el = svgRef.value;
|
||||
if (!el) return;
|
||||
const w = el.getBoundingClientRect?.().width || 0;
|
||||
if (w > 0) measuredWidth.value = Math.round(w);
|
||||
}
|
||||
onMounted(() => {
|
||||
measure();
|
||||
if (typeof ResizeObserver !== 'undefined' && svgRef.value) {
|
||||
resizeObserver = new ResizeObserver(measure);
|
||||
resizeObserver.observe(svgRef.value);
|
||||
} else {
|
||||
window.addEventListener('resize', measure);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver) resizeObserver.disconnect();
|
||||
else window.removeEventListener('resize', measure);
|
||||
});
|
||||
|
||||
const viewBoxAttr = computed(() => `0 0 ${effectiveVbWidth.value} ${props.height}`);
|
||||
const drawWidth = computed(() => Math.max(1, effectiveVbWidth.value - props.paddingLeft - props.paddingRight));
|
||||
const drawHeight = computed(() => Math.max(1, props.height - props.paddingTop - props.paddingBottom));
|
||||
const nPoints = computed(() => Math.min(props.data.length, props.maxPoints));
|
||||
|
||||
const dataSlice = computed(() => {
|
||||
const n = nPoints.value;
|
||||
if (n === 0) return [];
|
||||
return props.data.slice(props.data.length - n);
|
||||
});
|
||||
|
||||
const labelsSlice = computed(() => {
|
||||
const n = nPoints.value;
|
||||
if (!props.labels?.length || n === 0) return [];
|
||||
const start = Math.max(0, props.labels.length - n);
|
||||
return props.labels.slice(start);
|
||||
});
|
||||
|
||||
// Resolved domain. When valueMax is null we auto-scale; pad the upper
|
||||
// bound by 10% so the line never touches the top edge — looks more
|
||||
// natural and gives the axis a sane ceiling. Floor the dynamic range
|
||||
// at 1 to avoid divide-by-zero on flat-line data (e.g. all zeros).
|
||||
const yDomain = computed(() => {
|
||||
const min = props.valueMin;
|
||||
if (props.valueMax != null) return { min, max: props.valueMax };
|
||||
let max = min;
|
||||
for (const v of dataSlice.value) {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
if (max <= min) max = min + 1;
|
||||
return { min, max: max * 1.1 };
|
||||
});
|
||||
|
||||
function project(v) {
|
||||
const { min, max } = yDomain.value;
|
||||
const span = max - min;
|
||||
if (span <= 0) return props.paddingTop + drawHeight.value;
|
||||
const clipped = Math.max(min, Math.min(max, Number(v) || 0));
|
||||
const ratio = (clipped - min) / span;
|
||||
return Math.round(props.paddingTop + (drawHeight.value - ratio * drawHeight.value));
|
||||
}
|
||||
|
||||
const pointsArr = computed(() => {
|
||||
const n = nPoints.value;
|
||||
if (n === 0) return [];
|
||||
const slice = dataSlice.value;
|
||||
const w = drawWidth.value;
|
||||
const dx = n > 1 ? w / (n - 1) : 0;
|
||||
return slice.map((v, i) => {
|
||||
const x = Math.round(props.paddingLeft + i * dx);
|
||||
return [x, project(v)];
|
||||
});
|
||||
});
|
||||
|
||||
const pointsStr = computed(() => pointsArr.value.map((p) => `${p[0]},${p[1]}`).join(' '));
|
||||
|
||||
const areaPath = computed(() => {
|
||||
if (pointsArr.value.length === 0) return '';
|
||||
const first = pointsArr.value[0];
|
||||
const last = pointsArr.value[pointsArr.value.length - 1];
|
||||
const baseY = props.paddingTop + drawHeight.value;
|
||||
const line = pointsStr.value.replace(/ /g, ' L ');
|
||||
return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
|
||||
});
|
||||
|
||||
const gridLines = computed(() => {
|
||||
if (!props.showGrid) return [];
|
||||
const h = drawHeight.value;
|
||||
const w = drawWidth.value;
|
||||
return [0, 0.25, 0.5, 0.75, 1].map((r) => {
|
||||
const y = Math.round(props.paddingTop + h * r);
|
||||
return { x1: props.paddingLeft, y1: y, x2: props.paddingLeft + w, y2: y };
|
||||
});
|
||||
});
|
||||
|
||||
const lastPoint = computed(() => {
|
||||
if (pointsArr.value.length === 0) return null;
|
||||
return pointsArr.value[pointsArr.value.length - 1];
|
||||
});
|
||||
|
||||
// Y-axis tick rendering. We pick a small number of evenly spaced values
|
||||
// inside the resolved domain and run them through yFormatter — that's
|
||||
// what makes "MB/s" / "clients" / "%" all render correctly without the
|
||||
// caller having to subclass the component.
|
||||
const yTicks = computed(() => {
|
||||
if (!props.showAxes) return [];
|
||||
const { min, max } = yDomain.value;
|
||||
const out = [];
|
||||
// For percent-style domains keep the legacy fixed step; otherwise
|
||||
// default to 4 evenly spaced ticks (5 lines including the bottom).
|
||||
if (props.valueMax === 100 && props.valueMin === 0 && props.yTickStep > 0) {
|
||||
for (let p = min; p <= max; p += props.yTickStep) {
|
||||
const y = project(p);
|
||||
out.push({ y, label: props.yFormatter(p) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const ticks = 5;
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
const v = min + ((max - min) * i) / (ticks - 1);
|
||||
out.push({ y: project(v), label: props.yFormatter(v) });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const xTicks = computed(() => {
|
||||
if (!props.showAxes) return [];
|
||||
const labels = labelsSlice.value;
|
||||
const n = nPoints.value;
|
||||
if (n === 0) return [];
|
||||
const m = Math.max(2, props.tickCountX);
|
||||
const w = drawWidth.value;
|
||||
const dx = n > 1 ? w / (n - 1) : 0;
|
||||
const out = [];
|
||||
for (let i = 0; i < m; i++) {
|
||||
const idx = Math.round((i * (n - 1)) / (m - 1));
|
||||
const label = labels[idx] != null ? String(labels[idx]) : String(idx);
|
||||
const x = Math.round(props.paddingLeft + idx * dx);
|
||||
out.push({ x, label });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function onMouseMove(evt) {
|
||||
if (!props.showTooltip || pointsArr.value.length === 0) return;
|
||||
const rect = evt.currentTarget.getBoundingClientRect();
|
||||
const px = evt.clientX - rect.left;
|
||||
const x = (px / rect.width) * effectiveVbWidth.value;
|
||||
const n = nPoints.value;
|
||||
const dx = n > 1 ? drawWidth.value / (n - 1) : 0;
|
||||
const idx = Math.max(0, Math.min(n - 1, Math.round((x - props.paddingLeft) / (dx || 1))));
|
||||
hoverIdx.value = idx;
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
hoverIdx.value = -1;
|
||||
}
|
||||
|
||||
function fmtHoverText() {
|
||||
const idx = hoverIdx.value;
|
||||
if (idx < 0 || idx >= dataSlice.value.length) return '';
|
||||
const raw = Number(dataSlice.value[idx] || 0);
|
||||
const fmt = props.tooltipFormatter || props.yFormatter;
|
||||
const val = fmt(Number.isFinite(raw) ? raw : 0);
|
||||
const lab = labelsSlice.value[idx] != null ? labelsSlice.value[idx] : '';
|
||||
return `${val}${lab ? ' • ' + lab : ''}`;
|
||||
}
|
||||
|
||||
// Stable per-instance gradient id so multiple sparklines on a page
|
||||
// don't clobber each other's <defs id="spkGrad">.
|
||||
const gradId = `spkGrad-${Math.random().toString(36).slice(2, 9)}`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg ref="svgRef" width="100%" :height="height" :viewBox="viewBoxAttr" preserveAspectRatio="none"
|
||||
class="sparkline-svg" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
|
||||
<defs>
|
||||
<linearGradient :id="gradId" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" :stop-color="stroke" :stop-opacity="fillOpacity" />
|
||||
<stop offset="100%" :stop-color="stroke" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g v-if="showGrid">
|
||||
<line v-for="(g, i) in gridLines" :key="i" :x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2" :stroke="gridColor"
|
||||
stroke-width="1" class="cpu-grid-line" />
|
||||
</g>
|
||||
|
||||
<g v-if="showAxes">
|
||||
<text v-for="(t, i) in yTicks" :key="'y' + i" class="cpu-grid-y-text" :x="Math.max(0, paddingLeft - 4)"
|
||||
:y="t.y + 4" text-anchor="end" font-size="10">{{ t.label }}</text>
|
||||
<text v-for="(t, i) in xTicks" :key="'x' + i" class="cpu-grid-x-text" :x="t.x" :y="paddingTop + drawHeight + 14"
|
||||
text-anchor="middle" font-size="10">{{ t.label }}</text>
|
||||
</g>
|
||||
|
||||
<path v-if="areaPath" :d="areaPath" :fill="`url(#${gradId})`" stroke="none" />
|
||||
<polyline :points="pointsStr" fill="none" :stroke="stroke" :stroke-width="strokeWidth" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<circle v-if="showMarker && lastPoint" :cx="lastPoint[0]" :cy="lastPoint[1]" :r="markerRadius" :fill="stroke" />
|
||||
|
||||
<g v-if="showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx]">
|
||||
<line class="cpu-grid-h-line" :x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]" :y1="paddingTop"
|
||||
:y2="paddingTop + drawHeight" stroke="rgba(0,0,0,0.2)" stroke-width="1" />
|
||||
<circle :cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]" r="3.5" :fill="stroke" />
|
||||
<text class="cpu-grid-text" :x="pointsArr[hoverIdx][0]" :y="paddingTop + 12" text-anchor="middle"
|
||||
font-size="11">{{ fmtHoverText() }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sparkline-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Axis labels live on SVG <text> elements; Vue's scoped CSS doesn't
|
||||
reliably hash-attribute SVG descendants, so the dark-mode overrides
|
||||
have to live in a non-scoped block to actually take effect. The
|
||||
numbers are also small, so the dark-theme fills run at ~85% opacity
|
||||
for legibility (the previous 55% was washed out on navy backgrounds). -->
|
||||
<style>
|
||||
.sparkline-svg .cpu-grid-y-text,
|
||||
.sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-y-text,
|
||||
body.dark .sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-line {
|
||||
stroke: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-h-line {
|
||||
stroke: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
</style>
|
||||
@@ -1,311 +0,0 @@
|
||||
<script>
|
||||
// Use defineComponent so we can keep the parent + child components in
|
||||
// the same file with the provide() <-> inject relationship intact.
|
||||
import { defineComponent, h, computed, ref, resolveComponent, inject } from 'vue';
|
||||
import { DragOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const ROW_CLASS = 'sortable-row';
|
||||
|
||||
// Sortable a-table — drag-to-reorder rows using Pointer Events.
|
||||
//
|
||||
// Why a custom component:
|
||||
// - Old impl set draggable: true on every row, which broke text selection
|
||||
// in cells and let HTML5 start drags from anywhere on the row. This
|
||||
// version only initiates drag from an explicit handle, via Pointer
|
||||
// Events (one API for mouse + touch + pen).
|
||||
// - During drag, data-source is reordered live; the source row visually
|
||||
// slides into the target slot. The live reorder IS the visual feedback.
|
||||
// - On commit, emits onsort(sourceIndex, targetIndex) — same signature as
|
||||
// before so existing call sites stay unchanged.
|
||||
// - Keyboard support: ArrowUp/ArrowDown move the focused handle's row by
|
||||
// one; Escape cancels an in-flight drag.
|
||||
|
||||
export const TableSortableTrigger = defineComponent({
|
||||
name: 'TableSortableTrigger',
|
||||
props: {
|
||||
itemIndex: { type: Number, required: true },
|
||||
},
|
||||
setup(props) {
|
||||
const sortable = inject('sortable', null);
|
||||
const ariaLabel = computed(() => `Drag to reorder row ${(props.itemIndex ?? 0) + 1}`);
|
||||
|
||||
function onPointerDown(e) {
|
||||
sortable?.startDrag?.(e, props.itemIndex);
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
const move = sortable?.moveByKeyboard;
|
||||
if (!move) return;
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
move(-1, props.itemIndex);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
move(+1, props.itemIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return () => h(DragOutlined, {
|
||||
class: 'sortable-icon',
|
||||
role: 'button',
|
||||
tabindex: 0,
|
||||
'aria-label': ariaLabel.value,
|
||||
onPointerdown: onPointerDown,
|
||||
onKeydown: onKeyDown,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TableSortable',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
dataSource: { type: Array, default: () => [] },
|
||||
customRow: { type: Function, default: null },
|
||||
rowKey: { type: [String, Function], default: null },
|
||||
locale: {
|
||||
type: Object,
|
||||
default: () => ({ filterConfirm: 'OK', filterReset: 'Reset', emptyText: 'No data' }),
|
||||
},
|
||||
},
|
||||
emits: ['onsort'],
|
||||
setup(props, { emit, slots, attrs, expose }) {
|
||||
// null when idle; while dragging:
|
||||
// { sourceIndex, targetIndex, pointerId, sourceKey }
|
||||
const drag = ref(null);
|
||||
const rootRef = ref(null);
|
||||
|
||||
const isDragging = computed(() => drag.value !== null);
|
||||
|
||||
// Resolve the row key for a record. Used to identify the source row
|
||||
// even after data-source is reordered live during drag.
|
||||
function keyOf(record, fallback) {
|
||||
const rk = props.rowKey;
|
||||
if (typeof rk === 'function') return rk(record);
|
||||
if (typeof rk === 'string') return record?.[rk];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function attachListeners() {
|
||||
document.addEventListener('pointermove', onPointerMove, true);
|
||||
document.addEventListener('pointerup', onPointerUp, true);
|
||||
document.addEventListener('pointercancel', cancelDrag, true);
|
||||
document.addEventListener('keydown', cancelDrag, true);
|
||||
}
|
||||
|
||||
function detachListeners() {
|
||||
document.removeEventListener('pointermove', onPointerMove, true);
|
||||
document.removeEventListener('pointerup', onPointerUp, true);
|
||||
document.removeEventListener('pointercancel', cancelDrag, true);
|
||||
document.removeEventListener('keydown', cancelDrag, true);
|
||||
}
|
||||
|
||||
function startDrag(e, sourceIndex) {
|
||||
// Primary button only (mouse left / first touch).
|
||||
if (e.button != null && e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const record = props.dataSource?.[sourceIndex];
|
||||
drag.value = {
|
||||
sourceIndex,
|
||||
targetIndex: sourceIndex,
|
||||
pointerId: e.pointerId,
|
||||
sourceKey: keyOf(record, sourceIndex),
|
||||
};
|
||||
// Capture the pointer so move/up keep firing even if the cursor
|
||||
// leaves the icon. Try/catch — some older browsers throw on capture.
|
||||
if (e.target?.setPointerCapture && e.pointerId != null) {
|
||||
try { e.target.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
|
||||
}
|
||||
attachListeners();
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
const d = drag.value;
|
||||
if (!d) return;
|
||||
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
|
||||
const root = rootRef.value;
|
||||
if (!root) return;
|
||||
const rows = root.querySelectorAll(`tr.${ROW_CLASS}`);
|
||||
if (!rows.length) return;
|
||||
const y = e.clientY;
|
||||
const firstRect = rows[0].getBoundingClientRect();
|
||||
const lastRect = rows[rows.length - 1].getBoundingClientRect();
|
||||
let target = d.targetIndex;
|
||||
if (y < firstRect.top) {
|
||||
target = 0;
|
||||
} else if (y > lastRect.bottom) {
|
||||
target = rows.length - 1;
|
||||
} else {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const rect = rows[i].getBoundingClientRect();
|
||||
if (y >= rect.top && y <= rect.bottom) {
|
||||
target = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target !== d.targetIndex) {
|
||||
drag.value = { ...d, targetIndex: target };
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
const d = drag.value;
|
||||
if (!d) return;
|
||||
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
|
||||
detachListeners();
|
||||
const captured = d;
|
||||
drag.value = null;
|
||||
if (captured.sourceIndex !== captured.targetIndex) {
|
||||
emit('onsort', captured.sourceIndex, captured.targetIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDrag(e) {
|
||||
// Triggered by pointercancel and keydown. For keydown only act on
|
||||
// Escape; otherwise let the event propagate.
|
||||
if (e?.type === 'keydown' && e.key !== 'Escape') return;
|
||||
detachListeners();
|
||||
drag.value = null;
|
||||
}
|
||||
|
||||
function moveByKeyboard(direction, sourceIndex) {
|
||||
const target = sourceIndex + direction;
|
||||
if (target < 0 || target >= (props.dataSource?.length ?? 0)) return;
|
||||
emit('onsort', sourceIndex, target);
|
||||
}
|
||||
|
||||
function customRowRender(record, index) {
|
||||
const parent = typeof props.customRow === 'function' ? props.customRow(record, index) || {} : {};
|
||||
const d = drag.value;
|
||||
const isSource = d && keyOf(record, index) === d.sourceKey;
|
||||
// Vue 3 customRow shape: a flat object of attrs/listeners/class —
|
||||
// no nested props/on like Vue 2.
|
||||
return {
|
||||
...parent,
|
||||
class: { [ROW_CLASS]: true, 'sortable-source-row': !!isSource, ...(parent.class || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
// Render-data: dataSource with the source row spliced into targetIndex.
|
||||
// When idle the original list is returned unchanged so a-table can
|
||||
// diff against a stable reference.
|
||||
const records = computed(() => {
|
||||
const d = drag.value;
|
||||
const src = props.dataSource ?? [];
|
||||
if (!d || d.sourceIndex === d.targetIndex) return src;
|
||||
const list = src.slice();
|
||||
const [item] = list.splice(d.sourceIndex, 1);
|
||||
list.splice(d.targetIndex, 0, item);
|
||||
return list;
|
||||
});
|
||||
|
||||
expose({ startDrag, moveByKeyboard });
|
||||
|
||||
return {
|
||||
rootRef, drag, isDragging, records, slots, attrs,
|
||||
startDrag, moveByKeyboard, customRowRender,
|
||||
};
|
||||
},
|
||||
// provide() needs to live at the options level so child components in
|
||||
// the rendered subtree resolve the same instance methods.
|
||||
provide() {
|
||||
return {
|
||||
sortable: {
|
||||
startDrag: (...a) => this.startDrag(...a),
|
||||
moveByKeyboard: (...a) => this.moveByKeyboard(...a),
|
||||
},
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('pointermove', this.onPointerMove, true);
|
||||
document.removeEventListener('pointerup', this.onPointerUp, true);
|
||||
document.removeEventListener('pointercancel', this.cancelDrag, true);
|
||||
document.removeEventListener('keydown', this.cancelDrag, true);
|
||||
},
|
||||
render() {
|
||||
// Forward every passed slot to a-table by reusing the slot fn
|
||||
// directly. Vue 3 slots are scoped by default so no $scopedSlots dance.
|
||||
const tableSlots = {};
|
||||
for (const name of Object.keys(this.slots)) {
|
||||
tableSlots[name] = this.slots[name];
|
||||
}
|
||||
// Resolved at runtime so the user's app.use(Antd) registration wins;
|
||||
// avoids importing Table directly here.
|
||||
const ATable = resolveComponent('a-table');
|
||||
return h(
|
||||
'div',
|
||||
{ ref: 'rootRef' },
|
||||
[h(
|
||||
ATable,
|
||||
{
|
||||
...this.attrs,
|
||||
'data-source': this.records,
|
||||
'row-key': this.rowKey,
|
||||
customRow: this.customRowRender,
|
||||
locale: this.locale,
|
||||
class: ['sortable-table', { 'sortable-table-dragging': this.isDragging }],
|
||||
},
|
||||
tableSlots,
|
||||
)],
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.sortable-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sortable-icon:hover {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.sortable-icon:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sortable-icon:focus-visible {
|
||||
outline: 2px solid #008771;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.light .sortable-icon {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.light .sortable-icon:hover {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.sortable-table-dragging .sortable-source-row>td {
|
||||
background: rgba(0, 135, 113, 0.10) !important;
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.sortable-table-dragging .sortable-source-row .routing-index,
|
||||
.sortable-table-dragging .sortable-source-row .outbound-index {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.sortable-table-dragging .sortable-row>td {
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.sortable-table-dragging,
|
||||
.sortable-table-dragging * {
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Button, Input, Modal, message } from 'antd';
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
|
||||
interface TextModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
content: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export default function TextModal({ open, onClose, title, content, fileName = '' }: TextModalProps) {
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
async function copy() {
|
||||
const ok = await ClipboardManager.copyText(content || '');
|
||||
if (ok) {
|
||||
messageApi.success('Copied');
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!fileName) return;
|
||||
FileManager.downloadTextFile(content, fileName);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
footer={(
|
||||
<>
|
||||
{fileName && (
|
||||
<Button icon={<DownloadOutlined />} onClick={download}>{fileName}</Button>
|
||||
)}
|
||||
<Button type="primary" icon={<CopyOutlined />} onClick={copy}>Copy</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Input.TextArea
|
||||
value={content}
|
||||
readOnly
|
||||
autoSize={{ minRows: 10, maxRows: 20 }}
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: 12,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
<script setup>
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
|
||||
// Read-only text modal — used to surface multi-line export blobs
|
||||
// (subscription URLs, raw inbound JSON, generated share links) the
|
||||
// way the legacy txtModal did.
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
content: { type: String, default: '' },
|
||||
// When set, surfaces a download button that writes `content` to a
|
||||
// text file with this name.
|
||||
fileName: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
async function copy(value) {
|
||||
const ok = await ClipboardManager.copyText(value || '');
|
||||
if (ok) {
|
||||
message.success('Copied');
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function download(content, name) {
|
||||
if (!name) return;
|
||||
FileManager.downloadTextFile(content, name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :closable="true" @cancel="close">
|
||||
<a-textarea :value="content" readonly :auto-size="{ minRows: 10, maxRows: 20 }" class="text-modal-content" />
|
||||
<template #footer>
|
||||
<a-button v-if="fileName" @click="download(content, fileName)">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
{{ fileName }}
|
||||
</a-button>
|
||||
<a-button type="primary" @click="copy(content)">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
Copy
|
||||
</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-modal-content {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,49 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { BulbFilled, BulbOutlined } from '@ant-design/icons-vue';
|
||||
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const BulbIcon = computed(() => (theme.isDark ? BulbFilled : BulbOutlined));
|
||||
|
||||
function onDarkChange() {
|
||||
pauseAnimationsUntilLeave('change-theme');
|
||||
toggleTheme();
|
||||
}
|
||||
|
||||
function onUltraClick() {
|
||||
pauseAnimationsUntilLeave('change-theme-ultra');
|
||||
toggleUltra();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="[]">
|
||||
<a-sub-menu>
|
||||
<template #title>
|
||||
<span>
|
||||
<component :is="BulbIcon" />
|
||||
<span class="theme-label">{{ t('menu.theme') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<a-menu-item id="change-theme" class="ant-menu-theme-switch">
|
||||
<span>{{ t('menu.dark') }}</span>
|
||||
<a-switch :style="{ marginLeft: '2px' }" size="small" :checked="theme.isDark" @change="onDarkChange" />
|
||||
</a-menu-item>
|
||||
|
||||
<a-menu-item v-if="theme.isDark" id="change-theme-ultra" class="ant-menu-theme-switch">
|
||||
<span>{{ t('menu.ultraDark') }}</span>
|
||||
<a-checkbox :style="{ marginLeft: '2px' }" :checked="theme.isUltra" @click="onUltraClick" />
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</a-menu>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-label {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup>
|
||||
import { theme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||
|
||||
function onDarkChange() {
|
||||
pauseAnimationsUntilLeave('change-theme');
|
||||
toggleTheme();
|
||||
}
|
||||
|
||||
function onUltraClick() {
|
||||
toggleUltra();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-space id="change-theme" direction="vertical" :size="10" :style="{ width: '100%' }">
|
||||
<a-space direction="horizontal" size="small">
|
||||
<a-switch size="small" :checked="theme.isDark" @change="onDarkChange" />
|
||||
<span>Dark</span>
|
||||
</a-space>
|
||||
<a-space v-if="theme.isDark" direction="horizontal" size="small">
|
||||
<a-checkbox :checked="theme.isUltra" @click="onUltraClick" />
|
||||
<span>Ultra dark</span>
|
||||
</a-space>
|
||||
</a-space>
|
||||
</template>
|
||||
@@ -1,45 +0,0 @@
|
||||
// Module-scoped reactive ref for the panel's "Calendar Type" setting.
|
||||
// Loaded from /panel/setting/defaultSettings on first use, so any
|
||||
// component (modals, inbound forms, future pages) can read the same
|
||||
// value without prop-drilling and without re-fetching.
|
||||
//
|
||||
// useInbounds (which already reads defaultSettings for its own state)
|
||||
// calls setDatepicker() after its fetch so we don't issue a second
|
||||
// HTTP round-trip on the inbounds page.
|
||||
|
||||
import { readonly, ref } from 'vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const datepicker = ref('gregorian');
|
||||
let fetched = false;
|
||||
let pending = null;
|
||||
|
||||
async function loadOnce() {
|
||||
if (fetched) return;
|
||||
if (pending) {
|
||||
await pending;
|
||||
return;
|
||||
}
|
||||
pending = (async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
||||
if (msg?.success) {
|
||||
datepicker.value = msg.obj?.datepicker || 'gregorian';
|
||||
}
|
||||
} finally {
|
||||
fetched = true;
|
||||
pending = null;
|
||||
}
|
||||
})();
|
||||
await pending;
|
||||
}
|
||||
|
||||
export function setDatepicker(value) {
|
||||
fetched = true;
|
||||
datepicker.value = value || 'gregorian';
|
||||
}
|
||||
|
||||
export function useDatepicker() {
|
||||
loadOnce();
|
||||
return { datepicker: readonly(datepicker) };
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ref, onBeforeUnmount, onMounted } from 'vue';
|
||||
|
||||
const MOBILE_BREAKPOINT_PX = 768;
|
||||
|
||||
// Vue 3 replacement for the legacy MediaQueryMixin. Returns a reactive
|
||||
// `isMobile` ref that updates on window resize. Use inside <script setup>:
|
||||
//
|
||||
// const { isMobile } = useMediaQuery();
|
||||
export function useMediaQuery(breakpoint = MOBILE_BREAKPOINT_PX) {
|
||||
const compute = () => window.innerWidth <= breakpoint;
|
||||
const isMobile = ref(compute());
|
||||
|
||||
const onResize = () => {
|
||||
isMobile.value = compute();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
return { isMobile };
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Lightweight composable that fetches the node list once on mount and
|
||||
// exposes id→name + id→online lookups. Used by the Inbounds page so it
|
||||
// can render a Node selector and a Node column without pulling the
|
||||
// full pages/nodes/useNodes.js (which polls and owns CRUD state).
|
||||
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
export function useNodeList() {
|
||||
const nodes = ref([]);
|
||||
const fetched = ref(false);
|
||||
|
||||
async function refresh() {
|
||||
const msg = await HttpUtil.get('/panel/api/nodes/list');
|
||||
if (msg?.success) {
|
||||
nodes.value = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
}
|
||||
fetched.value = true;
|
||||
}
|
||||
|
||||
// Indexed by id for O(1) UI lookups (Node column on N-row tables).
|
||||
const byId = computed(() => {
|
||||
const m = new Map();
|
||||
for (const n of nodes.value) m.set(n.id, n);
|
||||
return m;
|
||||
});
|
||||
|
||||
function nameFor(id) {
|
||||
if (id == null) return null;
|
||||
return byId.value.get(id)?.name || null;
|
||||
}
|
||||
|
||||
function isOnline(id) {
|
||||
if (id == null) return true;
|
||||
const n = byId.value.get(id);
|
||||
return n != null && n.enable && n.status === 'online';
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
|
||||
return { nodes, fetched, refresh, byId, nameFor, isOnline };
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { Status } from '@/models/status.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
// Polls /panel/api/server/status and exposes a reactive Status object
|
||||
// + a `fetched` flag so consumers can show a spinner before the first
|
||||
// successful fetch.
|
||||
//
|
||||
// WebSocket integration is intentionally deferred to a later sub-phase.
|
||||
// Polling at 2s is the same fallback the legacy panel falls back to
|
||||
// when its websocket link drops, so we're shipping the proven path
|
||||
// first and adding the websocket on top later.
|
||||
export function useStatus() {
|
||||
const status = shallowRef(new Status());
|
||||
const fetched = ref(false);
|
||||
let timer = null;
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/status');
|
||||
if (msg?.success) {
|
||||
status.value = new Status(msg.obj);
|
||||
if (!fetched.value) fetched.value = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get status:', e);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
timer = window.setInterval(refresh, POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer != null) window.clearInterval(timer);
|
||||
});
|
||||
|
||||
return { status, fetched, refresh };
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { reactive, computed, watchEffect } from 'vue';
|
||||
import { theme as antdTheme } from 'ant-design-vue';
|
||||
|
||||
// Single shared theme state. `import { theme } from '@/composables/useTheme.js'`
|
||||
// from any component to read/toggle. Boot side-effects (apply current
|
||||
// theme to <body>/<html>) run once at module load so the page is in the
|
||||
// right theme before Vue mounts.
|
||||
|
||||
const STORAGE_DARK = 'dark-mode';
|
||||
const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
|
||||
|
||||
function readBool(key, fallback) {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return fallback;
|
||||
return raw === 'true';
|
||||
}
|
||||
|
||||
const isDark = readBool(STORAGE_DARK, true);
|
||||
const isUltra = readBool(STORAGE_ULTRA, false);
|
||||
|
||||
export const theme = reactive({
|
||||
isDark,
|
||||
isUltra,
|
||||
});
|
||||
|
||||
export const currentTheme = computed(() => (theme.isDark ? 'dark' : 'light'));
|
||||
|
||||
// AD-Vue 4 theme config consumed by every page's <a-config-provider>.
|
||||
// Three modes — light / dark / ultra-dark — all share AD-Vue's vanilla
|
||||
// blue primary. Dark uses a navy palette across page/cards/modals so
|
||||
// the sidebar blends with the rest of the surface; ultra-dark stays
|
||||
// neutral black on top of darkAlgorithm.
|
||||
const DARK_TOKENS = {
|
||||
colorBgBase: '#0a1426',
|
||||
colorBgLayout: '#0a1426',
|
||||
colorBgContainer: '#142340',
|
||||
colorBgElevated: '#1a2c4d',
|
||||
};
|
||||
const ULTRA_DARK_TOKENS = {
|
||||
colorBgBase: '#000',
|
||||
colorBgLayout: '#000',
|
||||
colorBgContainer: '#0a0a0a',
|
||||
colorBgElevated: '#141414',
|
||||
};
|
||||
|
||||
// AD-Vue 4 hardcodes navy `#001529` / `#002140` as the Layout sider
|
||||
// + trigger backgrounds and `#001529` / `#000c17` as the dark Menu item
|
||||
// backgrounds (see node_modules/ant-design-vue/es/{layout,menu}/style/
|
||||
// index.js). Override at the component-token level so the sider blends
|
||||
// with darkAlgorithm's neutral surfaces.
|
||||
// Dark theme uses a refined navy for the sidebar — distinct from the
|
||||
// neutral ultra-dark and warmer than AD-Vue's stock #001529.
|
||||
const DARK_LAYOUT_TOKENS = {
|
||||
colorBgHeader: '#0d1d33',
|
||||
colorBgTrigger: '#15294a',
|
||||
colorBgBody: '#000',
|
||||
};
|
||||
const ULTRA_DARK_LAYOUT_TOKENS = {
|
||||
colorBgHeader: '#0a0a0a',
|
||||
colorBgTrigger: '#141414',
|
||||
colorBgBody: '#000',
|
||||
};
|
||||
const DARK_MENU_TOKENS = {
|
||||
colorItemBg: '#0d1d33',
|
||||
colorSubItemBg: '#08142a',
|
||||
menuSubMenuBg: '#0d1d33',
|
||||
};
|
||||
const ULTRA_DARK_MENU_TOKENS = {
|
||||
colorItemBg: '#0a0a0a',
|
||||
colorSubItemBg: '#000',
|
||||
menuSubMenuBg: '#0a0a0a',
|
||||
};
|
||||
|
||||
export const antdThemeConfig = computed(() => {
|
||||
if (!theme.isDark) {
|
||||
return { algorithm: antdTheme.defaultAlgorithm };
|
||||
}
|
||||
return {
|
||||
algorithm: antdTheme.darkAlgorithm,
|
||||
token: theme.isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
|
||||
components: {
|
||||
Layout: theme.isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
|
||||
Menu: theme.isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export function toggleTheme() {
|
||||
theme.isDark = !theme.isDark;
|
||||
}
|
||||
|
||||
export function toggleUltra() {
|
||||
theme.isUltra = !theme.isUltra;
|
||||
}
|
||||
|
||||
// Briefly disable theme transition animations while a toggle is in
|
||||
// flight, then re-enable on mouseleave. Mirrors the legacy panel's
|
||||
// behavior of preventing flicker when hovering the theme menu.
|
||||
export function pauseAnimationsUntilLeave(elementId) {
|
||||
document.documentElement.setAttribute('data-theme-animations', 'off');
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
const restore = () => {
|
||||
document.documentElement.removeAttribute('data-theme-animations');
|
||||
el.removeEventListener('mouseleave', restore);
|
||||
el.removeEventListener('touchend', restore);
|
||||
};
|
||||
el.addEventListener('mouseleave', restore);
|
||||
el.addEventListener('touchend', restore);
|
||||
}
|
||||
|
||||
// Apply theme to DOM and persist whenever it changes.
|
||||
watchEffect(() => {
|
||||
document.body.setAttribute('class', theme.isDark ? 'dark' : 'light');
|
||||
localStorage.setItem(STORAGE_DARK, String(theme.isDark));
|
||||
|
||||
if (theme.isUltra) {
|
||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
localStorage.setItem(STORAGE_ULTRA, String(theme.isUltra));
|
||||
|
||||
// Keep the global #message container's class in sync so AD-Vue toasts
|
||||
// pick up the right styling.
|
||||
const msg = document.getElementById('message');
|
||||
if (msg) msg.className = theme.isDark ? 'dark' : 'light';
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { onBeforeUnmount, onMounted } from 'vue';
|
||||
import { WebSocketClient } from '@/api/websocket.js';
|
||||
|
||||
// One client per browser tab (= per multi-page entry). WebSocketClient is
|
||||
// idempotent: repeated connect() calls while the socket is already open
|
||||
// are no-ops, so multiple components on the same page can share a single
|
||||
// underlying connection without each spawning their own.
|
||||
let sharedClient = null;
|
||||
|
||||
function getSharedClient() {
|
||||
if (sharedClient) return sharedClient;
|
||||
const basePath = (typeof window !== 'undefined' && window.__X_UI_BASE_PATH__) || '';
|
||||
sharedClient = new WebSocketClient(basePath);
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
// useWebSocket lets a Vue component subscribe to live server-pushed
|
||||
// events. Pass a map of { eventName: handler } and the composable wires
|
||||
// connect()/disconnect() into the component lifecycle and unsubscribes
|
||||
// every handler on unmount so a stale closure can't fire after the
|
||||
// page has moved on.
|
||||
//
|
||||
// Example:
|
||||
// useWebSocket({
|
||||
// traffic: (payload) => applyTrafficEvent(payload),
|
||||
// client_stats: (payload) => applyClientStatsEvent(payload),
|
||||
// invalidate: ({ type }) => { if (type === 'inbounds') refresh(); },
|
||||
// });
|
||||
//
|
||||
// Built-in lifecycle events ('connected' / 'disconnected' / 'error')
|
||||
// can be subscribed to alongside server-emitted types.
|
||||
export function useWebSocket(handlers) {
|
||||
const client = getSharedClient();
|
||||
const entries = Object.entries(handlers || {});
|
||||
|
||||
onMounted(() => {
|
||||
for (const [event, fn] of entries) client.on(event, fn);
|
||||
client.connect();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const [event, fn] of entries) client.off(event, fn);
|
||||
// Don't disconnect — another mounted component on the same page may
|
||||
// still be subscribed. The client closes naturally on page unload.
|
||||
});
|
||||
|
||||
return { client };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<ApiDocsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import ClientsPage from '@/pages/clients/ClientsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<ClientsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import InboundsPage from '@/pages/inbounds/InboundsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<InboundsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
// Importing useTheme triggers the boot side-effect that applies the
|
||||
// stored theme to <body>/<html> before Vue mounts.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import IndexPage from '@/pages/index/IndexPage.vue';
|
||||
|
||||
setupAxios();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(IndexPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import IndexPage from '@/pages/index/IndexPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<IndexPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
// Importing this module triggers the boot side-effect that applies the
|
||||
// stored theme to <body>/<html> before Vue renders anything.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import LoginPage from '@/pages/login/LoginPage.vue';
|
||||
|
||||
setupAxios();
|
||||
|
||||
// Toasts attach to a #message div the page provides — keeps theme
|
||||
// styling in sync with the rest of the panel.
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(LoginPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import LoginPage from '@/pages/login/LoginPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<LoginPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import NodesPage from '@/pages/nodes/NodesPage.vue';
|
||||
|
||||
setupAxios();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(NodesPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import NodesPage from '@/pages/nodes/NodesPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<NodesPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
// Importing useTheme triggers the boot side-effect that applies the
|
||||
// stored theme to <body>/<html> before Vue mounts.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import SettingsPage from '@/pages/settings/SettingsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import SettingsPage from '@/pages/settings/SettingsPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<SettingsPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
// The sub page is served by the subscription HTTP server (sub/sub.go)
|
||||
// at /<linksPath>/<subId>?html=1. Go injects window.__SUB_PAGE_DATA__
|
||||
// with the parsed traffic/quota/expiry view-model and the rendered
|
||||
// share links — the SPA reads those at mount.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import SubPage from '@/pages/sub/SubPage.vue';
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(SubPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import SubPage from '@/pages/sub/SubPage';
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<SubPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import XrayPage from '@/pages/xray/XrayPage.vue';
|
||||
|
||||
setupAxios();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(XrayPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { message } from 'antd';
|
||||
import 'antd/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import { readyI18n } from '@/i18n/react';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import XrayPage from '@/pages/xray/XrayPage';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<XrayPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
});
|
||||
Vendored
+65
@@ -0,0 +1,65 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface SubPageData {
|
||||
sId?: string;
|
||||
enabled?: boolean;
|
||||
download?: string;
|
||||
upload?: string;
|
||||
total?: string;
|
||||
used?: string;
|
||||
remained?: string;
|
||||
totalByte?: string | number;
|
||||
expire?: string | number;
|
||||
lastOnline?: string | number;
|
||||
subUrl?: string;
|
||||
subJsonUrl?: string;
|
||||
subClashUrl?: string;
|
||||
subTitle?: string;
|
||||
links?: string[];
|
||||
datepicker?: 'gregorian' | 'jalalian';
|
||||
downloadByte?: string | number;
|
||||
uploadByte?: string | number;
|
||||
usedByte?: string | number;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
X_UI_BASE_PATH?: string;
|
||||
X_UI_CUR_VER?: string;
|
||||
__SUB_PAGE_DATA__?: SubPageData;
|
||||
}
|
||||
|
||||
declare module 'persian-calendar-suite' {
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
|
||||
type DateInput = string | number | null;
|
||||
type OutputFormat = 'iso' | 'shamsi' | 'gregorian' | 'hijri' | 'timestamp';
|
||||
|
||||
interface PersianDateTimePickerProps {
|
||||
value?: DateInput;
|
||||
onChange?: (value: number | string | null) => void;
|
||||
defaultValue?: string | number | 'now' | null;
|
||||
showTime?: boolean;
|
||||
minuteStep?: number;
|
||||
outputFormat?: OutputFormat;
|
||||
showFooter?: boolean;
|
||||
theme?: Record<string, unknown>;
|
||||
disabledHours?: number[];
|
||||
minDate?: string | Date | null;
|
||||
maxDate?: string | Date | null;
|
||||
enabledDates?: string[] | null;
|
||||
disabledDates?: string[] | null;
|
||||
disabledWeekDays?: number[];
|
||||
persianNumbers?: boolean;
|
||||
rtlCalendar?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const PersianDateTimePicker: ComponentType<PersianDateTimePickerProps>;
|
||||
export const PersianCalendar: ComponentType<Record<string, unknown>>;
|
||||
export const PersianDateRangePicker: ComponentType<Record<string, unknown>>;
|
||||
export const PersianTimePicker: ComponentType<Record<string, unknown>>;
|
||||
export const PersianTimeline: ComponentType<Record<string, unknown>>;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { AllSetting } from '@/models/setting';
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
export function useAllSetting() {
|
||||
const [allSetting, setAllSetting] = useState<AllSetting>(() => new AllSetting());
|
||||
const [oldAllSetting, setOldAllSetting] = useState<AllSetting>(() => new AllSetting());
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
const applyServerState = useCallback((obj: unknown) => {
|
||||
setAllSetting(new AllSetting(obj));
|
||||
setOldAllSetting(new AllSetting(obj));
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/setting/all') as ApiMsg;
|
||||
if (msg?.success) {
|
||||
applyServerState(msg.obj);
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
}
|
||||
}, [applyServerState]);
|
||||
|
||||
const saveAll = useCallback(async () => {
|
||||
setSpinning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/update', allSetting) as ApiMsg;
|
||||
if (msg?.success) await fetchAll();
|
||||
} finally {
|
||||
setSpinning(false);
|
||||
}
|
||||
}, [allSetting, fetchAll]);
|
||||
|
||||
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
|
||||
setAllSetting((prev) => {
|
||||
const next = new AllSetting(prev);
|
||||
Object.assign(next, patch);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveDisabled = useMemo(
|
||||
() => allSetting.equals(oldAllSetting),
|
||||
[allSetting, oldAllSetting],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
fetchAll();
|
||||
}, [fetchAll]);
|
||||
|
||||
return {
|
||||
allSetting,
|
||||
updateSetting,
|
||||
fetched,
|
||||
spinning,
|
||||
setSpinning,
|
||||
saveDisabled,
|
||||
fetchAll,
|
||||
saveAll,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
|
||||
|
||||
export interface ClientTraffic {
|
||||
up?: number;
|
||||
down?: number;
|
||||
total?: number;
|
||||
expiryTime?: number;
|
||||
enable?: boolean;
|
||||
lastOnline?: number;
|
||||
}
|
||||
|
||||
export interface ClientRecord {
|
||||
email: string;
|
||||
subId?: string;
|
||||
uuid?: string;
|
||||
password?: string;
|
||||
auth?: string;
|
||||
flow?: string;
|
||||
totalGB?: number;
|
||||
expiryTime?: number;
|
||||
limitIp?: number;
|
||||
tgId?: number | string;
|
||||
comment?: string;
|
||||
enable?: boolean;
|
||||
inboundIds?: number[];
|
||||
traffic?: ClientTraffic;
|
||||
reverse?: { tag?: string };
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface InboundOption {
|
||||
id: number;
|
||||
remark?: string;
|
||||
protocol?: string;
|
||||
port?: number;
|
||||
tlsFlowCapable?: boolean;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
msg?: string;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
interface SubSettings {
|
||||
enable: boolean;
|
||||
subURI: string;
|
||||
subJsonURI: string;
|
||||
subJsonEnable: boolean;
|
||||
}
|
||||
|
||||
export interface ClientQueryParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search?: string;
|
||||
filter?: string;
|
||||
protocol?: string;
|
||||
inbound?: number;
|
||||
sort?: string;
|
||||
order?: 'ascend' | 'descend';
|
||||
}
|
||||
|
||||
export interface ClientsSummary {
|
||||
total: number;
|
||||
active: number;
|
||||
online: string[];
|
||||
depleted: string[];
|
||||
expiring: string[];
|
||||
deactive: string[];
|
||||
}
|
||||
|
||||
interface ClientPageResponse {
|
||||
items: ClientRecord[];
|
||||
total: number;
|
||||
filtered: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
summary?: ClientsSummary;
|
||||
}
|
||||
|
||||
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
|
||||
|
||||
export function useClients() {
|
||||
const [clients, setClients] = useState<ClientRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [filtered, setFiltered] = useState(0);
|
||||
const [summary, setSummary] = useState<ClientsSummary>({
|
||||
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
|
||||
});
|
||||
const [inbounds, setInbounds] = useState<InboundOption[]>([]);
|
||||
const [onlines, setOnlines] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
|
||||
// Shallow-compare against the previous query so callers can pass a fresh
|
||||
// object on every render (the common React pattern) without triggering a
|
||||
// re-fetch when nothing actually changed.
|
||||
const setQuery = useCallback((next: ClientQueryParams) => {
|
||||
setQueryState((prev) => {
|
||||
if (
|
||||
prev.page === next.page
|
||||
&& prev.pageSize === next.pageSize
|
||||
&& (prev.search ?? '') === (next.search ?? '')
|
||||
&& (prev.filter ?? '') === (next.filter ?? '')
|
||||
&& (prev.protocol ?? '') === (next.protocol ?? '')
|
||||
&& (prev.inbound ?? 0) === (next.inbound ?? 0)
|
||||
&& (prev.sort ?? '') === (next.sort ?? '')
|
||||
&& (prev.order ?? '') === (next.order ?? '')
|
||||
) return prev;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const [subSettings, setSubSettings] = useState<SubSettings>({
|
||||
enable: false, subURI: '', subJsonURI: '', subJsonEnable: false,
|
||||
});
|
||||
const [ipLimitEnable, setIpLimitEnable] = useState(false);
|
||||
const [tgBotEnable, setTgBotEnable] = useState(false);
|
||||
const [expireDiff, setExpireDiff] = useState(0);
|
||||
const [trafficDiff, setTrafficDiff] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(0);
|
||||
|
||||
const clientsRef = useRef<ClientRecord[]>([]);
|
||||
const queryRef = useRef<ClientQueryParams>(query);
|
||||
const invalidateTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => { clientsRef.current = clients; }, [clients]);
|
||||
useEffect(() => { queryRef.current = query; }, [query]);
|
||||
|
||||
const buildQS = (p: ClientQueryParams) => {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set('page', String(p.page || 1));
|
||||
sp.set('pageSize', String(p.pageSize || DEFAULT_QUERY.pageSize));
|
||||
if (p.search) sp.set('search', p.search);
|
||||
if (p.filter) sp.set('filter', p.filter);
|
||||
if (p.protocol) sp.set('protocol', p.protocol);
|
||||
if (p.inbound && p.inbound > 0) sp.set('inbound', String(p.inbound));
|
||||
if (p.sort) sp.set('sort', p.sort);
|
||||
if (p.order) sp.set('order', p.order);
|
||||
return sp.toString();
|
||||
};
|
||||
|
||||
const refresh = useCallback(async (override?: ClientQueryParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = override ?? queryRef.current;
|
||||
const qs = buildQS(params);
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`) as ApiMsg<ClientPageResponse>;
|
||||
if (msg?.success && msg.obj) {
|
||||
setClients(Array.isArray(msg.obj.items) ? msg.obj.items : []);
|
||||
setTotal(msg.obj.total ?? 0);
|
||||
setFiltered(msg.obj.filtered ?? 0);
|
||||
if (msg.obj.summary) setSummary(msg.obj.summary);
|
||||
}
|
||||
setFetched(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Inbound options are picker-shaped and don't depend on the clients query —
|
||||
// fetch them once on mount instead of every refresh.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/options') as ApiMsg<InboundOption[]>;
|
||||
if (cancelled) return;
|
||||
if (msg?.success) setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const fetchSubSettings = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg<Record<string, unknown>>;
|
||||
if (!msg?.success) return;
|
||||
const s = msg.obj || {};
|
||||
setSubSettings({
|
||||
enable: !!s.subEnable,
|
||||
subURI: (s.subURI as string) || '',
|
||||
subJsonURI: (s.subJsonURI as string) || '',
|
||||
subJsonEnable: !!s.subJsonEnable,
|
||||
});
|
||||
setIpLimitEnable(!!s.ipLimitEnable);
|
||||
setTgBotEnable(!!s.tgBotEnable);
|
||||
setExpireDiff(((s.expireDiff as number) ?? 0) * 86400000);
|
||||
setTrafficDiff(((s.trafficDiff as number) ?? 0) * 1073741824);
|
||||
setPageSize((s.pageSize as number) ?? 0);
|
||||
}, []);
|
||||
|
||||
// hydrate fetches the full client record (uuid, password, flow, ...) for a
|
||||
// single email. The paged list endpoint omits these to keep the row payload
|
||||
// tiny; edit / info / qr / link modals call this to get a complete record
|
||||
// before opening.
|
||||
const hydrate = useCallback(async (email: string): Promise<{ client: ClientRecord; inboundIds: number[] } | null> => {
|
||||
if (!email) return null;
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`) as ApiMsg<{ client: ClientRecord; inboundIds: number[] }>;
|
||||
if (!msg?.success || !msg.obj) return null;
|
||||
return msg.obj;
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (payload: unknown) => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const update = useCallback(async (email: string, client: unknown) => {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const remove = useCallback(async (email: string, keepTraffic = false) => {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const url = keepTraffic
|
||||
? `/panel/api/clients/del/${encoded}?keepTraffic=1`
|
||||
: `/panel/api/clients/del/${encoded}`;
|
||||
const msg = await HttpUtil.post(url) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const removeMany = useCallback(async (emails: string[], keepTraffic = false) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return [];
|
||||
const suffix = keepTraffic ? '?keepTraffic=1' : '';
|
||||
const results = await Promise.all(emails.map((email) => {
|
||||
const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
|
||||
return HttpUtil.post(url, undefined, { silent: true }) as Promise<ApiMsg>;
|
||||
}));
|
||||
await refresh();
|
||||
return results;
|
||||
}, [refresh]);
|
||||
|
||||
const bulkAdjust = useCallback(async (emails: string[], addDays: number, addBytes: number) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return null;
|
||||
const msg = await HttpUtil.post(
|
||||
'/panel/api/clients/bulkAdjust',
|
||||
{ emails, addDays, addBytes },
|
||||
JSON_HEADERS,
|
||||
) as ApiMsg<{ adjusted: number; skipped?: { email: string; reason: string }[] }>;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const attach = useCallback(async (email: string, inboundIds: number[]) => {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const detach = useCallback(async (email: string, inboundIds: number[]) => {
|
||||
if (!email) return null;
|
||||
const encoded = encodeURIComponent(email);
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const resetTraffic = useCallback(async (client: ClientRecord) => {
|
||||
if (!client?.email) return null;
|
||||
const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`;
|
||||
const msg = await HttpUtil.post(url) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const resetAllTraffics = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics') as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const delDepleted = useCallback(async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/delDepleted') as ApiMsg<{ deleted?: number }>;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
|
||||
if (!client?.email) return null;
|
||||
const payload = {
|
||||
email: client.email,
|
||||
subId: client.subId,
|
||||
id: client.uuid,
|
||||
password: client.password,
|
||||
auth: client.auth,
|
||||
totalGB: client.totalGB || 0,
|
||||
expiryTime: client.expiryTime || 0,
|
||||
limitIp: client.limitIp || 0,
|
||||
comment: client.comment || '',
|
||||
enable: !!enable,
|
||||
};
|
||||
return update(client.email, payload);
|
||||
}, [update]);
|
||||
|
||||
const applyTrafficEvent = useCallback((payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { onlineClients?: string[] };
|
||||
if (Array.isArray(p.onlineClients)) {
|
||||
setOnlines(p.onlineClients);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyClientStatsEvent = useCallback((payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { clients?: ClientTraffic[] & { email?: string }[] };
|
||||
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
|
||||
const byEmail = new Map<string, ClientTraffic>();
|
||||
for (const row of p.clients as (ClientTraffic & { email?: string })[]) {
|
||||
if (row && row.email) byEmail.set(row.email, row);
|
||||
}
|
||||
const cur = clientsRef.current || [];
|
||||
let touched = false;
|
||||
const next = cur.slice();
|
||||
for (let i = 0; i < next.length; i++) {
|
||||
const row = next[i];
|
||||
const upd = byEmail.get(row?.email);
|
||||
if (!upd) continue;
|
||||
const merged: ClientTraffic = { ...(row.traffic || {}) };
|
||||
if (typeof upd.up === 'number') merged.up = upd.up;
|
||||
if (typeof upd.down === 'number') merged.down = upd.down;
|
||||
if (typeof upd.total === 'number') merged.total = upd.total;
|
||||
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
|
||||
if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
|
||||
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
|
||||
next[i] = { ...row, traffic: merged };
|
||||
touched = true;
|
||||
}
|
||||
if (touched) setClients(next);
|
||||
}, []);
|
||||
|
||||
const applyInvalidate = useCallback((payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const p = payload as { type?: string };
|
||||
if (p.type !== 'inbounds' && p.type !== 'clients') return;
|
||||
if (invalidateTimerRef.current != null) clearTimeout(invalidateTimerRef.current);
|
||||
invalidateTimerRef.current = window.setTimeout(() => {
|
||||
invalidateTimerRef.current = null;
|
||||
refresh();
|
||||
}, 200);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([refresh(query), fetchSubSettings()]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, fetchSubSettings]);
|
||||
|
||||
return {
|
||||
clients,
|
||||
total,
|
||||
filtered,
|
||||
summary,
|
||||
hydrate,
|
||||
query,
|
||||
setQuery,
|
||||
inbounds,
|
||||
onlines,
|
||||
loading,
|
||||
fetched,
|
||||
subSettings,
|
||||
ipLimitEnable,
|
||||
tgBotEnable,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
pageSize,
|
||||
refresh,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
removeMany,
|
||||
bulkAdjust,
|
||||
attach,
|
||||
detach,
|
||||
resetTraffic,
|
||||
resetAllTraffics,
|
||||
delDepleted,
|
||||
setEnable,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
type Calendar = 'gregorian' | 'jalalian';
|
||||
|
||||
let cachedValue: Calendar = 'gregorian';
|
||||
let fetched = false;
|
||||
let pending: Promise<void> | null = null;
|
||||
const listeners = new Set<(value: Calendar) => void>();
|
||||
|
||||
function notify(value: Calendar) {
|
||||
listeners.forEach((fn) => fn(value));
|
||||
}
|
||||
|
||||
async function loadOnce(): Promise<void> {
|
||||
if (fetched) return;
|
||||
if (pending) {
|
||||
await pending;
|
||||
return;
|
||||
}
|
||||
pending = (async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as {
|
||||
success?: boolean;
|
||||
obj?: { datepicker?: Calendar };
|
||||
};
|
||||
if (msg?.success) {
|
||||
cachedValue = msg.obj?.datepicker || 'gregorian';
|
||||
notify(cachedValue);
|
||||
}
|
||||
} finally {
|
||||
fetched = true;
|
||||
pending = null;
|
||||
}
|
||||
})();
|
||||
await pending;
|
||||
}
|
||||
|
||||
export function setDatepicker(value: Calendar) {
|
||||
fetched = true;
|
||||
cachedValue = value || 'gregorian';
|
||||
notify(cachedValue);
|
||||
}
|
||||
|
||||
export function useDatepicker() {
|
||||
const [datepicker, setLocal] = useState<Calendar>(cachedValue);
|
||||
|
||||
useEffect(() => {
|
||||
listeners.add(setLocal);
|
||||
loadOnce();
|
||||
return () => {
|
||||
listeners.delete(setLocal);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { datepicker };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT_PX = 768;
|
||||
|
||||
export function useMediaQuery(breakpoint: number = MOBILE_BREAKPOINT_PX) {
|
||||
const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth <= breakpoint);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsMobile(window.innerWidth <= breakpoint);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [breakpoint]);
|
||||
|
||||
return { isMobile };
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
export interface NodeRecord {
|
||||
id: number;
|
||||
name?: string;
|
||||
remark?: string;
|
||||
scheme?: string;
|
||||
address?: string;
|
||||
port?: number;
|
||||
basePath?: string;
|
||||
apiToken?: string;
|
||||
enable?: boolean;
|
||||
status?: 'online' | 'offline' | string;
|
||||
latencyMs?: number;
|
||||
cpuPct?: number;
|
||||
memPct?: number;
|
||||
xrayVersion?: string;
|
||||
panelVersion?: string;
|
||||
uptimeSecs?: number;
|
||||
inboundCount?: number;
|
||||
clientCount?: number;
|
||||
onlineCount?: number;
|
||||
depletedCount?: number;
|
||||
lastHeartbeat?: number;
|
||||
lastError?: string;
|
||||
allowPrivateAddress?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ApiMsg<T = unknown> {
|
||||
success?: boolean;
|
||||
msg?: string;
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
interface NodeTotals {
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
avgLatency: number;
|
||||
inbounds: number;
|
||||
clients: number;
|
||||
onlineClients: number;
|
||||
depleted: number;
|
||||
}
|
||||
|
||||
export function useNodes() {
|
||||
const [nodes, setNodes] = useState<NodeRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/nodes/list') as ApiMsg<NodeRecord[]>;
|
||||
if (msg?.success) {
|
||||
setNodes(Array.isArray(msg.obj) ? msg.obj : []);
|
||||
}
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyNodesEvent = useCallback((payload: unknown) => {
|
||||
if (Array.isArray(payload)) {
|
||||
setNodes(payload as NodeRecord[]);
|
||||
if (!fetchedRef.current) {
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (payload: Partial<NodeRecord>) => {
|
||||
const msg = await HttpUtil.post('/panel/api/nodes/add', payload) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const update = useCallback(async (id: number, payload: Partial<NodeRecord>) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/update/${id}`, payload) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const remove = useCallback(async (id: number) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/del/${id}`) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const setEnable = useCallback(async (id: number, enable: boolean) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }) as ApiMsg;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const testConnection = useCallback(async (payload: Partial<NodeRecord>) => {
|
||||
return await HttpUtil.post('/panel/api/nodes/test', payload) as ApiMsg<{
|
||||
status: string;
|
||||
latencyMs?: number;
|
||||
xrayVersion?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}, []);
|
||||
|
||||
const probe = useCallback(async (id: number) => {
|
||||
const msg = await HttpUtil.post(`/panel/api/nodes/probe/${id}`) as ApiMsg<{
|
||||
status: string;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
if (msg?.success) await refresh();
|
||||
return msg;
|
||||
}, [refresh]);
|
||||
|
||||
const totals = useMemo<NodeTotals>(() => {
|
||||
let online = 0;
|
||||
let offline = 0;
|
||||
let latencySum = 0;
|
||||
let latencyCount = 0;
|
||||
let inbounds = 0;
|
||||
let clients = 0;
|
||||
let onlineClients = 0;
|
||||
let depleted = 0;
|
||||
for (const n of nodes) {
|
||||
inbounds += n.inboundCount || 0;
|
||||
clients += n.clientCount || 0;
|
||||
onlineClients += n.onlineCount || 0;
|
||||
depleted += n.depletedCount || 0;
|
||||
if (!n.enable) continue;
|
||||
if (n.status === 'online') {
|
||||
online += 1;
|
||||
if (n.latencyMs && n.latencyMs > 0) {
|
||||
latencySum += n.latencyMs;
|
||||
latencyCount += 1;
|
||||
}
|
||||
} else if (n.status === 'offline') {
|
||||
offline += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: nodes.length,
|
||||
online,
|
||||
offline,
|
||||
avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
|
||||
inbounds,
|
||||
clients,
|
||||
onlineClients,
|
||||
depleted,
|
||||
};
|
||||
}, [nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
loading,
|
||||
fetched,
|
||||
totals,
|
||||
refresh,
|
||||
applyNodesEvent,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setEnable,
|
||||
testConnection,
|
||||
probe,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { Status } from '@/models/status';
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
export function useStatus() {
|
||||
const [status, setStatus] = useState<Status>(() => new Status());
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/status');
|
||||
if (msg?.success) {
|
||||
setStatus(new Status(msg.obj));
|
||||
if (!fetchedRef.current) {
|
||||
fetchedRef.current = true;
|
||||
setFetched(true);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get status:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const timer = window.setInterval(refresh, POLL_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
return { status, fetched, refresh };
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { theme as antdTheme } from 'antd';
|
||||
import type { ThemeConfig } from 'antd';
|
||||
|
||||
const STORAGE_DARK = 'dark-mode';
|
||||
const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
|
||||
|
||||
function readBool(key: string, fallback: boolean): boolean {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return fallback;
|
||||
return raw === 'true';
|
||||
}
|
||||
|
||||
function applyDom(isDark: boolean, isUltra: boolean) {
|
||||
document.body.setAttribute('class', isDark ? 'dark' : 'light');
|
||||
if (isUltra) {
|
||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
const msg = document.getElementById('message');
|
||||
if (msg) msg.className = isDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
// module load so the document is in the right theme before React mounts.
|
||||
const initialDark = readBool(STORAGE_DARK, true);
|
||||
const initialUltra = readBool(STORAGE_ULTRA, false);
|
||||
applyDom(initialDark, initialUltra);
|
||||
|
||||
const DARK_TOKENS = {
|
||||
colorBgBase: '#1a1b1f',
|
||||
colorBgLayout: '#1a1b1f',
|
||||
colorBgContainer: '#23252b',
|
||||
colorBgElevated: '#2d2f37',
|
||||
};
|
||||
const ULTRA_DARK_TOKENS = {
|
||||
colorBgBase: '#000',
|
||||
colorBgLayout: '#000',
|
||||
colorBgContainer: '#101013',
|
||||
colorBgElevated: '#1a1a1e',
|
||||
};
|
||||
const DARK_LAYOUT_TOKENS = {
|
||||
bodyBg: '#1a1b1f',
|
||||
headerBg: '#15161a',
|
||||
headerColor: '#ffffff',
|
||||
footerBg: '#1a1b1f',
|
||||
siderBg: '#15161a',
|
||||
triggerBg: '#23252b',
|
||||
triggerColor: '#ffffff',
|
||||
};
|
||||
const ULTRA_DARK_LAYOUT_TOKENS = {
|
||||
bodyBg: '#000',
|
||||
headerBg: '#050507',
|
||||
headerColor: '#ffffff',
|
||||
footerBg: '#000',
|
||||
siderBg: '#050507',
|
||||
triggerBg: '#1a1a1e',
|
||||
triggerColor: '#ffffff',
|
||||
};
|
||||
const DARK_MENU_TOKENS = {
|
||||
darkItemBg: '#15161a',
|
||||
darkSubMenuItemBg: '#1a1b1f',
|
||||
darkPopupBg: '#23252b',
|
||||
};
|
||||
const ULTRA_DARK_MENU_TOKENS = {
|
||||
darkItemBg: '#050507',
|
||||
darkSubMenuItemBg: '#000',
|
||||
darkPopupBg: '#101013',
|
||||
};
|
||||
|
||||
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
|
||||
if (!isDark) {
|
||||
return { algorithm: antdTheme.defaultAlgorithm };
|
||||
}
|
||||
return {
|
||||
algorithm: antdTheme.darkAlgorithm,
|
||||
token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
|
||||
components: {
|
||||
Layout: isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
|
||||
Menu: isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pauseAnimationsUntilLeave(elementId: string): void {
|
||||
document.documentElement.setAttribute('data-theme-animations', 'off');
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
const restore = () => {
|
||||
document.documentElement.removeAttribute('data-theme-animations');
|
||||
el.removeEventListener('mouseleave', restore);
|
||||
el.removeEventListener('touchend', restore);
|
||||
};
|
||||
el.addEventListener('mouseleave', restore);
|
||||
el.addEventListener('touchend', restore);
|
||||
}
|
||||
|
||||
interface ThemeContextValue {
|
||||
isDark: boolean;
|
||||
isUltra: boolean;
|
||||
toggleTheme: () => void;
|
||||
toggleUltra: () => void;
|
||||
antdThemeConfig: ThemeConfig;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [isDark, setIsDark] = useState<boolean>(initialDark);
|
||||
const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
|
||||
|
||||
useEffect(() => {
|
||||
applyDom(isDark, isUltra);
|
||||
localStorage.setItem(STORAGE_DARK, String(isDark));
|
||||
localStorage.setItem(STORAGE_ULTRA, String(isUltra));
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
const toggleTheme = useCallback(() => setIsDark((v) => !v), []);
|
||||
const toggleUltra = useCallback(() => setIsUltra((v) => !v), []);
|
||||
|
||||
const antdThemeConfig = useMemo(() => buildAntdThemeConfig(isDark, isUltra), [isDark, isUltra]);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({ isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig }),
|
||||
[isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
|
||||
return ctx;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user