mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-27 18:32:19 +03:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3a57d4c57 | ||
|
|
86813758cc | ||
|
|
8332ba67ae | ||
|
|
d8221a8153 | ||
|
|
789e92cddc | ||
|
|
7a5d6da28c | ||
|
|
71aca2018a | ||
|
|
6c71b725da | ||
|
|
a329882e0e | ||
|
|
60c54827aa | ||
|
|
aef35ee0de | ||
|
|
2b10808fbd | ||
|
|
25a86b9ee2 | ||
|
|
51ffba5961 | ||
|
|
5713c09980 | ||
|
|
7f8cbf4c4b | ||
|
|
bbfbd7eba6 | ||
|
|
79069d2b64 | ||
|
|
9c8cd08f90 | ||
|
|
33aada0c7c | ||
|
|
e44075a6e0 | ||
|
|
56b0be0b6a | ||
|
|
9b8a0c9b17 | ||
|
|
d1c0d77023 | ||
|
|
63fca9ef88 | ||
|
|
2e851978e6 | ||
|
|
fa1a19c03c | ||
|
|
7efa0d9ddd | ||
|
|
d12b186a69 | ||
|
|
39eb5baf42 | ||
|
|
876d55f274 | ||
|
|
1bad2fcba1 | ||
|
|
4c177f0cf1 | ||
|
|
797b08cd07 | ||
|
|
439245d42b | ||
|
|
535b89a352 | ||
|
|
7a2179535a | ||
|
|
6964d84742 | ||
|
|
451263f1db | ||
|
|
8e4c368200 | ||
|
|
522b1b64b0 |
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@@ -102,6 +102,21 @@ jobs:
|
||||
go test -run '^$' -fuzz 'FuzzParseLink$' -fuzztime=30s ./internal/util/link/
|
||||
go test -run '^$' -fuzz 'FuzzDecodeCertPin$' -fuzztime=30s ./internal/web/runtime/
|
||||
|
||||
golangci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Stub internal/web/dist for go:embed
|
||||
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: latest
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -118,7 +118,7 @@ jobs:
|
||||
cd x-ui/bin
|
||||
|
||||
# Download dependencies
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.6.22/"
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.6.27/"
|
||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-64.zip
|
||||
unzip Xray-linux-64.zip
|
||||
@@ -267,7 +267,7 @@ jobs:
|
||||
cd x-ui\bin
|
||||
|
||||
# Download Xray for Windows
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.6.22/"
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.6.27/"
|
||||
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"
|
||||
|
||||
53
.golangci.yml
Normal file
53
.golangci.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
build-tags: []
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
default: standard
|
||||
enable:
|
||||
- bodyclose
|
||||
- errorlint
|
||||
- noctx
|
||||
- misspell
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
- unconvert
|
||||
- usestdlibvars
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- std-error-handling
|
||||
paths:
|
||||
- frontend
|
||||
- internal/web/dist
|
||||
rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- errcheck
|
||||
- bodyclose
|
||||
- noctx
|
||||
# tools/openapigen relies on go/parser.ParseDir; migrating it to
|
||||
# golang.org/x/tools/go/packages is a generator change, out of scope here.
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: "SA1019: parser.ParseDir"
|
||||
# ST1005 (capitalized error strings) conflicts with intentional
|
||||
# user-facing error copy that tests assert verbatim.
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: "ST1005:"
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofumpt
|
||||
- goimports
|
||||
settings:
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- github.com/mhsanaei/3x-ui
|
||||
exclusions:
|
||||
paths:
|
||||
- frontend
|
||||
- internal/web/dist
|
||||
101
CLAUDE.md
Normal file
101
CLAUDE.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Operational guide for AI agents working in this repo. Long-form human docs:
|
||||
`CONTRIBUTING.md` (setup, testing philosophy) and `frontend/README.md`.
|
||||
Read those before large changes. This file is the short, must-follow version.
|
||||
|
||||
## Stack
|
||||
- Backend: Go 1.26 (`module github.com/mhsanaei/3x-ui/v3`), Gin, GORM.
|
||||
Runs Xray-core as a managed child process (`internal/xray/process.go`) and
|
||||
imports `github.com/xtls/xray-core` for config types + gRPC stats/handler/router
|
||||
API. MTProto inbounds run a second managed child — the `mtg` binary
|
||||
(`internal/mtproto/`) — outside Xray.
|
||||
- Storage: SQLite by default (`/etc/x-ui/x-ui.db` on Linux; the executable dir on
|
||||
Windows), PostgreSQL optional (`XUI_DB_TYPE` / `XUI_DB_DSN`). The CGo SQLite
|
||||
driver (`mattn/go-sqlite3`) needs a C compiler — `CGO_ENABLED=0` builds fail.
|
||||
- Frontend: React 19 + Ant Design 6 + Vite 8 + TypeScript in `frontend/`,
|
||||
built into `internal/web/dist/` (gitignored) and embedded via `embed.FS`.
|
||||
|
||||
## Repo map
|
||||
- `main.go` — entry point + `x-ui` CLI (run, migrate, migrate-db, setting, cert).
|
||||
- `internal/config/` — env parsing (XUI_DEBUG, XUI_LOG_LEVEL, XUI_LOG_FOLDER,
|
||||
XUI_BIN_FOLDER, XUI_SKIP_HSTS, XUI_PORT, XUI_DB_*).
|
||||
- `internal/database/` + `internal/database/model/` — GORM schema (Inbound,
|
||||
Client, Setting, User), inbound Protocol enum, AutoMigrate + hand-written
|
||||
migrations in `db.go`.
|
||||
- `internal/xray/` — Xray child-process lifecycle, config generation, gRPC API.
|
||||
- `internal/mtproto/` — MTProto inbounds via the bundled `mtg` binary.
|
||||
- `internal/sub/` — subscription server (raw / JSON / Clash).
|
||||
- `internal/eventbus/` — in-process pub/sub (outbound/node health, xray.crash,
|
||||
cpu.high, memory.high, login.attempt).
|
||||
- `internal/logger/`, `internal/util/` (link, crypto, sys, ldap, …),
|
||||
`internal/tunnelmonitor/` — shared infrastructure.
|
||||
- `internal/web/` — Gin server (embeds `dist/` + `translation/`).
|
||||
- `controller/` — panel + REST API handlers; OpenAPI at /panel/api/openapi.json.
|
||||
- `service/` — business logic (InboundService, SettingService, XrayService,
|
||||
node sync); subpackages tgbot/, email/, outbound/, panel/, integration/.
|
||||
- `job/` — cron jobs (traffic, fail2ban IP-limit, node heartbeat/sync, LDAP).
|
||||
- `middleware/`, `entity/`, `global/`, `session/` (CSRF), `network/`,
|
||||
`runtime/` (master/sub-node over mTLS), `websocket/`.
|
||||
- `locale/` + `translation/` — i18n, 13 embedded locale JSON files.
|
||||
- `frontend/` — React + TS source (see `frontend/CLAUDE.md`).
|
||||
- `tools/openapigen/` — Go generator that emits frontend types + Zod/JSON schemas
|
||||
into `frontend/src/generated/` from Go structs. The OpenAPI doc itself
|
||||
(`frontend/public/openapi.json`) is assembled from those + `endpoints.ts` by
|
||||
`frontend/scripts/build-openapi.mjs`.
|
||||
|
||||
## Hard rules (non-negotiable)
|
||||
- NO `//` line comments in committed Go/TS. Names carry meaning; rename instead
|
||||
of annotating. Exempt: `//go:build`, `//go:generate`, and other directives.
|
||||
HTML `<!-- -->` is fine. (A linter cannot enforce this — you must.)
|
||||
- New `g.POST`/`g.GET` in `internal/web/controller/` REQUIRES a matching entry
|
||||
in `frontend/src/pages/api-docs/endpoints.ts`, then `make gen` (or
|
||||
`cd frontend && npm run gen`). It is a hand-maintained registry — nothing checks
|
||||
it against the Go routes, so an omitted route silently vanishes from the docs.
|
||||
- Response examples come from Go struct `example:` tags via `tools/openapigen` —
|
||||
never hand-write them. A new struct must be added to openapigen's `StructAllow`
|
||||
allowlist (`tools/openapigen/main.go`) or it is silently omitted from
|
||||
schemas/examples (and `build-openapi.mjs` then fails on the missing schema).
|
||||
- A new English i18n key must be added to EVERY locale JSON in
|
||||
`internal/web/translation/` (13 files). Missing keys fall back to en-US (or
|
||||
render the raw key if absent there too); nothing fails the build, so they are
|
||||
easy to miss.
|
||||
- DB / model changes require a migration in `internal/database/db.go`.
|
||||
- Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `docs`,
|
||||
`style`): `<area>: short imperative summary`, then a body explaining the why.
|
||||
|
||||
## Go conventions
|
||||
- Stdlib `testing` only (no testify). Table-driven, `t.Run` subtests,
|
||||
`t.Helper()` on helpers. Assert the exact value / typed error / emitted
|
||||
string, never just `err != nil`. Prefer real deps over mocks: throwaway DB via
|
||||
`database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` +
|
||||
`t.Cleanup(func() { _ = database.CloseDB() })`; `httptest` for HTTP.
|
||||
`internal/sub`'s `initSubDB(t)` is the template.
|
||||
- Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`.
|
||||
|
||||
## Frontend conventions (summary; full version in frontend/CLAUDE.md)
|
||||
- Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites.
|
||||
- TS strict; `@typescript-eslint/no-explicit-any` is an error. Zod schemas in
|
||||
`src/schemas/` are the source of truth; infer types with `z.infer`, never
|
||||
hand-write. Do not edit `src/generated/`.
|
||||
- Editing `frontend/src` does NOT change what users see until the Vite build is
|
||||
regenerated into `internal/web/dist/`. In `XUI_DEBUG=true`, HTML is served from
|
||||
the frozen embedded FS but JS/CSS off disk — after `npm run build` you MUST
|
||||
restart `go run .` or you get a blank page with 404s.
|
||||
- After touching share-link logic (`src/lib/xray/`), run `npm run test` (golden
|
||||
fixtures); regenerate snapshots (`npx vitest run -u`) only for intentional
|
||||
output changes, never to make a red test green.
|
||||
|
||||
## Build, test, verify
|
||||
Run `make help` for all targets. The full local gate that mirrors CI:
|
||||
|
||||
make verify
|
||||
|
||||
Common targets: `make gen` (regenerate Zod/OpenAPI), `make lint` (Go + frontend),
|
||||
`make test` (Go `-shuffle=on` + frontend), `make race`, `make build`. See `Makefile`.
|
||||
|
||||
## Definition of done (before opening a PR)
|
||||
1. `make gen` and confirm `git diff` on `frontend/src/generated` +
|
||||
`frontend/public/openapi.json` is clean.
|
||||
2. `make verify` passes.
|
||||
3. Diff is focused; refactors are separate from feature work.
|
||||
@@ -184,11 +184,11 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit
|
||||
- **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.
|
||||
- **Persian and Arabic users are first-class.** When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows. (Full RTL layout is not currently wired through AntD `ConfigProvider direction` — only the Jalali date picker is RTL-aware — so treat RTL as an open area, not a solved one.)
|
||||
- **Schemas over `any`.** New config shapes go in `src/schemas/`; `@typescript-eslint/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules.
|
||||
- **Document new endpoints.** Every new `g.POST`/`g.GET` in `internal/web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
|
||||
- **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
|
||||
- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — currently `8.0.16` — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
|
||||
- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — read the live version there rather than trusting a number quoted here — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
|
||||
|
||||
### Project layout
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ esac
|
||||
MTG_VER="2.2.8"
|
||||
mkdir -p build/bin
|
||||
cd build/bin
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.22/Xray-linux-${ARCH}.zip"
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.27/Xray-linux-${ARCH}.zip"
|
||||
unzip "Xray-linux-${ARCH}.zip"
|
||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
||||
mv xray "xray-linux-${FNAME}"
|
||||
|
||||
75
Makefile
Normal file
75
Makefile
Normal file
@@ -0,0 +1,75 @@
|
||||
# Canonical task runner. Mirrors .github/workflows/ci.yml so `make verify`
|
||||
# reproduces the PR gate locally. Run `make help` for the list.
|
||||
|
||||
SHELL := bash
|
||||
GO_PKGS = $(shell go list ./... | grep -v '/frontend/node_modules/')
|
||||
FRONTEND = frontend
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
||||
awk 'BEGIN {FS = ":.*?## "}; {printf " %-14s %s\n", $$1, $$2}'
|
||||
|
||||
# go:embed of internal/web/dist needs the dir to exist even when the
|
||||
# frontend bundle has not been built. CI stubs it the same way.
|
||||
.PHONY: dist-stub
|
||||
dist-stub:
|
||||
@mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
|
||||
|
||||
.PHONY: gen
|
||||
gen: ## Regenerate Zod schemas + OpenAPI from Go sources
|
||||
cd $(FRONTEND) && npm run gen
|
||||
|
||||
.PHONY: gen-check
|
||||
gen-check: gen ## Fail if generated files are stale
|
||||
git diff --exit-code -- frontend/src/generated frontend/public/openapi.json
|
||||
|
||||
.PHONY: lint-go
|
||||
lint-go: dist-stub ## golangci-lint on Go sources
|
||||
golangci-lint run
|
||||
|
||||
.PHONY: lint-fe
|
||||
lint-fe: ## ESLint on frontend sources
|
||||
cd $(FRONTEND) && npm run lint
|
||||
|
||||
.PHONY: lint
|
||||
lint: lint-go lint-fe ## All linters
|
||||
|
||||
.PHONY: typecheck
|
||||
typecheck: ## tsc --noEmit
|
||||
cd $(FRONTEND) && npm run typecheck
|
||||
|
||||
.PHONY: test-go
|
||||
test-go: dist-stub ## Go tests (shuffle, no cache)
|
||||
go test -shuffle=on -count=1 $(GO_PKGS)
|
||||
|
||||
.PHONY: race
|
||||
race: dist-stub ## Go tests with the race detector (needs a C compiler)
|
||||
go test -race -shuffle=on -count=1 $(GO_PKGS)
|
||||
|
||||
.PHONY: test-fe
|
||||
test-fe: ## Frontend tests (vitest)
|
||||
cd $(FRONTEND) && npm test
|
||||
|
||||
.PHONY: test
|
||||
test: test-go test-fe ## All tests
|
||||
|
||||
.PHONY: vulncheck
|
||||
vulncheck: dist-stub ## govulncheck
|
||||
go run golang.org/x/vuln/cmd/govulncheck@latest ./...
|
||||
|
||||
.PHONY: build-fe
|
||||
build-fe: ## Build the Vite bundles into internal/web/dist
|
||||
cd $(FRONTEND) && npm run build
|
||||
|
||||
.PHONY: build
|
||||
build: build-fe ## Build the frontend then the Go binary
|
||||
go build ./...
|
||||
|
||||
# The PR gate. Matches ci.yml: codegen freshness, both linters, typecheck,
|
||||
# both test suites, and a full build.
|
||||
.PHONY: verify
|
||||
verify: gen-check lint typecheck test build ## Full local gate (mirrors CI)
|
||||
@echo "verify: OK"
|
||||
60
frontend/CLAUDE.md
Normal file
60
frontend/CLAUDE.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# frontend/CLAUDE.md
|
||||
|
||||
Frontend agent guide. Full detail: `frontend/README.md` and the root
|
||||
`CONTRIBUTING.md` ("Working on the frontend"). This is the short version.
|
||||
|
||||
## What this is
|
||||
React 19 + Ant Design 6 + Vite 8 + TypeScript. The Vite config is
|
||||
`vite.config.js` (plain JS). Three bundles, each emitted into
|
||||
`internal/web/dist/` and embedded into the Go binary:
|
||||
- `index.html` — admin panel SPA (entry `src/main.tsx`; react-router under
|
||||
`/panel`, lazy routes).
|
||||
- `login.html` — login + 2FA (`src/entries/login.tsx`).
|
||||
- `subpage.html` — public subscription viewer (`src/entries/subpage.tsx`).
|
||||
The `@` import alias maps to `src/`.
|
||||
|
||||
## Data flow
|
||||
- Server state via TanStack Query (`src/api/`, keys in `src/api/queryKeys.ts`);
|
||||
invalidate on mutation. WebSocket pushes feed the cache
|
||||
(`src/api/websocketBridge.ts`).
|
||||
- Local UI state in the page (`useState`); shared concerns via `src/hooks/`.
|
||||
Extend an existing hook before adding a global.
|
||||
- Zod (`src/schemas/`) is the single source of truth for the xray config model.
|
||||
Infer types with `z.infer`. Go-side types are mirrored into `src/generated/`
|
||||
by `npm run gen:zod` (`go run ./tools/openapigen`) — do not hand-edit that
|
||||
folder (every file is marked `DO NOT EDIT`).
|
||||
- xray domain logic (links, defaults, form<->wire adapters) is pure functions in
|
||||
`src/lib/xray/`. HTTP goes through `HttpUtil` in `src/utils/index.ts`.
|
||||
|
||||
## Rules
|
||||
- Ant Design 6 only; no Tailwind/shadcn (a migration was rolled back).
|
||||
- Function components + hooks only; no class components.
|
||||
- No `//` line comments in committed TS/TSX. HTML comments are fine.
|
||||
- TS strict; `no-explicit-any` is an error. Validate form fields with
|
||||
`antdRule(Schema.shape.field, t)` from `@/utils/zodForm`, not inline
|
||||
`z.string()`.
|
||||
- New `g.POST`/`g.GET` route => add it to `src/pages/api-docs/endpoints.ts`,
|
||||
then `npm run gen`.
|
||||
- i18n strings live in `internal/web/translation/<locale>.json`, NOT under
|
||||
`frontend/`, and are shared with the Go backend. A new English key must be
|
||||
added to every locale. Interpolation here uses single braces `{var}`, not the
|
||||
i18next default `{{var}}`.
|
||||
- Persian/Arabic (RTL) users are first-class — isolate code identifiers on their
|
||||
own line when writing Persian text in labels/toasts.
|
||||
- Vite is pinned to an exact version (no `^`) — bump deliberately, then verify
|
||||
`npm run dev` AND `npm run build`.
|
||||
|
||||
## Adding a panel route
|
||||
1. `src/pages/<page>/<Page>.tsx` (kebab folder, PascalCase component).
|
||||
2. Register in `src/routes.tsx` under `/panel` (lazy import).
|
||||
3. Add a sidebar link in `src/layouts/AppSidebar.tsx` if it needs nav.
|
||||
Only standalone bundles (login/subpage) need a new `.html` + `src/entries/*` +
|
||||
`rollupOptions.input` (in `vite.config.js`) + a Go controller route.
|
||||
|
||||
## Commands
|
||||
- `npm run dev` (HMR on :5173, proxies to the Go panel on :2053 — start Go first).
|
||||
- `npm run typecheck` / `npm run lint` / `npm run test` / `npm run build`.
|
||||
- `npm run gen` = `gen:zod` (Go → `src/generated/`) + `gen:api`
|
||||
(`build-openapi.mjs` → `public/openapi.json`).
|
||||
- After `npm run build`, RESTART `go run .` (see the XUI_DEBUG gotcha in root
|
||||
CLAUDE.md) before checking the panel.
|
||||
@@ -100,7 +100,7 @@ frontend/
|
||||
├── generated/ # Code-generated zod + ts types from Go
|
||||
│ # (DO NOT hand-edit — regenerated by gen:zod)
|
||||
├── models/ # Thin legacy types still in transit
|
||||
│ # (DBInbound, Status, AllSetting, reality-targets)
|
||||
│ # (DBInbound, Status, AllSetting)
|
||||
├── styles/ # Shared CSS modules
|
||||
├── test/ # Vitest specs + golden fixtures
|
||||
│ ├── *.test.ts
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
@@ -44,4 +45,12 @@ export default [
|
||||
'react-hooks/refs': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.tsx'],
|
||||
plugins: { 'jsx-a11y': jsxA11y },
|
||||
rules: {
|
||||
...jsxA11y.flatConfigs.recommended.rules,
|
||||
'jsx-a11y/no-autofocus': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
1620
frontend/package-lock.json
generated
1620
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,16 +21,16 @@
|
||||
"gen:zod": "cd .. && go run ./tools/openapigen"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.5",
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@tanstack/react-query": "^5.101.1",
|
||||
"@tanstack/react-query-devtools": "^5.101.1",
|
||||
"antd": "^6.4.5",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-query-devtools": "^5.101.2",
|
||||
"antd": "^6.5.0",
|
||||
"axios": "^1.18.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"i18next": "^26.3.2",
|
||||
"i18next": "^26.3.3",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.5",
|
||||
"qs": "^6.15.3",
|
||||
@@ -51,7 +51,8 @@
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
@@ -61,6 +62,9 @@
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"overrides": {
|
||||
"eslint-plugin-jsx-a11y": {
|
||||
"eslint": "$eslint"
|
||||
},
|
||||
"dompurify": "^3.4.11",
|
||||
"react-copy-to-clipboard": "^5.1.1",
|
||||
"react-inspector": "^9.0.0",
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
"ldapInboundTags": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapInsecureSkipVerify": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapInvertFlag": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -429,6 +432,7 @@
|
||||
"ldapFlagField",
|
||||
"ldapHost",
|
||||
"ldapInboundTags",
|
||||
"ldapInsecureSkipVerify",
|
||||
"ldapInvertFlag",
|
||||
"ldapPassword",
|
||||
"ldapPort",
|
||||
@@ -589,6 +593,9 @@
|
||||
"ldapInboundTags": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapInsecureSkipVerify": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapInvertFlag": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -941,6 +948,7 @@
|
||||
"ldapFlagField",
|
||||
"ldapHost",
|
||||
"ldapInboundTags",
|
||||
"ldapInsecureSkipVerify",
|
||||
"ldapInvertFlag",
|
||||
"ldapPassword",
|
||||
"ldapPort",
|
||||
@@ -1084,6 +1092,12 @@
|
||||
"Client": {
|
||||
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
|
||||
"properties": {
|
||||
"allowedIPs": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"auth": {
|
||||
"description": "Auth password (Hysteria)",
|
||||
"type": "string"
|
||||
@@ -1120,6 +1134,9 @@
|
||||
"description": "Unique client identifier",
|
||||
"type": "string"
|
||||
},
|
||||
"keepAlive": {
|
||||
"type": "integer"
|
||||
},
|
||||
"limitIp": {
|
||||
"description": "IP limit for this client",
|
||||
"type": "integer"
|
||||
@@ -1128,6 +1145,15 @@
|
||||
"description": "Client password",
|
||||
"type": "string"
|
||||
},
|
||||
"preSharedKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"privateKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"publicKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"reset": {
|
||||
"description": "Reset period in days",
|
||||
"type": "integer"
|
||||
@@ -1201,6 +1227,9 @@
|
||||
},
|
||||
"ClientRecord": {
|
||||
"properties": {
|
||||
"allowedIPs": {
|
||||
"type": "string"
|
||||
},
|
||||
"auth": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1228,12 +1257,24 @@
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"keepAlive": {
|
||||
"type": "integer"
|
||||
},
|
||||
"limitIp": {
|
||||
"type": "integer"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"preSharedKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"privateKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"publicKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"reset": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -1258,6 +1299,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"allowedIPs",
|
||||
"auth",
|
||||
"comment",
|
||||
"createdAt",
|
||||
@@ -1267,8 +1309,12 @@
|
||||
"flow",
|
||||
"group",
|
||||
"id",
|
||||
"keepAlive",
|
||||
"limitIp",
|
||||
"password",
|
||||
"preSharedKey",
|
||||
"privateKey",
|
||||
"publicKey",
|
||||
"reset",
|
||||
"reverse",
|
||||
"security",
|
||||
@@ -1525,7 +1571,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"vlessRoute": {
|
||||
"description": "VlessRoute is a free-form port/range routing spec (e.g. \"53,443,1000-2000\");\nstored verbatim, format-validated on the frontend.",
|
||||
"description": "Single VLESS route value (0-65535) baked into the subscription UUID's 3rd\ngroup (bytes 6-7), which xray reads via net.PortFromBytes(id[6:8]). Empty = none.",
|
||||
"example": "443",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -1808,6 +1855,15 @@
|
||||
"tlsFlowCapable": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"wgDns": {
|
||||
"type": "string"
|
||||
},
|
||||
"wgMtu": {
|
||||
"type": "integer"
|
||||
},
|
||||
"wgPublicKey": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2146,6 +2202,104 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RealityScanResult": {
|
||||
"properties": {
|
||||
"alpn": {
|
||||
"example": "h2",
|
||||
"type": "string"
|
||||
},
|
||||
"certIssuer": {
|
||||
"example": "Google Trust Services",
|
||||
"type": "string"
|
||||
},
|
||||
"certSubject": {
|
||||
"example": "cloudflare.com",
|
||||
"type": "string"
|
||||
},
|
||||
"certValid": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"curveID": {
|
||||
"example": "X25519",
|
||||
"type": "string"
|
||||
},
|
||||
"feasible": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"h2": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"host": {
|
||||
"example": "www.cloudflare.com",
|
||||
"type": "string"
|
||||
},
|
||||
"ip": {
|
||||
"example": "104.16.124.96",
|
||||
"type": "string"
|
||||
},
|
||||
"latencyMs": {
|
||||
"example": 180,
|
||||
"type": "integer"
|
||||
},
|
||||
"notAfter": {
|
||||
"example": "2026-08-01T00:00:00Z",
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"example": 443,
|
||||
"type": "integer"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"serverNames": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"target": {
|
||||
"example": "www.cloudflare.com:443",
|
||||
"type": "string"
|
||||
},
|
||||
"tls13": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"tlsVersion": {
|
||||
"example": "1.3",
|
||||
"type": "string"
|
||||
},
|
||||
"x25519": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"alpn",
|
||||
"certIssuer",
|
||||
"certSubject",
|
||||
"certValid",
|
||||
"curveID",
|
||||
"feasible",
|
||||
"h2",
|
||||
"host",
|
||||
"ip",
|
||||
"latencyMs",
|
||||
"notAfter",
|
||||
"port",
|
||||
"reason",
|
||||
"serverNames",
|
||||
"target",
|
||||
"tls13",
|
||||
"tlsVersion",
|
||||
"x25519"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Setting": {
|
||||
"description": "Setting stores key-value configuration settings for the 3x-ui panel.",
|
||||
"properties": {
|
||||
@@ -2607,7 +2761,10 @@
|
||||
"remark": "VLESS-443",
|
||||
"ssMethod": "",
|
||||
"tag": "in-443-tcp",
|
||||
"tlsFlowCapable": true
|
||||
"tlsFlowCapable": true,
|
||||
"wgDns": "",
|
||||
"wgMtu": 0,
|
||||
"wgPublicKey": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2617,6 +2774,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/inbounds/allLinks": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Inbounds"
|
||||
],
|
||||
"summary": "Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, mtproto) across all inbounds and all of their clients. Links are rendered through the subscription engine, so the configured remark template (name-only display part) is applied per client — the same output the client info/QR pages use. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing. Used by the panel’s \"Export all inbound links\" action.",
|
||||
"operationId": "get_panel_api_inbounds_allLinks",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": [
|
||||
"vless://uuid@host:443?security=reality&...#Germany-alice",
|
||||
"vmess://eyJ2IjoyLC..."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/inbounds/get/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -4637,6 +4831,145 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/server/scanRealityTarget": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Server"
|
||||
],
|
||||
"summary": "Run a live TLS 1.3 probe against a candidate REALITY target and return a feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate) plus the certificate SAN DNS names.",
|
||||
"operationId": "post_panel_api_server_scanRealityTarget",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {
|
||||
"$ref": "#/components/schemas/RealityScanResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": {
|
||||
"alpn": "h2",
|
||||
"certIssuer": "Google Trust Services",
|
||||
"certSubject": "cloudflare.com",
|
||||
"certValid": true,
|
||||
"curveID": "X25519",
|
||||
"feasible": true,
|
||||
"h2": true,
|
||||
"host": "www.cloudflare.com",
|
||||
"ip": "104.16.124.96",
|
||||
"latencyMs": 180,
|
||||
"notAfter": "2026-08-01T00:00:00Z",
|
||||
"port": 443,
|
||||
"reason": "",
|
||||
"serverNames": [
|
||||
""
|
||||
],
|
||||
"target": "www.cloudflare.com:443",
|
||||
"tls13": true,
|
||||
"tlsVersion": "1.3",
|
||||
"x25519": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/server/scanRealityTargets": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Server"
|
||||
],
|
||||
"summary": "Probe/discover REALITY targets and return each verdict ranked by feasibility then latency. Each comma-separated token may be a domain (validated with SNI), a bare IP, or a CIDR range (discovered without SNI by reading the certificate domain). When empty, a built-in seed list is probed.",
|
||||
"operationId": "post_panel_api_server_scanRealityTargets",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RealityScanResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": [
|
||||
{
|
||||
"alpn": "h2",
|
||||
"certIssuer": "Google Trust Services",
|
||||
"certSubject": "cloudflare.com",
|
||||
"certValid": true,
|
||||
"curveID": "X25519",
|
||||
"feasible": true,
|
||||
"h2": true,
|
||||
"host": "www.cloudflare.com",
|
||||
"ip": "104.16.124.96",
|
||||
"latencyMs": 180,
|
||||
"notAfter": "2026-08-01T00:00:00Z",
|
||||
"port": 443,
|
||||
"reason": "",
|
||||
"serverNames": [
|
||||
""
|
||||
],
|
||||
"target": "www.cloudflare.com:443",
|
||||
"tls13": true,
|
||||
"tlsVersion": "1.3",
|
||||
"x25519": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/server/clientIps": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -5516,7 +5849,7 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). Returns the adjusted count and per-email skip reasons.",
|
||||
"summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). Returns the adjusted count and per-email skip reasons.",
|
||||
"operationId": "post_panel_api_clients_bulkAdjust",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -6345,6 +6678,55 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/groups/resetTraffic": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Reset only the group-level traffic counter shown on the groups page. Snapshots the current up/down sum of the group's members as a baseline so the group total reads zero, while leaving each client's own counters (and their quotas) untouched. No Xray restart is triggered. Creates the client_groups row if the group exists only as a derived label.",
|
||||
"operationId": "post_panel_api_clients_groups_resetTraffic",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
},
|
||||
"example": {
|
||||
"name": "customer-a"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": {
|
||||
"name": "customer-a"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/resetTraffic/{email}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -7749,7 +8131,7 @@
|
||||
],
|
||||
"updatedAt": 0,
|
||||
"verifyPeerCertByName": "",
|
||||
"vlessRoute": ""
|
||||
"vlessRoute": "443"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7841,7 +8223,7 @@
|
||||
],
|
||||
"updatedAt": 0,
|
||||
"verifyPeerCertByName": "",
|
||||
"vlessRoute": ""
|
||||
"vlessRoute": "443"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7936,7 +8318,7 @@
|
||||
],
|
||||
"updatedAt": 0,
|
||||
"verifyPeerCertByName": "",
|
||||
"vlessRoute": ""
|
||||
"vlessRoute": "443"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8076,7 +8458,7 @@
|
||||
],
|
||||
"updatedAt": 0,
|
||||
"verifyPeerCertByName": "",
|
||||
"vlessRoute": ""
|
||||
"vlessRoute": "443"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8188,7 +8570,7 @@
|
||||
],
|
||||
"updatedAt": 0,
|
||||
"verifyPeerCertByName": "",
|
||||
"vlessRoute": ""
|
||||
"vlessRoute": "443"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { AllSetting } from '@/models/setting';
|
||||
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
|
||||
type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
|
||||
|
||||
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
||||
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
|
||||
@@ -42,19 +44,21 @@ export function useAllSettings() {
|
||||
}, []);
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (next: AllSetting): Promise<Msg<unknown>> => {
|
||||
const body = AllSettingSchema.partial().safeParse(next);
|
||||
mutationFn: async (next: SettingSavePayload): Promise<Msg<unknown>> => {
|
||||
const payload = { ...next };
|
||||
const body = AllSettingSchema.partial().safeParse(payload);
|
||||
if (!body.success) {
|
||||
console.warn('[zod] setting/update body failed validation', body.error.issues);
|
||||
}
|
||||
return HttpUtil.post('/panel/api/setting/update', body.success ? body.data : next);
|
||||
return HttpUtil.post('/panel/api/setting/update', body.success ? { ...payload, ...body.data } : payload);
|
||||
},
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
||||
},
|
||||
});
|
||||
|
||||
const saveAll = useCallback(() => saveMut.mutateAsync(draft), [saveMut, draft]);
|
||||
const saveAll = useCallback(() => saveMut.mutateAsync({ ...draft }), [saveMut, draft]);
|
||||
const savePayload = useCallback((payload: SettingSavePayload) => saveMut.mutateAsync(payload), [saveMut]);
|
||||
const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
|
||||
|
||||
return {
|
||||
@@ -65,5 +69,6 @@ export function useAllSettings() {
|
||||
setSpinning: setExtraSpinning,
|
||||
saveDisabled,
|
||||
saveAll,
|
||||
savePayload,
|
||||
};
|
||||
}
|
||||
|
||||
40
frontend/src/components/clients/ConfigBlock.css
Normal file
40
frontend/src/components/clients/ConfigBlock.css
Normal file
@@ -0,0 +1,40 @@
|
||||
.config-block {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.config-block .ant-collapse-header {
|
||||
align-items: center !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.config-block .ant-collapse-header-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-block .ant-collapse-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-block-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.config-block .ant-collapse-content-box {
|
||||
padding: 8px !important;
|
||||
}
|
||||
|
||||
.config-block-text {
|
||||
display: block;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
padding: 6px 8px;
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
border-radius: 4px;
|
||||
user-select: all;
|
||||
}
|
||||
81
frontend/src/components/clients/ConfigBlock.tsx
Normal file
81
frontend/src/components/clients/ConfigBlock.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Collapse, Popover, Tag, Tooltip, message } from 'antd';
|
||||
import { CopyOutlined, DownloadOutlined, QrcodeOutlined } from '@ant-design/icons';
|
||||
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
import { QrPanel } from '@/pages/inbounds/qr';
|
||||
import './ConfigBlock.css';
|
||||
|
||||
interface ConfigBlockProps {
|
||||
label: string;
|
||||
text: string;
|
||||
fileName: string;
|
||||
qrRemark?: string;
|
||||
showQr?: boolean;
|
||||
tagColor?: string;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export default function ConfigBlock({
|
||||
label,
|
||||
text,
|
||||
fileName,
|
||||
qrRemark = '',
|
||||
showQr = true,
|
||||
tagColor = 'gold',
|
||||
defaultOpen = false,
|
||||
}: ConfigBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
|
||||
async function copy() {
|
||||
const ok = await ClipboardManager.copyText(text);
|
||||
if (ok) messageApi.success(t('copied'));
|
||||
}
|
||||
|
||||
const actions = (
|
||||
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
|
||||
<div className="config-block-actions" onClick={(e: MouseEvent) => e.stopPropagation()}>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={copy} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('download')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
aria-label={t('download')}
|
||||
onClick={() => FileManager.downloadTextFile(text, fileName)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{showQr && (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
destroyOnHidden
|
||||
content={<QrPanel value={text} remark={qrRemark || label} size={220} />}
|
||||
>
|
||||
<Tooltip title={t('pages.clients.qrCode')}>
|
||||
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Collapse
|
||||
className="config-block"
|
||||
defaultActiveKey={defaultOpen ? ['cfg'] : []}
|
||||
items={[{
|
||||
key: 'cfg',
|
||||
label: <Tag color={tagColor} style={{ margin: 0, fontWeight: 600, letterSpacing: '0.3px' }}>{label}</Tag>,
|
||||
extra: actions,
|
||||
children: <code className="config-block-text">{text}</code>,
|
||||
}]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseCircleFilled } from '@ant-design/icons';
|
||||
import { DatePicker } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -53,6 +54,7 @@ export default function DateTimePicker({
|
||||
placeholder = '',
|
||||
disabled = false,
|
||||
}: DateTimePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { datepicker } = useDatepicker();
|
||||
const { isDark, isUltra } = useTheme();
|
||||
const jalaliRef = useRef<HTMLDivElement>(null);
|
||||
@@ -100,7 +102,7 @@ export default function DateTimePicker({
|
||||
<button
|
||||
type="button"
|
||||
className="jdp-clear"
|
||||
aria-label="clear"
|
||||
aria-label={t('clear')}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input, Space } from 'antd';
|
||||
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
@@ -74,6 +75,7 @@ function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record<string, strin
|
||||
}
|
||||
|
||||
export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
// Local state holds rows including blanks. Without it, addRow() would
|
||||
// append a {name:'', value:''} that rowsToMap immediately filters out
|
||||
// before reaching the form, so the new row would never reach UI. The
|
||||
@@ -130,7 +132,7 @@ export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEdit
|
||||
placeholder="Value"
|
||||
onChange={(e) => setRow(idx, { value: e.target.value })}
|
||||
/>
|
||||
<Button icon={<MinusOutlined />} onClick={() => removeRow(idx)} />
|
||||
<Button aria-label={t('remove')} icon={<MinusOutlined />} onClick={() => removeRow(idx)} />
|
||||
</Space.Compact>
|
||||
))}
|
||||
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EditorView, basicSetup } from 'codemirror';
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import { json, jsonParseLinter } from '@codemirror/lang-json';
|
||||
@@ -92,6 +93,7 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
|
||||
const onChangeRef = useRef(onChange);
|
||||
const valueRef = useRef(value);
|
||||
const { isDark, isUltra } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
@@ -173,7 +175,7 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
|
||||
});
|
||||
}, [readOnly]);
|
||||
|
||||
return <div ref={hostRef} className="json-editor-host" />;
|
||||
return <div ref={hostRef} className="json-editor-host" aria-label={t('jsonEditor')} />;
|
||||
});
|
||||
|
||||
export default JsonEditor;
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
addonAfter={
|
||||
suffix={
|
||||
<Popover
|
||||
content={<RemarkVarPicker onPick={insertToken} />}
|
||||
trigger="click"
|
||||
@@ -55,7 +55,7 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
|
||||
title={t('pages.hosts.remarkVars.title')}
|
||||
>
|
||||
<Tooltip title={t('pages.hosts.remarkVars.title')}>
|
||||
<Button type="text" size="small" icon={<CodeOutlined />} style={{ margin: '0 -7px' }} />
|
||||
<Button type="text" size="small" icon={<CodeOutlined />} aria-label={t('pages.hosts.remarkVars.title')} style={{ marginInlineEnd: -7 }} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Tag, Tooltip, Typography } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { REMARK_VARIABLES, REMARK_VAR_GROUPS, wrapToken } from '@/lib/remark/remarkVariables';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
|
||||
interface RemarkVarPickerProps {
|
||||
/** Called with the bare token (e.g. "EMAIL") when a chip is clicked. */
|
||||
@@ -28,7 +29,10 @@ export default function RemarkVarPicker({ onPick }: RemarkVarPickerProps) {
|
||||
{REMARK_VARIABLES.filter((v) => v.group === group).map((v) => (
|
||||
<Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
|
||||
<Tag
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onPick(v.token)}
|
||||
onKeyDown={activateOnKey(() => onPick(v.token))}
|
||||
style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }}
|
||||
>
|
||||
{wrapToken(v.token)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import './InputAddon.css';
|
||||
|
||||
interface InputAddonProps {
|
||||
@@ -6,14 +7,19 @@ interface InputAddonProps {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
onClick?: () => void;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export default function InputAddon({ children, className = '', style, onClick }: InputAddonProps) {
|
||||
export default function InputAddon({ children, className = '', style, onClick, ariaLabel }: InputAddonProps) {
|
||||
return (
|
||||
<span
|
||||
className={`input-addon ${className}`.trim()}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
aria-label={onClick ? ariaLabel : undefined}
|
||||
onKeyDown={onClick ? activateOnKey(onClick) : undefined}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cloneElement, Fragment, isValidElement, useId, type ReactElement, type ReactNode } from 'react';
|
||||
import { Col, Row } from 'antd';
|
||||
import './SettingListItem.css';
|
||||
|
||||
@@ -18,17 +18,22 @@ export default function SettingListItem({
|
||||
control,
|
||||
}: SettingListItemProps) {
|
||||
const padding = paddings === 'small' ? '10px 20px' : '20px';
|
||||
const titleId = useId();
|
||||
const node = control ?? children;
|
||||
const labelledNode = title && isValidElement(node) && node.type !== Fragment
|
||||
? cloneElement(node as ReactElement<{ 'aria-labelledby'?: string }>, { 'aria-labelledby': titleId })
|
||||
: node;
|
||||
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>}
|
||||
{title && <div className="setting-list-title" id={titleId}>{title}</div>}
|
||||
{description && <div className="setting-list-description">{description}</div>}
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
{control ?? children}
|
||||
{labelledNode}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function NotificationCard({ icon, title, extra, children }: Props) {
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
bordered
|
||||
variant="outlined"
|
||||
title={<span>{icon} {title}</span>}
|
||||
extra={extra}
|
||||
style={{ borderWidth: 1 }}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function NotificationGroup({ config, selected, onToggle, onToggleAll, all
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
|
||||
{config.events.map((event) => (
|
||||
<NotificationEvent
|
||||
key={event.key}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tag } from 'antd';
|
||||
|
||||
interface Props {
|
||||
@@ -10,11 +11,12 @@ interface Props {
|
||||
}
|
||||
|
||||
function MasterCheckbox({ checked, indeterminate, onChange }: { checked: boolean; indeterminate: boolean; onChange: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.indeterminate = indeterminate;
|
||||
}, [indeterminate]);
|
||||
return <input ref={ref} type="checkbox" checked={checked} onChange={onChange} style={{ cursor: 'pointer' }} />;
|
||||
return <input ref={ref} type="checkbox" aria-label={t('pages.clients.selectAll')} checked={checked} onChange={onChange} style={{ cursor: 'pointer' }} />;
|
||||
}
|
||||
|
||||
export function NotificationHeader({ count, total, allSelected, indeterminate, onToggleAll }: Props) {
|
||||
|
||||
@@ -181,8 +181,18 @@ export default function Sparkline({
|
||||
const minColor = extrema?.minColor ?? DEFAULT_MIN_COLOR;
|
||||
const maxColor = extrema?.maxColor ?? DEFAULT_MAX_COLOR;
|
||||
|
||||
const ariaSummary = useMemo(() => {
|
||||
if (points.length === 0) return name1 ?? '';
|
||||
const last = points[points.length - 1];
|
||||
const parts: string[] = [];
|
||||
parts.push(name1 ? `${name1}: ${yFormatter(last.value)}` : yFormatter(last.value));
|
||||
if (hasSeries2 && name2) parts.push(`${name2}: ${yFormatter(last.value2)}`);
|
||||
if (hasSeries3 && name3) parts.push(`${name3}: ${yFormatter(last.value3)}`);
|
||||
return parts.join(', ');
|
||||
}, [points, name1, name2, name3, hasSeries2, hasSeries3, yFormatter]);
|
||||
|
||||
return (
|
||||
<div className="sparkline-container">
|
||||
<div className="sparkline-container" role={ariaSummary ? 'img' : undefined} aria-label={ariaSummary || undefined}>
|
||||
{extremaPoints && (
|
||||
<div className="sparkline-extrema" aria-hidden="true">
|
||||
<span className="extrema-item" style={{ color: maxColor }}>
|
||||
|
||||
@@ -16,6 +16,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"ldapFlagField": "",
|
||||
"ldapHost": "",
|
||||
"ldapInboundTags": "",
|
||||
"ldapInsecureSkipVerify": false,
|
||||
"ldapInvertFlag": false,
|
||||
"ldapPassword": "",
|
||||
"ldapPort": 0,
|
||||
@@ -118,6 +119,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"ldapFlagField": "",
|
||||
"ldapHost": "",
|
||||
"ldapInboundTags": "",
|
||||
"ldapInsecureSkipVerify": false,
|
||||
"ldapInvertFlag": false,
|
||||
"ldapPassword": "",
|
||||
"ldapPort": 0,
|
||||
@@ -212,6 +214,9 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"token": "new-token-string"
|
||||
},
|
||||
"Client": {
|
||||
"allowedIPs": [
|
||||
""
|
||||
],
|
||||
"auth": "",
|
||||
"comment": "",
|
||||
"created_at": 0,
|
||||
@@ -221,8 +226,12 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"flow": "",
|
||||
"group": "",
|
||||
"id": "",
|
||||
"keepAlive": 0,
|
||||
"limitIp": 0,
|
||||
"password": "",
|
||||
"preSharedKey": "",
|
||||
"privateKey": "",
|
||||
"publicKey": "",
|
||||
"reset": 0,
|
||||
"reverse": null,
|
||||
"security": "",
|
||||
@@ -238,6 +247,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"inboundId": 0
|
||||
},
|
||||
"ClientRecord": {
|
||||
"allowedIPs": "",
|
||||
"auth": "",
|
||||
"comment": "",
|
||||
"createdAt": 0,
|
||||
@@ -247,8 +257,12 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"flow": "",
|
||||
"group": "",
|
||||
"id": 0,
|
||||
"keepAlive": 0,
|
||||
"limitIp": 0,
|
||||
"password": "",
|
||||
"preSharedKey": "",
|
||||
"privateKey": "",
|
||||
"publicKey": "",
|
||||
"reset": 0,
|
||||
"reverse": null,
|
||||
"security": "",
|
||||
@@ -326,7 +340,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
],
|
||||
"updatedAt": 0,
|
||||
"verifyPeerCertByName": "",
|
||||
"vlessRoute": ""
|
||||
"vlessRoute": "443"
|
||||
},
|
||||
"Inbound": {
|
||||
"clientStats": [
|
||||
@@ -392,7 +406,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"remark": "VLESS-443",
|
||||
"ssMethod": "",
|
||||
"tag": "in-443-tcp",
|
||||
"tlsFlowCapable": true
|
||||
"tlsFlowCapable": true,
|
||||
"wgDns": "",
|
||||
"wgMtu": 0,
|
||||
"wgPublicKey": ""
|
||||
},
|
||||
"Msg": {
|
||||
"msg": "",
|
||||
@@ -463,6 +480,28 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"xrayState": "",
|
||||
"xrayVersion": "25.10.31"
|
||||
},
|
||||
"RealityScanResult": {
|
||||
"alpn": "h2",
|
||||
"certIssuer": "Google Trust Services",
|
||||
"certSubject": "cloudflare.com",
|
||||
"certValid": true,
|
||||
"curveID": "X25519",
|
||||
"feasible": true,
|
||||
"h2": true,
|
||||
"host": "www.cloudflare.com",
|
||||
"ip": "104.16.124.96",
|
||||
"latencyMs": 180,
|
||||
"notAfter": "2026-08-01T00:00:00Z",
|
||||
"port": 443,
|
||||
"reason": "",
|
||||
"serverNames": [
|
||||
""
|
||||
],
|
||||
"target": "www.cloudflare.com:443",
|
||||
"tls13": true,
|
||||
"tlsVersion": "1.3",
|
||||
"x25519": true
|
||||
},
|
||||
"Setting": {
|
||||
"id": 0,
|
||||
"key": "",
|
||||
|
||||
@@ -58,6 +58,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"ldapInboundTags": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapInsecureSkipVerify": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapInvertFlag": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -403,6 +406,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"ldapFlagField",
|
||||
"ldapHost",
|
||||
"ldapInboundTags",
|
||||
"ldapInsecureSkipVerify",
|
||||
"ldapInvertFlag",
|
||||
"ldapPassword",
|
||||
"ldapPort",
|
||||
@@ -563,6 +567,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"ldapInboundTags": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapInsecureSkipVerify": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapInvertFlag": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -915,6 +922,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"ldapFlagField",
|
||||
"ldapHost",
|
||||
"ldapInboundTags",
|
||||
"ldapInsecureSkipVerify",
|
||||
"ldapInvertFlag",
|
||||
"ldapPassword",
|
||||
"ldapPort",
|
||||
@@ -1058,6 +1066,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"Client": {
|
||||
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
|
||||
"properties": {
|
||||
"allowedIPs": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"auth": {
|
||||
"description": "Auth password (Hysteria)",
|
||||
"type": "string"
|
||||
@@ -1094,6 +1108,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "Unique client identifier",
|
||||
"type": "string"
|
||||
},
|
||||
"keepAlive": {
|
||||
"type": "integer"
|
||||
},
|
||||
"limitIp": {
|
||||
"description": "IP limit for this client",
|
||||
"type": "integer"
|
||||
@@ -1102,6 +1119,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "Client password",
|
||||
"type": "string"
|
||||
},
|
||||
"preSharedKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"privateKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"publicKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"reset": {
|
||||
"description": "Reset period in days",
|
||||
"type": "integer"
|
||||
@@ -1175,6 +1201,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"ClientRecord": {
|
||||
"properties": {
|
||||
"allowedIPs": {
|
||||
"type": "string"
|
||||
},
|
||||
"auth": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1202,12 +1231,24 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"keepAlive": {
|
||||
"type": "integer"
|
||||
},
|
||||
"limitIp": {
|
||||
"type": "integer"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"preSharedKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"privateKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"publicKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"reset": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -1232,6 +1273,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"allowedIPs",
|
||||
"auth",
|
||||
"comment",
|
||||
"createdAt",
|
||||
@@ -1241,8 +1283,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"flow",
|
||||
"group",
|
||||
"id",
|
||||
"keepAlive",
|
||||
"limitIp",
|
||||
"password",
|
||||
"preSharedKey",
|
||||
"privateKey",
|
||||
"publicKey",
|
||||
"reset",
|
||||
"reverse",
|
||||
"security",
|
||||
@@ -1499,7 +1545,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "string"
|
||||
},
|
||||
"vlessRoute": {
|
||||
"description": "VlessRoute is a free-form port/range routing spec (e.g. \"53,443,1000-2000\");\nstored verbatim, format-validated on the frontend.",
|
||||
"description": "Single VLESS route value (0-65535) baked into the subscription UUID's 3rd\ngroup (bytes 6-7), which xray reads via net.PortFromBytes(id[6:8]). Empty = none.",
|
||||
"example": "443",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -1782,6 +1829,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"tlsFlowCapable": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"wgDns": {
|
||||
"type": "string"
|
||||
},
|
||||
"wgMtu": {
|
||||
"type": "integer"
|
||||
},
|
||||
"wgPublicKey": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2120,6 +2176,104 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RealityScanResult": {
|
||||
"properties": {
|
||||
"alpn": {
|
||||
"example": "h2",
|
||||
"type": "string"
|
||||
},
|
||||
"certIssuer": {
|
||||
"example": "Google Trust Services",
|
||||
"type": "string"
|
||||
},
|
||||
"certSubject": {
|
||||
"example": "cloudflare.com",
|
||||
"type": "string"
|
||||
},
|
||||
"certValid": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"curveID": {
|
||||
"example": "X25519",
|
||||
"type": "string"
|
||||
},
|
||||
"feasible": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"h2": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"host": {
|
||||
"example": "www.cloudflare.com",
|
||||
"type": "string"
|
||||
},
|
||||
"ip": {
|
||||
"example": "104.16.124.96",
|
||||
"type": "string"
|
||||
},
|
||||
"latencyMs": {
|
||||
"example": 180,
|
||||
"type": "integer"
|
||||
},
|
||||
"notAfter": {
|
||||
"example": "2026-08-01T00:00:00Z",
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"example": 443,
|
||||
"type": "integer"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"serverNames": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"target": {
|
||||
"example": "www.cloudflare.com:443",
|
||||
"type": "string"
|
||||
},
|
||||
"tls13": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"tlsVersion": {
|
||||
"example": "1.3",
|
||||
"type": "string"
|
||||
},
|
||||
"x25519": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"alpn",
|
||||
"certIssuer",
|
||||
"certSubject",
|
||||
"certValid",
|
||||
"curveID",
|
||||
"feasible",
|
||||
"h2",
|
||||
"host",
|
||||
"ip",
|
||||
"latencyMs",
|
||||
"notAfter",
|
||||
"port",
|
||||
"reason",
|
||||
"serverNames",
|
||||
"target",
|
||||
"tls13",
|
||||
"tlsVersion",
|
||||
"x25519"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Setting": {
|
||||
"description": "Setting stores key-value configuration settings for the 3x-ui panel.",
|
||||
"properties": {
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface AllSetting {
|
||||
ldapFlagField: string;
|
||||
ldapHost: string;
|
||||
ldapInboundTags: string;
|
||||
ldapInsecureSkipVerify: boolean;
|
||||
ldapInvertFlag: boolean;
|
||||
ldapPassword: string;
|
||||
ldapPort: number;
|
||||
@@ -125,6 +126,7 @@ export interface AllSettingView {
|
||||
ldapFlagField: string;
|
||||
ldapHost: string;
|
||||
ldapInboundTags: string;
|
||||
ldapInsecureSkipVerify: boolean;
|
||||
ldapInvertFlag: boolean;
|
||||
ldapPassword: string;
|
||||
ldapPort: number;
|
||||
@@ -222,6 +224,7 @@ export interface ApiTokenView {
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
allowedIPs?: string[];
|
||||
auth?: string;
|
||||
comment: string;
|
||||
created_at?: number;
|
||||
@@ -231,8 +234,12 @@ export interface Client {
|
||||
flow?: string;
|
||||
group?: string;
|
||||
id?: string;
|
||||
keepAlive?: number;
|
||||
limitIp: number;
|
||||
password?: string;
|
||||
preSharedKey?: string;
|
||||
privateKey?: string;
|
||||
publicKey?: string;
|
||||
reset: number;
|
||||
reverse?: ClientReverse | null;
|
||||
security: string;
|
||||
@@ -250,6 +257,7 @@ export interface ClientInbound {
|
||||
}
|
||||
|
||||
export interface ClientRecord {
|
||||
allowedIPs: string;
|
||||
auth: string;
|
||||
comment: string;
|
||||
createdAt: number;
|
||||
@@ -259,8 +267,12 @@ export interface ClientRecord {
|
||||
flow: string;
|
||||
group: string;
|
||||
id: number;
|
||||
keepAlive: number;
|
||||
limitIp: number;
|
||||
password: string;
|
||||
preSharedKey: string;
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
reset: number;
|
||||
reverse: unknown;
|
||||
security: string;
|
||||
@@ -389,6 +401,9 @@ export interface InboundOption {
|
||||
ssMethod: string;
|
||||
tag: string;
|
||||
tlsFlowCapable: boolean;
|
||||
wgDns?: string;
|
||||
wgMtu?: number;
|
||||
wgPublicKey?: string;
|
||||
}
|
||||
|
||||
export interface Msg {
|
||||
@@ -462,6 +477,27 @@ export interface ProbeResultUI {
|
||||
xrayVersion: string;
|
||||
}
|
||||
|
||||
export interface RealityScanResult {
|
||||
alpn: string;
|
||||
certIssuer: string;
|
||||
certSubject: string;
|
||||
certValid: boolean;
|
||||
curveID: string;
|
||||
feasible: boolean;
|
||||
h2: boolean;
|
||||
host: string;
|
||||
ip: string;
|
||||
latencyMs: number;
|
||||
notAfter: string;
|
||||
port: number;
|
||||
reason: string;
|
||||
serverNames: string[];
|
||||
target: string;
|
||||
tls13: boolean;
|
||||
tlsVersion: string;
|
||||
x25519: boolean;
|
||||
}
|
||||
|
||||
export interface Setting {
|
||||
id: number;
|
||||
key: string;
|
||||
|
||||
@@ -34,6 +34,7 @@ export const AllSettingSchema = z.object({
|
||||
ldapFlagField: z.string(),
|
||||
ldapHost: z.string(),
|
||||
ldapInboundTags: z.string(),
|
||||
ldapInsecureSkipVerify: z.boolean(),
|
||||
ldapInvertFlag: z.boolean(),
|
||||
ldapPassword: z.string(),
|
||||
ldapPort: z.number().int().min(0).max(65535),
|
||||
@@ -138,6 +139,7 @@ export const AllSettingViewSchema = z.object({
|
||||
ldapFlagField: z.string(),
|
||||
ldapHost: z.string(),
|
||||
ldapInboundTags: z.string(),
|
||||
ldapInsecureSkipVerify: z.boolean(),
|
||||
ldapInvertFlag: z.boolean(),
|
||||
ldapPassword: z.string(),
|
||||
ldapPort: z.number().int().min(0).max(65535),
|
||||
@@ -238,6 +240,7 @@ export const ApiTokenViewSchema = z.object({
|
||||
export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
|
||||
|
||||
export const ClientSchema = z.object({
|
||||
allowedIPs: z.array(z.string()).optional(),
|
||||
auth: z.string().optional(),
|
||||
comment: z.string(),
|
||||
created_at: z.number().int().optional(),
|
||||
@@ -247,8 +250,12 @@ export const ClientSchema = z.object({
|
||||
flow: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
keepAlive: z.number().int().optional(),
|
||||
limitIp: z.number().int(),
|
||||
password: z.string().optional(),
|
||||
preSharedKey: z.string().optional(),
|
||||
privateKey: z.string().optional(),
|
||||
publicKey: z.string().optional(),
|
||||
reset: z.number().int(),
|
||||
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
|
||||
security: z.string(),
|
||||
@@ -268,6 +275,7 @@ export const ClientInboundSchema = z.object({
|
||||
export type ClientInbound = z.infer<typeof ClientInboundSchema>;
|
||||
|
||||
export const ClientRecordSchema = z.object({
|
||||
allowedIPs: z.string(),
|
||||
auth: z.string(),
|
||||
comment: z.string(),
|
||||
createdAt: z.number().int(),
|
||||
@@ -277,8 +285,12 @@ export const ClientRecordSchema = z.object({
|
||||
flow: z.string(),
|
||||
group: z.string(),
|
||||
id: z.number().int(),
|
||||
keepAlive: z.number().int(),
|
||||
limitIp: z.number().int(),
|
||||
password: z.string(),
|
||||
preSharedKey: z.string(),
|
||||
privateKey: z.string(),
|
||||
publicKey: z.string(),
|
||||
reset: z.number().int(),
|
||||
reverse: z.unknown(),
|
||||
security: z.string(),
|
||||
@@ -416,6 +428,9 @@ export const InboundOptionSchema = z.object({
|
||||
ssMethod: z.string(),
|
||||
tag: z.string(),
|
||||
tlsFlowCapable: z.boolean(),
|
||||
wgDns: z.string().optional(),
|
||||
wgMtu: z.number().int().optional(),
|
||||
wgPublicKey: z.string().optional(),
|
||||
});
|
||||
export type InboundOption = z.infer<typeof InboundOptionSchema>;
|
||||
|
||||
@@ -494,6 +509,28 @@ export const ProbeResultUISchema = z.object({
|
||||
});
|
||||
export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
|
||||
|
||||
export const RealityScanResultSchema = z.object({
|
||||
alpn: z.string(),
|
||||
certIssuer: z.string(),
|
||||
certSubject: z.string(),
|
||||
certValid: z.boolean(),
|
||||
curveID: z.string(),
|
||||
feasible: z.boolean(),
|
||||
h2: z.boolean(),
|
||||
host: z.string(),
|
||||
ip: z.string(),
|
||||
latencyMs: z.number().int(),
|
||||
notAfter: z.string(),
|
||||
port: z.number().int(),
|
||||
reason: z.string(),
|
||||
serverNames: z.array(z.string()),
|
||||
target: z.string(),
|
||||
tls13: z.boolean(),
|
||||
tlsVersion: z.string(),
|
||||
x25519: z.boolean(),
|
||||
});
|
||||
export type RealityScanResult = z.infer<typeof RealityScanResultSchema>;
|
||||
|
||||
export const SettingSchema = z.object({
|
||||
id: z.number().int(),
|
||||
key: z.string(),
|
||||
|
||||
@@ -47,6 +47,7 @@ interface SubSettings {
|
||||
subJsonEnable: boolean;
|
||||
subClashURI: string;
|
||||
subClashEnable: boolean;
|
||||
publicHost: string;
|
||||
}
|
||||
|
||||
export interface ClientQueryParams {
|
||||
@@ -240,6 +241,7 @@ export function useClients() {
|
||||
subJsonEnable: !!defaults.subJsonEnable,
|
||||
subClashURI: (defaults.subClashURI as string) || '',
|
||||
subClashEnable: !!defaults.subClashEnable,
|
||||
publicHost: (defaults.subDomain as string) || (defaults.webDomain as string) || '',
|
||||
}), [
|
||||
defaults.subEnable,
|
||||
defaults.subURI,
|
||||
@@ -247,6 +249,8 @@ export function useClients() {
|
||||
defaults.subJsonEnable,
|
||||
defaults.subClashURI,
|
||||
defaults.subClashEnable,
|
||||
defaults.subDomain,
|
||||
defaults.webDomain,
|
||||
]);
|
||||
|
||||
const ipLimitEnable = !!defaults.ipLimitEnable;
|
||||
|
||||
@@ -75,6 +75,33 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sidebar-docs {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ant-color-text-secondary);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-docs:hover,
|
||||
.sidebar-docs:focus-visible {
|
||||
background-color: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
|
||||
color: var(--ant-color-primary);
|
||||
transform: scale(1.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-docs .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sidebar-theme-cycle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
MessageOutlined,
|
||||
MoonFilled,
|
||||
MoonOutlined,
|
||||
ReadOutlined,
|
||||
SafetyOutlined,
|
||||
SettingOutlined,
|
||||
SunOutlined,
|
||||
@@ -40,6 +41,7 @@ import './AppSidebar.css';
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||
const DOCS_URL = 'https://docs.sanaei.dev/';
|
||||
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
||||
const LOGOUT_KEY = '__logout__';
|
||||
|
||||
@@ -83,6 +85,21 @@ function DonateButton({ ariaLabel }: { ariaLabel: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DocsButton({ ariaLabel }: { ariaLabel: string }) {
|
||||
return (
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sidebar-docs"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
>
|
||||
<ReadOutlined />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) {
|
||||
if (!version) return null;
|
||||
const label = formatPanelVersion(version);
|
||||
@@ -254,6 +271,7 @@ export default function AppSidebar() {
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="brand-actions">
|
||||
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
<ThemeCycleButton
|
||||
id="theme-cycle"
|
||||
@@ -306,6 +324,7 @@ export default function AppSidebar() {
|
||||
<span className="drawer-brand">3X-UI</span>
|
||||
</div>
|
||||
<div className="drawer-header-actions">
|
||||
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
<ThemeCycleButton
|
||||
id="theme-cycle-drawer"
|
||||
@@ -351,7 +370,7 @@ export default function AppSidebar() {
|
||||
<button
|
||||
className="drawer-handle"
|
||||
type="button"
|
||||
aria-label={t('menu.dashboard')}
|
||||
aria-label={t('menu.openMenu')}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
<MenuOutlined />
|
||||
|
||||
@@ -17,6 +17,7 @@ export type HostLinkInput = Pick<
|
||||
| 'echConfigList'
|
||||
| 'overrideSniFromAddress'
|
||||
| 'keepSniBlank'
|
||||
| 'vlessRoute'
|
||||
>;
|
||||
|
||||
// hostToExternalProxyEntry projects a host onto the ExternalProxyEntry shape the
|
||||
@@ -48,5 +49,6 @@ export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntr
|
||||
host.pinnedPeerCertSha256 && host.pinnedPeerCertSha256.length > 0 ? host.pinnedPeerCertSha256 : undefined,
|
||||
verifyPeerCertByName: host.verifyPeerCertByName || undefined,
|
||||
echConfigList: host.echConfigList || undefined,
|
||||
vlessRoute: host.vlessRoute || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +34,12 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
||||
{enabled && (
|
||||
<>
|
||||
<Form.Item name={[...name, 'destOverride']} wrapperCol={{ md: { span: 14, offset: 8 } }}>
|
||||
<Select mode="multiple" className="sniffing-options" options={DEST_OPTIONS} />
|
||||
<Select
|
||||
mode="multiple"
|
||||
className="sniffing-options"
|
||||
aria-label={t('pages.inbounds.sniffingDestOverride')}
|
||||
options={DEST_OPTIONS}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.sniffingMetadataOnly')}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NamePath } from 'antd/es/form/interface';
|
||||
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
|
||||
// Editor for sockopt.customSockopt — a list of raw setsockopt() options. Each
|
||||
// entry is rendered as a titled group of labeled fields (system / level / opt /
|
||||
// type / value) instead of one cramped inline row, so it reads like the rest of
|
||||
@@ -49,7 +51,11 @@ export default function CustomSockoptList({
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
style={{ marginInlineStart: 8 }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={() => remove(field.name)}
|
||||
onKeyDown={activateOnKey(() => remove(field.name))}
|
||||
/>
|
||||
</Divider>
|
||||
<Form.Item label="System" name={[field.name, 'system']}>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { AutoComplete, Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
import type { NamePath } from 'antd/es/form/interface';
|
||||
|
||||
import { RandomUtil } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
|
||||
const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({ value, label: value }));
|
||||
@@ -224,6 +226,7 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
|
||||
}
|
||||
|
||||
function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormInstance }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Form.List name={[...base, 'tcp']}>
|
||||
{(fields, { add, remove }) => (
|
||||
@@ -233,6 +236,7 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -265,12 +269,20 @@ function TcpMaskItem({
|
||||
// type change). All Form.Item `name=` use RELATIVE paths within the
|
||||
// outer Form.List context.
|
||||
const absolutePath = [...listPath, fieldName];
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
TCP Mask {displayIndex}
|
||||
<DeleteOutlined className="danger-icon" onClick={onRemove} />
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={onRemove}
|
||||
onKeyDown={activateOnKey(onRemove)}
|
||||
/>
|
||||
</Divider>
|
||||
|
||||
<Form.Item label="Type" name={[fieldName, 'type']}>
|
||||
@@ -415,12 +427,13 @@ function FragmentRangeList({
|
||||
validator?: (rule: unknown, value: unknown) => Promise<void>;
|
||||
minItems?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Form.List name={listName}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<Form.Item label={label}>
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => add('')} />
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
|
||||
</Form.Item>
|
||||
{fields.map((field, idx) => (
|
||||
<Form.Item
|
||||
@@ -431,8 +444,17 @@ function FragmentRangeList({
|
||||
>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
addonAfter={fields.length > minItems
|
||||
? <DeleteOutlined className="danger-icon" onClick={() => remove(field.name)} />
|
||||
suffix={fields.length > minItems
|
||||
? (
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={() => remove(field.name)}
|
||||
onKeyDown={activateOnKey(() => remove(field.name))}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -475,6 +497,7 @@ function HeaderCustomGroups({
|
||||
form: FormInstance;
|
||||
absoluteSettingsPath: (string | number)[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{(['clients', 'servers'] as const).map((groupKey) => (
|
||||
@@ -486,6 +509,7 @@ function HeaderCustomGroups({
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => addGroup([defaultClientServerItem()])}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -493,7 +517,14 @@ function HeaderCustomGroups({
|
||||
<div key={group.key}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
{groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
|
||||
<DeleteOutlined className="danger-icon" onClick={() => removeGroup(group.name)} />
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={() => removeGroup(group.name)}
|
||||
onKeyDown={activateOnKey(() => removeGroup(group.name))}
|
||||
/>
|
||||
</Divider>
|
||||
<Form.List name={[group.name]}>
|
||||
{(items, { add: addItem, remove: removeItem }) => (
|
||||
@@ -502,6 +533,7 @@ function HeaderCustomGroups({
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => addItem(defaultClientServerItem())}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -531,6 +563,7 @@ function HeaderCustomGroups({
|
||||
function UdpMasksList({
|
||||
base, form, isHysteria, isWireguard, network,
|
||||
}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; isWireguard: boolean; network: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Form.List name={[...base, 'udp']}>
|
||||
{(fields, { add, remove }) => (
|
||||
@@ -540,6 +573,7 @@ function UdpMasksList({
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => {
|
||||
const def = isHysteria || isWireguard ? 'salamander' : 'mkcp-legacy';
|
||||
add({ type: def, settings: defaultUdpMaskSettings(def) });
|
||||
@@ -578,6 +612,7 @@ function UdpMaskItem({
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const absolutePath = [...listPath, fieldName];
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onTypeChange = (v: string) => {
|
||||
form.setFieldValue([...absolutePath, 'settings'], defaultUdpMaskSettings(v));
|
||||
@@ -605,7 +640,14 @@ function UdpMaskItem({
|
||||
<div>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
UDP Mask {displayIndex}
|
||||
<DeleteOutlined className="danger-icon" onClick={onRemove} />
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={onRemove}
|
||||
onKeyDown={activateOnKey(onRemove)}
|
||||
/>
|
||||
</Divider>
|
||||
|
||||
<Form.Item label="Type" name={[fieldName, 'type']}>
|
||||
@@ -735,6 +777,7 @@ function SalamanderUdpMaskSettings({
|
||||
form: FormInstance;
|
||||
absolutePath: (string | number)[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const packetSizePath = [...absolutePath, 'settings', 'packetSize'];
|
||||
const packetSize = Form.useWatch(packetSizePath, { form, preserve: true });
|
||||
const mode = typeof packetSize === 'string' && packetSize.trim() !== '' ? 'gecko' : 'salamander';
|
||||
@@ -776,6 +819,7 @@ function SalamanderUdpMaskSettings({
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
aria-label={t('regenerate')}
|
||||
onClick={() => form.setFieldValue(
|
||||
[...absolutePath, 'settings', 'password'],
|
||||
RandomUtil.randomLowerAndNum(16),
|
||||
@@ -810,7 +854,7 @@ function GeckoPacketSizeInput({
|
||||
return (
|
||||
<Space.Compact block>
|
||||
<InputNumber
|
||||
addonBefore="Min"
|
||||
prefix="Min"
|
||||
min={GECKO_MIN_PACKET_SIZE}
|
||||
max={GECKO_MAX_PACKET_SIZE}
|
||||
precision={0}
|
||||
@@ -820,7 +864,7 @@ function GeckoPacketSizeInput({
|
||||
style={{ width: '50%' }}
|
||||
/>
|
||||
<InputNumber
|
||||
addonBefore="Max"
|
||||
prefix="Max"
|
||||
min={GECKO_MIN_PACKET_SIZE}
|
||||
max={GECKO_MAX_PACKET_SIZE}
|
||||
precision={0}
|
||||
@@ -840,6 +884,7 @@ function UdpHeaderCustom({
|
||||
form: FormInstance;
|
||||
absoluteSettingsPath: (string | number)[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{(['client', 'server'] as const).map((groupKey) => (
|
||||
@@ -851,6 +896,7 @@ function UdpHeaderCustom({
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => add(defaultUdpClientServerItem())}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -858,7 +904,14 @@ function UdpHeaderCustom({
|
||||
<div key={item.key}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
{groupKey === 'client' ? 'Client' : 'Server'} {ci + 1}
|
||||
<DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={() => remove(item.name)}
|
||||
onKeyDown={activateOnKey(() => remove(item.name))}
|
||||
/>
|
||||
</Divider>
|
||||
<ItemEditor
|
||||
fieldName={item.name}
|
||||
@@ -883,6 +936,7 @@ function NoiseItems({
|
||||
form: FormInstance;
|
||||
absoluteSettingsPath: (string | number)[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Reset" name={[udpFieldName, 'settings', 'reset']}>
|
||||
@@ -896,6 +950,7 @@ function NoiseItems({
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => add(defaultNoiseItem())}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -903,7 +958,14 @@ function NoiseItems({
|
||||
<div key={item.key}>
|
||||
<Divider style={{ margin: 0 }}>
|
||||
Noise {ni + 1}
|
||||
<DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={() => remove(item.name)}
|
||||
onKeyDown={activateOnKey(() => remove(item.name))}
|
||||
/>
|
||||
</Divider>
|
||||
<ItemEditor
|
||||
fieldName={item.name}
|
||||
@@ -930,6 +992,7 @@ function ItemEditor({
|
||||
delayMode?: 'number' | 'string';
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const onTypeChange = (v: string) => {
|
||||
if (v === 'base64') {
|
||||
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
|
||||
@@ -1005,6 +1068,7 @@ function ItemEditor({
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
aria-label={t('regenerate')}
|
||||
onClick={() => form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())}
|
||||
/>
|
||||
</Space.Compact>
|
||||
|
||||
@@ -263,24 +263,20 @@ export interface WireguardInboundSeed {
|
||||
mtu?: number;
|
||||
secretKey?: string;
|
||||
noKernelTun?: boolean;
|
||||
peerPrivateKey?: string;
|
||||
}
|
||||
|
||||
// WireGuard is multi-client now: a new inbound holds only the server identity
|
||||
// (secretKey/mtu) and starts with no clients. Clients (peers) are added later
|
||||
// through the client modal, which generates each one's keypair and a unique
|
||||
// tunnel address. peers stays empty for backward-compatible parsing.
|
||||
export function createDefaultWireguardInboundSettings(
|
||||
seed: WireguardInboundSeed = {},
|
||||
): WireguardInboundSettings {
|
||||
const peerKp = seed.peerPrivateKey
|
||||
? { privateKey: seed.peerPrivateKey, publicKey: Wireguard.generateKeypair(seed.peerPrivateKey).publicKey }
|
||||
: Wireguard.generateKeypair();
|
||||
return {
|
||||
mtu: seed.mtu ?? 1420,
|
||||
secretKey: seed.secretKey ?? Wireguard.generateKeypair().privateKey,
|
||||
peers: [{
|
||||
privateKey: peerKp.privateKey,
|
||||
publicKey: peerKp.publicKey,
|
||||
allowedIPs: ['10.0.0.2/32'],
|
||||
keepAlive: 0,
|
||||
}],
|
||||
peers: [],
|
||||
clients: [],
|
||||
noKernelTun: seed.noKernelTun ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TrojanClientSchema,
|
||||
VlessClientSchema,
|
||||
VmessClientSchema,
|
||||
WireguardClientSchema,
|
||||
} from '@/schemas/protocols/inbound';
|
||||
import type { StreamSettings } from '@/schemas/api/inbound';
|
||||
import type { Sniffing } from '@/schemas/primitives';
|
||||
@@ -234,6 +235,7 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null {
|
||||
case 'trojan': return TrojanClientSchema;
|
||||
case 'shadowsocks': return ShadowsocksClientSchema;
|
||||
case 'hysteria': return HysteriaClientSchema;
|
||||
case 'wireguard': return WireguardClientSchema;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +326,18 @@ export interface GenVlessLinkInput {
|
||||
externalProxy?: ExternalProxyEntry | null;
|
||||
}
|
||||
|
||||
// Mirror of the Go applyVlessRoute: bake a single 0-65535 value into the UUID's
|
||||
// 3rd group (bytes 6-7), which xray reads as the vless route. Empty/invalid/non-
|
||||
// UUID input is returned unchanged.
|
||||
export function applyVlessRoute(id: string, route: string | undefined): string {
|
||||
const r = (route ?? '').trim();
|
||||
if (r === '' || !/^\d{1,5}$/.test(r)) return id;
|
||||
const n = Number(r);
|
||||
if (n > 65535) return id;
|
||||
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id)) return id;
|
||||
return id.slice(0, 14) + n.toString(16).padStart(4, '0') + id.slice(18);
|
||||
}
|
||||
|
||||
// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
|
||||
// query carries network type, encryption, network-specific knobs, and
|
||||
// security-specific knobs (TLS fingerprint/alpn/sni or Reality
|
||||
@@ -437,7 +449,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
params.set('flow', flow);
|
||||
}
|
||||
|
||||
const url = new URL(`vless://${clientId}@${formatUrlHost(address)}:${port}`);
|
||||
const url = new URL(`vless://${applyVlessRoute(clientId, externalProxy?.vlessRoute)}@${formatUrlHost(address)}:${port}`);
|
||||
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||
url.hash = encodeURIComponent(remark);
|
||||
return url.toString();
|
||||
@@ -828,7 +840,7 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
|
||||
let txt = `[Interface]\n`;
|
||||
txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
|
||||
txt += `Address = ${peer.allowedIPs[0] ?? ''}\n`;
|
||||
txt += `DNS = 1.1.1.1, 1.0.0.1\n`;
|
||||
txt += `DNS = ${settings.dns || '1.1.1.1, 1.0.0.1'}\n`;
|
||||
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
|
||||
txt += `MTU = ${settings.mtu}\n`;
|
||||
}
|
||||
@@ -846,6 +858,66 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
|
||||
return txt;
|
||||
}
|
||||
|
||||
export function wireguardConfigFromLink(link: string, fallbackRemark = ''): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(link);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
const scheme = url.protocol.replace(/:$/, '');
|
||||
if (scheme !== 'wireguard' && scheme !== 'wg') return '';
|
||||
|
||||
const params = url.searchParams;
|
||||
const pick = (...keys: string[]): string => {
|
||||
for (const k of keys) {
|
||||
const v = params.get(k);
|
||||
if (v) return v;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
let privateKey: string;
|
||||
try {
|
||||
privateKey = decodeURIComponent(url.username);
|
||||
} catch {
|
||||
privateKey = url.username;
|
||||
}
|
||||
const host = url.hostname;
|
||||
const endpoint = host ? (url.port ? `${host}:${url.port}` : host) : '';
|
||||
const address = pick('address', 'ip') || '10.0.0.2/32';
|
||||
const publicKey = pick('publickey', 'publicKey', 'public_key', 'peerPublicKey');
|
||||
const dns = pick('dns') || '1.1.1.1, 1.0.0.1';
|
||||
const mtu = pick('mtu');
|
||||
const psk = pick('presharedkey', 'preshared_key', 'pre-shared-key', 'psk');
|
||||
const keepAlive = pick('keepalive', 'persistentkeepalive', 'persistent_keepalive');
|
||||
const allowedIPs = pick('allowedips', 'allowed_ips') || '0.0.0.0/0, ::/0';
|
||||
|
||||
let remark = fallbackRemark;
|
||||
try {
|
||||
const decoded = decodeURIComponent(url.hash.replace(/^#/, ''));
|
||||
if (decoded) remark = decoded;
|
||||
} catch {
|
||||
const raw = url.hash.replace(/^#/, '');
|
||||
if (raw) remark = raw;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
'[Interface]',
|
||||
`PrivateKey = ${privateKey}`,
|
||||
`Address = ${address}`,
|
||||
`DNS = ${dns}`,
|
||||
];
|
||||
if (mtu && Number(mtu) > 0) lines.push(`MTU = ${mtu}`);
|
||||
lines.push('');
|
||||
if (remark) lines.push(`# ${remark}`);
|
||||
lines.push('[Peer]', `PublicKey = ${publicKey}`);
|
||||
if (psk) lines.push(`PresharedKey = ${psk}`);
|
||||
lines.push(`AllowedIPs = ${allowedIPs}`, `Endpoint = ${endpoint}`);
|
||||
if (keepAlive && Number(keepAlive) > 0) lines.push(`PersistentKeepalive = ${keepAlive}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export type { WireguardInboundPeer };
|
||||
|
||||
function isUnixSocketListen(listen: string): boolean {
|
||||
@@ -1126,14 +1198,30 @@ export interface GenWireguardFanoutInput {
|
||||
fallbackHostname: string;
|
||||
}
|
||||
|
||||
// WireGuard is multi-client: each client is one accepted peer. The canonical
|
||||
// store is settings.clients; legacy single-config inbounds (pre-migration) are
|
||||
// still rendered from settings.peers. Both carry the privateKey/allowedIPs/
|
||||
// preSharedKey/keepAlive the link and .conf need, so they project to the same
|
||||
// peer shape and reuse genWireguardLink/genWireguardConfig unchanged.
|
||||
function wgRenderPeers(settings: WireguardInboundSettings): WireguardInboundPeer[] {
|
||||
const clients = settings.clients ?? [];
|
||||
if (clients.length > 0) {
|
||||
return clients.map((c) => ({ ...c, publicKey: c.publicKey ?? '' }));
|
||||
}
|
||||
return settings.peers;
|
||||
}
|
||||
|
||||
export function genWireguardLinks(input: GenWireguardFanoutInput): string {
|
||||
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
||||
if (inbound.protocol !== 'wireguard') return '';
|
||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||
const sep = '-';
|
||||
return inbound.settings.peers
|
||||
const baseSettings = inbound.settings as WireguardInboundSettings;
|
||||
const peers = wgRenderPeers(baseSettings);
|
||||
const settings: WireguardInboundSettings = { ...baseSettings, peers };
|
||||
return peers
|
||||
.map((p, i) => genWireguardLink({
|
||||
settings: inbound.settings as WireguardInboundSettings,
|
||||
settings,
|
||||
address: addr,
|
||||
port: inbound.port,
|
||||
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
||||
@@ -1147,9 +1235,12 @@ export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
|
||||
if (inbound.protocol !== 'wireguard') return '';
|
||||
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
||||
const sep = '-';
|
||||
return inbound.settings.peers
|
||||
const baseSettings = inbound.settings as WireguardInboundSettings;
|
||||
const peers = wgRenderPeers(baseSettings);
|
||||
const settings: WireguardInboundSettings = { ...baseSettings, peers };
|
||||
return peers
|
||||
.map((p, i) => genWireguardConfig({
|
||||
settings: inbound.settings as WireguardInboundSettings,
|
||||
settings,
|
||||
address: addr,
|
||||
port: inbound.port,
|
||||
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
export interface RealityTarget {
|
||||
target: string;
|
||||
sni: string;
|
||||
}
|
||||
|
||||
export const REALITY_TARGETS: readonly RealityTarget[] = [
|
||||
{ target: 'www.amazon.com:443', sni: 'www.amazon.com' },
|
||||
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
|
||||
{ target: 'www.oracle.com:443', sni: 'www.oracle.com' },
|
||||
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
|
||||
{ target: 'www.amd.com:443', sni: 'www.amd.com' },
|
||||
{ target: 'www.intel.com:443', sni: 'www.intel.com' },
|
||||
{ target: 'www.sony.com:443', sni: 'www.sony.com' },
|
||||
];
|
||||
|
||||
export function getRandomRealityTarget(): RealityTarget {
|
||||
const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
|
||||
const selected = REALITY_TARGETS[randomIndex];
|
||||
return {
|
||||
target: selected.target,
|
||||
sni: selected.sni,
|
||||
};
|
||||
}
|
||||
@@ -68,6 +68,7 @@ export class AllSetting {
|
||||
ldapHost = '';
|
||||
ldapPort = 389;
|
||||
ldapUseTLS = false;
|
||||
ldapInsecureSkipVerify = false;
|
||||
ldapBindDN = '';
|
||||
ldapPassword = '';
|
||||
ldapBaseDN = '';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConfigProvider, Layout } from 'antd';
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import 'swagger-ui-react/swagger-ui.css';
|
||||
@@ -12,6 +13,7 @@ const openApiUrl = `${basePath}panel/api/openapi.json`;
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const pageClass = useMemo(() => {
|
||||
const classes = ['api-docs-page'];
|
||||
@@ -27,7 +29,7 @@ export default function ApiDocsPage() {
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
<div className="docs-wrapper">
|
||||
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
|
||||
<SwaggerUI
|
||||
url={openApiUrl}
|
||||
docExpansion="list"
|
||||
|
||||
@@ -126,6 +126,14 @@ export const sections: readonly Section[] = [
|
||||
responseSchema: 'InboundOption',
|
||||
responseSchemaArray: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/allLinks',
|
||||
summary:
|
||||
'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, mtproto) across all inbounds and all of their clients. Links are rendered through the subscription engine, so the configured remark template (name-only display part) is applied per client — the same output the client info/QR pages use. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing. Used by the panel’s "Export all inbound links" action.',
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#Germany-alice",\n "vmess://eyJ2IjoyLC..."\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/get/:id',
|
||||
@@ -489,6 +497,27 @@ export const sections: readonly Section[] = [
|
||||
body: 'server=cloudflare-dns.com',
|
||||
response: '{\n "success": true,\n "obj": [\n "e8e2d3..."\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/scanRealityTarget',
|
||||
summary: 'Run a live TLS 1.3 probe against a candidate REALITY target and return a feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate) plus the certificate SAN DNS names.',
|
||||
params: [
|
||||
{ name: 'target', in: 'body (form)', type: 'string', desc: 'Candidate target as host or host:port (default port 443), e.g. www.cloudflare.com:443.' },
|
||||
],
|
||||
body: 'target=www.cloudflare.com:443',
|
||||
responseSchema: 'RealityScanResult',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/scanRealityTargets',
|
||||
summary: 'Probe/discover REALITY targets and return each verdict ranked by feasibility then latency. Each comma-separated token may be a domain (validated with SNI), a bare IP, or a CIDR range (discovered without SNI by reading the certificate domain). When empty, a built-in seed list is probed.',
|
||||
params: [
|
||||
{ name: 'targets', in: 'body (form)', type: 'string', optional: true, desc: 'Optional comma-separated tokens: domain[:port], IP[:port], or CIDR (e.g. 104.16.0.0/24). When omitted, a built-in seed list is probed.' },
|
||||
],
|
||||
body: 'targets=104.16.0.0/24,www.apple.com:443',
|
||||
responseSchema: 'RealityScanResult',
|
||||
responseSchemaArray: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/clientIps',
|
||||
@@ -644,7 +673,7 @@ export const sections: readonly Section[] = [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/bulkAdjust',
|
||||
summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. The optional flow directive sets the XTLS flow on every client: "none" clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound supports it (omit or "" to leave it unchanged). Returns the adjusted count and per-email skip reasons.',
|
||||
summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: "none" clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound supports it (omit or "" to leave it unchanged). Returns the adjusted count and per-email skip reasons.',
|
||||
body: '{\n "emails": ["alice", "bob"],\n "addDays": 30,\n "addBytes": 53687091200,\n "flow": "xtls-rprx-vision"\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "adjusted": 2,\n "skipped": [\n { "email": "carol", "reason": "unlimited expiry" }\n ]\n }\n}',
|
||||
},
|
||||
@@ -755,6 +784,13 @@ export const sections: readonly Section[] = [
|
||||
body: '{\n "name": "customer-a"\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "affected": 5\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/groups/resetTraffic',
|
||||
summary: 'Reset only the group-level traffic counter shown on the groups page. Snapshots the current up/down sum of the group\'s members as a baseline so the group total reads zero, while leaving each client\'s own counters (and their quotas) untouched. No Xray restart is triggered. Creates the client_groups row if the group exists only as a derived label.',
|
||||
body: '{\n "name": "customer-a"\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "name": "customer-a"\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/resetTraffic/:email',
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function BulkAttachInboundsModal({
|
||||
{t('pages.clients.attachToInboundsDesc', { count })}
|
||||
</Typography.Paragraph>
|
||||
{targetOptions.length === 0 ? (
|
||||
<Alert type="info" showIcon message={t('pages.clients.attachToInboundsNoTargets')} />
|
||||
<Alert type="info" showIcon title={t('pages.clients.attachToInboundsNoTargets')} />
|
||||
) : (
|
||||
<>
|
||||
<SelectAllClearButtons
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function BulkDetachInboundsModal({
|
||||
{t('pages.clients.detachFromInboundsDesc', { count })}
|
||||
</Typography.Paragraph>
|
||||
{targetOptions.length === 0 ? (
|
||||
<Alert type="info" showIcon message={t('pages.clients.detachFromInboundsNoTargets')} />
|
||||
<Alert type="info" showIcon title={t('pages.clients.detachFromInboundsNoTargets')} />
|
||||
) : (
|
||||
<>
|
||||
<SelectAllClearButtons
|
||||
|
||||
@@ -16,7 +16,7 @@ import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
|
||||
const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
|
||||
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
|
||||
]);
|
||||
|
||||
interface ClientBulkAddModalProps {
|
||||
@@ -280,6 +280,7 @@ export default function ClientBulkAddModal({
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t('regenerate')}
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { HttpUtil, RandomUtil } from '@/utils';
|
||||
import { HttpUtil, RandomUtil, Wireguard } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
@@ -35,7 +35,7 @@ const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const;
|
||||
|
||||
const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
|
||||
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
|
||||
]);
|
||||
|
||||
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
|
||||
@@ -113,6 +113,10 @@ interface FormState {
|
||||
enable: boolean;
|
||||
inboundIds: number[];
|
||||
externalLinks: ExternalLinkRow[];
|
||||
wgPrivateKey: string;
|
||||
wgPublicKey: string;
|
||||
wgPreSharedKey: string;
|
||||
wgAllowedIPs: string;
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
@@ -137,6 +141,10 @@ function emptyForm(): FormState {
|
||||
enable: true,
|
||||
inboundIds: [],
|
||||
externalLinks: [],
|
||||
wgPrivateKey: '',
|
||||
wgPublicKey: '',
|
||||
wgPreSharedKey: '',
|
||||
wgAllowedIPs: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -237,6 +245,10 @@ export default function ClientFormModal({
|
||||
enable: !!client.enable,
|
||||
inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
|
||||
externalLinks: toExternalLinkRows(attachedExternalLinks),
|
||||
wgPrivateKey: client.privateKey || '',
|
||||
wgPublicKey: client.publicKey || '',
|
||||
wgPreSharedKey: client.preSharedKey || '',
|
||||
wgAllowedIPs: client.allowedIPs || '',
|
||||
};
|
||||
if (et < 0) {
|
||||
next.delayedStart = true;
|
||||
@@ -250,6 +262,7 @@ export default function ClientFormModal({
|
||||
setForm(next);
|
||||
void loadIps();
|
||||
} else {
|
||||
const wgKeypair = Wireguard.generateKeypair();
|
||||
setForm({
|
||||
...emptyForm(),
|
||||
email: RandomUtil.randomLowerAndNum(10),
|
||||
@@ -257,6 +270,8 @@ export default function ClientFormModal({
|
||||
subId: RandomUtil.randomLowerAndNum(16),
|
||||
password: RandomUtil.randomLowerAndNum(16),
|
||||
auth: RandomUtil.randomLowerAndNum(16),
|
||||
wgPrivateKey: wgKeypair.privateKey,
|
||||
wgPublicKey: wgKeypair.publicKey,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -287,6 +302,14 @@ export default function ClientFormModal({
|
||||
return ids;
|
||||
}, [inbounds]);
|
||||
|
||||
const wireguardIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
for (const row of inbounds || []) {
|
||||
if (row && row.protocol === 'wireguard') ids.add(row.id);
|
||||
}
|
||||
return ids;
|
||||
}, [inbounds]);
|
||||
|
||||
const ss2022Method = useMemo(() => {
|
||||
for (const id of form.inboundIds || []) {
|
||||
const ib = (inbounds || []).find((row) => row.id === id);
|
||||
@@ -317,6 +340,16 @@ export default function ClientFormModal({
|
||||
[form.inboundIds, vmessIds],
|
||||
);
|
||||
|
||||
const showWireguard = useMemo(
|
||||
() => (form.inboundIds || []).some((id) => wireguardIds.has(id)),
|
||||
[form.inboundIds, wireguardIds],
|
||||
);
|
||||
|
||||
function regenerateWireguardKeys() {
|
||||
const kp = Wireguard.generateKeypair();
|
||||
setForm((prev) => ({ ...prev, wgPrivateKey: kp.privateKey, wgPublicKey: kp.publicKey }));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!showFlow && form.flow) {
|
||||
|
||||
@@ -453,6 +486,14 @@ export default function ClientFormModal({
|
||||
clientPayload.reverse = { tag: reverseTag };
|
||||
}
|
||||
|
||||
if (showWireguard) {
|
||||
clientPayload.privateKey = form.wgPrivateKey;
|
||||
clientPayload.publicKey = form.wgPublicKey;
|
||||
if (form.wgPreSharedKey) {
|
||||
clientPayload.preSharedKey = form.wgPreSharedKey;
|
||||
}
|
||||
}
|
||||
|
||||
const externalLinks: ExternalLinkInput[] = form.externalLinks
|
||||
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
|
||||
.filter((r) => r.value !== '');
|
||||
@@ -541,7 +582,7 @@ export default function ClientFormModal({
|
||||
onChange={(e) => update('email', e.target.value)}
|
||||
/>
|
||||
{!isEdit && (
|
||||
<Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
|
||||
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
|
||||
)}
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
@@ -562,7 +603,7 @@ export default function ClientFormModal({
|
||||
onChange={(v) => update('limitIp', Number(v) || 0)} />
|
||||
{isEdit && (
|
||||
<Tooltip title={t('pages.clients.ipLog')}>
|
||||
<Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
|
||||
<Button aria-label={t('pages.clients.ipLog')} icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
|
||||
{clientIps.length > 0 ? clientIps.length : ''}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -676,7 +717,7 @@ export default function ClientFormModal({
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Switch checked={form.enable} onChange={(v) => update('enable', v)} />
|
||||
<Switch aria-label={t('enable')} checked={form.enable} onChange={(v) => update('enable', v)} />
|
||||
<span style={{ marginLeft: 8 }}>{t('enable')}</span>
|
||||
</Form.Item>
|
||||
</>
|
||||
@@ -690,28 +731,28 @@ export default function ClientFormModal({
|
||||
<Form.Item label={t('pages.clients.uuid')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
|
||||
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.clients.password')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
|
||||
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
|
||||
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regeneratePassword} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.clients.subId')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
|
||||
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.clients.hysteriaAuth')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
|
||||
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
@@ -736,6 +777,38 @@ export default function ClientFormModal({
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{showWireguard && (
|
||||
<>
|
||||
<Form.Item label={t('pages.clients.wireguardPrivateKey')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input
|
||||
value={form.wgPrivateKey}
|
||||
style={{ flex: 1 }}
|
||||
onChange={(e) => {
|
||||
const priv = e.target.value;
|
||||
update('wgPrivateKey', priv);
|
||||
update('wgPublicKey', priv ? Wireguard.generateKeypair(priv).publicKey : '');
|
||||
}}
|
||||
/>
|
||||
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateWireguardKeys} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.clients.wireguardPublicKey')}>
|
||||
<Input value={form.wgPublicKey} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.clients.wireguardPreSharedKey')}>
|
||||
<Input
|
||||
value={form.wgPreSharedKey}
|
||||
onChange={(e) => update('wgPreSharedKey', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
{isEdit && form.wgAllowedIPs && (
|
||||
<Form.Item label={t('pages.clients.wireguardAllowedIPs')}>
|
||||
<Input value={form.wgAllowedIPs} disabled />
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -758,11 +831,12 @@ export default function ClientFormModal({
|
||||
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<Input
|
||||
value={row.value}
|
||||
aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
|
||||
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
|
||||
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
|
||||
/>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
|
||||
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
@@ -778,11 +852,12 @@ export default function ClientFormModal({
|
||||
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<Input
|
||||
value={row.value}
|
||||
aria-label="https://provider.example/sub/…"
|
||||
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
|
||||
placeholder="https://provider.example/sub/…"
|
||||
/>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
|
||||
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
|
||||
import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
|
||||
import { QrPanel } from '@/pages/inbounds/qr';
|
||||
import ConfigBlock from '@/components/clients/ConfigBlock';
|
||||
import { buildWireguardClientConfig, findWireguardInbound, isWireguardClient } from './wireguardConfig';
|
||||
import './ClientInfoModal.css';
|
||||
|
||||
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||||
@@ -35,6 +37,7 @@ interface SubSettings {
|
||||
subJsonEnable: boolean;
|
||||
subClashURI: string;
|
||||
subClashEnable: boolean;
|
||||
publicHost?: string;
|
||||
}
|
||||
|
||||
interface ClientInfoModalProps {
|
||||
@@ -58,6 +61,7 @@ const DEFAULT_SUB: SubSettings = {
|
||||
subJsonEnable: false,
|
||||
subClashURI: '',
|
||||
subClashEnable: false,
|
||||
publicHost: '',
|
||||
};
|
||||
|
||||
export default function ClientInfoModal({
|
||||
@@ -132,6 +136,11 @@ export default function ClientInfoModal({
|
||||
}, [client?.subId, subSettings?.subClashEnable, subSettings?.subClashURI]);
|
||||
|
||||
const showSubscription = !!(subSettings?.enable && client?.subId);
|
||||
const wgInbound = useMemo(() => findWireguardInbound(client, inboundsById), [client, inboundsById]);
|
||||
const wgConfigText = useMemo(() => {
|
||||
if (!client || !wgInbound || !isWireguardClient(client)) return '';
|
||||
return buildWireguardClientConfig(client, wgInbound, window.location.hostname, subSettings?.publicHost ?? '');
|
||||
}, [client, wgInbound, subSettings?.publicHost]);
|
||||
|
||||
async function copyValue(text: string) {
|
||||
if (!text) return;
|
||||
@@ -211,7 +220,7 @@ export default function ClientInfoModal({
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.subId || '-'}</Tag>
|
||||
{client.subId && (
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.subId!)} />
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.subId!)} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -220,7 +229,7 @@ export default function ClientInfoModal({
|
||||
<td>{t('pages.clients.uuid')}</td>
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.uuid}</Tag>
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.uuid!)} />
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.uuid!)} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -229,7 +238,7 @@ export default function ClientInfoModal({
|
||||
<td>{t('password')}</td>
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.password}</Tag>
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.password!)} />
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.password!)} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -238,7 +247,7 @@ export default function ClientInfoModal({
|
||||
<td>{t('pages.clients.auth')}</td>
|
||||
<td>
|
||||
<Tag className="info-large-tag">{client.auth}</Tag>
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.auth!)} />
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.auth!)} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -286,7 +295,7 @@ export default function ClientInfoModal({
|
||||
<tr>
|
||||
<td>{t('pages.inbounds.IPLimitlog')}</td>
|
||||
<td>
|
||||
<Button size="small" icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
|
||||
<Button size="small" icon={<EyeOutlined />} aria-label={t('pages.clients.ipLog')} loading={ipsLoading} onClick={openIpsModal}>
|
||||
{clientIps.length > 0 ? clientIps.length : ''}
|
||||
</Button>
|
||||
</td>
|
||||
@@ -350,44 +359,6 @@ export default function ClientInfoModal({
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{links.length > 0 && (
|
||||
<>
|
||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||
{links.map((link, idx) => {
|
||||
const parts = parseLinkParts(link);
|
||||
const fallback = `${t('pages.clients.link')} ${idx + 1}`;
|
||||
const rowTitle = (parts && linkMetaText(parts)) || fallback;
|
||||
const qrRemark = parts?.remark || rowTitle;
|
||||
const canQr = !isPostQuantumLink(link);
|
||||
return (
|
||||
<div key={idx} className="link-row">
|
||||
{parts
|
||||
? <LinkTags parts={parts} />
|
||||
: <Tag className="link-row-tag">LINK</Tag>}
|
||||
<span className="link-row-title" title={rowTitle}>{rowTitle}</span>
|
||||
<div className="link-row-actions">
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />
|
||||
</Tooltip>
|
||||
{canQr && (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
destroyOnHidden
|
||||
content={<QrPanel value={link} remark={qrRemark} size={220} />}
|
||||
>
|
||||
<Tooltip title={t('pages.clients.qrCode')}>
|
||||
<Button size="small" icon={<QrcodeOutlined />} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showSubscription && subLink && (
|
||||
<>
|
||||
<Divider>{t('subscription.title')}</Divider>
|
||||
@@ -404,7 +375,7 @@ export default function ClientInfoModal({
|
||||
</a>
|
||||
<div className="link-row-actions">
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subLink)} />
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subLink)} />
|
||||
</Tooltip>
|
||||
<Popover
|
||||
trigger="click"
|
||||
@@ -413,7 +384,7 @@ export default function ClientInfoModal({
|
||||
content={<QrPanel value={subLink} remark={`${client.email} — ${t('subscription.title')}`} size={220} />}
|
||||
>
|
||||
<Tooltip title={t('pages.clients.qrCode')}>
|
||||
<Button size="small" icon={<QrcodeOutlined />} />
|
||||
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -432,7 +403,7 @@ export default function ClientInfoModal({
|
||||
</a>
|
||||
<div className="link-row-actions">
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subJsonLink)} />
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subJsonLink)} />
|
||||
</Tooltip>
|
||||
<Popover
|
||||
trigger="click"
|
||||
@@ -441,7 +412,7 @@ export default function ClientInfoModal({
|
||||
content={<QrPanel value={subJsonLink} remark={`${client.email} — JSON`} size={220} />}
|
||||
>
|
||||
<Tooltip title={t('pages.clients.qrCode')}>
|
||||
<Button size="small" icon={<QrcodeOutlined />} />
|
||||
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -463,7 +434,7 @@ export default function ClientInfoModal({
|
||||
</a>
|
||||
<div className="link-row-actions">
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subClashLink)} />
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subClashLink)} />
|
||||
</Tooltip>
|
||||
<Popover
|
||||
trigger="click"
|
||||
@@ -472,7 +443,7 @@ export default function ClientInfoModal({
|
||||
content={<QrPanel value={subClashLink} remark={`${client.email} — Clash / Mihomo`} size={220} />}
|
||||
>
|
||||
<Tooltip title={t('pages.clients.qrCode')}>
|
||||
<Button size="small" icon={<QrcodeOutlined />} />
|
||||
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -480,6 +451,56 @@ export default function ClientInfoModal({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{links.length > 0 && (
|
||||
<>
|
||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||
{links.map((link, idx) => {
|
||||
const parts = parseLinkParts(link);
|
||||
const fallback = `${t('pages.clients.link')} ${idx + 1}`;
|
||||
const rowTitle = (parts && linkMetaText(parts)) || fallback;
|
||||
const qrRemark = parts?.remark || rowTitle;
|
||||
const canQr = !isPostQuantumLink(link);
|
||||
return (
|
||||
<div key={idx} className="link-row">
|
||||
{parts
|
||||
? <LinkTags parts={parts} />
|
||||
: <Tag className="link-row-tag">LINK</Tag>}
|
||||
<span className="link-row-title" title={rowTitle}>{rowTitle}</span>
|
||||
<div className="link-row-actions">
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(link)} />
|
||||
</Tooltip>
|
||||
{canQr && (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
destroyOnHidden
|
||||
content={<QrPanel value={link} remark={qrRemark} size={220} />}
|
||||
>
|
||||
<Tooltip title={t('pages.clients.qrCode')}>
|
||||
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{wgConfigText && client && (
|
||||
<>
|
||||
<Divider>{t('pages.clients.wireguardConfig')}</Divider>
|
||||
<ConfigBlock
|
||||
label={t('pages.clients.config')}
|
||||
text={wgConfigText}
|
||||
fileName={`${client.email}.conf`}
|
||||
qrRemark={client.email || 'peer'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Collapse, Modal, Spin } from 'antd';
|
||||
import { Collapse, Modal, Spin, Tag } from 'antd';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
|
||||
import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
|
||||
import { QrPanel } from '@/pages/inbounds/qr';
|
||||
import type { ClientRecord } from '@/hooks/useClients';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
import { buildWireguardClientConfig, findWireguardInbound, isWireguardClient } from './wireguardConfig';
|
||||
|
||||
interface SubSettings {
|
||||
enable: boolean;
|
||||
subURI: string;
|
||||
subJsonURI: string;
|
||||
subJsonEnable: boolean;
|
||||
publicHost?: string;
|
||||
}
|
||||
|
||||
interface ClientQrModalProps {
|
||||
open: boolean;
|
||||
client: ClientRecord | null;
|
||||
inboundsById: Record<number, InboundOption>;
|
||||
subSettings?: SubSettings;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
@@ -26,11 +29,12 @@ interface ApiMsg<T = unknown> {
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false };
|
||||
const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false, publicHost: '' };
|
||||
|
||||
export default function ClientQrModal({
|
||||
open,
|
||||
client,
|
||||
inboundsById,
|
||||
subSettings = DEFAULT_SUB,
|
||||
onOpenChange,
|
||||
}: ClientQrModalProps) {
|
||||
@@ -49,7 +53,13 @@ export default function ClientQrModal({
|
||||
return subSettings.subJsonURI + client.subId;
|
||||
}, [client?.subId, subSettings?.enable, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
|
||||
|
||||
const hasAnything = !!subLink || !!subJsonLink || links.length > 0;
|
||||
const wgInbound = useMemo(() => findWireguardInbound(client, inboundsById), [client, inboundsById]);
|
||||
const wgConfigText = useMemo(() => {
|
||||
if (!client || !wgInbound || !isWireguardClient(client)) return '';
|
||||
return buildWireguardClientConfig(client, wgInbound, window.location.hostname, subSettings?.publicHost ?? '');
|
||||
}, [client, wgInbound, subSettings?.publicHost]);
|
||||
|
||||
const hasAnything = !!subLink || !!subJsonLink || !!wgConfigText || links.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !client?.subId) {
|
||||
@@ -112,8 +122,21 @@ export default function ClientQrModal({
|
||||
),
|
||||
});
|
||||
});
|
||||
if (wgConfigText) {
|
||||
out.push({
|
||||
key: 'wg-config',
|
||||
label: <Tag color="cyan" style={{ margin: 0 }}>{t('pages.clients.wireguardConfig')}</Tag>,
|
||||
children: (
|
||||
<QrPanel
|
||||
value={wgConfigText}
|
||||
remark={client?.email || 'peer'}
|
||||
downloadName={`${client?.email || 'peer'}.conf`}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [subLink, subJsonLink, links, client?.email, t]);
|
||||
}, [subLink, subJsonLink, wgConfigText, links, client?.email, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
UsergroupAddOutlined,
|
||||
UsergroupDeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
@@ -752,19 +753,19 @@ export default function ClientsPage() {
|
||||
render: (_v, record) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title={t('pages.clients.qrCode')}>
|
||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
|
||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} onClick={() => onShowQr(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('pages.clients.clientInfo')}>
|
||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
|
||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} aria-label={t('pages.clients.clientInfo')} onClick={() => onShowInfo(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('pages.inbounds.resetTraffic')}>
|
||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
|
||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} aria-label={t('pages.inbounds.resetTraffic')} onClick={() => onResetTraffic(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('edit')}>
|
||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
||||
<Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(record)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
@@ -1039,7 +1040,7 @@ export default function ClientsPage() {
|
||||
title={
|
||||
<div className="card-toolbar">
|
||||
{selectedRowKeys.length === 0 ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd} aria-label={t('pages.clients.addClients')}>
|
||||
{!isMobile && t('pages.clients.addClients')}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -1154,7 +1155,7 @@ export default function ClientsPage() {
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button icon={<MoreOutlined />}>
|
||||
<Button icon={<MoreOutlined />} aria-label={t('more')}>
|
||||
{!isMobile && t('more')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
@@ -1164,6 +1165,7 @@ export default function ClientsPage() {
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={onBulkDelete}
|
||||
style={{ marginInlineStart: 'auto' }}
|
||||
aria-label={t('delete')}
|
||||
>
|
||||
{!isMobile && t('delete')}
|
||||
</Button>
|
||||
@@ -1180,6 +1182,7 @@ export default function ClientsPage() {
|
||||
prefix={<SearchOutlined />}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
style={{ maxWidth: 320 }}
|
||||
aria-label={t('search')}
|
||||
/>
|
||||
<Badge count={activeCount} size="small" offset={[-4, 4]}>
|
||||
<Button
|
||||
@@ -1187,12 +1190,14 @@ export default function ClientsPage() {
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
onClick={() => setFilterDrawerOpen(true)}
|
||||
type={activeCount > 0 ? 'primary' : 'default'}
|
||||
aria-label={t('filter')}
|
||||
>
|
||||
{!isMobile && t('filter')}
|
||||
</Button>
|
||||
</Badge>
|
||||
<Select
|
||||
value={sortValueFor(sortColumn, sortOrder)}
|
||||
aria-label={t('sort')}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
suffixIcon={<SortAscendingOutlined />}
|
||||
style={{ minWidth: isMobile ? 130 : 200 }}
|
||||
@@ -1365,9 +1370,16 @@ export default function ClientsPage() {
|
||||
<span className="tag-name">{row.email}</span>
|
||||
{bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
|
||||
{bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</Tag>}
|
||||
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="card-actions">
|
||||
<Tooltip title={t('pages.clients.clientInfo')}>
|
||||
<InfoCircleOutlined className="row-action-trigger" onClick={() => onShowInfo(row)} />
|
||||
<InfoCircleOutlined
|
||||
className="row-action-trigger"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.clients.clientInfo')}
|
||||
onClick={() => onShowInfo(row)}
|
||||
onKeyDown={activateOnKey(() => onShowInfo(row))}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
checked={!!row.enable}
|
||||
@@ -1404,7 +1416,7 @@ export default function ClientsPage() {
|
||||
],
|
||||
}}
|
||||
>
|
||||
<MoreOutlined className="row-action-trigger" />
|
||||
<Button type="text" size="small" className="row-action-trigger" icon={<MoreOutlined />} aria-label={t('more')} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1459,6 +1471,7 @@ export default function ClientsPage() {
|
||||
<ClientQrModal
|
||||
open={qrOpen}
|
||||
client={qrClient}
|
||||
inboundsById={inboundsById}
|
||||
subSettings={subSettings}
|
||||
onOpenChange={setQrOpen}
|
||||
/>
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function SubLinksModal({
|
||||
key: 'actions',
|
||||
width: 64,
|
||||
render: (_v, row) => (
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copy(row.link, t('copied'))} />
|
||||
<Button size="small" type="text" aria-label={t('copy')} icon={<CopyOutlined />} onClick={() => copy(row.link, t('copied'))} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
44
frontend/src/pages/clients/wireguardConfig.ts
Normal file
44
frontend/src/pages/clients/wireguardConfig.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { preferPublicHost } from '@/lib/xray/inbound-link';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
|
||||
export function isWireguardClient(client: ClientRecord | null | undefined): boolean {
|
||||
if (!client) return false;
|
||||
return !!(client.privateKey || client.publicKey || client.allowedIPs || client.preSharedKey || client.keepAlive);
|
||||
}
|
||||
|
||||
export function findWireguardInbound(
|
||||
client: ClientRecord | null | undefined,
|
||||
inboundsById: Record<number, InboundOption>,
|
||||
): InboundOption | undefined {
|
||||
return (client?.inboundIds || [])
|
||||
.map((id) => inboundsById[id])
|
||||
.find((ib) => ib?.protocol === 'wireguard');
|
||||
}
|
||||
|
||||
export function buildWireguardClientConfig(
|
||||
client: ClientRecord,
|
||||
inbound: InboundOption | undefined,
|
||||
host = window.location.hostname,
|
||||
publicHost = '',
|
||||
): string {
|
||||
const endpointHost = preferPublicHost(host, publicHost);
|
||||
const address = client.allowedIPs || '10.0.0.2/32';
|
||||
const endpoint = `${endpointHost}:${inbound?.port || ''}`;
|
||||
const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
|
||||
const remark = [inboundName, client.email, client.comment].filter(Boolean).join(' - ');
|
||||
const lines = [
|
||||
'[Interface]',
|
||||
`PrivateKey = ${client.privateKey || client.password || ''}`,
|
||||
`Address = ${address}`,
|
||||
`DNS = ${inbound?.wgDns || '1.1.1.1, 1.0.0.1'}`,
|
||||
];
|
||||
if (inbound?.wgMtu && inbound.wgMtu > 0) lines.push(`MTU = ${inbound.wgMtu}`);
|
||||
lines.push('');
|
||||
if (remark) lines.push(`# ${remark}`);
|
||||
lines.push('[Peer]', `PublicKey = ${inbound?.wgPublicKey || ''}`);
|
||||
if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
|
||||
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
|
||||
if (client.keepAlive && client.keepAlive > 0) lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export default function GroupAddClientsModal({
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{rows.length === 0 ? (
|
||||
<Alert type="info" showIcon message={t('pages.groups.addToGroupEmpty')} />
|
||||
<Alert type="info" showIcon title={t('pages.groups.addToGroupEmpty')} />
|
||||
) : (
|
||||
<Table<ClientRow>
|
||||
size="small"
|
||||
|
||||
@@ -126,9 +126,9 @@ export default function GroupsPage() {
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
});
|
||||
|
||||
const bulkResetMut = useMutation({
|
||||
mutationFn: (body: { emails: string[] }) =>
|
||||
HttpUtil.post('/panel/api/clients/bulkResetTraffic', body, JSON_HEADERS),
|
||||
const groupResetMut = useMutation({
|
||||
mutationFn: (body: { name: string }) =>
|
||||
HttpUtil.post('/panel/api/clients/groups/resetTraffic', body, JSON_HEADERS),
|
||||
onSuccess: (msg) => { if (msg?.success) invalidate(); },
|
||||
});
|
||||
|
||||
@@ -321,17 +321,14 @@ export default function GroupsPage() {
|
||||
}
|
||||
modal.confirm({
|
||||
title: t('pages.groups.resetConfirmTitle', { name: g.name }),
|
||||
content: t('pages.groups.resetConfirmContent', { count: g.clientCount }),
|
||||
content: t('pages.groups.resetConfirmContent'),
|
||||
okText: t('reset'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const emails = await fetchEmailsForGroup(g.name);
|
||||
if (emails.length === 0) return;
|
||||
const msg = await bulkResetMut.mutateAsync({ emails });
|
||||
const msg = await groupResetMut.mutateAsync({ name: g.name });
|
||||
if (msg?.success) {
|
||||
const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
|
||||
messageApi.success(t('pages.groups.resetSuccess', { count: affected }));
|
||||
messageApi.success(t('pages.groups.resetSuccess', { name: g.name }));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -407,10 +404,10 @@ export default function GroupsPage() {
|
||||
render: (_v, row) => (
|
||||
<Space size={4}>
|
||||
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
|
||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
|
||||
<Button aria-label={t('more')} size="small" type="text" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
|
||||
</Dropdown>
|
||||
<Tooltip title={t('pages.groups.rename')}>
|
||||
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
|
||||
<Button aria-label={t('pages.groups.rename')} size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
@@ -525,7 +522,7 @@ export default function GroupsPage() {
|
||||
hoverable
|
||||
title={
|
||||
<div className="card-toolbar">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
<Button aria-label={t('pages.groups.addGroup')} type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{!isMobile && t('pages.groups.addGroup')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -260,7 +260,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
|
||||
<Input placeholder="53,443,1000-2000" />
|
||||
<Input placeholder="443" />
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
|
||||
<Select
|
||||
|
||||
@@ -84,16 +84,16 @@ export default function HostList(props: HostListProps) {
|
||||
return (
|
||||
<Space size={2}>
|
||||
<Tooltip title={t('pages.hosts.moveUp')}>
|
||||
<Button size="small" type="text" icon={<ArrowUpOutlined />} disabled={idx === 0} onClick={() => onMove(h, 'up')} />
|
||||
<Button size="small" type="text" icon={<ArrowUpOutlined />} aria-label={t('pages.hosts.moveUp')} disabled={idx === 0} onClick={() => onMove(h, 'up')} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('pages.hosts.moveDown')}>
|
||||
<Button size="small" type="text" icon={<ArrowDownOutlined />} disabled={idx >= count - 1} onClick={() => onMove(h, 'down')} />
|
||||
<Button size="small" type="text" icon={<ArrowDownOutlined />} aria-label={t('pages.hosts.moveDown')} disabled={idx >= count - 1} onClick={() => onMove(h, 'down')} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('edit')}>
|
||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(h)} />
|
||||
<Button size="small" type="text" icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(h)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(h)} />
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(h)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
|
||||
@@ -292,21 +292,10 @@ export default function InboundsPage() {
|
||||
}, [subSettings, openText, t]);
|
||||
|
||||
const exportAllLinks = useCallback(async () => {
|
||||
const hydrated = await Promise.all(
|
||||
dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
|
||||
);
|
||||
const out: string[] = [];
|
||||
for (const ib of hydrated) {
|
||||
const projected = checkFallback(ib);
|
||||
out.push(genInboundLinks({
|
||||
inbound: inboundFromDb(projected),
|
||||
remark: projected.remark,
|
||||
hostOverride: hostOverrideFor(ib),
|
||||
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
|
||||
}));
|
||||
}
|
||||
openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') });
|
||||
}, [dbInbounds, hydrateInbound, checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/allLinks');
|
||||
const links = msg?.success && Array.isArray(msg.obj) ? (msg.obj as string[]) : [];
|
||||
openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: links.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') });
|
||||
}, [openText, t]);
|
||||
|
||||
const exportAllSubs = useCallback(async () => {
|
||||
const hydrated = await Promise.all(
|
||||
|
||||
@@ -164,6 +164,7 @@ export default function AttachClientsModal({
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Input.Search
|
||||
allowClear
|
||||
aria-label={t('search')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
|
||||
@@ -192,10 +193,11 @@ export default function AttachClientsModal({
|
||||
</Space>
|
||||
|
||||
{targetOptions.length === 0 ? (
|
||||
<Alert type="info" showIcon message={t('pages.inbounds.attachClientsNoTargets')} />
|
||||
<Alert type="info" showIcon title={t('pages.inbounds.attachClientsNoTargets')} />
|
||||
) : (
|
||||
<Select
|
||||
mode="multiple"
|
||||
aria-label={t('pages.inbounds.attachClientsTargets')}
|
||||
style={{ width: '100%' }}
|
||||
value={targetIds}
|
||||
onChange={setTargetIds}
|
||||
|
||||
@@ -180,7 +180,7 @@ export default function AttachExistingClientsModal({
|
||||
</Typography.Paragraph>
|
||||
|
||||
{noClients ? (
|
||||
<Alert type="info" showIcon message={t('pages.inbounds.attachExistingNoClients')} />
|
||||
<Alert type="info" showIcon title={t('pages.inbounds.attachExistingNoClients')} />
|
||||
) : (
|
||||
<Spin spinning={loading}>
|
||||
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
|
||||
@@ -188,6 +188,7 @@ export default function AttachExistingClientsModal({
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
allowClear
|
||||
aria-label={t('search')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
|
||||
@@ -196,6 +197,7 @@ export default function AttachExistingClientsModal({
|
||||
{groupOptions.length > 0 && (
|
||||
<Select
|
||||
allowClear
|
||||
aria-label={t('pages.clients.group')}
|
||||
value={groupFilter}
|
||||
onChange={(v) => setGroupFilter(v)}
|
||||
options={groupOptions}
|
||||
|
||||
@@ -152,6 +152,7 @@ export default function DetachClientsModal({
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Input.Search
|
||||
allowClear
|
||||
aria-label={t('search')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
|
||||
|
||||
@@ -66,6 +66,7 @@ export default function FallbacksCard({
|
||||
>
|
||||
<Space.Compact block style={{ marginBottom: 8 }}>
|
||||
<Select
|
||||
aria-label={t('pages.inbounds.fallbacks.pickInbound')}
|
||||
value={record.childId}
|
||||
options={fallbackChildOptions}
|
||||
placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'}
|
||||
@@ -78,23 +79,25 @@ export default function FallbacksCard({
|
||||
onChange={(v) => updateFallback(record.rowKey, { childId: v ?? null })}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t('pages.inbounds.form.moveUp')}
|
||||
disabled={idx === 0}
|
||||
onClick={() => moveFallback(idx, -1)}
|
||||
title={t('pages.inbounds.form.moveUp')}
|
||||
icon={<ArrowUpOutlined />}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t('pages.inbounds.form.moveDown')}
|
||||
disabled={idx === fallbacks.length - 1}
|
||||
onClick={() => moveFallback(idx, 1)}
|
||||
title={t('pages.inbounds.form.moveDown')}
|
||||
icon={<ArrowDownOutlined />}
|
||||
/>
|
||||
<Button danger onClick={() => removeFallback(idx)} icon={<DeleteOutlined />} />
|
||||
<Button aria-label={t('delete')} danger onClick={() => removeFallback(idx)} icon={<DeleteOutlined />} />
|
||||
</Space.Compact>
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
addonBefore="SNI"
|
||||
prefix="SNI"
|
||||
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
|
||||
value={record.name}
|
||||
onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })}
|
||||
@@ -102,7 +105,7 @@ export default function FallbacksCard({
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
addonBefore="ALPN"
|
||||
prefix="ALPN"
|
||||
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
|
||||
value={record.alpn}
|
||||
onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })}
|
||||
@@ -110,7 +113,7 @@ export default function FallbacksCard({
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
addonBefore="Path"
|
||||
prefix="Path"
|
||||
placeholder="/"
|
||||
value={record.path}
|
||||
onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })}
|
||||
@@ -118,7 +121,7 @@ export default function FallbacksCard({
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
addonBefore="Dest"
|
||||
prefix="Dest"
|
||||
placeholder={t('pages.inbounds.fallbacks.destPlaceholder') || 'auto'}
|
||||
value={record.dest}
|
||||
onChange={(e) => updateFallback(record.rowKey, { dest: e.target.value })}
|
||||
@@ -126,7 +129,7 @@ export default function FallbacksCard({
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<InputNumber
|
||||
addonBefore="xver"
|
||||
prefix="xver"
|
||||
min={0}
|
||||
max={2}
|
||||
style={{ width: '100%' }}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'antd';
|
||||
|
||||
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
|
||||
import type { RealityScanResult } from '@/generated/types';
|
||||
import {
|
||||
rawInboundToFormValues,
|
||||
formValuesToWirePayload,
|
||||
@@ -174,6 +175,8 @@ export default function InboundFormModal({
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [form] = Form.useForm<InboundFormValues>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<RealityScanResult | null>(null);
|
||||
const {
|
||||
fallbacks,
|
||||
fallbackChildOptions,
|
||||
@@ -241,7 +244,9 @@ export default function InboundFormModal({
|
||||
clearRealityKeypair,
|
||||
genMldsa65,
|
||||
clearMldsa65,
|
||||
randomizeRealityTarget,
|
||||
scanRealityTarget,
|
||||
scanRealityCandidates,
|
||||
applyRealityScanResult,
|
||||
randomizeShortIds,
|
||||
getNewEchCert,
|
||||
clearEchCert,
|
||||
@@ -250,7 +255,7 @@ export default function InboundFormModal({
|
||||
setCertFromPanel,
|
||||
clearCertFiles,
|
||||
onSecurityChange,
|
||||
} = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null });
|
||||
} = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning });
|
||||
|
||||
|
||||
const toggleSockopt = (on: boolean) => {
|
||||
@@ -273,12 +278,6 @@ export default function InboundFormModal({
|
||||
form.setFieldValue(['settings', 'secretKey'], kp.privateKey);
|
||||
};
|
||||
|
||||
const regenWgPeerKeypair = (peerName: number) => {
|
||||
const kp = Wireguard.generateKeypair();
|
||||
form.setFieldValue(['settings', 'peers', peerName, 'privateKey'], kp.privateKey);
|
||||
form.setFieldValue(['settings', 'peers', peerName, 'publicKey'], kp.publicKey);
|
||||
};
|
||||
|
||||
const matchesVlessAuth = (
|
||||
block: { id?: string; label?: string } | undefined | null,
|
||||
authId: string,
|
||||
@@ -347,6 +346,7 @@ export default function InboundFormModal({
|
||||
: buildAddModeValues();
|
||||
form.resetFields();
|
||||
form.setFieldsValue(initial);
|
||||
setScanResult(null);
|
||||
const initialTag = (initial.tag ?? '') as string;
|
||||
autoTagRef.current = isAutoInboundTag(initialTag, {
|
||||
port: initial.port ?? 0,
|
||||
@@ -689,7 +689,7 @@ export default function InboundFormModal({
|
||||
|
||||
const protocolTab = (
|
||||
<>
|
||||
{protocol === Protocols.WIREGUARD && <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} regenWgPeerKeypair={regenWgPeerKeypair} />}
|
||||
{protocol === Protocols.WIREGUARD && <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} />}
|
||||
|
||||
{protocol === Protocols.TUN && <TunFields />}
|
||||
|
||||
@@ -890,7 +890,11 @@ export default function InboundFormModal({
|
||||
{security === 'reality' && (
|
||||
<RealityForm
|
||||
saving={saving}
|
||||
randomizeRealityTarget={randomizeRealityTarget}
|
||||
scanning={scanning}
|
||||
scanResult={scanResult}
|
||||
scanRealityTarget={scanRealityTarget}
|
||||
scanRealityCandidates={scanRealityCandidates}
|
||||
applyRealityScanResult={applyRealityScanResult}
|
||||
randomizeShortIds={randomizeShortIds}
|
||||
genRealityKeypair={genRealityKeypair}
|
||||
clearRealityKeypair={clearRealityKeypair}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function AccountsList() {
|
||||
<Form.Item name={[field.name, 'pass']} noStyle>
|
||||
<Input placeholder={t('password')} />
|
||||
</Form.Item>
|
||||
<Button onClick={() => remove(field.name)}>
|
||||
<Button aria-label={t('remove')} onClick={() => remove(field.name)}>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function MtprotoFields() {
|
||||
<Input readOnly style={{ width: 'calc(100% - 32px)' }} />
|
||||
</Form.Item>
|
||||
<Button
|
||||
aria-label={t('regenerate')}
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
const domain = form.getFieldValue(['settings', 'fakeTlsDomain']);
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFie
|
||||
<Input style={{ width: 'calc(100% - 32px)' }} />
|
||||
</Form.Item>
|
||||
<Button
|
||||
aria-label={t('regenerate')}
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
const method = form.getFieldValue(['settings', 'method']);
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function TunFields() {
|
||||
<Form.List name={['settings', 'gateway']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<Form.Item label={t('pages.inbounds.info.gateway')}>
|
||||
<Button size="small" onClick={() => add('')}>
|
||||
<Button aria-label={t('add')} size="small" onClick={() => add('')}>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
{fields.map((field, j) => (
|
||||
@@ -23,7 +23,7 @@ export default function TunFields() {
|
||||
<Form.Item name={field.name} noStyle>
|
||||
<Input placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'} />
|
||||
</Form.Item>
|
||||
<Button size="small" onClick={() => remove(field.name)}>
|
||||
<Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
@@ -34,7 +34,7 @@ export default function TunFields() {
|
||||
<Form.List name={['settings', 'dns']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<Form.Item label="DNS">
|
||||
<Button size="small" onClick={() => add('')}>
|
||||
<Button aria-label={t('add')} size="small" onClick={() => add('')}>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
{fields.map((field, j) => (
|
||||
@@ -42,7 +42,7 @@ export default function TunFields() {
|
||||
<Form.Item name={field.name} noStyle>
|
||||
<Input placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'} />
|
||||
</Form.Item>
|
||||
<Button size="small" onClick={() => remove(field.name)}>
|
||||
<Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
@@ -62,7 +62,7 @@ export default function TunFields() {
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button size="small" onClick={() => add('')}>
|
||||
<Button aria-label={t('add')} size="small" onClick={() => add('')}>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
{fields.map((field, j) => (
|
||||
@@ -70,7 +70,7 @@ export default function TunFields() {
|
||||
<Form.Item name={field.name} noStyle>
|
||||
<Input placeholder={j === 0 ? '0.0.0.0/0' : '::/0'} />
|
||||
</Form.Item>
|
||||
<Button size="small" onClick={() => remove(field.name)}>
|
||||
<Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
@@ -1,44 +1,14 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { Wireguard } from '@/utils';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
interface WireguardFieldsProps {
|
||||
wgPubKey: string;
|
||||
regenInboundWg: () => void;
|
||||
regenWgPeerKeypair: (name: number) => void;
|
||||
}
|
||||
|
||||
function nextWgPeerAllowedIP(peers: Array<{ allowedIPs?: string[] }> | undefined): string {
|
||||
const fallback = '10.0.0.2/32';
|
||||
let maxInt = -1;
|
||||
let prefix = 32;
|
||||
for (const peer of peers ?? []) {
|
||||
for (const ip of peer?.allowedIPs ?? []) {
|
||||
const m = /^\s*(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?\s*$/.exec(String(ip));
|
||||
if (!m) continue;
|
||||
const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
||||
if (octets.some((o) => o > 255)) continue;
|
||||
const asInt = octets[0] * 16777216 + octets[1] * 65536 + octets[2] * 256 + octets[3];
|
||||
if (asInt > maxInt) {
|
||||
maxInt = asInt;
|
||||
prefix = m[5] !== undefined ? Math.min(Number(m[5]), 32) : 32;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxInt < 0) return fallback;
|
||||
const next = maxInt + 1;
|
||||
const a = Math.floor(next / 16777216) % 256;
|
||||
const b = Math.floor(next / 65536) % 256;
|
||||
const c = Math.floor(next / 256) % 256;
|
||||
const d = next % 256;
|
||||
return `${a}.${b}.${c}.${d}/${prefix}`;
|
||||
}
|
||||
|
||||
export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerKeypair }: WireguardFieldsProps) {
|
||||
export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const form = Form.useFormInstance();
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('pages.xray.wireguard.secretKey')}>
|
||||
@@ -46,7 +16,7 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
|
||||
<Form.Item name={['settings', 'secretKey']} noStyle>
|
||||
<Input style={{ width: 'calc(100% - 32px)' }} />
|
||||
</Form.Item>
|
||||
<Button icon={<ReloadOutlined />} onClick={regenInboundWg} />
|
||||
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenInboundWg} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.wireguard.publicKey')}>
|
||||
@@ -55,6 +25,9 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
|
||||
<Form.Item name={['settings', 'mtu']} label="MTU">
|
||||
<InputNumber />
|
||||
</Form.Item>
|
||||
<Form.Item name={['settings', 'dns']} label={t('pages.inbounds.info.dns')}>
|
||||
<Input placeholder="1.1.1.1, 1.0.0.1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['settings', 'noKernelTun']}
|
||||
label={t('pages.inbounds.info.noKernelTun')}
|
||||
@@ -74,96 +47,6 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.List name={['settings', 'peers']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<Form.Item label={t('pages.inbounds.form.peers')}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const kp = Wireguard.generateKeypair();
|
||||
const peers = form.getFieldValue(['settings', 'peers']) as Array<{ allowedIPs?: string[] }> | undefined;
|
||||
add({
|
||||
privateKey: kp.privateKey,
|
||||
publicKey: kp.publicKey,
|
||||
allowedIPs: [nextWgPeerAllowedIP(peers)],
|
||||
keepAlive: 0,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<PlusOutlined /> {t('pages.inbounds.form.addPeer')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
{fields.map((field, idx) => (
|
||||
<div key={field.key} className="wg-peer">
|
||||
<Divider titlePlacement="center">
|
||||
<Space>
|
||||
<span>{t('pages.inbounds.info.peerNumber', { n: idx + 1 })}</span>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{() => {
|
||||
const comment = form.getFieldValue(['settings', 'peers', field.name, 'comment']) as string | undefined;
|
||||
return comment ? <span style={{ opacity: 0.65 }}>— {comment}</span> : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() => remove(field.name)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Divider>
|
||||
<Form.Item name={[field.name, 'comment']} label={t('comment')}>
|
||||
<Input placeholder="e.g. Alice's laptop" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.wireguard.secretKey')}>
|
||||
<Space.Compact block>
|
||||
<Form.Item name={[field.name, 'privateKey']} noStyle>
|
||||
<Input style={{ width: 'calc(100% - 32px)' }} />
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => regenWgPeerKeypair(field.name)}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'publicKey']} label={t('pages.xray.wireguard.publicKey')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'preSharedKey']} label="PSK">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.List name={[field.name, 'allowedIPs']}>
|
||||
{(ipFields, { add: addIp, remove: removeIp }) => (
|
||||
<Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
|
||||
<Button size="small" onClick={() => addIp('')}>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
{ipFields.map((ipField) => (
|
||||
<Space.Compact key={ipField.key} block className="mt-4">
|
||||
<Form.Item name={ipField.name} noStyle>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
{ipFields.length > 1 && (
|
||||
<Button size="small" onClick={() => removeIp(ipField.name)}>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
)}
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item name={[field.name, 'keepAlive']} label={t('pages.inbounds.form.keepAlive')}>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input, Modal, Space, Table, Tag, Tooltip, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import type { RealityScanResult } from '@/generated/types';
|
||||
|
||||
interface RealityTargetScannerModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
|
||||
onPick: (result: RealityScanResult) => void;
|
||||
}
|
||||
|
||||
export default function RealityTargetScannerModal({
|
||||
open,
|
||||
onClose,
|
||||
scanRealityCandidates,
|
||||
onPick,
|
||||
}: RealityTargetScannerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<RealityScanResult[]>([]);
|
||||
const scanRef = useRef(scanRealityCandidates);
|
||||
scanRef.current = scanRealityCandidates;
|
||||
|
||||
const runScan = useCallback(async (targets?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setResults(await scanRef.current(targets));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setResults([]);
|
||||
runScan();
|
||||
}, [open, runScan]);
|
||||
|
||||
const columns: ColumnsType<RealityScanResult> = [
|
||||
{
|
||||
title: t('pages.inbounds.form.target'),
|
||||
dataIndex: 'target',
|
||||
key: 'target',
|
||||
width: 200,
|
||||
render: (target: string, row) => (
|
||||
<Tooltip title={row.ip ? `${target} — ${row.ip}` : target}>
|
||||
<div style={{ lineHeight: 1.25 }}>
|
||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{target}</div>
|
||||
{row.ip ? <div style={{ color: '#999', fontSize: 12 }}>{row.ip}</div> : null}
|
||||
</div>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.form.scanStatus'),
|
||||
dataIndex: 'feasible',
|
||||
key: 'feasible',
|
||||
width: 95,
|
||||
render: (feasible: boolean, row) =>
|
||||
feasible ? (
|
||||
<Tag color="success">{t('pages.inbounds.form.scanFeasible')}</Tag>
|
||||
) : (
|
||||
<Tooltip title={row.reason}>
|
||||
<Tag color="warning">{t('pages.inbounds.form.scanNotFeasible')}</Tag>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'TLS',
|
||||
dataIndex: 'tlsVersion',
|
||||
key: 'tlsVersion',
|
||||
width: 60,
|
||||
render: (v: string) => v || '—',
|
||||
},
|
||||
{
|
||||
title: 'ALPN',
|
||||
dataIndex: 'alpn',
|
||||
key: 'alpn',
|
||||
width: 75,
|
||||
render: (v: string) => v || '—',
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.form.scanCurve'),
|
||||
dataIndex: 'curveID',
|
||||
key: 'curveID',
|
||||
width: 130,
|
||||
render: (v: string) => v || '—',
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.form.scanCert'),
|
||||
dataIndex: 'certSubject',
|
||||
key: 'certSubject',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (_: string, row) =>
|
||||
row.certValid ? (
|
||||
<Tooltip title={`${row.certSubject} (${row.certIssuer})`}>
|
||||
<span>{row.certSubject || '—'}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tag>{t('pages.inbounds.form.scanCertInvalid')}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.form.scanLatency'),
|
||||
dataIndex: 'latencyMs',
|
||||
key: 'latencyMs',
|
||||
width: 85,
|
||||
render: (v: number) => (v > 0 ? `${v} ms` : '—'),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'action',
|
||||
width: 64,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onPick(row);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t('pages.inbounds.form.scanUse')}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
<Button key="rescan" onClick={() => runScan(query.trim() || undefined)} loading={loading}>
|
||||
{t('pages.inbounds.form.scanRescan')}
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={onClose}>
|
||||
{t('close')}
|
||||
</Button>,
|
||||
]}
|
||||
title={t('pages.inbounds.form.scanModalTitle')}
|
||||
width={960}
|
||||
>
|
||||
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{t('pages.inbounds.form.scanModalDesc')}
|
||||
</Typography.Paragraph>
|
||||
<Input.Search
|
||||
allowClear
|
||||
enterButton={t('pages.inbounds.form.scan')}
|
||||
loading={loading}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onSearch={() => runScan(query.trim() || undefined)}
|
||||
placeholder={t('pages.inbounds.form.scanDiscoverPlaceholder')}
|
||||
/>
|
||||
<Table<RealityScanResult>
|
||||
size="small"
|
||||
rowKey="target"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={results}
|
||||
pagination={false}
|
||||
scroll={{ y: 360 }}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Collapse, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
|
||||
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
|
||||
import type { RealityScanResult } from '@/generated/types';
|
||||
import RealityTargetScannerModal from './RealityTargetScannerModal';
|
||||
|
||||
interface RealityFormProps {
|
||||
saving: boolean;
|
||||
randomizeRealityTarget: () => void;
|
||||
scanning: boolean;
|
||||
scanResult: RealityScanResult | null;
|
||||
scanRealityTarget: () => void;
|
||||
scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
|
||||
applyRealityScanResult: (result: RealityScanResult) => void;
|
||||
randomizeShortIds: () => void;
|
||||
genRealityKeypair: () => void;
|
||||
clearRealityKeypair: () => void;
|
||||
@@ -17,7 +24,11 @@ interface RealityFormProps {
|
||||
|
||||
export default function RealityForm({
|
||||
saving,
|
||||
randomizeRealityTarget,
|
||||
scanning,
|
||||
scanResult,
|
||||
scanRealityTarget,
|
||||
scanRealityCandidates,
|
||||
applyRealityScanResult,
|
||||
randomizeShortIds,
|
||||
genRealityKeypair,
|
||||
clearRealityKeypair,
|
||||
@@ -25,6 +36,7 @@ export default function RealityForm({
|
||||
clearMldsa65,
|
||||
}: RealityFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
@@ -49,7 +61,7 @@ export default function RealityForm({
|
||||
label={t('pages.inbounds.form.target')}
|
||||
tooltip={t('pages.inbounds.form.realityTargetHint')}
|
||||
>
|
||||
<Space.Compact block>
|
||||
<Space.Compact block style={{ display: 'flex' }}>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'realitySettings', 'target']}
|
||||
noStyle
|
||||
@@ -62,21 +74,48 @@ export default function RealityForm({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input style={{ width: 'calc(100% - 32px)' }} placeholder="example.com:443" />
|
||||
<Input style={{ flex: 1 }} placeholder="example.com:443" />
|
||||
</Form.Item>
|
||||
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
|
||||
<Button icon={<RadarChartOutlined />} loading={scanning} onClick={scanRealityTarget}>
|
||||
{t('pages.inbounds.form.scan')}
|
||||
</Button>
|
||||
<Button icon={<SearchOutlined />} onClick={() => setScannerOpen(true)}>
|
||||
{t('pages.inbounds.form.findTargets')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="SNI">
|
||||
<Space.Compact block style={{ display: 'flex' }}>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'realitySettings', 'serverNames']}
|
||||
noStyle
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
|
||||
</Space.Compact>
|
||||
{scanResult && (
|
||||
<Form.Item label=" " colon={false}>
|
||||
<Alert
|
||||
type={scanResult.feasible ? 'success' : 'warning'}
|
||||
showIcon
|
||||
title={
|
||||
scanResult.feasible
|
||||
? t('pages.inbounds.form.scanFeasible')
|
||||
: scanResult.reason || t('pages.inbounds.form.scanNotFeasible')
|
||||
}
|
||||
description={
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="TLS">{scanResult.tlsVersion || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="ALPN">{scanResult.alpn || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('pages.inbounds.form.scanCurve')}>
|
||||
{scanResult.curveID || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('pages.inbounds.form.scanCert')}>
|
||||
{scanResult.certValid
|
||||
? `${scanResult.certSubject} (${scanResult.certIssuer})`
|
||||
: t('pages.inbounds.form.scanCertInvalid')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('pages.inbounds.form.scanLatency')}>
|
||||
{scanResult.latencyMs > 0 ? `${scanResult.latencyMs} ms` : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}>
|
||||
<Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['streamSettings', 'realitySettings', 'maxTimediff']}
|
||||
@@ -104,7 +143,7 @@ export default function RealityForm({
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
<Button icon={<ReloadOutlined />} onClick={randomizeShortIds} />
|
||||
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeShortIds} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
@@ -201,6 +240,12 @@ export default function RealityForm({
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<RealityTargetScannerModal
|
||||
open={scannerOpen}
|
||||
onClose={() => setScannerOpen(false)}
|
||||
scanRealityCandidates={scanRealityCandidates}
|
||||
onPick={applyRealityScanResult}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ export default function TlsForm({
|
||||
<>
|
||||
<Form.Item label={t('certificate')}>
|
||||
<Button
|
||||
aria-label={t('add')}
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => add({
|
||||
@@ -242,7 +243,7 @@ export default function TlsForm({
|
||||
name={[certField.name, 'ocspStapling']}
|
||||
label="OCSP Stapling"
|
||||
>
|
||||
<InputNumber min={0} addonAfter="s" style={{ width: '50%' }} />
|
||||
<InputNumber min={0} suffix="s" style={{ width: '50%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={[certField.name, 'oneTimeLoading']}
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function SockoptForm({
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Sockopt">
|
||||
<Switch checked={on} onChange={toggleSockopt} />
|
||||
<Switch checked={on} onChange={toggleSockopt} aria-label="Sockopt" />
|
||||
</Form.Item>
|
||||
{on && (
|
||||
<>
|
||||
|
||||
@@ -4,10 +4,10 @@ import type { FormInstance } from 'antd';
|
||||
import type { MessageInstance } from 'antd/es/message/interface';
|
||||
|
||||
import { HttpUtil, RandomUtil } from '@/utils';
|
||||
import { getRandomRealityTarget } from '@/models/reality-targets';
|
||||
import { createTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
|
||||
import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
|
||||
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
|
||||
import type { RealityScanResult } from '@/generated/types';
|
||||
|
||||
interface UseSecurityActionsArgs {
|
||||
form: FormInstance<InboundFormValues>;
|
||||
@@ -17,13 +17,15 @@ interface UseSecurityActionsArgs {
|
||||
// Panel" must read the node's own cert paths for a node-assigned inbound —
|
||||
// the central panel's paths don't exist on the node. See issue #4854.
|
||||
nodeId: number | null;
|
||||
setScanResult: Dispatch<SetStateAction<RealityScanResult | null>>;
|
||||
setScanning: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
// Server-side TLS / Reality key + certificate generation handlers for the
|
||||
// inbound modal's security tab. Each talks to a /panel server endpoint and
|
||||
// writes the result back into the form. Lifted out of InboundFormModal so
|
||||
// the modal body stays focused on orchestration.
|
||||
export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseSecurityActionsArgs) {
|
||||
export function useSecurityActions({ form, setSaving, messageApi, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const genRealityKeypair = async () => {
|
||||
@@ -64,13 +66,55 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], '');
|
||||
};
|
||||
|
||||
const randomizeRealityTarget = () => {
|
||||
const tgt = getRandomRealityTarget() as { target: string; sni: string };
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'target'], tgt.target);
|
||||
form.setFieldValue(
|
||||
['streamSettings', 'realitySettings', 'serverNames'],
|
||||
tgt.sni.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
const applyRealityScanResult = (r: RealityScanResult) => {
|
||||
setScanResult(r);
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'target'], r.target);
|
||||
if (r.serverNames?.length) {
|
||||
form.setFieldValue(['streamSettings', 'realitySettings', 'serverNames'], r.serverNames);
|
||||
}
|
||||
};
|
||||
|
||||
const scanRealityTarget = async () => {
|
||||
const target = ((form.getFieldValue(['streamSettings', 'realitySettings', 'target']) as string | undefined) ?? '').trim();
|
||||
if (!target) {
|
||||
messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
|
||||
return;
|
||||
}
|
||||
setScanning(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<RealityScanResult>(
|
||||
'/panel/api/server/scanRealityTarget',
|
||||
{ target },
|
||||
{ silent: true },
|
||||
);
|
||||
if (!msg?.success || !msg.obj) {
|
||||
setScanResult(null);
|
||||
messageApi.error(msg?.msg || t('pages.inbounds.toasts.scanRealityTargetError'));
|
||||
return;
|
||||
}
|
||||
const r = msg.obj;
|
||||
applyRealityScanResult(r);
|
||||
if (r.feasible) {
|
||||
messageApi.success(t('pages.inbounds.toasts.scanRealityTargetFeasible'));
|
||||
} else {
|
||||
messageApi.warning(r.reason || t('pages.inbounds.toasts.scanRealityTargetNotFeasible'));
|
||||
}
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scanRealityCandidates = async (targets?: string): Promise<RealityScanResult[]> => {
|
||||
const msg = await HttpUtil.post<RealityScanResult[]>(
|
||||
'/panel/api/server/scanRealityTargets',
|
||||
targets ? { targets } : {},
|
||||
{ silent: true },
|
||||
);
|
||||
if (!msg?.success || !Array.isArray(msg.obj)) {
|
||||
messageApi.error(msg?.msg || t('pages.inbounds.toasts.scanRealityTargetError'));
|
||||
return [];
|
||||
}
|
||||
return msg.obj;
|
||||
};
|
||||
|
||||
const randomizeShortIds = () => {
|
||||
@@ -209,6 +253,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
|
||||
};
|
||||
|
||||
const onSecurityChange = async (next: string) => {
|
||||
setScanResult(null);
|
||||
const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
|
||||
const cleaned: Record<string, unknown> = { ...current, security: next };
|
||||
delete cleaned.tlsSettings;
|
||||
@@ -218,9 +263,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
|
||||
}
|
||||
if (next === 'reality') {
|
||||
const reality = RealityStreamSettingsSchema.parse({}) as Record<string, unknown>;
|
||||
const tgt = getRandomRealityTarget() as { target: string; sni: string };
|
||||
reality.target = tgt.target;
|
||||
reality.serverNames = tgt.sni.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
reality.target = '';
|
||||
reality.serverNames = [];
|
||||
reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean);
|
||||
cleaned.realitySettings = reality;
|
||||
}
|
||||
@@ -244,7 +288,9 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
|
||||
clearRealityKeypair,
|
||||
genMldsa65,
|
||||
clearMldsa65,
|
||||
randomizeRealityTarget,
|
||||
scanRealityTarget,
|
||||
scanRealityCandidates,
|
||||
applyRealityScanResult,
|
||||
randomizeShortIds,
|
||||
getNewEchCert,
|
||||
clearEchCert,
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip } from 'antd';
|
||||
import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, IntlUtil, SizeFormatter, ColorUtils } from '@/utils';
|
||||
import { HttpUtil, IntlUtil, SizeFormatter, ColorUtils, Wireguard } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { Protocols } from '@/schemas/primitives';
|
||||
import { InfinityIcon } from '@/components/ui';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
@@ -208,6 +209,11 @@ export default function InboundInfoModal({
|
||||
return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
|
||||
}, [clientStats, clientSettings]);
|
||||
|
||||
const wgPubKey = useMemo(() => {
|
||||
if (!dbInbound?.isWireguard || !inbound?.settings?.secretKey) return '';
|
||||
return Wireguard.generateKeypair(inbound.settings.secretKey as string).publicKey;
|
||||
}, [dbInbound?.isWireguard, inbound?.settings?.secretKey]);
|
||||
|
||||
const formatLastOnline = useCallback(
|
||||
(email: string) => {
|
||||
const ts = lastOnlineMap[email];
|
||||
@@ -331,9 +337,9 @@ export default function InboundInfoModal({
|
||||
)}
|
||||
</div>
|
||||
<div className="ip-log-actions">
|
||||
<SyncOutlined spin={refreshing} onClick={() => loadClientIps()} />
|
||||
<SyncOutlined spin={refreshing} role="button" tabIndex={0} aria-label={t('refresh')} onClick={() => loadClientIps()} onKeyDown={activateOnKey(() => loadClientIps())} />
|
||||
<Tooltip title={t('pages.inbounds.IPLimitlogclear')}>
|
||||
<DeleteOutlined onClick={() => clearClientIps()} />
|
||||
<DeleteOutlined role="button" tabIndex={0} aria-label={t('pages.inbounds.IPLimitlogclear')} onClick={() => clearClientIps()} onKeyDown={activateOnKey(() => clearClientIps())} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
@@ -389,7 +395,7 @@ export default function InboundInfoModal({
|
||||
<div className="tg-row">
|
||||
<Tag color="blue">{clientSettings.tgId}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(clientSettings.tgId, t)} />
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(clientSettings.tgId, t)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
@@ -403,7 +409,7 @@ export default function InboundInfoModal({
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">{link.remark || `Link ${idx + 1}`}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(link.link, t)} />
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(link.link, t)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<code className="link-panel-text">{link.link}</code>
|
||||
@@ -419,7 +425,7 @@ export default function InboundInfoModal({
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">{t('subscription.title')}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(subLink, t)} />
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(subLink, t)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<a href={subLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subLink}</a>
|
||||
@@ -429,7 +435,7 @@ export default function InboundInfoModal({
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">JSON</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(subJsonLink, t)} />
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(subJsonLink, t)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<a href={subJsonLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subJsonLink}</a>
|
||||
@@ -507,7 +513,7 @@ export default function InboundInfoModal({
|
||||
<dd className="value-block">
|
||||
<code className="value-code">{encryptionLabel}</code>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(encryptionLabel, t)} />
|
||||
<Button size="small" className="value-copy" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(encryptionLabel, t)} />
|
||||
</Tooltip>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -632,7 +638,7 @@ export default function InboundInfoModal({
|
||||
<dd className="value-block">
|
||||
<code className="value-code">{inbound.settings.secret as string}</code>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(inbound.settings.secret as string, t)} />
|
||||
<Button size="small" className="value-copy" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(inbound.settings.secret as string, t)} />
|
||||
</Tooltip>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -683,7 +689,7 @@ export default function InboundInfoModal({
|
||||
<dd className="value-block">
|
||||
<code className="value-code">{links[0].link}</code>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(links[0].link, t)} />
|
||||
<Button size="small" className="value-copy" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(links[0].link, t)} />
|
||||
</Tooltip>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -725,7 +731,7 @@ export default function InboundInfoModal({
|
||||
<span className="account-sep">:</span>
|
||||
<Tag className="value-tag">{account.pass}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
|
||||
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
|
||||
</Tooltip>
|
||||
<Space size={4} wrap className="share-buttons">
|
||||
<Tooltip title={`socks5://${account.user}:${account.pass}@${dbInbound.address}:${dbInbound.port}`}>
|
||||
@@ -774,7 +780,7 @@ export default function InboundInfoModal({
|
||||
<span className="account-sep">:</span>
|
||||
<Tag className="value-tag">{account.pass}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
|
||||
</Tooltip>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -791,7 +797,7 @@ export default function InboundInfoModal({
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>{t('pages.xray.wireguard.publicKey')}</dt>
|
||||
<dd><Tag className="value-tag">{inbound.settings.pubKey as string}</Tag></dd>
|
||||
<dd><Tag className="value-tag">{wgPubKey}</Tag></dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>{t('pages.inbounds.info.mtu')}</dt>
|
||||
@@ -840,10 +846,10 @@ export default function InboundInfoModal({
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">{t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(wireguardConfigs[idx], t)} />
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(wireguardConfigs[idx], t)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('download')}>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => downloadText(wireguardConfigs[idx], `peer-${idx + 1}.conf`)} />
|
||||
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} onClick={() => downloadText(wireguardConfigs[idx], `peer-${idx + 1}.conf`)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<code className="link-panel-text">{wireguardConfigs[idx]}</code>
|
||||
@@ -854,7 +860,7 @@ export default function InboundInfoModal({
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">Peer {idx + 1} link</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(wireguardLinks[idx], t)} />
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(wireguardLinks[idx], t)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<code className="link-panel-text">{wireguardLinks[idx]}</code>
|
||||
@@ -873,7 +879,7 @@ export default function InboundInfoModal({
|
||||
<div className="link-panel-header">
|
||||
<Tag color="green">{link.remark || `Link ${idx + 1}`}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(link.link, t)} />
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(link.link, t)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<code className="link-panel-text">{link.link}</code>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
|
||||
import { buildRowActionsMenu } from './RowActions';
|
||||
import { useInboundColumns } from './useInboundColumns';
|
||||
@@ -160,11 +161,11 @@ export default function InboundList({
|
||||
hoverable
|
||||
title={(
|
||||
<Space>
|
||||
<Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />}>
|
||||
<Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />} aria-label={t('pages.inbounds.addInbound')}>
|
||||
{!isMobile && t('pages.inbounds.addInbound')}
|
||||
</Button>
|
||||
<Dropdown trigger={['click']} menu={generalActionsMenu}>
|
||||
<Button type="primary" icon={<MenuOutlined />}>
|
||||
<Button type="primary" icon={<MenuOutlined />} aria-label={t('pages.inbounds.generalActions')}>
|
||||
{!isMobile && t('pages.inbounds.generalActions')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
@@ -175,6 +176,7 @@ export default function InboundList({
|
||||
options={nodeFilterOptions}
|
||||
popupMatchSelectWidth={false}
|
||||
style={{ minWidth: isMobile ? 90 : 140 }}
|
||||
aria-label={t('pages.clients.filters.nodes')}
|
||||
/>
|
||||
)}
|
||||
{selectedRowKeys.length > 0 && (
|
||||
@@ -182,7 +184,7 @@ export default function InboundList({
|
||||
<Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
|
||||
{t('pages.inbounds.selectedCount', { count: selectedRowKeys.length })}
|
||||
</Tag>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleBulkDelete}>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleBulkDelete} aria-label={t('delete')}>
|
||||
{!isMobile && t('delete')}
|
||||
</Button>
|
||||
</>
|
||||
@@ -221,9 +223,16 @@ export default function InboundList({
|
||||
/>
|
||||
<span className="card-id">#{record.id}</span>
|
||||
<span className="tag-name">{record.remark}</span>
|
||||
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="card-actions">
|
||||
<Tooltip title={t('pages.inbounds.inboundInfo')}>
|
||||
<InfoCircleOutlined className="row-action-trigger" onClick={() => setStatsRecord(record)} />
|
||||
<InfoCircleOutlined
|
||||
className="row-action-trigger"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.inbounds.inboundInfo')}
|
||||
onClick={() => setStatsRecord(record)}
|
||||
onKeyDown={activateOnKey(() => setStatsRecord(record))}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
checked={record.enable}
|
||||
@@ -238,7 +247,7 @@ export default function InboundList({
|
||||
onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }),
|
||||
}}
|
||||
>
|
||||
<MoreOutlined className="row-action-trigger" onClick={(e) => e.preventDefault()} />
|
||||
<Button type="text" size="small" className="row-action-trigger" icon={<MoreOutlined />} aria-label={t('more')} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients
|
||||
label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
} else if (!record.isWireguard) {
|
||||
items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('pages.inbounds.inboundInfo') });
|
||||
}
|
||||
items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
|
||||
@@ -69,7 +69,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="action-buttons">
|
||||
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
|
||||
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onClick('edit')} />
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
@@ -77,7 +77,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
|
||||
onClick: ({ key }) => onClick(key as RowAction),
|
||||
}}
|
||||
>
|
||||
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
|
||||
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<MoreOutlined />} aria-label={t('more')} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -81,7 +81,6 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
|
||||
}
|
||||
|
||||
export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
|
||||
if (dbInbound.isWireguard) return true;
|
||||
if (dbInbound.isSS) {
|
||||
return !isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(dbInbound.settings) });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||
import { InfinityIcon } from '@/components/ui';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import { coerceInboundJsonField } from '@/models/dbinbound';
|
||||
|
||||
import { RowActionsCell } from './RowActions';
|
||||
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
|
||||
@@ -51,6 +52,28 @@ export function useInboundColumns({
|
||||
const { datepicker } = useDatepicker();
|
||||
|
||||
return useMemo(() => {
|
||||
const fallbackClientCount = (record: DBInboundRecord): ClientCountEntry | null => {
|
||||
const settings = coerceInboundJsonField(record.settings) as {
|
||||
clients?: { email?: string; enable?: boolean }[];
|
||||
};
|
||||
const clients = Array.isArray(settings.clients) ? settings.clients : [];
|
||||
if (clients.length === 0) return null;
|
||||
const active = clients
|
||||
.filter((client) => client.email && client.enable !== false)
|
||||
.map((client) => client.email!);
|
||||
const deactive = clients
|
||||
.filter((client) => client.email && client.enable === false)
|
||||
.map((client) => client.email!);
|
||||
return {
|
||||
clients: clients.length,
|
||||
active,
|
||||
deactive,
|
||||
depleted: [],
|
||||
expiring: [],
|
||||
online: [],
|
||||
};
|
||||
};
|
||||
|
||||
const cols: TableColumnType<DBInboundRecord>[] = [
|
||||
{
|
||||
title: 'ID',
|
||||
@@ -174,14 +197,14 @@ export function useInboundColumns({
|
||||
align: 'left',
|
||||
width: 200,
|
||||
render: (_, record) => {
|
||||
const cc = clientCount[record.id];
|
||||
const cc = clientCount[record.id] || fallbackClientCount(record);
|
||||
if (!cc) return null;
|
||||
return (
|
||||
<>
|
||||
<Tag className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>
|
||||
<TeamOutlined /> {cc.clients}
|
||||
</Tag>
|
||||
{cc.active.length > 0 && (
|
||||
{cc.active.length > 0 ? (
|
||||
<Popover
|
||||
title={t('subscription.active')}
|
||||
content={(
|
||||
@@ -192,6 +215,8 @@ export function useInboundColumns({
|
||||
>
|
||||
<Tag color="green" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.active.length}</Tag>
|
||||
</Popover>
|
||||
) : (
|
||||
<Tag color="green" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>0</Tag>
|
||||
)}
|
||||
{cc.deactive.length > 0 && (
|
||||
<Popover
|
||||
|
||||
@@ -35,6 +35,7 @@ interface QrItem {
|
||||
header: string;
|
||||
value: string;
|
||||
downloadName?: string;
|
||||
showQr?: boolean;
|
||||
}
|
||||
|
||||
export default function QrCodeModal({
|
||||
@@ -122,7 +123,7 @@ export default function QrCodeModal({
|
||||
downloadName: `peer-${idx + 1}.conf`,
|
||||
});
|
||||
if (wireguardLinks[idx]) {
|
||||
items.push({ key: `wl${idx}`, header: `Peer ${idx + 1} link`, value: wireguardLinks[idx] });
|
||||
items.push({ key: `wl${idx}`, header: `Peer ${idx + 1} link`, value: wireguardLinks[idx], showQr: false });
|
||||
}
|
||||
});
|
||||
return items;
|
||||
@@ -137,7 +138,7 @@ export default function QrCodeModal({
|
||||
value={item.value}
|
||||
remark={item.header}
|
||||
downloadName={item.downloadName || ''}
|
||||
showQr={!isPostQuantumLink(item.value)}
|
||||
showQr={item.showQr !== false && !isPostQuantumLink(item.value)}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button, QRCode, Tag, Tooltip, message } from 'antd';
|
||||
import { CopyOutlined, DownloadOutlined, PictureOutlined } from '@ant-design/icons';
|
||||
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import './QrPanel.css';
|
||||
|
||||
interface QrPanelProps {
|
||||
@@ -96,21 +97,29 @@ export default function QrPanel({
|
||||
<div className="qr-panel-header">
|
||||
<Tag color="green" className="qr-remark">{remark}</Tag>
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={copy} />
|
||||
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={copy} />
|
||||
</Tooltip>
|
||||
{showQr && (
|
||||
<Tooltip title={t('downloadImage') !== 'downloadImage' ? t('downloadImage') : 'Download Image'}>
|
||||
<Button size="small" icon={<PictureOutlined />} onClick={downloadImage} />
|
||||
<Tooltip title={t('downloadImage')}>
|
||||
<Button size="small" icon={<PictureOutlined />} aria-label={t('downloadImage')} onClick={downloadImage} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{downloadName && (
|
||||
<Tooltip title={t('download')}>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={download} />
|
||||
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} onClick={download} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{showQr && (
|
||||
<div ref={qrRef} className="qr-panel-canvas">
|
||||
<div
|
||||
ref={qrRef}
|
||||
className="qr-panel-canvas"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('copy')}
|
||||
onClick={copyImage}
|
||||
onKeyDown={activateOnKey(copyImage)}
|
||||
>
|
||||
<Tooltip title={t('copy')}>
|
||||
<QRCode
|
||||
className="qr-code"
|
||||
@@ -120,7 +129,6 @@ export default function QrPanel({
|
||||
bordered={false}
|
||||
color="#000000"
|
||||
bgColor="#ffffff"
|
||||
onClick={copyImage}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
|
||||
{isPostgres ? t('pages.index.exportDatabasePgDesc') : t('pages.index.exportDatabaseDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} />
|
||||
<Button type="primary" aria-label={t('pages.index.exportDatabase')} onClick={exportDb} icon={<DownloadOutlined />} />
|
||||
</div>
|
||||
|
||||
<div className="backup-item">
|
||||
@@ -93,7 +93,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
|
||||
{isPostgres ? t('pages.index.migrationDownloadPgDesc') : t('pages.index.migrationDownloadDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" onClick={exportMigration} icon={<DownloadOutlined />} />
|
||||
<Button type="primary" aria-label={t('pages.index.migrationDownload')} onClick={exportMigration} icon={<DownloadOutlined />} />
|
||||
</div>
|
||||
|
||||
<div className="backup-item">
|
||||
@@ -103,7 +103,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
|
||||
{isPostgres ? t('pages.index.importDatabasePgDesc') : t('pages.index.importDatabaseDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" onClick={importDb} icon={<UploadOutlined />} />
|
||||
<Button type="primary" aria-label={t('pages.index.importDatabase')} onClick={importDb} icon={<UploadOutlined />} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -200,6 +200,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
|
||||
onChange={(e) => setRow(i, { file: e.target.value })}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t('delete')}
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => setRows((p) => p.filter((_, j) => j !== i))}
|
||||
/>
|
||||
|
||||
@@ -34,10 +34,12 @@ import {
|
||||
DatabaseOutlined,
|
||||
ForkOutlined,
|
||||
CopyOutlined,
|
||||
TelegramFilled,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
|
||||
import { formatPanelVersion } from '@/lib/panel-version';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useStatusQuery } from '@/api/queries/useStatusQuery';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
@@ -66,7 +68,6 @@ export default function IndexPage() {
|
||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||
|
||||
const [accessLogEnable, setAccessLogEnable] = useState(false);
|
||||
const [isDevBuild, setIsDevBuild] = useState(false);
|
||||
const [devChannelEnable, setDevChannelEnable] = useState(false);
|
||||
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
|
||||
currentVersion: '',
|
||||
@@ -90,12 +91,11 @@ export default function IndexPage() {
|
||||
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
||||
|
||||
useEffect(() => {
|
||||
HttpUtil.post<{ accessLogEnable?: boolean; isDevBuild?: boolean; devChannelEnable?: boolean }>(
|
||||
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
|
||||
'/panel/api/setting/defaultSettings',
|
||||
).then((msg) => {
|
||||
if (msg?.success && msg.obj) {
|
||||
setAccessLogEnable(!!msg.obj.accessLogEnable);
|
||||
setIsDevBuild(!!msg.obj.isDevBuild);
|
||||
setDevChannelEnable(!!msg.obj.devChannelEnable);
|
||||
}
|
||||
});
|
||||
@@ -128,11 +128,7 @@ export default function IndexPage() {
|
||||
}, [refresh]);
|
||||
|
||||
function openPanelVersion() {
|
||||
if (panelUpdateInfo.updateAvailable || isDevBuild) {
|
||||
setPanelUpdateOpen(true);
|
||||
} else {
|
||||
window.open('https://github.com/MHSanaei/3x-ui/releases', '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
setPanelUpdateOpen(true);
|
||||
}
|
||||
|
||||
async function handleChannelChange(dev: boolean) {
|
||||
@@ -217,15 +213,15 @@ export default function IndexPage() {
|
||||
title={t('menu.link')}
|
||||
hoverable
|
||||
actions={[
|
||||
<Space className="action" key="logs" onClick={() => setLogsOpen(true)}>
|
||||
<Space className="action" key="logs" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={() => setLogsOpen(true)} onKeyDown={activateOnKey(() => setLogsOpen(true))}>
|
||||
<BarsOutlined />
|
||||
{!isMobile && <span>{t('pages.index.logs')}</span>}
|
||||
</Space>,
|
||||
<Space className="action" key="config" onClick={openConfig}>
|
||||
<Space className="action" key="config" role="button" tabIndex={0} aria-label={t('pages.index.config')} onClick={openConfig} onKeyDown={activateOnKey(openConfig)}>
|
||||
<ControlOutlined />
|
||||
{!isMobile && <span>{t('pages.index.config')}</span>}
|
||||
</Space>,
|
||||
<Space className="action" key="backup" onClick={() => setBackupOpen(true)}>
|
||||
<Space className="action" key="backup" role="button" tabIndex={0} aria-label={t('pages.index.backupTitle')} onClick={() => setBackupOpen(true)} onKeyDown={activateOnKey(() => setBackupOpen(true))}>
|
||||
<CloudServerOutlined />
|
||||
{!isMobile && <span>{t('pages.index.backupTitle')}</span>}
|
||||
</Space>,
|
||||
@@ -249,23 +245,18 @@ export default function IndexPage() {
|
||||
}
|
||||
hoverable
|
||||
actions={[
|
||||
<Space className="action" key="tg" onClick={openTelegram}>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="currentColor"
|
||||
className="tg-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21.93 4.34a1.5 1.5 0 0 0-2.05-1.6L2.97 9.6c-.92.36-.91 1.66.02 1.99l4.32 1.53 1.7 5.23a1 1 0 0 0 1.68.36l2.43-2.43 4.36 3.21a1.5 1.5 0 0 0 2.36-.91l3.09-13.86a1.5 1.5 0 0 0 0-.38ZM9.97 14.66l-.55 3.36-1.36-4.2 9.8-7.05-7.89 7.89Z" />
|
||||
</svg>
|
||||
<Space className="action" key="tg" role="button" tabIndex={0} aria-label="@XrayUI" onClick={openTelegram} onKeyDown={activateOnKey(openTelegram)}>
|
||||
<TelegramFilled className="tg-icon" aria-hidden="true" />
|
||||
{!isMobile && <span>@XrayUI</span>}
|
||||
</Space>,
|
||||
<Space
|
||||
key="panel-version"
|
||||
className={`action ${panelUpdateInfo.updateAvailable ? 'action-update' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.updatePanel')}
|
||||
onClick={openPanelVersion}
|
||||
onKeyDown={activateOnKey(openPanelVersion)}
|
||||
>
|
||||
<CloudDownloadOutlined />
|
||||
{!isMobile && (
|
||||
@@ -288,7 +279,11 @@ export default function IndexPage() {
|
||||
<Space
|
||||
className="action"
|
||||
key="sys-history"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.systemHistoryTitle')}
|
||||
onClick={() => setSysHistoryOpen(true)}
|
||||
onKeyDown={activateOnKey(() => setSysHistoryOpen(true))}
|
||||
>
|
||||
<AreaChartOutlined />
|
||||
{!isMobile && <span>{t('pages.index.systemHistoryTitle')}</span>}
|
||||
@@ -296,7 +291,11 @@ export default function IndexPage() {
|
||||
<Space
|
||||
className="action"
|
||||
key="xray-metrics"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.xrayMetricsTitle')}
|
||||
onClick={() => setXrayMetricsOpen(true)}
|
||||
onKeyDown={activateOnKey(() => setXrayMetricsOpen(true))}
|
||||
>
|
||||
<AreaChartOutlined />
|
||||
{!isMobile && <span>{t('pages.index.xrayMetricsTitle')}</span>}
|
||||
@@ -403,12 +402,20 @@ export default function IndexPage() {
|
||||
{showIp ? (
|
||||
<EyeOutlined
|
||||
className="ip-toggle-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.toggleIpVisibility')}
|
||||
onClick={() => setShowIp(false)}
|
||||
onKeyDown={activateOnKey(() => setShowIp(false))}
|
||||
/>
|
||||
) : (
|
||||
<EyeInvisibleOutlined
|
||||
className="ip-toggle-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.toggleIpVisibility')}
|
||||
onClick={() => setShowIp(true)}
|
||||
onKeyDown={activateOnKey(() => setShowIp(true))}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
@@ -463,7 +470,6 @@ export default function IndexPage() {
|
||||
<PanelUpdateModal
|
||||
open={panelUpdateOpen}
|
||||
info={panelUpdateInfo}
|
||||
isDevBuild={isDevBuild}
|
||||
devChannelEnable={devChannelEnable}
|
||||
onChannelChange={handleChannelChange}
|
||||
onClose={() => setPanelUpdateOpen(false)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button, Checkbox, Form, Modal, Select, Space } from 'antd';
|
||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { parseLogLine } from './logParse';
|
||||
import './LogModal.css';
|
||||
@@ -71,7 +72,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
|
||||
const titleNode = (
|
||||
<>
|
||||
{t('pages.index.logs')}
|
||||
<SyncOutlined spin={loading} className="reload-icon" onClick={refresh} />
|
||||
<SyncOutlined spin={loading} className="reload-icon" role="button" tabIndex={0} aria-label={t('refresh')} onClick={refresh} onKeyDown={activateOnKey(refresh)} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -125,7 +126,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item className="download-item">
|
||||
<Button type="primary" onClick={download} icon={<DownloadOutlined />} />
|
||||
<Button type="primary" onClick={download} icon={<DownloadOutlined />} aria-label={t('download')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ interface BusyEvent {
|
||||
interface PanelUpdateModalProps {
|
||||
open: boolean;
|
||||
info: PanelUpdateInfo;
|
||||
isDevBuild?: boolean;
|
||||
devChannelEnable?: boolean;
|
||||
onChannelChange?: (dev: boolean) => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
@@ -35,7 +34,6 @@ interface PanelUpdateModalProps {
|
||||
export default function PanelUpdateModal({
|
||||
open,
|
||||
info,
|
||||
isDevBuild,
|
||||
devChannelEnable,
|
||||
onChannelChange,
|
||||
onClose,
|
||||
@@ -113,18 +111,16 @@ export default function PanelUpdateModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDevBuild && (
|
||||
<div className="version-list">
|
||||
<div className="version-list-item">
|
||||
<span>{t('pages.index.devChannel')}</span>
|
||||
<Switch
|
||||
checked={!!devChannelEnable}
|
||||
loading={channelBusy}
|
||||
onChange={handleChannel}
|
||||
/>
|
||||
</div>
|
||||
<div className="version-list">
|
||||
<div className="version-list-item">
|
||||
<span>{t('pages.index.devChannel')}</span>
|
||||
<Switch
|
||||
checked={!!devChannelEnable}
|
||||
loading={channelBusy}
|
||||
onChange={handleChannel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{devChannelEnable && (
|
||||
<Alert
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Alert, Button, Collapse, Modal, Radio, Spin, Tag, Tooltip } from 'antd'
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import type { Status } from '@/models/status';
|
||||
import GeodataSection from './GeodataSection';
|
||||
import './VersionModal.css';
|
||||
@@ -145,7 +146,11 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
|
||||
<Tooltip title={t('update')}>
|
||||
<ReloadOutlined
|
||||
className="reload-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('update')}
|
||||
onClick={() => updateGeofile(file)}
|
||||
onKeyDown={activateOnKey(() => updateGeofile(file))}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button, Checkbox, Form, Input, Modal, Select, Tag } from 'antd';
|
||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import './XrayLogModal.css';
|
||||
@@ -132,7 +133,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
|
||||
title={
|
||||
<>
|
||||
{t('pages.index.accessLogs')}
|
||||
<SyncOutlined spin={loading} className="reload-icon" onClick={refresh} />
|
||||
<SyncOutlined spin={loading} className="reload-icon" role="button" tabIndex={0} aria-label={t('refresh')} onClick={refresh} onKeyDown={activateOnKey(refresh)} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -177,7 +178,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item className="download-item">
|
||||
<Button type="primary" onClick={download} icon={<DownloadOutlined />} />
|
||||
<Button type="primary" onClick={download} icon={<DownloadOutlined />} aria-label={t('download')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import type { Status } from '@/models/status';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import './XrayStatusCard.css';
|
||||
|
||||
interface XrayStatusCardProps {
|
||||
@@ -67,7 +68,7 @@ export default function XrayStatusCard({
|
||||
<span>{t('pages.index.xrayStatusError')}</span>
|
||||
</Col>
|
||||
<Col>
|
||||
<BarsOutlined className="cursor-pointer" onClick={onOpenLogs} />
|
||||
<BarsOutlined className="cursor-pointer" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={onOpenLogs} onKeyDown={activateOnKey(onOpenLogs)} />
|
||||
</Col>
|
||||
</Row>
|
||||
}
|
||||
@@ -90,21 +91,21 @@ export default function XrayStatusCard({
|
||||
// sense when one is configured (unlike IP limit, which no longer needs it)
|
||||
...(accessLogEnable
|
||||
? [
|
||||
<Space className="action" key="xraylogs" onClick={onOpenXrayLogs}>
|
||||
<Space className="action" key="xraylogs" role="button" tabIndex={0} aria-label={t('pages.index.accessLogs')} onClick={onOpenXrayLogs} onKeyDown={activateOnKey(onOpenXrayLogs)}>
|
||||
<BarsOutlined />
|
||||
{!isMobile && <span>{t('pages.index.accessLogs')}</span>}
|
||||
</Space>,
|
||||
]
|
||||
: []),
|
||||
<Space className="action" key="stop" onClick={onStopXray}>
|
||||
<Space className="action" key="stop" role="button" tabIndex={0} aria-label={t('pages.index.stopXray')} onClick={onStopXray} onKeyDown={activateOnKey(onStopXray)}>
|
||||
<PoweroffOutlined />
|
||||
{!isMobile && <span>{t('pages.index.stopXray')}</span>}
|
||||
</Space>,
|
||||
<Space className="action" key="restart" onClick={onRestartXray}>
|
||||
<Space className="action" key="restart" role="button" tabIndex={0} aria-label={t('pages.index.restartXray')} onClick={onRestartXray} onKeyDown={activateOnKey(onRestartXray)}>
|
||||
<ReloadOutlined />
|
||||
{!isMobile && <span>{t('pages.index.restartXray')}</span>}
|
||||
</Space>,
|
||||
<Space className="action" key="switch" onClick={onOpenVersionSwitch}>
|
||||
<Space className="action" key="switch" role="button" tabIndex={0} aria-label={t('pages.index.xraySwitch')} onClick={onOpenVersionSwitch} onKeyDown={activateOnKey(onOpenVersionSwitch)}>
|
||||
<ToolOutlined />
|
||||
{!isMobile && (
|
||||
<span>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import NodeHistoryPanel from './NodeHistoryPanel';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import { isPanelUpdateAvailable } from '@/lib/panel-version';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import './NodeList.css';
|
||||
|
||||
interface NodeListProps {
|
||||
@@ -245,18 +246,18 @@ export default function NodeList({
|
||||
) : (
|
||||
<Space>
|
||||
<Tooltip title={t('pages.nodes.probe')}>
|
||||
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
|
||||
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<ThunderboltOutlined />} aria-label={t('pages.nodes.probe')} onClick={() => onProbe(record)} />
|
||||
</Tooltip>
|
||||
{isUpdateEligible(record) && (
|
||||
<Tooltip title={t('pages.nodes.updatePanel')}>
|
||||
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
|
||||
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<CloudDownloadOutlined />} aria-label={t('pages.nodes.updatePanel')} onClick={() => onUpdateNode(record)} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t('edit')}>
|
||||
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
|
||||
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button type="text" size="small" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
|
||||
<Button type="text" size="small" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(record)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
@@ -296,9 +297,9 @@ export default function NodeList({
|
||||
{t('pages.nodes.address')}
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{showAddress ? (
|
||||
<EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
|
||||
<EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(false)} onKeyDown={activateOnKey(() => setShowAddress(false))} />
|
||||
) : (
|
||||
<EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
|
||||
<EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(true)} onKeyDown={activateOnKey(() => setShowAddress(true))} />
|
||||
)}
|
||||
</Tooltip>
|
||||
</span>
|
||||
@@ -367,7 +368,7 @@ export default function NodeList({
|
||||
<span>{record.panelVersion || '-'}</span>
|
||||
{canUpdate && (
|
||||
<Tooltip title={`${t('pages.nodes.updateAvailable')}: ${latestVersion}`}>
|
||||
<Tag color="orange" style={{ margin: 0, cursor: 'pointer' }} onClick={() => onUpdateNode(record)}>
|
||||
<Tag color="orange" style={{ margin: 0, cursor: 'pointer' }} role="button" tabIndex={0} onClick={() => onUpdateNode(record)} onKeyDown={activateOnKey(() => onUpdateNode(record))}>
|
||||
{t('pages.nodes.updateAvailable')}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
@@ -467,15 +468,32 @@ export default function NodeList({
|
||||
</div>
|
||||
) : (
|
||||
<div key={record.id} className="node-card">
|
||||
<div className="card-head" onClick={() => toggleExpanded(record.id)}>
|
||||
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- mouse click-to-expand mirrors the keyboard-accessible chevron disclosure button */}
|
||||
<div
|
||||
className="card-head"
|
||||
onClick={(e) => {
|
||||
if (!(e.target as HTMLElement).closest('.card-actions')) toggleExpanded(record.id);
|
||||
}}
|
||||
>
|
||||
<RightOutlined
|
||||
className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expandedIds.has(record.id)}
|
||||
aria-label={record.name}
|
||||
onKeyDown={activateOnKey(() => toggleExpanded(record.id))}
|
||||
/>
|
||||
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||
<span className="node-name">{record.name}</span>
|
||||
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="card-actions">
|
||||
<Tooltip title={t('info')}>
|
||||
<InfoCircleOutlined
|
||||
className="row-action-trigger"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('info')}
|
||||
onClick={() => setStatsNode(record)}
|
||||
onKeyDown={activateOnKey(() => setStatsNode(record))}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
@@ -512,7 +530,7 @@ export default function NodeList({
|
||||
],
|
||||
}}
|
||||
>
|
||||
<MoreOutlined className="row-action-trigger" />
|
||||
<Button type="text" size="small" className="row-action-trigger" icon={<MoreOutlined />} aria-label={t('more')} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -555,9 +573,9 @@ export default function NodeList({
|
||||
</a>
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{showAddress ? (
|
||||
<EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
|
||||
<EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(false)} onKeyDown={activateOnKey(() => setShowAddress(false))} />
|
||||
) : (
|
||||
<EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
|
||||
<EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(true)} onKeyDown={activateOnKey(() => setShowAddress(true))} />
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void })
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 8 }}
|
||||
message={t('pages.index.devChannelWarning')}
|
||||
title={t('pages.index.devChannelWarning')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -312,6 +312,17 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
|
||||
<Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.ldap.skipTlsVerify')}
|
||||
description={t('pages.settings.ldap.skipTlsVerifyDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.ldapInsecureSkipVerify}
|
||||
disabled={!allSetting.ldapUseTLS}
|
||||
onChange={(v) => updateSetting({ ldapInsecureSkipVerify: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.bindDn')}>
|
||||
<Input value={allSetting.ldapBindDN} onChange={(e) => updateSetting({ ldapBindDN: e.target.value })} />
|
||||
</SettingListItem>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { ClipboardManager, HttpUtil, RandomUtil } from '@/utils';
|
||||
import { ClipboardManager, HttpUtil, IntlUtil, RandomUtil } from '@/utils';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
@@ -37,6 +37,13 @@ interface ApiTokenRow {
|
||||
interface SecurityTabProps {
|
||||
allSetting: AllSetting;
|
||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||
saveSetting: (payload: Partial<AllSetting> & Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const UNIX_MILLISECONDS_THRESHOLD = 100_000_000_000;
|
||||
|
||||
function apiTokenCreatedAtMilliseconds(createdAt: number): number {
|
||||
return createdAt < UNIX_MILLISECONDS_THRESHOLD ? createdAt * 1000 : createdAt;
|
||||
}
|
||||
|
||||
type TfaType = 'set' | 'confirm';
|
||||
@@ -59,7 +66,7 @@ const TFA_INITIAL: TfaState = {
|
||||
onConfirm: () => {},
|
||||
};
|
||||
|
||||
export default function SecurityTab({ allSetting, updateSetting }: SecurityTabProps) {
|
||||
export default function SecurityTab({ allSetting, updateSetting, saveSetting }: SecurityTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
@@ -93,10 +100,10 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||
setUser((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const sendUpdateUser = useCallback(async () => {
|
||||
const sendUpdateUser = useCallback(async (twoFactorCode = '') => {
|
||||
setUpdating(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/setting/updateUser', user) as ApiMsg;
|
||||
const msg = await HttpUtil.post('/panel/api/setting/updateUser', { ...user, twoFactorCode }) as ApiMsg;
|
||||
if (msg?.success) {
|
||||
await HttpUtil.post('/logout');
|
||||
const basePath = window.X_UI_BASE_PATH || '/';
|
||||
@@ -112,9 +119,11 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||
openTfa({
|
||||
title: t('pages.settings.security.twoFactorModalChangeCredentialsTitle'),
|
||||
description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'),
|
||||
token: allSetting.twoFactorToken,
|
||||
token: '',
|
||||
type: 'confirm',
|
||||
onConfirm: (ok: boolean) => { if (ok) sendUpdateUser(); },
|
||||
onConfirm: (ok: boolean, code?: string) => {
|
||||
if (ok) sendUpdateUser(code || '');
|
||||
},
|
||||
});
|
||||
} else {
|
||||
sendUpdateUser();
|
||||
@@ -194,7 +203,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||
|
||||
function formatTokenDate(ts: number): string {
|
||||
if (!ts) return '';
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
return IntlUtil.formatDate(apiTokenCreatedAtMilliseconds(ts));
|
||||
}
|
||||
|
||||
function toggleTwoFactor() {
|
||||
@@ -218,12 +227,21 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||
openTfa({
|
||||
title: t('pages.settings.security.twoFactorModalDeleteTitle'),
|
||||
description: t('pages.settings.security.twoFactorModalRemoveStep'),
|
||||
token: allSetting.twoFactorToken,
|
||||
token: '',
|
||||
type: 'confirm',
|
||||
onConfirm: (ok: boolean) => {
|
||||
onConfirm: async (ok: boolean, code?: string) => {
|
||||
if (!ok) return;
|
||||
messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
|
||||
updateSetting({ twoFactorEnable: false, twoFactorToken: '' });
|
||||
const next = {
|
||||
...allSetting,
|
||||
twoFactorEnable: false,
|
||||
twoFactorToken: '',
|
||||
twoFactorCode: code || '',
|
||||
};
|
||||
const msg = await saveSetting(next) as ApiMsg;
|
||||
if (msg?.success) {
|
||||
messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
|
||||
updateSetting({ twoFactorEnable: false, twoFactorToken: '', hasTwoFactorToken: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export default function SettingsPage() {
|
||||
setSpinning,
|
||||
saveDisabled,
|
||||
saveAll,
|
||||
savePayload,
|
||||
} = useAllSettings();
|
||||
|
||||
const [entryHost, setEntryHost] = useState('');
|
||||
@@ -196,7 +197,7 @@ export default function SettingsPage() {
|
||||
|
||||
const categoryBody = useMemo(() => {
|
||||
switch (activeSlug) {
|
||||
case 'security': return <SecurityTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
case 'security': return <SecurityTab allSetting={allSetting} updateSetting={updateSetting} saveSetting={savePayload} />;
|
||||
case 'telegram': return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
case 'email': return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
case 'subscription': return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
|
||||
@@ -115,6 +115,7 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
|
||||
value={state.mode}
|
||||
options={modeOptions}
|
||||
onChange={onModeChange}
|
||||
aria-label={t('pages.settings.telegramNotifyTime')}
|
||||
/>
|
||||
{state.mode === 'every' && (
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
@@ -123,12 +124,14 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
|
||||
style={{ width: '50%' }}
|
||||
value={state.num}
|
||||
onChange={(v) => update({ num: Math.max(1, Number(v) || 1) })}
|
||||
aria-label={t('pages.settings.notifyTime.interval')}
|
||||
/>
|
||||
<Select<Unit>
|
||||
style={{ width: '50%' }}
|
||||
value={state.unit}
|
||||
options={unitOptions}
|
||||
onChange={(unit) => update({ unit })}
|
||||
aria-label={t('pages.settings.notifyTime.unit')}
|
||||
/>
|
||||
</Space.Compact>
|
||||
)}
|
||||
@@ -137,6 +140,7 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
|
||||
value={state.custom}
|
||||
placeholder="0 30 8 * * *"
|
||||
onChange={(e) => update({ custom: e.target.value })}
|
||||
aria-label={t('pages.settings.notifyTime.custom')}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button, Divider, Input, Modal, QRCode, message } from 'antd';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
|
||||
import { ClipboardManager } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { TotpCodeSchema } from '@/schemas/login';
|
||||
import './TwoFactorModal.css';
|
||||
|
||||
@@ -108,7 +109,14 @@ export default function TwoFactorModal({
|
||||
<p>{t('pages.settings.security.twoFactorModalSteps')}</p>
|
||||
<Divider />
|
||||
<p>{t('pages.settings.security.twoFactorModalFirstStep')}</p>
|
||||
<div className="qr-wrap">
|
||||
<div
|
||||
className="qr-wrap"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('copy')}
|
||||
onClick={copyToken}
|
||||
onKeyDown={activateOnKey(copyToken)}
|
||||
>
|
||||
<QRCode
|
||||
className="qr-code"
|
||||
value={qrValue}
|
||||
@@ -119,18 +127,17 @@ export default function TwoFactorModal({
|
||||
bgColor="#ffffff"
|
||||
errorLevel="L"
|
||||
title={t('copy')}
|
||||
onClick={copyToken}
|
||||
/>
|
||||
<span className="qr-token">{token}</span>
|
||||
</div>
|
||||
<Divider />
|
||||
<p>{t('pages.settings.security.twoFactorModalSecondStep')}</p>
|
||||
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} />
|
||||
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} aria-label={t('twoFactorCode')} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>{description}</p>
|
||||
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} />
|
||||
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} aria-label={t('twoFactorCode')} />
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
/* Builds a settings category tab label: icon + text on desktop, and on
|
||||
@@ -6,7 +6,10 @@ import { Tooltip } from 'antd';
|
||||
old top tab bar's icons-only behaviour. */
|
||||
export function catTabLabel(icon: ReactNode, text: ReactNode, iconsOnly: boolean): ReactNode {
|
||||
if (iconsOnly) {
|
||||
return <Tooltip title={text}>{icon}</Tooltip>;
|
||||
const labelledIcon = typeof text === 'string' && isValidElement(icon)
|
||||
? cloneElement(icon as ReactElement<{ 'aria-label'?: string }>, { 'aria-label': text })
|
||||
: icon;
|
||||
return <Tooltip title={text}>{labelledIcon}</Tooltip>;
|
||||
}
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
@@ -31,8 +31,9 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
||||
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
|
||||
import { isPostQuantumLink, wireguardConfigFromLink } from '@/lib/xray/inbound-link';
|
||||
import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
|
||||
import ConfigBlock from '@/components/clients/ConfigBlock';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import SubUsageSummary from './SubUsageSummary';
|
||||
@@ -423,8 +424,10 @@ export default function SubPage() {
|
||||
const rowTitle = parts?.remark || fallback;
|
||||
const qrLabel = parts?.remark || rowTitle;
|
||||
const canQr = !isPostQuantumLink(link);
|
||||
const isWireguardLink = link.startsWith('wireguard://') || link.startsWith('wg://');
|
||||
return (
|
||||
<div key={link} className="sub-link-row">
|
||||
<Fragment key={link}>
|
||||
<div className="sub-link-row">
|
||||
{parts
|
||||
? <LinkTags parts={parts} />
|
||||
: <Tag className="sub-link-tag">LINK</Tag>}
|
||||
@@ -468,6 +471,16 @@ export default function SubPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isWireguardLink && (
|
||||
<ConfigBlock
|
||||
label={t('pages.clients.wireguardConfig')}
|
||||
text={wireguardConfigFromLink(link, rowTitle)}
|
||||
fileName={`${rowTitle || 'peer'}.conf`}
|
||||
qrRemark={rowTitle}
|
||||
tagColor="cyan"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
40
frontend/src/pages/xray/DeletionImpactList.tsx
Normal file
40
frontend/src/pages/xray/DeletionImpactList.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { DeletionImpact } from './reference-cleanup';
|
||||
|
||||
interface DeletionImpactListProps {
|
||||
impact: DeletionImpact;
|
||||
}
|
||||
|
||||
export default function DeletionImpactList({ impact }: DeletionImpactListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const rule of impact.rules) {
|
||||
lines.push(
|
||||
rule.fate === 'removed'
|
||||
? t('pages.xray.refCleanup.ruleRemoved', { label: rule.label })
|
||||
: t('pages.xray.refCleanup.ruleModified', { label: rule.label, keeps: rule.keeps ?? '' }),
|
||||
);
|
||||
}
|
||||
for (const balancer of impact.balancers) {
|
||||
lines.push(t('pages.xray.refCleanup.balancerRemoved', { tag: balancer.tag }));
|
||||
}
|
||||
if (impact.observatory) lines.push(t('pages.xray.observatory.deleteAlsoObservatory'));
|
||||
if (impact.burst) lines.push(t('pages.xray.observatory.deleteAlsoBurst'));
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ marginBottom: 8 }}>{t('pages.xray.refCleanup.header')}</p>
|
||||
<ul style={{ margin: 0, paddingInlineStart: 20 }}>
|
||||
{lines.map((line, i) => (
|
||||
<li key={i}>
|
||||
<bdi>{line}</bdi>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,10 +68,14 @@ export default function BalancerFormModal({
|
||||
}: BalancerFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<FormState>(() => initialState(balancer));
|
||||
const [touched, setTouched] = useState<Partial<Record<keyof FormState, boolean>>>({});
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const isEdit = balancer != null;
|
||||
|
||||
const update = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||
setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true }));
|
||||
setState((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const parsed = useMemo(
|
||||
() => BalancerFormSchema.safeParse(state),
|
||||
@@ -89,8 +93,17 @@ export default function BalancerFormModal({
|
||||
return map;
|
||||
}, [parsed, t]);
|
||||
|
||||
const showTagIssue = submitAttempted || !!touched.tag;
|
||||
const showSelectorIssue = submitAttempted || !!touched.selector;
|
||||
const tagError = showTagIssue ? issues.tag : '';
|
||||
const selectorError = showSelectorIssue ? issues.selector : '';
|
||||
const showDuplicate = showTagIssue && duplicateTag;
|
||||
|
||||
function submit() {
|
||||
if (!parsed.success || duplicateTag) return;
|
||||
if (!parsed.success || duplicateTag) {
|
||||
setSubmitAttempted(true);
|
||||
return;
|
||||
}
|
||||
const values = { ...parsed.data };
|
||||
if (values.strategy !== 'leastLoad') delete values.settings;
|
||||
onConfirm(values);
|
||||
@@ -128,7 +141,6 @@ export default function BalancerFormModal({
|
||||
title={title}
|
||||
okText={okText}
|
||||
cancelText={t('close')}
|
||||
okButtonProps={{ disabled: !parsed.success || duplicateTag }}
|
||||
mask={{ closable: false }}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
@@ -137,8 +149,8 @@ export default function BalancerFormModal({
|
||||
<Form.Item
|
||||
label={t('pages.xray.balancer.tag')}
|
||||
required
|
||||
validateStatus={issues.tag ? 'error' : duplicateTag ? 'warning' : ''}
|
||||
help={issues.tag || (duplicateTag ? t('pages.xray.balancer.tagDuplicate') : '')}
|
||||
validateStatus={tagError ? 'error' : showDuplicate ? 'warning' : ''}
|
||||
help={tagError || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')}
|
||||
hasFeedback
|
||||
>
|
||||
<Input
|
||||
@@ -157,8 +169,8 @@ export default function BalancerFormModal({
|
||||
<Form.Item
|
||||
label={t('pages.xray.balancer.selector')}
|
||||
required
|
||||
validateStatus={issues.selector ? 'error' : ''}
|
||||
help={issues.selector || ''}
|
||||
validateStatus={selectorError ? 'error' : ''}
|
||||
help={selectorError || ''}
|
||||
hasFeedback
|
||||
>
|
||||
<Select
|
||||
@@ -212,16 +224,18 @@ export default function BalancerFormModal({
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => updateBaselines([...baselines, ''])}
|
||||
/>
|
||||
{baselines.map((b, idx) => (
|
||||
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
|
||||
<Input
|
||||
value={b}
|
||||
aria-label={t('pages.xray.balancer.baselines')}
|
||||
placeholder="e.g. 1s"
|
||||
onChange={(e) => updateBaselines(baselines.map((x, i) => (i === idx ? e.target.value : x)))}
|
||||
/>
|
||||
<InputAddon onClick={() => updateBaselines(baselines.filter((_, i) => i !== idx))}>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => updateBaselines(baselines.filter((_, i) => i !== idx))}>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
@@ -232,28 +246,32 @@ export default function BalancerFormModal({
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => updateCosts([...costs, { regexp: false, match: '', value: 1 }])}
|
||||
/>
|
||||
{costs.map((c, idx) => (
|
||||
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
|
||||
<Switch
|
||||
checked={c.regexp}
|
||||
aria-label={t('pages.xray.balancer.costRegexp')}
|
||||
checkedChildren="re"
|
||||
unCheckedChildren="lit"
|
||||
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
|
||||
/>
|
||||
<Input
|
||||
value={c.match}
|
||||
aria-label={t('pages.xray.balancer.costMatch')}
|
||||
placeholder="tag pattern"
|
||||
onChange={(e) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
|
||||
/>
|
||||
<InputNumber
|
||||
value={c.value}
|
||||
aria-label={t('pages.xray.balancer.costValue')}
|
||||
placeholder="weight"
|
||||
style={{ width: 100 }}
|
||||
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
|
||||
/>
|
||||
<InputAddon onClick={() => updateCosts(costs.filter((_, i) => i !== idx))}>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => updateCosts(costs.filter((_, i) => i !== idx))}>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user