diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38659bc5b..f436d5053 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: - "go.sum" - "frontend/**" - ".nvmrc" + - "Makefile" - ".github/workflows/ci.yml" push: branches: @@ -18,6 +19,7 @@ on: - "go.sum" - "frontend/**" - ".nvmrc" + - "Makefile" - ".github/workflows/ci.yml" permissions: @@ -188,6 +190,9 @@ jobs: - name: Lint run: npm run lint working-directory: frontend + - name: Format check + run: npm run format:check + working-directory: frontend - name: Typecheck run: npm run typecheck working-directory: frontend diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml index 36ea16162..2d4c2c20b 100644 --- a/.github/workflows/docs-ci.yml +++ b/.github/workflows/docs-ci.yml @@ -42,6 +42,9 @@ jobs: - name: Lint run: pnpm lint + - name: Format check + run: pnpm format:check + - name: Test run: pnpm test diff --git a/CLAUDE.md b/CLAUDE.md index c8431ded2..f361f78f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ file locations when it can answer in one hop. ## 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 +- TS strict; oxlint's `typescript/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/`. - Node 24 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type @@ -147,7 +147,8 @@ reads as a broken repo, not a missing step. Run `make dist-stub` once; every `make` Go target already depends on it, which is why `make test-go` beats `go test ./...`. Run `make help` for all targets. The local gate: - make verify # gen-check + lint + typecheck + test + build + build-storybook + make verify # gen-check + lint + format-check + typecheck + test + build + # + build-storybook That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`, `make vulncheck`, a live-Postgres job (where a SKIP counts as a failure) and a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 94fd38b97..79769bc05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -186,7 +186,7 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit - **Function components + hooks** everywhere. No class components. - **Comments in committed Go/TS/TSX: 2 lines MAX per comment block**, spent on the *why* a name cannot hold — an invariant, an issue number, a non-obvious constraint. Names should carry the meaning; rename rather than annotate. Compiler and tool directives (`//go:build`, `//go:generate`, `//nolint:`) are exempt, and HTML `` is fine for template structure. - **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. +- **Schemas over `any`.** New config shapes go in `src/schemas/`; oxlint's `typescript/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` — 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. @@ -200,7 +200,8 @@ frontend/ ├── login.html — login + 2FA entry ├── subpage.html — public subscription viewer entry ├── tsconfig.json — strict, jsx: "react-jsx", paths "@/*" → "src/*" -├── eslint.config.js — ESLint flat config (@eslint/js + typescript-eslint + react-hooks) +├── .oxlintrc.json — oxlint config (typescript + react-hooks + jsx-a11y) +├── tools/oxlint/ — input-number-guard.mjs (#6121/#6127 guard as a JS plugin) ├── vite.config.js ├── vitest.config.ts ├── scripts/ — build-openapi.mjs (endpoints.ts → openapi.json) @@ -279,7 +280,7 @@ CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml ### CI -`.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`test`/`build`/`build-storybook`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green. +`.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`format:check`/`test`/`build`/`build-storybook`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green. ## Sending a pull request @@ -288,7 +289,7 @@ CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml 3. Run the relevant checks before pushing: - `go build ./...` - `go test ./...` (when Go code changed) - - `cd frontend && npm run typecheck && npm run lint && npm run test && npm run build && npm run build-storybook` (when the frontend changed; CI runs this same set on every PR via `.github/workflows/ci.yml`) + - `cd frontend && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run build-storybook` (when the frontend changed; CI runs this same set on every PR via `.github/workflows/ci.yml`) 4. Commit messages follow the existing pattern in `git log` — `: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged. 5. Open the PR against `main` with a brief description of what changed and how to test it. diff --git a/Makefile b/Makefile index 07b5d97a1..c177f8412 100644 --- a/Makefile +++ b/Makefile @@ -31,12 +31,16 @@ lint-go: dist-stub ## golangci-lint on Go sources golangci-lint run .PHONY: lint-fe -lint-fe: ## ESLint on frontend sources +lint-fe: ## oxlint on frontend sources cd $(FRONTEND) && npm run lint .PHONY: lint lint: lint-go lint-fe ## All linters +.PHONY: format-check +format-check: ## oxfmt in check mode on frontend sources + cd $(FRONTEND) && npm run format:check + .PHONY: typecheck typecheck: ## tsc --noEmit cd $(FRONTEND) && npm run typecheck @@ -76,8 +80,8 @@ build: build-fe ## Build the frontend then the Go binary build-storybook: ## Build the static Storybook (compile-checks all stories) cd $(FRONTEND) && npm run build-storybook -# The PR gate. Matches ci.yml: codegen freshness, both linters, typecheck, -# both test suites, a full build, and the Storybook compile-check. +# The PR gate. Matches ci.yml: codegen freshness, both linters, the formatter, +# typecheck, both test suites, a full build, and the Storybook compile-check. .PHONY: verify -verify: gen-check lint typecheck msw-worker-check test build build-storybook ## Full local gate (mirrors CI) +verify: gen-check lint format-check typecheck msw-worker-check test build build-storybook ## Full local gate (mirrors CI) @echo "verify: OK" diff --git a/docs/.oxfmtrc.json b/docs/.oxfmtrc.json new file mode 100644 index 000000000..f6e659041 --- /dev/null +++ b/docs/.oxfmtrc.json @@ -0,0 +1,21 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "ignorePatterns": [ + "node_modules", + ".next", + ".source", + "out", + "pnpm-lock.yaml", + "public/openapi.json", + // Reflowing MDX prose merges headings into paragraphs and collapses lists + // inside JSX components (Steps/Callout). Author MDX by hand. + "content/**/*.mdx", + // Generated API reference pages (fumadocs-openapi output). + "content/docs/**/reference/api" + ] +} diff --git a/docs/.oxlintrc.json b/docs/.oxlintrc.json new file mode 100644 index 000000000..bab0ed8e7 --- /dev/null +++ b/docs/.oxlintrc.json @@ -0,0 +1,42 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "ignorePatterns": [ + ".next/**", + ".source/**", + "out/**", + "node_modules/**", + "next-env.d.ts", + "content/docs/**/reference/api/**" + ], + "plugins": ["typescript", "react", "nextjs", "jsx-a11y", "import"], + "categories": { + "correctness": "error" + }, + "env": { + "browser": true, + "node": true, + "es2022": true + }, + "rules": { + "no-var": "error", + "prefer-const": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "typescript/no-explicit-any": "error", + "typescript/no-unused-vars": "warn", + "typescript/ban-ts-comment": "error", + "typescript/no-empty-object-type": "error", + "typescript/no-namespace": "error", + "typescript/no-require-imports": "error", + "typescript/no-this-alias": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/no-unused-expressions": "warn", + "typescript/no-wrapper-object-types": "error", + "typescript/prefer-as-const": "error", + "typescript/triple-slash-reference": "error", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "import/no-anonymous-default-export": "warn", + "jsx-a11y/prefer-tag-over-role": "off" + } +} diff --git a/docs/.prettierignore b/docs/.prettierignore deleted file mode 100644 index dc38002d5..000000000 --- a/docs/.prettierignore +++ /dev/null @@ -1,10 +0,0 @@ -node_modules -.next -.source -out -pnpm-lock.yaml -public/openapi.json -# Don't let Prettier reflow MDX prose — it merges headings into paragraphs and -# collapses lists inside JSX components (Steps/Callout). Author MDX by hand. -content/**/*.mdx -content/docs/**/reference/api diff --git a/docs/.prettierrc.json b/docs/.prettierrc.json deleted file mode 100644 index 4cbc711cd..000000000 --- a/docs/.prettierrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "semi": true, - "singleQuote": true, - "trailingComma": "all", - "printWidth": 100, - "tabWidth": 2 -} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 342b58e1f..5992d27a0 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -20,12 +20,12 @@ pnpm dev # http://localhost:3000 | `pnpm build` | Production build | | `pnpm start` | Serve the production build | | `pnpm typecheck` | Generate MDX/route types and run `tsc --noEmit` | -| `pnpm lint` | ESLint (flat config) | -| `pnpm format` | Format with Prettier | +| `pnpm lint` | oxlint (`.oxlintrc.json`) | +| `pnpm format` | Format with oxfmt (`.oxfmtrc.json`) | | `pnpm test` | Run unit tests (Vitest) for `lib/xray/*` pure logic | | `pnpm gen:api` | Generate the API reference from `public/openapi.json` | -Before opening a pull request, please run `pnpm typecheck`, `pnpm lint`, and +Before opening a pull request, please run `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `pnpm test` — these are the same checks that CI runs on every PR. ## License diff --git a/docs/README.md b/docs/README.md index 536da19ab..d73d2457d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,15 +63,15 @@ ever leaves your browser**: ## Tech stack -| Layer | Technology | -| ---------- | ---------------------------------------------------------- | -| Framework | [Next.js 16](https://nextjs.org) (App Router) · React 19 | -| Docs | [Fumadocs](https://fumadocs.dev) (`-ui` / `-core` / `-mdx`) | -| Styling | [Tailwind CSS v4](https://tailwindcss.com) | -| Search | [Orama](https://orama.com) static index | -| Language | TypeScript (strict) | -| Tests | [Vitest](https://vitest.dev) for the pure `lib/xray` logic | -| Tooling | pnpm · ESLint 9 · Prettier | +| Layer | Technology | +| --------- | ----------------------------------------------------------- | +| Framework | [Next.js 16](https://nextjs.org) (App Router) · React 19 | +| Docs | [Fumadocs](https://fumadocs.dev) (`-ui` / `-core` / `-mdx`) | +| Styling | [Tailwind CSS v4](https://tailwindcss.com) | +| Search | [Orama](https://orama.com) static index | +| Language | TypeScript (strict) | +| Tests | [Vitest](https://vitest.dev) for the pure `lib/xray` logic | +| Tooling | pnpm · oxlint · oxfmt | ## Quick start @@ -86,13 +86,13 @@ pnpm dev # http://localhost:3000 Useful scripts: -| Script | Description | -| ---------------- | -------------------------------------------- | -| `pnpm dev` | Start the dev server | -| `pnpm build` | Production build (also typechecks) | -| `pnpm typecheck` | Generate MDX/route types and `tsc --noEmit` | -| `pnpm lint` | Run ESLint | -| `pnpm test` | Run unit tests (Vitest) | +| Script | Description | +| ---------------- | ------------------------------------------- | +| `pnpm dev` | Start the dev server | +| `pnpm build` | Production build (also typechecks) | +| `pnpm typecheck` | Generate MDX/route types and `tsc --noEmit` | +| `pnpm lint` | Run oxlint (`.oxlintrc.json`) | +| `pnpm test` | Run unit tests (Vitest) | See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full list and project conventions. diff --git a/docs/architecture.md b/docs/architecture.md index a9f63ee3f..e9eaf7302 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,17 +29,17 @@ token), with a process restart as the fallback on older binaries. Servers and processes, all launched from `main.go`: -| Server / process | Package | Purpose | Default port | -|---|---|---|---| -| **Panel** | `internal/web` | Admin REST/WS API + serves the embedded SPA | 2053 | -| **Subscription** | `internal/sub` | Public endpoint that hands out client configs (raw / JSON / Clash) | `subPort` setting | -| **Xray-core** | supervised via `internal/xray` | The actual proxy engine; a child process, not Go code | `inbounds[].port` | -| **mtg-multi** | supervised via `internal/mtproto` | MTProto proxy child process for MTProto inbounds (multi-secret) | per inbound | +| Server / process | Package | Purpose | Default port | +| ---------------- | --------------------------------- | ------------------------------------------------------------------ | ----------------- | +| **Panel** | `internal/web` | Admin REST/WS API + serves the embedded SPA | 2053 | +| **Subscription** | `internal/sub` | Public endpoint that hands out client configs (raw / JSON / Clash) | `subPort` setting | +| **Xray-core** | supervised via `internal/xray` | The actual proxy engine; a child process, not Go code | `inbounds[].port` | +| **mtg-multi** | supervised via `internal/mtproto` | MTProto proxy child process for MTProto inbounds (multi-secret) | per inbound | Two key ideas that explain most of the complexity: 1. **The DB → Xray config pipeline.** Inbounds/clients live in the DB. On every change the - backend regenerates the Xray config and applies it — preferring a *hot diff* (live gRPC + backend regenerates the Xray config and applies it — preferring a _hot diff_ (live gRPC API mutation) over a full process restart. See §5.1. 2. **The Runtime abstraction (multi-node).** A panel can manage remote "nodes" (other 3x-ui instances). Every state-changing inbound/client operation is dispatched through a @@ -52,6 +52,7 @@ Two key ideas that explain most of the complexity: ## 2. Tech stack **Backend (Go 1.26):** + - Web framework: **Gin** (`gin-gonic/gin`) + sessions (cookie store), gzip. - ORM: **GORM** with **SQLite** (default) or **PostgreSQL** (`XUI_DB_TYPE=postgres`). - Scheduler: **robfig/cron/v3** (seconds-precision) for all background jobs. @@ -61,6 +62,7 @@ Two key ideas that explain most of the complexity: - Misc: gorilla/websocket, gopsutil (system stats), go-qrcode, gotp (2FA TOTP). **Frontend (`frontend/`):** + - **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**. - Data layer: **TanStack Query** (`@tanstack/react-query`) over the native **Fetch API**; **Zod 4** schemas. - Router: **react-router 8**. Charts: **uPlot** (`frontend/src/components/viz/Sparkline.tsx`). Editor: **CodeMirror 6**. @@ -95,7 +97,7 @@ Browser (React, fetch) ``` The controller layer is thin. **Business logic lives in services.** When something is wrong -with *behavior*, the bug is almost always in a service file, not a controller. +with _behavior_, the bug is almost always in a service file, not a controller. ### 3.2 Subscription request (end-user fetching their config) @@ -312,8 +314,8 @@ Restart is debounced via an atomic "need restart" flag (`SetToNeedRestart` / ### 5.2 Runtime abstraction — Local vs Remote (multi-node) ⭐ most important A "node" (`model.Node`) is another 3x-ui instance this panel controls. Every state-changing -inbound/client operation goes through the `runtime.Runtime` interface so the *same service -code* works whether the target is the local Xray or a remote node. +inbound/client operation goes through the `runtime.Runtime` interface so the _same service +code_ works whether the target is the local Xray or a remote node. - **Interface:** `internal/web/runtime/runtime.go` — `Name`, `AddInbound`, `DelInbound`, `UpdateInbound`, `AddUser`, `RemoveUser`, `UpdateUser`, `DeleteUser`, `AddClient`, @@ -329,7 +331,7 @@ code* works whether the target is the local Xray or a remote node. - **Dispatch:** `manager.go` → `Manager.RuntimeFor(nodeID *int)`; `nil` nodeID → `Local`, otherwise a cached/lazy-loaded `Remote`. `InvalidateNode(id)` drops a cached remote client. -**Node identity & attribution (the hard part).** Inbounds carry a `NodeID` *and* an +**Node identity & attribution (the hard part).** Inbounds carry a `NodeID` _and_ an `OriginNodeGuid`. Because inbounds can be pushed across hops, the panel attributes traffic and online clients back to the originating panel using **stable GUIDs** rather than local IDs. Relevant logic: `service/inbound_node.go` (`ReconcileNode`, `SetRemoteTraffic`, GUID merge, @@ -338,6 +340,7 @@ tracking). Node "dirty" flags drive an **anti-entropy reconciliation** so an off inbound edits converge once it reconnects. **Where to look for node bugs:** + - Operation not reaching a node → `runtime/remote.go` + `runtime/manager.go`. - Wrong traffic/online attribution across hops → `service/inbound_node.go` (GUID merge paths). - Node shown offline / stale status → `job/node_heartbeat_job.go` + `service/node.go` (`Probe`, `UpdateHeartbeat`). @@ -360,28 +363,28 @@ Periodic resets: `job/periodic_traffic_reset_job.go` (keyed off `Inbound.Traffic All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` method in `internal/web/job/`: -| Schedule | Job | Purpose / condition | -|---|---|---| -| `@every 1s` | `check_xray_running_job` | Restart Xray if it died (2 consecutive down checks) | -| `@every 30s` | (inline func in `startTask`) | Debounced Xray restart — consumes the "need restart" flag (§5.1) | -| `@every 5s` | `xray_traffic_job` | Pull traffic stats from Xray (5s start delay) | -| `@every 5s` | `node_heartbeat_job` | Probe child nodes (online/offline) | -| `@every 5s` | `node_traffic_sync_job` | Pull + merge node traffic; push reconciliation | -| `@every 10s` | `check_client_ip_job` | Enforce per-client IP limits | -| `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds | -| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs | -| `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB | -| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets | -| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")`, `periodic_traffic_reset_job("monthly")` | IP-limit and Xray access/error log cleanup; daily resets and due monthly resets | -| `@weekly` | `periodic_traffic_reset_job("weekly")` | Weekly traffic resets | -| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable | -| default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable | -| `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes | -| `@every 1m` | `check_cpu_usage` | Only if a CPU alarm is configured (TG or email); publishes `cpu.high` | -| `@every 1m` | `check_memory_usage` | Only if a memory alarm is configured; publishes `memory.high` | -| configurable | `free_os_memory` | Only if `sys.MemoryReleaseIntervalMinutes() > 0`; returns heap to OS | +| Schedule | Job | Purpose / condition | +| ------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | +| `@every 1s` | `check_xray_running_job` | Restart Xray if it died (2 consecutive down checks) | +| `@every 30s` | (inline func in `startTask`) | Debounced Xray restart — consumes the "need restart" flag (§5.1) | +| `@every 5s` | `xray_traffic_job` | Pull traffic stats from Xray (5s start delay) | +| `@every 5s` | `node_heartbeat_job` | Probe child nodes (online/offline) | +| `@every 5s` | `node_traffic_sync_job` | Pull + merge node traffic; push reconciliation | +| `@every 10s` | `check_client_ip_job` | Enforce per-client IP limits | +| `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds | +| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs | +| `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB | +| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets | +| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")`, `periodic_traffic_reset_job("monthly")` | IP-limit and Xray access/error log cleanup; daily resets and due monthly resets | +| `@weekly` | `periodic_traffic_reset_job("weekly")` | Weekly traffic resets | +| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable | +| default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable | +| `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes | +| `@every 1m` | `check_cpu_usage` | Only if a CPU alarm is configured (TG or email); publishes `cpu.high` | +| `@every 1m` | `check_memory_usage` | Only if a memory alarm is configured; publishes `memory.high` | +| configurable | `free_os_memory` | Only if `sys.MemoryReleaseIntervalMinutes() > 0`; returns heap to OS | -To change *when* something runs, edit `startTask()`. To change *what* it does, edit the job file. +To change _when_ something runs, edit `startTask()`. To change _what_ it does, edit the job file. ### 5.5 Type generation (Go → TypeScript) ⚠️ don't hand-edit generated files @@ -400,8 +403,9 @@ frontend types (`cd frontend && npm run gen`) instead of editing `src/generated/ ### 5.6 Share-link / subscription generation Two distinct code paths produce client configs: + - **Per-client links in the panel** (the "copy link" / QR in the UI): `service/client_link.go` - + `util/link/outbound.go`. + - `util/link/outbound.go`. - **Subscription endpoint** (what a client app polls): `internal/sub/service.go` (raw links), `internal/sub/json_service.go` (JSON), `internal/sub/clash_service.go` (Clash YAML). **`Host` rows** (`model.Host`, edited under /panel/api/hosts) override address/SNI/path/ @@ -438,70 +442,70 @@ Xray restart. GORM models in `internal/database/model/` (main file `model.go` + siblings); all registered for AutoMigrate in `internal/database/db.go`. -| Model | Table role | Notable fields | -|---|---|---| -| `User` | Admin login | bcrypt password, `LoginEpoch` (invalidates sessions) | -| `Inbound` | An Xray inbound | `Tag` (unique), `Port`, `Protocol`, `Settings`/`StreamSettings`/`Sniffing` (JSON), `Enable`, `TrafficReset`, `NodeID`, **`OriginNodeGuid`**, `ClientStats` (assoc) | -| `Client` | In-memory client view | UUID/email/flow/limits (parsed from inbound JSON; not persisted) | -| `ClientRecord` | Persisted client (`clients`) | `Email` (unique), `SubID`, `UUID`, `TotalGB`, `ExpiryTime`, `LimitIP`, `Group`, `Reset` | -| `ClientGroup` / `ClientInbound` | Grouping + client↔inbound join | many-to-many wiring, `FlowOverride` | -| `ClientExternalLink` | Extra links attached to a client | `Kind`, `Value`, `Remark`, `SortIndex` | -| `Host` | Subscription host overrides (per inbound) | `Address`, `Port`, `Sni`, `Path`, `Security`, `Fingerprint`, `SortOrder`, visibility/exclusion flags | -| `Node` | A managed child panel | `Guid`, `Address`, `Status`, `TlsVerifyMode`, `PinnedCertSha256`, `ConfigDirty`, version/heartbeat/metric fields | -| `NodeClientTraffic` | Per-node client traffic baseline | cross-node merge (anti-double-count) | -| `NodeClientIp` | Per-node client IP attribution | `NodeGuid`, `Email`, `Ips` | -| `ClientGlobalTraffic` | Cross-master usage totals | `MasterGuid`, `Email`, `Up`, `Down` | -| `xray.ClientTraffic` | Per-client counters (`client_traffics`) | `Email`, `Up`, `Down`, `Total`, `ExpiryTime`, `LastOnline` | -| `InboundClientIps` | IP set per client email | drives IP-limit enforcement | -| `OutboundTraffics` | Outbound counters | per outbound tag | -| `OutboundSubscription` | External provider subs | Warp/Nord style | -| `Setting` | Key/value panel settings | everything configurable | -| `ApiToken` | REST API tokens | SHA-256 hash (plaintext shown once) | -| `InboundFallback` | Fallback routing on a shared port | SNI/ALPN/path → dest | -| `HistoryOfSeeders` | Seeder bookkeeping | prevents re-running one-off migrations | +| Model | Table role | Notable fields | +| ------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `User` | Admin login | bcrypt password, `LoginEpoch` (invalidates sessions) | +| `Inbound` | An Xray inbound | `Tag` (unique), `Port`, `Protocol`, `Settings`/`StreamSettings`/`Sniffing` (JSON), `Enable`, `TrafficReset`, `NodeID`, **`OriginNodeGuid`**, `ClientStats` (assoc) | +| `Client` | In-memory client view | UUID/email/flow/limits (parsed from inbound JSON; not persisted) | +| `ClientRecord` | Persisted client (`clients`) | `Email` (unique), `SubID`, `UUID`, `TotalGB`, `ExpiryTime`, `LimitIP`, `Group`, `Reset` | +| `ClientGroup` / `ClientInbound` | Grouping + client↔inbound join | many-to-many wiring, `FlowOverride` | +| `ClientExternalLink` | Extra links attached to a client | `Kind`, `Value`, `Remark`, `SortIndex` | +| `Host` | Subscription host overrides (per inbound) | `Address`, `Port`, `Sni`, `Path`, `Security`, `Fingerprint`, `SortOrder`, visibility/exclusion flags | +| `Node` | A managed child panel | `Guid`, `Address`, `Status`, `TlsVerifyMode`, `PinnedCertSha256`, `ConfigDirty`, version/heartbeat/metric fields | +| `NodeClientTraffic` | Per-node client traffic baseline | cross-node merge (anti-double-count) | +| `NodeClientIp` | Per-node client IP attribution | `NodeGuid`, `Email`, `Ips` | +| `ClientGlobalTraffic` | Cross-master usage totals | `MasterGuid`, `Email`, `Up`, `Down` | +| `xray.ClientTraffic` | Per-client counters (`client_traffics`) | `Email`, `Up`, `Down`, `Total`, `ExpiryTime`, `LastOnline` | +| `InboundClientIps` | IP set per client email | drives IP-limit enforcement | +| `OutboundTraffics` | Outbound counters | per outbound tag | +| `OutboundSubscription` | External provider subs | Warp/Nord style | +| `Setting` | Key/value panel settings | everything configurable | +| `ApiToken` | REST API tokens | SHA-256 hash (plaintext shown once) | +| `InboundFallback` | Fallback routing on a shared port | SNI/ALPN/path → dest | +| `HistoryOfSeeders` | Seeder bookkeeping | prevents re-running one-off migrations | --- ## 7. Symptom → File index (start here when debugging) -| Symptom / task | Primary file(s) | Then check | -|---|---|---| -| Add/modify an **API endpoint** | `controller/.go` (route registration at top of each file) | corresponding `service/*.go`, `frontend/src/pages/api-docs/endpoints.ts` | -| **Inbound** create/update/delete behavior | `service/inbound.go`, `service/inbound_clients.go` | `runtime/*`, `service/xray.go` | -| **Client** CRUD / limits / expiry | `service/client_crud.go`, `service/client_inbound_apply.go` | model `ClientRecord`, `service/inbound_traffic.go` | -| **Bulk** client operations slow/wrong | `service/client_bulk.go` | `service/client_paging.go` | -| Xray **won't apply** a config change | `service/xray.go` (`RestartXray`, `tryHotApply`) | `xray/hot_diff.go`, `xray/process.go` | -| Xray **restarts when it shouldn't** (kills connections) | `xray/hot_diff.go` (diff not classified as hot) | `service/xray.go` | -| **Traffic** counts wrong / reset behavior | `service/inbound_traffic.go`, `job/xray_traffic_job.go` | `service/traffic_writer.go`, `job/periodic_traffic_reset_job.go` | -| **Node** operation not propagating | `runtime/remote.go`, `runtime/manager.go` | `service/inbound_node.go` | -| **Multi-hop / cross-node attribution** (traffic or online clients on wrong panel) | `service/inbound_node.go` (GUID merge, `synthNodeGuid`, `effectiveNodeGuid`) | `service/node.go`, model `OriginNodeGuid`/`Node.Guid` | -| Node stuck **offline / stale** | `job/node_heartbeat_job.go`, `service/node.go` (`Probe`, `UpdateHeartbeat`) | `runtime/tls_client.go` (TLS verify) | -| Node **TLS / mTLS** auth failures | `runtime/tls_client.go`, `service/node_mtls.go`, `service/setting_mtls.go` | `service/node.go` (`FetchCertFingerprint`) | -| Offline node edits **not reconciling** on reconnect | `service/inbound_node.go` (`ReconcileNode`, dirty flags) | `service/node.go` (`MarkNodeDirty`/`NodeSyncState`) | -| **Share link / QR** malformed (per protocol) | `service/client_link.go`, `util/link/outbound.go` | `frontend/src/lib/xray/`, `frontend/src/schemas/protocols/` | -| **Subscription** output wrong (raw/JSON/Clash) | `internal/sub/service.go` | `sub/json_service.go`, `sub/clash_service.go`, sub golden tests | -| Subscription **host overrides** not applied | `service/host.go`, `sub/host_sub.go` | model `Host`, `frontend/src/pages/hosts/` | -| **External subscription** import/aggregation | `sub/external_subscription.go`, `sub/external_config.go` | `sub/clash_external.go` | -| **Settings** not saving / defaults | `service/setting.go`, `controller/setting.go` | model `Setting` | -| **Login / 2FA / sessions / CSRF** | `controller/index.go`, `service/panel/user.go`, `middleware/` | `session/` | -| **API tokens** | `service/panel/api_token.go`, `controller/setting.go` | model `ApiToken` | -| **Port conflict** on inbound add | `service/port_conflict.go` | `controller/inbound.go` | -| **Fallbacks** (shared 443, SNI routing) | `service/fallback.go`, `controller/inbound.go` | model `InboundFallback` | -| **Geo category browser** empty / won't open | `xray/geodata/` (`Store`, `reader.go`), `service/geodata.go` | `controller/xray_setting.go` (`/panel/api/xray/geodata/*`), asset dir = `config.GetBinFolderPath()` | -| **`geosite:`/`geoip:` token** reported unknown in a routing rule | `xray/geodata/token.go`, `service/geodata.go` (`Validate`) | `frontend/src/lib/xray/geoTokens.ts`, `frontend/src/components/geodata/` | -| **Telegram bot** commands | `service/tgbot/` | `job/stats_notify_job.go` | -| **Email notifications** | `service/email/` | `internal/eventbus/` (consumers) | -| **CPU / memory alerts** not firing | `job/check_cpu_usage.go`, `job/check_memory_usage.go` | `internal/eventbus/`, notifier settings in `service/setting.go` | -| Xray auto-restart on **dead tunnel** | `internal/tunnelmonitor/` | `XUI_TUNNEL_HEALTH_*` in `internal/config/` | -| **WARP / Nord** outbound integration | `service/integration/warp.go` / `nord.go` | `service/outbound_subscription.go` | -| **MTProto** proxy issues | `internal/mtproto/manager.go`, `mtproto/process*.go` | `job/mtproto_job.go` | -| **DB migration** / new column | `internal/database/db.go` (AutoMigrate list), `migrate_data.go` | `model/model.go` | -| **Cron schedule** changes | `web.go` → `startTask()` | the specific `job/*.go` | -| **CORS / security headers / HTTPS** | `middleware/`, `web.go` (`initRouter`, TLS setup) | `config/` (env) | -| **Env vars / paths / DB type** | `internal/config/config.go` | `.env.example` | -| **Frontend route / screen** | `frontend/src/pages//`, `frontend/src/routes.tsx` | `frontend/src/api/queries/` | -| **Frontend ↔ backend type mismatch** | regenerate: `cd frontend && npm run gen` (`tools/openapigen`) | `frontend/src/generated/` | -| **System status / CPU / metrics** | `service/server.go`, `service/xray_metrics.go`, `service/metric_history.go` | `controller/server.go`, gopsutil | +| Symptom / task | Primary file(s) | Then check | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Add/modify an **API endpoint** | `controller/.go` (route registration at top of each file) | corresponding `service/*.go`, `frontend/src/pages/api-docs/endpoints.ts` | +| **Inbound** create/update/delete behavior | `service/inbound.go`, `service/inbound_clients.go` | `runtime/*`, `service/xray.go` | +| **Client** CRUD / limits / expiry | `service/client_crud.go`, `service/client_inbound_apply.go` | model `ClientRecord`, `service/inbound_traffic.go` | +| **Bulk** client operations slow/wrong | `service/client_bulk.go` | `service/client_paging.go` | +| Xray **won't apply** a config change | `service/xray.go` (`RestartXray`, `tryHotApply`) | `xray/hot_diff.go`, `xray/process.go` | +| Xray **restarts when it shouldn't** (kills connections) | `xray/hot_diff.go` (diff not classified as hot) | `service/xray.go` | +| **Traffic** counts wrong / reset behavior | `service/inbound_traffic.go`, `job/xray_traffic_job.go` | `service/traffic_writer.go`, `job/periodic_traffic_reset_job.go` | +| **Node** operation not propagating | `runtime/remote.go`, `runtime/manager.go` | `service/inbound_node.go` | +| **Multi-hop / cross-node attribution** (traffic or online clients on wrong panel) | `service/inbound_node.go` (GUID merge, `synthNodeGuid`, `effectiveNodeGuid`) | `service/node.go`, model `OriginNodeGuid`/`Node.Guid` | +| Node stuck **offline / stale** | `job/node_heartbeat_job.go`, `service/node.go` (`Probe`, `UpdateHeartbeat`) | `runtime/tls_client.go` (TLS verify) | +| Node **TLS / mTLS** auth failures | `runtime/tls_client.go`, `service/node_mtls.go`, `service/setting_mtls.go` | `service/node.go` (`FetchCertFingerprint`) | +| Offline node edits **not reconciling** on reconnect | `service/inbound_node.go` (`ReconcileNode`, dirty flags) | `service/node.go` (`MarkNodeDirty`/`NodeSyncState`) | +| **Share link / QR** malformed (per protocol) | `service/client_link.go`, `util/link/outbound.go` | `frontend/src/lib/xray/`, `frontend/src/schemas/protocols/` | +| **Subscription** output wrong (raw/JSON/Clash) | `internal/sub/service.go` | `sub/json_service.go`, `sub/clash_service.go`, sub golden tests | +| Subscription **host overrides** not applied | `service/host.go`, `sub/host_sub.go` | model `Host`, `frontend/src/pages/hosts/` | +| **External subscription** import/aggregation | `sub/external_subscription.go`, `sub/external_config.go` | `sub/clash_external.go` | +| **Settings** not saving / defaults | `service/setting.go`, `controller/setting.go` | model `Setting` | +| **Login / 2FA / sessions / CSRF** | `controller/index.go`, `service/panel/user.go`, `middleware/` | `session/` | +| **API tokens** | `service/panel/api_token.go`, `controller/setting.go` | model `ApiToken` | +| **Port conflict** on inbound add | `service/port_conflict.go` | `controller/inbound.go` | +| **Fallbacks** (shared 443, SNI routing) | `service/fallback.go`, `controller/inbound.go` | model `InboundFallback` | +| **Geo category browser** empty / won't open | `xray/geodata/` (`Store`, `reader.go`), `service/geodata.go` | `controller/xray_setting.go` (`/panel/api/xray/geodata/*`), asset dir = `config.GetBinFolderPath()` | +| **`geosite:`/`geoip:` token** reported unknown in a routing rule | `xray/geodata/token.go`, `service/geodata.go` (`Validate`) | `frontend/src/lib/xray/geoTokens.ts`, `frontend/src/components/geodata/` | +| **Telegram bot** commands | `service/tgbot/` | `job/stats_notify_job.go` | +| **Email notifications** | `service/email/` | `internal/eventbus/` (consumers) | +| **CPU / memory alerts** not firing | `job/check_cpu_usage.go`, `job/check_memory_usage.go` | `internal/eventbus/`, notifier settings in `service/setting.go` | +| Xray auto-restart on **dead tunnel** | `internal/tunnelmonitor/` | `XUI_TUNNEL_HEALTH_*` in `internal/config/` | +| **WARP / Nord** outbound integration | `service/integration/warp.go` / `nord.go` | `service/outbound_subscription.go` | +| **MTProto** proxy issues | `internal/mtproto/manager.go`, `mtproto/process*.go` | `job/mtproto_job.go` | +| **DB migration** / new column | `internal/database/db.go` (AutoMigrate list), `migrate_data.go` | `model/model.go` | +| **Cron schedule** changes | `web.go` → `startTask()` | the specific `job/*.go` | +| **CORS / security headers / HTTPS** | `middleware/`, `web.go` (`initRouter`, TLS setup) | `config/` (env) | +| **Env vars / paths / DB type** | `internal/config/config.go` | `.env.example` | +| **Frontend route / screen** | `frontend/src/pages//`, `frontend/src/routes.tsx` | `frontend/src/api/queries/` | +| **Frontend ↔ backend type mismatch** | regenerate: `cd frontend && npm run gen` (`tools/openapigen`) | `frontend/src/generated/` | +| **System status / CPU / metrics** | `service/server.go`, `service/xray_metrics.go`, `service/metric_history.go` | `controller/server.go`, gopsutil | --- @@ -522,7 +526,7 @@ for AutoMigrate in `internal/database/db.go`. Regenerate instead. 7. **Models are the contract.** Changing a model field that crosses the API boundary means: update `model.go` → handle migration in `db.go`/`migrate_data.go` → regenerate frontend types. -8. **Two servers, two concerns.** Admin features go in `internal/web`; anything an *end user* +8. **Two servers, two concerns.** Admin features go in `internal/web`; anything an _end user_ fetches goes in `internal/sub`. Don't blur them. 9. **Cross-cutting notifications go through `internal/eventbus/`** — publish an event instead of importing the Telegram/email services into producers. @@ -536,6 +540,7 @@ The canonical gate is the **Makefile** (mirrors CI): `make verify`. Also: `make frontend), `make race`, `make build`. Run `make help` for everything. Raw commands: **Backend (Go):** + ```bash go build ./... # compile everything go test ./... # run all Go tests (many *_test.go alongside sources) @@ -548,11 +553,12 @@ go run main.go # run the panel locally (serves embedded dis ``` **Frontend (`cd frontend`, Node 24 — see `.nvmrc`):** + ```bash npm install npm run dev # Vite dev server on :5173; proxies API to Go backend on :2053 (run `go run main.go` too) npm run typecheck # tsc --noEmit -npm run lint # eslint src +npm run lint # oxlint src npm run test # vitest (incl. golden config-generation snapshots) npm run gen # regenerate src/generated/* from Go (gen:zod + gen:api) npm run build # gen:api + vite build → outputs to internal/web/dist (then rebuild Go binary to embed) diff --git a/docs/components/tools/api-request-builder.tsx b/docs/components/tools/api-request-builder.tsx index de0b878f6..4f4b567dd 100644 --- a/docs/components/tools/api-request-builder.tsx +++ b/docs/components/tools/api-request-builder.tsx @@ -1,7 +1,12 @@ 'use client'; import { useId, useState } from 'react'; -import { buildCurl, buildFetchSnippet, type ApiRequestInput, type HttpMethod } from '@/lib/xray/api-client'; +import { + buildCurl, + buildFetchSnippet, + type ApiRequestInput, + type HttpMethod, +} from '@/lib/xray/api-client'; import { ToolFrame } from './tool-frame'; import { TextField, SelectField } from './shared/fields'; import { OutputBlock } from './shared/output-block'; diff --git a/docs/components/tools/routing-builder.tsx b/docs/components/tools/routing-builder.tsx index deee35e0e..75c55a8ce 100644 --- a/docs/components/tools/routing-builder.tsx +++ b/docs/components/tools/routing-builder.tsx @@ -38,8 +38,24 @@ const DEFAULT_BALANCERS: BalancerRow[] = [ { tag: 'balancer', selector: 'proxy', strategy: 'leastPing', fallbackTag: '' }, ]; const DEFAULT_RULES: RuleRow[] = [ - { domain: 'geosite:category-ads-all', ip: '', port: '', network: 'any', inboundTag: '', targetKind: 'outbound', targetTag: 'block' }, - { domain: '', ip: 'geoip:private', port: '', network: 'any', inboundTag: '', targetKind: 'outbound', targetTag: 'direct' }, + { + domain: 'geosite:category-ads-all', + ip: '', + port: '', + network: 'any', + inboundTag: '', + targetKind: 'outbound', + targetTag: 'block', + }, + { + domain: '', + ip: 'geoip:private', + port: '', + network: 'any', + inboundTag: '', + targetKind: 'outbound', + targetTag: 'direct', + }, ]; function list(s: string): string[] { @@ -113,7 +129,10 @@ export function RoutingBuilder() { type="button" className={addBtn} onClick={() => - setBalancers((p) => [...p, { tag: '', selector: '', strategy: 'random', fallbackTag: '' }]) + setBalancers((p) => [ + ...p, + { tag: '', selector: '', strategy: 'random', fallbackTag: '' }, + ]) } > Add balancer @@ -163,7 +182,15 @@ export function RoutingBuilder() { onClick={() => setRules((p) => [ ...p, - { domain: '', ip: '', port: '', network: 'any', inboundTag: '', targetKind: 'outbound', targetTag: '' }, + { + domain: '', + ip: '', + port: '', + network: 'any', + inboundTag: '', + targetKind: 'outbound', + targetTag: '', + }, ]) } > @@ -174,13 +201,47 @@ export function RoutingBuilder() { {rules.map((r, i) => (
- patchRule(i, { domain: v })} placeholder="geosite:google, example.com" /> - patchRule(i, { ip: v })} placeholder="geoip:cn, 1.1.1.1" /> - patchRule(i, { port: v })} placeholder="443 or 1000-2000" /> - patchRule(i, { network: v })} options={NETWORKS} /> - patchRule(i, { inboundTag: v })} placeholder="optional" /> - patchRule(i, { targetKind: v as 'outbound' | 'balancer' })} options={TARGET_KINDS} /> - patchRule(i, { targetTag: v })} /> + patchRule(i, { domain: v })} + placeholder="geosite:google, example.com" + /> + patchRule(i, { ip: v })} + placeholder="geoip:cn, 1.1.1.1" + /> + patchRule(i, { port: v })} + placeholder="443 or 1000-2000" + /> + patchRule(i, { network: v })} + options={NETWORKS} + /> + patchRule(i, { inboundTag: v })} + placeholder="optional" + /> + patchRule(i, { targetKind: v as 'outbound' | 'balancer' })} + options={TARGET_KINDS} + /> + patchRule(i, { targetTag: v })} + />
+
Last confirmed: {value || '—'}
) : type === 'textarea' ? ( { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }} + ref={(el) => { + textareaRef.current = + (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } }) + ?.resizableTextArea?.textArea ?? null; + }} aria-label={title} value={value} onChange={(e) => setValue(e.target.value)} diff --git a/frontend/src/components/feedback/TextModal.stories.tsx b/frontend/src/components/feedback/TextModal.stories.tsx index c37f90430..bf5a99c43 100644 --- a/frontend/src/components/feedback/TextModal.stories.tsx +++ b/frontend/src/components/feedback/TextModal.stories.tsx @@ -21,8 +21,13 @@ const meta = { open: { description: 'Whether the modal is visible.' }, title: { description: 'Modal title text.' }, content: { description: 'Text shown when no `tabs` are provided.' }, - fileName: { description: 'When set, adds a download button that saves the active content under this name.' }, - json: { description: 'Render the content in a read-only JSON editor with syntax highlighting.' }, + fileName: { + description: + 'When set, adds a download button that saves the active content under this name.', + }, + json: { + description: 'Render the content in a read-only JSON editor with syntax highlighting.', + }, tabs: { description: 'Optional list of `{ key, label, content }` documents shown as tabs.' }, onClose: { description: 'Called when the modal is dismissed.' }, }, diff --git a/frontend/src/components/feedback/TextModal.tsx b/frontend/src/components/feedback/TextModal.tsx index fcb73b218..f58a42d35 100644 --- a/frontend/src/components/feedback/TextModal.tsx +++ b/frontend/src/components/feedback/TextModal.tsx @@ -22,7 +22,15 @@ interface TextModalProps { tabs?: TextModalTab[]; } -export default function TextModal({ open, onClose, title, content, fileName = '', json = false, tabs }: TextModalProps) { +export default function TextModal({ + open, + onClose, + title, + content, + fileName = '', + json = false, + tabs, +}: TextModalProps) { const { t } = useTranslation(); const [messageApi, messageContextHolder] = message.useMessage(); const [activeKey, setActiveKey] = useState(''); @@ -55,37 +63,41 @@ export default function TextModal({ open, onClose, title, content, fileName = '' title={title} onCancel={onClose} destroyOnHidden - footer={( - <> - {fileName && ( - - )} - - - )} - > - {tabs && tabs.length > 0 && ( - ({ key: tab.key, label: tab.label }))} - /> - )} - {json ? ( - - ) : ( - - )} + footer={ + <> + {fileName && ( + + )} + + + } + > + {tabs && tabs.length > 0 && ( + ({ key: tab.key, label: tab.label }))} + /> + )} + {json ? ( + + ) : ( + + )} ); diff --git a/frontend/src/components/form/DateTimePicker.css b/frontend/src/components/form/DateTimePicker.css index b8f289f9b..52756f796 100644 --- a/frontend/src/components/form/DateTimePicker.css +++ b/frontend/src/components/form/DateTimePicker.css @@ -27,7 +27,7 @@ .jdp-dark input::placeholder, .jdp-ultra input::placeholder { - color: rgba(255, 255, 255, 0.30) !important; + color: rgba(255, 255, 255, 0.3) !important; } .jdp-disabled { @@ -62,7 +62,7 @@ } .jdp-dark .jdp-clear { - color: rgba(255, 255, 255, 0.30); + color: rgba(255, 255, 255, 0.3); } .jdp-dark .jdp-clear:hover, diff --git a/frontend/src/components/form/DateTimePicker.stories.tsx b/frontend/src/components/form/DateTimePicker.stories.tsx index 9349384ad..7ca22ef5e 100644 --- a/frontend/src/components/form/DateTimePicker.stories.tsx +++ b/frontend/src/components/form/DateTimePicker.stories.tsx @@ -17,7 +17,9 @@ function ClientExpiryDemo() {
- {value ? `user1@node-de expiryTime: ${value.valueOf()}` : 'user1@node-de expiryTime: 0 (never expires)'} + {value + ? `user1@node-de expiryTime: ${value.valueOf()}` + : 'user1@node-de expiryTime: 0 (never expires)'}
); diff --git a/frontend/src/components/form/DateTimePicker.tsx b/frontend/src/components/form/DateTimePicker.tsx index 0e955ed61..805147b76 100644 --- a/frontend/src/components/form/DateTimePicker.tsx +++ b/frontend/src/components/form/DateTimePicker.tsx @@ -90,7 +90,10 @@ export default function DateTimePicker({ if (datepicker === 'jalalian') { return ( -
+
; @@ -63,7 +71,14 @@ function WireShapeDemo() { return (
-
+      
         {JSON.stringify(value ?? {}, null, 2)}
       
diff --git a/frontend/src/components/form/HeaderMapEditor.tsx b/frontend/src/components/form/HeaderMapEditor.tsx index 41a4973fc..76c3c8607 100644 --- a/frontend/src/components/form/HeaderMapEditor.tsx +++ b/frontend/src/components/form/HeaderMapEditor.tsx @@ -24,10 +24,7 @@ import { InputAddon } from '@/components/ui'; export type HeaderMapMode = 'v1' | 'v2'; -export type HeaderMapValue = - | Record - | Record - | undefined; +export type HeaderMapValue = Record | Record | undefined; interface HeaderRow { name: string; @@ -55,7 +52,10 @@ function mapToRows(value: HeaderMapValue): HeaderRow[] { return out; } -function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record | Record { +function rowsToMap( + rows: HeaderRow[], + mode: HeaderMapMode, +): Record | Record { if (mode === 'v1') { const map: Record = {}; for (const r of rows) { @@ -132,7 +132,11 @@ export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEdit placeholder="Value" onChange={(e) => setRow(idx, { value: e.target.value })} /> -
diff --git a/frontend/src/components/form/RemarkVarPicker.stories.tsx b/frontend/src/components/form/RemarkVarPicker.stories.tsx index ffc6f49e0..8ead10795 100644 --- a/frontend/src/components/form/RemarkVarPicker.stories.tsx +++ b/frontend/src/components/form/RemarkVarPicker.stories.tsx @@ -20,7 +20,10 @@ const meta = { }, }, argTypes: { - onPick: { description: 'Called with the bare token (e.g. "EMAIL") when a chip is clicked or activated via keyboard.' }, + onPick: { + description: + 'Called with the bare token (e.g. "EMAIL") when a chip is clicked or activated via keyboard.', + }, }, } satisfies Meta; @@ -29,7 +32,9 @@ export default meta; type Story = StoryObj; function TemplateBuilderDemo() { - const [template, setTemplate] = useState('{{INBOUND}}-{{EMAIL}} {{STATUS_EMOJI}} {{TRAFFIC_LEFT}} left'); + const [template, setTemplate] = useState( + '{{INBOUND}}-{{EMAIL}} {{STATUS_EMOJI}} {{TRAFFIC_LEFT}} left', + ); return (
{t('pages.hosts.remarkVars.intro')} - {REMARK_VAR_GROUPS.filter((group) => variables.some((v) => v.group === group)).map((group) => ( -
-
- {t(`pages.hosts.remarkVars.groups.${group}`)} + {REMARK_VAR_GROUPS.filter((group) => variables.some((v) => v.group === group)).map( + (group) => ( +
+
+ {t(`pages.hosts.remarkVars.groups.${group}`)} +
+
+ {variables + .filter((v) => v.group === group) + .map((v) => ( + + onPick(v.token)} + onKeyDown={activateOnKey(() => onPick(v.token))} + style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }} + > + {wrapToken(v.token)} + + + ))} +
-
- {variables.filter((v) => v.group === group).map((v) => ( - - onPick(v.token)} - onKeyDown={activateOnKey(() => onPick(v.token))} - style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }} - > - {wrapToken(v.token)} - - - ))} -
-
- ))} + ), + )}
); } diff --git a/frontend/src/components/form/SelectAllClearButtons.stories.tsx b/frontend/src/components/form/SelectAllClearButtons.stories.tsx index df999564e..d2f695e72 100644 --- a/frontend/src/components/form/SelectAllClearButtons.stories.tsx +++ b/frontend/src/components/form/SelectAllClearButtons.stories.tsx @@ -33,11 +33,23 @@ const meta = { }, }, argTypes: { - options: { description: 'Option list whose values define the "all" set; matches the AntD Select option shape.' }, + options: { + description: + 'Option list whose values define the "all" set; matches the AntD Select option shape.', + }, value: { description: 'Currently selected values (controlled).' }, - onChange: { description: 'Called with the union of the current selection and every option value, or with an empty array on clear.' }, - selectAllLabel: { description: 'Override for the "Select all" button text; defaults to the translated inbound copy.' }, - clearLabel: { description: 'Override for the "Clear all" button text; defaults to the translated inbound copy.' }, + onChange: { + description: + 'Called with the union of the current selection and every option value, or with an empty array on clear.', + }, + selectAllLabel: { + description: + 'Override for the "Select all" button text; defaults to the translated inbound copy.', + }, + clearLabel: { + description: + 'Override for the "Clear all" button text; defaults to the translated inbound copy.', + }, }, } satisfies Meta; diff --git a/frontend/src/components/form/SelectAllClearButtons.tsx b/frontend/src/components/form/SelectAllClearButtons.tsx index a5a46a19b..e6e25864b 100644 --- a/frontend/src/components/form/SelectAllClearButtons.tsx +++ b/frontend/src/components/form/SelectAllClearButtons.tsx @@ -35,11 +35,7 @@ export default function SelectAllClearButtons {selectAllLabel ?? t('pages.clients.selectAllInbounds')} -
diff --git a/frontend/src/components/form/rhf/FormField.stories.tsx b/frontend/src/components/form/rhf/FormField.stories.tsx index c69f01876..3c010267c 100644 --- a/frontend/src/components/form/rhf/FormField.stories.tsx +++ b/frontend/src/components/form/rhf/FormField.stories.tsx @@ -25,14 +25,24 @@ const meta = { }, argTypes: { name: { description: 'Field path — a dotted string or an array of segments joined with dots.' }, - control: { description: 'Optional react-hook-form control; falls back to the surrounding FormProvider.' }, + control: { + description: 'Optional react-hook-form control; falls back to the surrounding FormProvider.', + }, label: { description: 'Form.Item label.' }, tooltip: { description: 'Form.Item tooltip shown next to the label.' }, extra: { description: 'Helper text rendered below the input.' }, - valueProp: { description: 'Prop the child receives the value on: `value` (default) or `checked` for switches.' }, - transform: { description: 'Optional input/output mappers, e.g. bytes stored in the form but GB shown in the input.' }, + valueProp: { + description: + 'Prop the child receives the value on: `value` (default) or `checked` for switches.', + }, + transform: { + description: + 'Optional input/output mappers, e.g. bytes stored in the form but GB shown in the input.', + }, onAfterChange: { description: 'Called with the stored value after every change.' }, - rules: { description: 'Controller-level validation rules applied on top of the form resolver.' }, + rules: { + description: 'Controller-level validation rules applied on top of the form resolver.', + }, required: { description: 'Marks the label with the required asterisk.' }, noStyle: { description: 'Render the bare input without Form.Item chrome.' }, children: { description: 'The single Ant Design control to wire up.' }, @@ -56,7 +66,12 @@ function ClientDemo() { return (
- + @@ -96,7 +111,9 @@ function TrafficDemo() { > - Form state: {totalBytes.toLocaleString()} bytes + + Form state: {totalBytes.toLocaleString()} bytes +
); diff --git a/frontend/src/components/form/rhf/useZodForm.ts b/frontend/src/components/form/rhf/useZodForm.ts index c8f175824..62546c786 100644 --- a/frontend/src/components/form/rhf/useZodForm.ts +++ b/frontend/src/components/form/rhf/useZodForm.ts @@ -7,7 +7,9 @@ export function useZodForm( schema: z.ZodType, options?: Omit, 'resolver'>, ): UseFormReturn { - const resolver = zodResolver(schema as z.ZodType) as Resolver; + const resolver = zodResolver( + schema as z.ZodType, + ) as Resolver; return useForm({ mode: 'onSubmit', reValidateMode: 'onChange', diff --git a/frontend/src/components/geodata/GeoBrowserModal.stories.tsx b/frontend/src/components/geodata/GeoBrowserModal.stories.tsx index aeba7cfde..502cfb2f4 100644 --- a/frontend/src/components/geodata/GeoBrowserModal.stories.tsx +++ b/frontend/src/components/geodata/GeoBrowserModal.stories.tsx @@ -42,7 +42,9 @@ function deactivate(routes: GeoRoutes): void { function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) { const [client] = useState(() => { activate(routes); - return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); }); useEffect(() => { activate(routes); @@ -61,121 +63,379 @@ const cross = (names: string[], suffixes: string[]): GeoEntry[] => names.flatMap((name) => suffixes.map((suffix) => domain(`${name}.${suffix}`))); const CC_TLDS = [ - 'ae', 'al', 'am', 'at', 'az', 'ba', 'be', 'bg', 'bi', 'bj', 'ca', 'cat', 'cd', 'cf', 'cg', 'ch', - 'ci', 'cl', 'cm', 'co.id', 'co.il', 'co.in', 'co.jp', 'co.ke', 'co.kr', 'co.ma', 'co.nz', 'co.th', - 'co.uk', 'co.uz', 'co.ve', 'co.za', 'com.ar', 'com.au', 'com.bd', 'com.br', 'com.co', 'com.cu', - 'com.eg', 'com.gt', 'com.hk', 'com.mx', 'com.my', 'com.ng', 'com.pe', 'com.ph', 'com.pk', - 'com.sa', 'com.sg', 'com.tr', 'com.tw', 'com.ua', 'com.uy', 'com.vn', 'cz', 'de', 'dj', 'dk', - 'dz', 'ee', 'es', 'fi', 'fr', 'ga', 'ge', 'gl', 'gm', 'gr', 'hn', 'hr', 'ht', 'hu', 'ie', 'iq', - 'is', 'it', 'je', 'jo', 'kg', 'kz', 'la', 'li', 'lk', 'lt', 'lu', 'lv', 'ly', 'md', 'me', 'mg', - 'mk', 'ml', 'mn', 'mu', 'mv', 'mw', 'ne', 'nl', 'no', 'nu', 'pl', 'pt', 'ro', 'rs', 'ru', 'rw', - 'se', 'sh', 'si', 'sk', 'sm', 'sn', 'so', 'sr', 'st', 'td', 'tg', 'tk', 'tl', 'tm', 'tn', 'to', - 'tt', 'vg', 'vu', 'ws', + 'ae', + 'al', + 'am', + 'at', + 'az', + 'ba', + 'be', + 'bg', + 'bi', + 'bj', + 'ca', + 'cat', + 'cd', + 'cf', + 'cg', + 'ch', + 'ci', + 'cl', + 'cm', + 'co.id', + 'co.il', + 'co.in', + 'co.jp', + 'co.ke', + 'co.kr', + 'co.ma', + 'co.nz', + 'co.th', + 'co.uk', + 'co.uz', + 'co.ve', + 'co.za', + 'com.ar', + 'com.au', + 'com.bd', + 'com.br', + 'com.co', + 'com.cu', + 'com.eg', + 'com.gt', + 'com.hk', + 'com.mx', + 'com.my', + 'com.ng', + 'com.pe', + 'com.ph', + 'com.pk', + 'com.sa', + 'com.sg', + 'com.tr', + 'com.tw', + 'com.ua', + 'com.uy', + 'com.vn', + 'cz', + 'de', + 'dj', + 'dk', + 'dz', + 'ee', + 'es', + 'fi', + 'fr', + 'ga', + 'ge', + 'gl', + 'gm', + 'gr', + 'hn', + 'hr', + 'ht', + 'hu', + 'ie', + 'iq', + 'is', + 'it', + 'je', + 'jo', + 'kg', + 'kz', + 'la', + 'li', + 'lk', + 'lt', + 'lu', + 'lv', + 'ly', + 'md', + 'me', + 'mg', + 'mk', + 'ml', + 'mn', + 'mu', + 'mv', + 'mw', + 'ne', + 'nl', + 'no', + 'nu', + 'pl', + 'pt', + 'ro', + 'rs', + 'ru', + 'rw', + 'se', + 'sh', + 'si', + 'sk', + 'sm', + 'sn', + 'so', + 'sr', + 'st', + 'td', + 'tg', + 'tk', + 'tl', + 'tm', + 'tn', + 'to', + 'tt', + 'vg', + 'vu', + 'ws', ]; const AD_HOSTS = [ - 'adform', 'adnxs', 'adroll', 'adsrvr', 'amplitude', 'appsflyer', 'bluekai', 'branch', - 'casalemedia', 'criteo', 'flurry', 'moatads', 'mopub', 'openx', 'outbrain', 'pubmatic', - 'quantserve', 'rubiconproject', 'scorecardresearch', 'sharethrough', 'smartadserver', 'taboola', - 'teads', 'yieldmo', 'zemanta', + 'adform', + 'adnxs', + 'adroll', + 'adsrvr', + 'amplitude', + 'appsflyer', + 'bluekai', + 'branch', + 'casalemedia', + 'criteo', + 'flurry', + 'moatads', + 'mopub', + 'openx', + 'outbrain', + 'pubmatic', + 'quantserve', + 'rubiconproject', + 'scorecardresearch', + 'sharethrough', + 'smartadserver', + 'taboola', + 'teads', + 'yieldmo', + 'zemanta', ]; const CN_BRANDS = [ - '58', 'alibaba', 'alipay', 'aliyun', 'baidu', 'bilibili', 'cnblogs', 'csdn', 'ctrip', 'douban', - 'gitee', 'huawei', 'iqiyi', 'jd', 'kuaishou', 'meituan', 'netease', 'pinduoduo', 'qq', 'sina', - 'sohu', 'taobao', 'tencent', 'tmall', 'toutiao', 'weibo', 'xiaomi', 'youku', 'zhihu', + '58', + 'alibaba', + 'alipay', + 'aliyun', + 'baidu', + 'bilibili', + 'cnblogs', + 'csdn', + 'ctrip', + 'douban', + 'gitee', + 'huawei', + 'iqiyi', + 'jd', + 'kuaishou', + 'meituan', + 'netease', + 'pinduoduo', + 'qq', + 'sina', + 'sohu', + 'taobao', + 'tencent', + 'tmall', + 'toutiao', + 'weibo', + 'xiaomi', + 'youku', + 'zhihu', ]; const SITE_ENTRIES: Record = { amazon: [ - domain('amazon.com'), domain('amazonaws.com'), domain('media-amazon.com'), - domain('ssl-images-amazon.com'), domain('primevideo.com'), domain('awsstatic.com'), - domain('cloudfront.net'), full('www.amazon.co.jp'), + domain('amazon.com'), + domain('amazonaws.com'), + domain('media-amazon.com'), + domain('ssl-images-amazon.com'), + domain('primevideo.com'), + domain('awsstatic.com'), + domain('cloudfront.net'), + full('www.amazon.co.jp'), ], apple: [ - domain('apple.com'), domain('icloud.com'), domain('cdn-apple.com'), domain('mzstatic.com'), - domain('apple-cloudkit.com'), domain('itunes.com'), domain('me.com'), domain('appstore.com'), + domain('apple.com'), + domain('icloud.com'), + domain('cdn-apple.com'), + domain('mzstatic.com'), + domain('apple-cloudkit.com'), + domain('itunes.com'), + domain('me.com'), + domain('appstore.com'), ], 'category-ads': [ - domain('adcolony.com'), domain('applovin.com'), domain('chartboost.com'), - domain('inmobi.com'), domain('unityads.unity3d.com'), keyword('banner-ad'), + domain('adcolony.com'), + domain('applovin.com'), + domain('chartboost.com'), + domain('inmobi.com'), + domain('unityads.unity3d.com'), + keyword('banner-ad'), ], 'category-ads-all': [ - domain('doubleclick.net'), domain('googleadservices.com'), domain('googlesyndication.com'), - domain('adservice.google.com'), full('ads.yahoo.com'), keyword('adservice'), - keyword('advertising'), regexp('^ad[0-9]{1,3}\\.'), ...cross(AD_HOSTS, ['com', 'net', 'io', 'ru']), + domain('doubleclick.net'), + domain('googleadservices.com'), + domain('googlesyndication.com'), + domain('adservice.google.com'), + full('ads.yahoo.com'), + keyword('adservice'), + keyword('advertising'), + regexp('^ad[0-9]{1,3}\\.'), + ...cross(AD_HOSTS, ['com', 'net', 'io', 'ru']), ], cloudflare: [ - domain('cloudflare.com'), domain('cloudflare-dns.com'), domain('cloudflareinsights.com'), - domain('workers.dev'), domain('pages.dev'), domain('cf-ipfs.com'), + domain('cloudflare.com'), + domain('cloudflare-dns.com'), + domain('cloudflareinsights.com'), + domain('workers.dev'), + domain('pages.dev'), + domain('cf-ipfs.com'), ], cn: [full('www.gov.cn'), keyword('chinanet'), ...cross(CN_BRANDS, ['com', 'cn', 'com.cn'])], discord: [ - domain('discord.com'), domain('discord.gg'), domain('discordapp.com'), - domain('discordapp.net'), domain('discord.media'), + domain('discord.com'), + domain('discord.gg'), + domain('discordapp.com'), + domain('discordapp.net'), + domain('discord.media'), ], facebook: [ - domain('facebook.com'), domain('fbcdn.net'), domain('fb.com'), domain('messenger.com'), - domain('fbsbx.com'), domain('facebook.net'), full('m.facebook.com'), + domain('facebook.com'), + domain('fbcdn.net'), + domain('fb.com'), + domain('messenger.com'), + domain('fbsbx.com'), + domain('facebook.net'), + full('m.facebook.com'), ], 'geolocation-!cn': [ - keyword('proxy'), regexp('.*\\.onion$'), domain('wikipedia.org'), domain('bbc.com'), - domain('nytimes.com'), domain('reuters.com'), domain('medium.com'), domain('reddit.com'), + keyword('proxy'), + regexp('.*\\.onion$'), + domain('wikipedia.org'), + domain('bbc.com'), + domain('nytimes.com'), + domain('reuters.com'), + domain('medium.com'), + domain('reddit.com'), ], 'geolocation-cn': [ - domain('gov.cn'), domain('edu.cn'), domain('org.cn'), domain('net.cn'), + domain('gov.cn'), + domain('edu.cn'), + domain('org.cn'), + domain('net.cn'), ...cross(CN_BRANDS.slice(0, 18), ['cn']), ], github: [ - domain('github.com'), domain('githubusercontent.com'), domain('githubassets.com'), - domain('github.io'), domain('ghcr.io'), domain('git.io'), + domain('github.com'), + domain('githubusercontent.com'), + domain('githubassets.com'), + domain('github.io'), + domain('ghcr.io'), + domain('git.io'), ], google: [ - domain('google.com'), domain('googleapis.com'), domain('gstatic.com'), - domain('googleusercontent.com'), domain('google-analytics.com'), domain('googletagmanager.com'), - domain('ggpht.com'), domain('withgoogle.com'), domain('android.com'), domain('chromium.org'), - domain('abc.xyz'), full('dl.google.com'), ...CC_TLDS.map((tld) => domain(`google.${tld}`)), + domain('google.com'), + domain('googleapis.com'), + domain('gstatic.com'), + domain('googleusercontent.com'), + domain('google-analytics.com'), + domain('googletagmanager.com'), + domain('ggpht.com'), + domain('withgoogle.com'), + domain('android.com'), + domain('chromium.org'), + domain('abc.xyz'), + full('dl.google.com'), + ...CC_TLDS.map((tld) => domain(`google.${tld}`)), ], instagram: [domain('instagram.com'), domain('cdninstagram.com'), domain('ig.me')], microsoft: [ - domain('microsoft.com'), domain('live.com'), domain('office.com'), domain('office365.com'), - domain('windows.net'), domain('windowsupdate.com'), domain('msn.com'), domain('azure.com'), - domain('sharepoint.com'), domain('skype.com'), domain('bing.com'), + domain('microsoft.com'), + domain('live.com'), + domain('office.com'), + domain('office365.com'), + domain('windows.net'), + domain('windowsupdate.com'), + domain('msn.com'), + domain('azure.com'), + domain('sharepoint.com'), + domain('skype.com'), + domain('bing.com'), ], netflix: [ - domain('netflix.com'), domain('netflix.net'), domain('nflximg.com'), domain('nflximg.net'), - domain('nflxvideo.net'), domain('nflxso.net'), domain('nflxext.com'), full('fast.com'), + domain('netflix.com'), + domain('netflix.net'), + domain('nflximg.com'), + domain('nflximg.net'), + domain('nflxvideo.net'), + domain('nflxso.net'), + domain('nflxext.com'), + full('fast.com'), ], openai: [ - domain('openai.com'), domain('chatgpt.com'), domain('oaistatic.com'), - domain('oaiusercontent.com'), domain('sora.com'), + domain('openai.com'), + domain('chatgpt.com'), + domain('oaistatic.com'), + domain('oaiusercontent.com'), + domain('sora.com'), ], spotify: [ - domain('spotify.com'), domain('scdn.co'), domain('spotifycdn.com'), domain('spoti.fi'), + domain('spotify.com'), + domain('scdn.co'), + domain('spotifycdn.com'), + domain('spoti.fi'), domain('spotifycdn.net'), ], steam: [ - domain('steampowered.com'), domain('steamcommunity.com'), domain('steamstatic.com'), - domain('steamcontent.com'), domain('valvesoftware.com'), + domain('steampowered.com'), + domain('steamcommunity.com'), + domain('steamstatic.com'), + domain('steamcontent.com'), + domain('valvesoftware.com'), ], telegram: [ - domain('telegram.org'), domain('telegram.me'), domain('t.me'), domain('telesco.pe'), - domain('tdesktop.com'), domain('telegra.ph'), domain('cdn-telegram.org'), - full('comments.app'), keyword('telegram'), + domain('telegram.org'), + domain('telegram.me'), + domain('t.me'), + domain('telesco.pe'), + domain('tdesktop.com'), + domain('telegra.ph'), + domain('cdn-telegram.org'), + full('comments.app'), + keyword('telegram'), ], tiktok: [ - domain('tiktok.com'), domain('tiktokcdn.com'), domain('tiktokv.com'), - domain('byteoversea.com'), domain('ibytedtos.com'), domain('musical.ly'), + domain('tiktok.com'), + domain('tiktokcdn.com'), + domain('tiktokv.com'), + domain('byteoversea.com'), + domain('ibytedtos.com'), + domain('musical.ly'), ], twitch: [domain('twitch.tv'), domain('ttvnw.net'), domain('jtvnw.net'), domain('twitchcdn.net')], twitter: [ - domain('twitter.com'), domain('x.com'), domain('t.co'), domain('twimg.com'), + domain('twitter.com'), + domain('x.com'), + domain('t.co'), + domain('twimg.com'), domain('periscope.tv'), ], whatsapp: [domain('whatsapp.com'), domain('whatsapp.net'), domain('wa.me')], youtube: [ - domain('youtube.com'), domain('youtu.be'), domain('ytimg.com'), domain('googlevideo.com'), - domain('youtube-nocookie.com'), domain('yt.be'), + domain('youtube.com'), + domain('youtu.be'), + domain('ytimg.com'), + domain('googlevideo.com'), + domain('youtube-nocookie.com'), + domain('yt.be'), ], }; @@ -192,68 +452,206 @@ const SITE_ATTRIBUTES: Record = { }; const CN_BLOCKS = [ - '1.0.1.0/24', '1.0.2.0/23', '1.0.8.0/21', '14.0.12.0/22', '27.0.128.0/21', '36.0.0.0/22', - '39.0.0.0/24', '42.0.0.0/22', '58.14.0.0/15', '59.32.0.0/11', '61.128.0.0/10', '101.16.0.0/12', - '103.1.8.0/22', '106.0.0.0/10', '110.6.0.0/15', '111.0.0.0/10', '112.0.0.0/10', '113.0.0.0/9', - '114.28.0.0/16', '116.0.0.0/9', '117.8.0.0/13', '118.24.0.0/15', '119.0.0.0/9', '120.0.0.0/10', - '121.0.0.0/8', '124.0.0.0/8', '125.32.0.0/11', '139.196.0.0/14', '140.75.0.0/16', '175.0.0.0/12', - '180.76.0.0/16', '182.16.0.0/12', '183.0.0.0/10', '202.0.0.0/12', '203.0.0.0/12', '210.0.0.0/12', - '211.64.0.0/11', '218.0.0.0/9', '219.72.0.0/14', '220.112.0.0/12', '221.0.0.0/9', '222.16.0.0/12', - '2001:250::/35', '2400:3200::/32', '2408:8000::/20', + '1.0.1.0/24', + '1.0.2.0/23', + '1.0.8.0/21', + '14.0.12.0/22', + '27.0.128.0/21', + '36.0.0.0/22', + '39.0.0.0/24', + '42.0.0.0/22', + '58.14.0.0/15', + '59.32.0.0/11', + '61.128.0.0/10', + '101.16.0.0/12', + '103.1.8.0/22', + '106.0.0.0/10', + '110.6.0.0/15', + '111.0.0.0/10', + '112.0.0.0/10', + '113.0.0.0/9', + '114.28.0.0/16', + '116.0.0.0/9', + '117.8.0.0/13', + '118.24.0.0/15', + '119.0.0.0/9', + '120.0.0.0/10', + '121.0.0.0/8', + '124.0.0.0/8', + '125.32.0.0/11', + '139.196.0.0/14', + '140.75.0.0/16', + '175.0.0.0/12', + '180.76.0.0/16', + '182.16.0.0/12', + '183.0.0.0/10', + '202.0.0.0/12', + '203.0.0.0/12', + '210.0.0.0/12', + '211.64.0.0/11', + '218.0.0.0/9', + '219.72.0.0/14', + '220.112.0.0/12', + '221.0.0.0/9', + '222.16.0.0/12', + '2001:250::/35', + '2400:3200::/32', + '2408:8000::/20', ]; -const CN_EXTRA_BLOCKS = Array.from({ length: 96 }, (_, index) => - `${39 + Math.floor(index / 16)}.${(index % 16) * 16}.0.0/12`, +const CN_EXTRA_BLOCKS = Array.from( + { length: 96 }, + (_, index) => `${39 + Math.floor(index / 16)}.${(index % 16) * 16}.0.0/12`, ); const IP_ENTRIES: Record = { cloudflare: [ - '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '104.16.0.0/13', '104.24.0.0/14', - '108.162.192.0/18', '131.0.72.0/22', '141.101.64.0/18', '162.158.0.0/15', '172.64.0.0/13', - '173.245.48.0/20', '188.114.96.0/20', '190.93.240.0/20', '197.234.240.0/22', '198.41.128.0/17', - '2400:cb00::/32', '2606:4700::/32', + '103.21.244.0/22', + '103.22.200.0/22', + '103.31.4.0/22', + '104.16.0.0/13', + '104.24.0.0/14', + '108.162.192.0/18', + '131.0.72.0/22', + '141.101.64.0/18', + '162.158.0.0/15', + '172.64.0.0/13', + '173.245.48.0/20', + '188.114.96.0/20', + '190.93.240.0/20', + '197.234.240.0/22', + '198.41.128.0/17', + '2400:cb00::/32', + '2606:4700::/32', ].map(cidr), cn: [...CN_BLOCKS, ...CN_EXTRA_BLOCKS].map(cidr), facebook: [ - '31.13.24.0/21', '31.13.64.0/18', '66.220.144.0/20', '69.63.176.0/20', '69.171.224.0/19', - '157.240.0.0/16', '179.60.192.0/22', '185.60.216.0/22', '2a03:2880::/32', + '31.13.24.0/21', + '31.13.64.0/18', + '66.220.144.0/20', + '69.63.176.0/20', + '69.171.224.0/19', + '157.240.0.0/16', + '179.60.192.0/22', + '185.60.216.0/22', + '2a03:2880::/32', ].map(cidr), google: [ - '8.8.4.0/24', '8.8.8.0/24', '34.64.0.0/10', '35.184.0.0/13', '64.233.160.0/19', '66.102.0.0/20', - '72.14.192.0/18', '74.125.0.0/16', '108.177.8.0/21', '142.250.0.0/15', '172.217.0.0/16', - '216.58.192.0/19', '2404:6800::/32', '2607:f8b0::/32', + '8.8.4.0/24', + '8.8.8.0/24', + '34.64.0.0/10', + '35.184.0.0/13', + '64.233.160.0/19', + '66.102.0.0/20', + '72.14.192.0/18', + '74.125.0.0/16', + '108.177.8.0/21', + '142.250.0.0/15', + '172.217.0.0/16', + '216.58.192.0/19', + '2404:6800::/32', + '2607:f8b0::/32', ].map(cidr), ir: [ - '2.144.0.0/14', '5.22.0.0/17', '31.2.128.0/17', '37.32.0.0/19', '46.32.0.0/19', '78.38.0.0/15', - '80.191.0.0/16', '85.15.0.0/18', '91.98.0.0/15', '178.22.72.0/21', '185.8.172.0/22', - '188.34.0.0/17', '217.218.0.0/15', + '2.144.0.0/14', + '5.22.0.0/17', + '31.2.128.0/17', + '37.32.0.0/19', + '46.32.0.0/19', + '78.38.0.0/15', + '80.191.0.0/16', + '85.15.0.0/18', + '91.98.0.0/15', + '178.22.72.0/21', + '185.8.172.0/22', + '188.34.0.0/17', + '217.218.0.0/15', ].map(cidr), netflix: [ - '23.246.0.0/18', '37.77.184.0/21', '45.57.0.0/17', '64.120.128.0/17', '66.197.128.0/17', - '108.175.32.0/20', '185.2.220.0/22', '192.173.64.0/18', '198.38.96.0/19', '198.45.48.0/20', + '23.246.0.0/18', + '37.77.184.0/21', + '45.57.0.0/17', + '64.120.128.0/17', + '66.197.128.0/17', + '108.175.32.0/20', + '185.2.220.0/22', + '192.173.64.0/18', + '198.38.96.0/19', + '198.45.48.0/20', ].map(cidr), private: [ - '0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', - '192.0.0.0/24', '192.0.2.0/24', '192.168.0.0/16', '198.18.0.0/15', '198.51.100.0/24', - '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', '255.255.255.255/32', '::1/128', 'fc00::/7', + '0.0.0.0/8', + '10.0.0.0/8', + '100.64.0.0/10', + '127.0.0.0/8', + '169.254.0.0/16', + '172.16.0.0/12', + '192.0.0.0/24', + '192.0.2.0/24', + '192.168.0.0/16', + '198.18.0.0/15', + '198.51.100.0/24', + '203.0.113.0/24', + '224.0.0.0/4', + '240.0.0.0/4', + '255.255.255.255/32', + '::1/128', + 'fc00::/7', 'fe80::/10', ].map(cidr), ru: [ - '2.60.0.0/14', '5.8.0.0/19', '31.6.0.0/17', '37.9.0.0/19', '46.16.0.0/21', '62.76.0.0/18', - '77.37.128.0/17', '78.24.216.0/21', '79.104.0.0/15', '80.64.128.0/19', '81.16.96.0/19', - '82.140.128.0/18', '85.113.0.0/16', '87.226.0.0/16', '91.77.0.0/16', '93.157.0.0/17', - '94.19.0.0/16', '95.24.0.0/13', '178.176.0.0/13', '188.128.0.0/13', '213.87.0.0/16', - '217.66.152.0/21', '2a00:1148::/32', + '2.60.0.0/14', + '5.8.0.0/19', + '31.6.0.0/17', + '37.9.0.0/19', + '46.16.0.0/21', + '62.76.0.0/18', + '77.37.128.0/17', + '78.24.216.0/21', + '79.104.0.0/15', + '80.64.128.0/19', + '81.16.96.0/19', + '82.140.128.0/18', + '85.113.0.0/16', + '87.226.0.0/16', + '91.77.0.0/16', + '93.157.0.0/17', + '94.19.0.0/16', + '95.24.0.0/13', + '178.176.0.0/13', + '188.128.0.0/13', + '213.87.0.0/16', + '217.66.152.0/21', + '2a00:1148::/32', ].map(cidr), telegram: [ - '91.108.4.0/22', '91.108.8.0/22', '91.108.12.0/22', '91.108.16.0/22', '91.108.20.0/22', - '91.108.56.0/22', '149.154.160.0/20', '2001:67c:4e8::/48', '2001:b28:f23d::/48', + '91.108.4.0/22', + '91.108.8.0/22', + '91.108.12.0/22', + '91.108.16.0/22', + '91.108.20.0/22', + '91.108.56.0/22', + '149.154.160.0/20', + '2001:67c:4e8::/48', + '2001:b28:f23d::/48', '2001:b28:f23f::/48', ].map(cidr), us: [ - '3.0.0.0/9', '12.0.0.0/8', '23.192.0.0/11', '34.192.0.0/10', '50.16.0.0/14', '52.0.0.0/10', - '63.64.0.0/11', '65.0.0.0/10', '68.32.0.0/11', '71.0.0.0/11', '96.0.0.0/9', '128.0.0.0/10', - '199.0.0.0/12', '208.64.0.0/12', '2600:1f00::/24', + '3.0.0.0/9', + '12.0.0.0/8', + '23.192.0.0/11', + '34.192.0.0/10', + '50.16.0.0/14', + '52.0.0.0/10', + '63.64.0.0/11', + '65.0.0.0/10', + '68.32.0.0/11', + '71.0.0.0/11', + '96.0.0.0/9', + '128.0.0.0/10', + '199.0.0.0/12', + '208.64.0.0/12', + '2600:1f00::/24', ].map(cidr), }; @@ -305,10 +703,11 @@ const OVERSIZED_FILE: GeoFile = { error: 'geodata file is too large to browse', }; -const DATASETS: Record }> = { - 'geosite.dat': { categories: SITE_CATEGORIES, entries: SITE_ENTRIES }, - 'geoip.dat': { categories: IP_CATEGORIES, entries: IP_ENTRIES }, -}; +const DATASETS: Record }> = + { + 'geosite.dat': { categories: SITE_CATEGORIES, entries: SITE_ENTRIES }, + 'geoip.dat': { categories: IP_CATEGORIES, entries: IP_ENTRIES }, + }; function routesFor(files: GeoFile[]): GeoRoutes { return { @@ -316,7 +715,9 @@ function routesFor(files: GeoFile[]): GeoRoutes { '/panel/api/xray/geodata/categories': (query) => { const dataset = DATASETS[query.get('file') ?? '']; const needle = (query.get('q') ?? '').trim().toLowerCase(); - const items = (dataset?.categories ?? []).filter((category) => category.code.includes(needle)); + const items = (dataset?.categories ?? []).filter((category) => + category.code.includes(needle), + ); return { total: items.length, items }; }, '/panel/api/xray/geodata/entries': (query) => { @@ -351,7 +752,7 @@ function BrowserDemo(props: GeoBrowserModalProps) { useEffect(() => setOpen(props.open), [props.open]); useEffect(() => setValue(props.value), [props.value]); return ( - + {value || 'no rule yet'} @@ -398,12 +799,14 @@ const meta = { argTypes: { open: { description: 'Whether the modal is visible.' }, kind: { - description: 'Which database layout the rule targets: `site` for domain rules, `ip` for CIDR rules. Decides the preselected database and the token prefix.', + description: + 'Which database layout the rule targets: `site` for domain rules, `ip` for CIDR rules. Decides the preselected database and the token prefix.', control: 'inline-radio', options: ['site', 'ip'], }, value: { - description: 'Current rule string, comma separated. Tokens that match a category in the opened database come back preselected.', + description: + 'Current rule string, comma separated. Tokens that match a category in the opened database come back preselected.', }, onApply: { description: 'Called with the merged rule string when Apply is pressed.' }, onClose: { description: 'Called when the modal is dismissed.' }, diff --git a/frontend/src/components/geodata/GeoBrowserModal.tsx b/frontend/src/components/geodata/GeoBrowserModal.tsx index 612c29245..f08b014c0 100644 --- a/frontend/src/components/geodata/GeoBrowserModal.tsx +++ b/frontend/src/components/geodata/GeoBrowserModal.tsx @@ -1,6 +1,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Alert, Button, Empty, Input, Modal, Pagination, Select, Space, Table, Tag, Tooltip, Typography } from 'antd'; +import { + Alert, + Button, + Empty, + Input, + Modal, + Pagination, + Select, + Space, + Table, + Tag, + Tooltip, + Typography, +} from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { useGeodataCategories, useGeodataEntries, useGeodataFiles } from '@/api/queries/useGeodata'; @@ -25,7 +38,9 @@ export interface GeoBrowserModalProps { // A geosite category inside an ip rule (or the reverse) is a config Xray will // reject, so a field only ever offers databases of its own kind. function databasesFor(files: GeoFile[], kind: GeoKind): GeoFile[] { - return files.filter((file) => file.kind === kind || (file.error && namePrefersKind(file.name, kind))); + return files.filter( + (file) => file.kind === kind || (file.error && namePrefersKind(file.name, kind)), + ); } function namePrefersKind(name: string, kind: GeoKind): boolean { @@ -38,7 +53,13 @@ function preferredFile(files: GeoFile[], kind: GeoKind): string | undefined { return usable.find((file) => file.name === preferredName)?.name ?? usable[0]?.name; } -export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: GeoBrowserModalProps) { +export default function GeoBrowserModal({ + open, + kind, + value, + onApply, + onClose, +}: GeoBrowserModalProps) { const { t } = useTranslation(); const [file, setFile] = useState(undefined); const [categoryQuery, setCategoryQuery] = useState(''); @@ -120,7 +141,10 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: seededFilesRef.current.add(file); const fromValue = selectionFromValue(value, new Set(tokens)); if (fromValue.length > 0) { - setSelected((previous) => [...previous, ...fromValue.filter((token) => !previous.includes(token))]); + setSelected((previous) => [ + ...previous, + ...fromValue.filter((token) => !previous.includes(token)), + ]); } }, [open, file, categories, fileKind, value]); @@ -149,7 +173,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: // The table reports keys for the rows it currently shows, so a selection // made before the search box was narrowed must survive untouched. const shown = new Set( - visibleCategories.map((category) => canonicalToken(tokenFor(file, category.code, fileKind))), + visibleCategories.map((category) => + canonicalToken(tokenFor(file, category.code, fileKind)), + ), ); setSelected((previous) => { const kept = previous.filter((token) => { @@ -157,7 +183,10 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: return !shown.has(canonical) || chosenCanonical.has(canonical); }); const keptCanonical = new Set(kept.map(canonicalToken)); - return [...kept, ...[...chosen].filter((token) => !keptCanonical.has(canonicalToken(token)))]; + return [ + ...kept, + ...[...chosen].filter((token) => !keptCanonical.has(canonicalToken(token))), + ]; }); }, [visibleCategories, file, fileKind], @@ -174,7 +203,7 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: {category.attributes?.length > 0 && ( {category.attributes.map((attribute) => ( - + @{attribute} ))} @@ -199,7 +228,7 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: dataIndex: 'kind', width: 88, render: (entryKind: string) => ( - + {entryKind} ), @@ -214,7 +243,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: const fileOptions = files.map((candidate) => ({ value: candidate.name, - label: candidate.error ? `${candidate.name} — ${describeFileError(candidate.error, t)}` : candidate.name, + label: candidate.error + ? `${candidate.name} — ${describeFileError(candidate.error, t)}` + : candidate.name, disabled: !!candidate.error, })); @@ -229,9 +260,14 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: const entriesTotal = entriesQuery.data?.total ?? 0; const activeCategory = categories.find((category) => category.code === activeCode); const countLabel = activeCategory - ? t(fileKind === 'ip' ? 'pages.xray.geoBrowser.subnetsCount' : 'pages.xray.geoBrowser.entriesCount', { - count: activeCategory.entries.toLocaleString(), - }) + ? t( + fileKind === 'ip' + ? 'pages.xray.geoBrowser.subnetsCount' + : 'pages.xray.geoBrowser.entriesCount', + { + count: activeCategory.entries.toLocaleString(), + }, + ) : ''; return ( @@ -245,7 +281,14 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: cancelText={t('close')} className="geo-browser-modal" > - {filesQuery.isError && } + {filesQuery.isError && ( + + )} {!filesQuery.isError && !filesQuery.isLoading && files.length === 0 ? ( {t('pages.xray.geoBrowser.noFiles')}
- {t('pages.xray.geoBrowser.noFilesHint')} + + {t('pages.xray.geoBrowser.noFilesHint')} +
} /> @@ -279,7 +324,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: allowClear />
@@ -377,7 +431,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
{selected.length === 0 ? ( - {t('pages.xray.geoBrowser.emptySelection')} + + {t('pages.xray.geoBrowser.emptySelection')} + ) : ( <> @@ -386,7 +442,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: key={token} closable color="processing" - onClose={() => setSelected((previous) => previous.filter((item) => item !== token))} + onClose={() => + setSelected((previous) => previous.filter((item) => item !== token)) + } > {token} diff --git a/frontend/src/components/geodata/GeoTokenInput.stories.tsx b/frontend/src/components/geodata/GeoTokenInput.stories.tsx index 522f9298f..6da9b7f00 100644 --- a/frontend/src/components/geodata/GeoTokenInput.stories.tsx +++ b/frontend/src/components/geodata/GeoTokenInput.stories.tsx @@ -44,7 +44,9 @@ function deactivate(routes: GeoRoutes): void { function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) { const [client] = useState(() => { activate(routes); - return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); }); useEffect(() => { activate(routes); @@ -58,25 +60,55 @@ const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value }); const SITE_ENTRIES: Record = { 'category-ads-all': [ - domain('doubleclick.net'), domain('googleadservices.com'), domain('googlesyndication.com'), - domain('criteo.com'), domain('taboola.com'), domain('outbrain.com'), + domain('doubleclick.net'), + domain('googleadservices.com'), + domain('googlesyndication.com'), + domain('criteo.com'), + domain('taboola.com'), + domain('outbrain.com'), + ], + cn: [ + domain('baidu.com'), + domain('qq.com'), + domain('taobao.com'), + domain('weibo.com'), + domain('bilibili.com'), ], - cn: [domain('baidu.com'), domain('qq.com'), domain('taobao.com'), domain('weibo.com'), domain('bilibili.com')], google: [ - domain('google.com'), domain('googleapis.com'), domain('gstatic.com'), - domain('googleusercontent.com'), domain('ggpht.com'), domain('android.com'), + domain('google.com'), + domain('googleapis.com'), + domain('gstatic.com'), + domain('googleusercontent.com'), + domain('ggpht.com'), + domain('android.com'), + ], + netflix: [ + domain('netflix.com'), + domain('nflximg.net'), + domain('nflxvideo.net'), + domain('fast.com'), ], - netflix: [domain('netflix.com'), domain('nflximg.net'), domain('nflxvideo.net'), domain('fast.com')], telegram: [domain('telegram.org'), domain('t.me'), domain('telesco.pe'), domain('telegra.ph')], - youtube: [domain('youtube.com'), domain('youtu.be'), domain('ytimg.com'), domain('googlevideo.com')], + youtube: [ + domain('youtube.com'), + domain('youtu.be'), + domain('ytimg.com'), + domain('googlevideo.com'), + ], }; const IP_ENTRIES: Record = { cloudflare: ['104.16.0.0/13', '172.64.0.0/13', '2606:4700::/32'].map(cidr), cn: ['1.0.1.0/24', '36.0.0.0/22', '116.0.0.0/9', '2408:8000::/20'].map(cidr), private: [ - '10.0.0.0/8', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', '192.168.0.0/16', - '::1/128', 'fc00::/7', 'fe80::/10', + '10.0.0.0/8', + '127.0.0.0/8', + '169.254.0.0/16', + '172.16.0.0/12', + '192.168.0.0/16', + '::1/128', + 'fc00::/7', + 'fe80::/10', ].map(cidr), telegram: ['91.108.4.0/22', '149.154.160.0/20', '2001:b28:f23d::/48'].map(cidr), }; @@ -95,10 +127,14 @@ function categoriesOf( .map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] })); } -const DATASETS: Record }> = { - 'geosite.dat': { categories: categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES), entries: SITE_ENTRIES }, - 'geoip.dat': { categories: categoriesOf(IP_ENTRIES), entries: IP_ENTRIES }, -}; +const DATASETS: Record }> = + { + 'geosite.dat': { + categories: categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES), + entries: SITE_ENTRIES, + }, + 'geoip.dat': { categories: categoriesOf(IP_ENTRIES), entries: IP_ENTRIES }, + }; const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12); @@ -125,7 +161,9 @@ function referenceOf(token: string, isIP: boolean): { file: string; code: string if (prefix === 'geosite') return { file: 'geosite.dat', code: code(rest.join(':')) }; if (prefix === 'geoip') return { file: 'geoip.dat', code: code(rest.join(':')) }; if (prefix === 'ext') return { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) }; - return isIP && prefix === 'ext-ip' ? { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) } : null; + return isIP && prefix === 'ext-ip' + ? { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) } + : null; } function validate(tokens: string[], isIP: boolean): GeodataTokenIssue[] { @@ -180,7 +218,7 @@ function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoToken const [current, setCurrent] = useState(value); useEffect(() => setCurrent(value), [value]); return ( - + @@ -209,10 +247,15 @@ const meta = { args: { kind: 'domain' }, argTypes: { value: { description: 'Comma separated rule string held by the parent form.' }, - onChange: { description: 'Called with the full rule string on every edit and on Apply from the browser.' }, - onBlur: { description: 'Forwarded to the input; used by React Hook Form to mark the field touched.' }, + onChange: { + description: 'Called with the full rule string on every edit and on Apply from the browser.', + }, + onBlur: { + description: 'Forwarded to the input; used by React Hook Form to mark the field touched.', + }, kind: { - description: 'Which database the tokens are validated against: `domain` for geosite, `ip` for geoip.', + description: + 'Which database the tokens are validated against: `domain` for geosite, `ip` for geoip.', control: 'inline-radio', options: ['domain', 'ip'], }, @@ -242,6 +285,8 @@ export const UnknownCategory: Story = { args: { kind: 'domain', value: 'geosite:blabla, geosite:google' }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(await canvas.findByText(/geosite:blabla/, undefined, { timeout: 3000 })).toBeVisible(); + await expect( + await canvas.findByText(/geosite:blabla/, undefined, { timeout: 3000 }), + ).toBeVisible(); }, }; diff --git a/frontend/src/components/geodata/GeoTokenInput.tsx b/frontend/src/components/geodata/GeoTokenInput.tsx index 9cbd4e5dd..a7b0b4b6b 100644 --- a/frontend/src/components/geodata/GeoTokenInput.tsx +++ b/frontend/src/components/geodata/GeoTokenInput.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import type { Ref } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Input, Tooltip, Typography } from 'antd'; +import { Button, Input, Space, Tooltip, Typography } from 'antd'; import type { InputRef } from 'antd'; import { DatabaseOutlined } from '@ant-design/icons'; @@ -33,7 +33,15 @@ export interface GeoTokenInputProps { ref?: Ref; } -export default function GeoTokenInput({ value = '', onChange, onBlur, kind, placeholder, id, ref }: GeoTokenInputProps) { +export default function GeoTokenInput({ + value = '', + onChange, + onBlur, + kind, + placeholder, + id, + ref, +}: GeoTokenInputProps) { const { t } = useTranslation(); const [browsing, setBrowsing] = useState(false); const [issues, setIssues] = useState([]); @@ -72,25 +80,23 @@ export default function GeoTokenInput({ value = '', onChange, onBlur, kind, plac return ( <> - onChange?.(event.target.value)} - onBlur={onBlur} - addonAfter={ - -
))} @@ -644,7 +731,9 @@ function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) { } function HeaderCustomGroups({ - tcpFieldName, form, absoluteSettingsPath, + tcpFieldName, + form, + absoluteSettingsPath, }: { tcpFieldName: number; form: FormInstance; @@ -695,7 +784,12 @@ function HeaderCustomGroups({ key={item.key} fieldName={item.name} form={form} - absoluteItemPath={[...absoluteSettingsPath, groupKey, group.name, item.name]} + absoluteItemPath={[ + ...absoluteSettingsPath, + groupKey, + group.name, + item.name, + ]} delayMode="number" onRemove={() => removeItem(item.name)} /> @@ -714,8 +808,18 @@ function HeaderCustomGroups({ } function UdpMasksList({ - base, form, isHysteria, isWireguard, network, -}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; isWireguard: boolean; network: string }) { + base, + form, + isHysteria, + isWireguard, + network, +}: { + base: (string | number)[]; + form: FormInstance; + isHysteria: boolean; + isWireguard: boolean; + network: string; +}) { const { t } = useTranslation(); return ( @@ -753,7 +857,14 @@ function UdpMasksList({ } function UdpMaskItem({ - fieldName, displayIndex, form, listPath, isHysteria, isWireguard, network, onRemove, + fieldName, + displayIndex, + form, + listPath, + isHysteria, + isWireguard, + network, + onRemove, }: { fieldName: number; displayIndex: number; @@ -778,16 +889,16 @@ function UdpMaskItem({ const options = isHysteria ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }] : [ - // Salamander is the mask xray-core's own wireguard finalmask example - // uses; it stays hysteria-only elsewhere to keep legacy parity. - ...(isWireguard ? [{ value: 'salamander', label: 'Salamander' }] : []), - { value: 'mkcp-legacy', label: 'mKCP Legacy' }, - { value: 'xdns', label: 'xDNS' }, - { value: 'xicmp', label: 'xICMP' }, - { value: 'realm', label: 'Realm' }, - { value: 'header-custom', label: 'Header Custom' }, - { value: 'noise', label: 'Noise' }, - ]; + // Salamander is the mask xray-core's own wireguard finalmask example + // uses; it stays hysteria-only elsewhere to keep legacy parity. + ...(isWireguard ? [{ value: 'salamander', label: 'Salamander' }] : []), + { value: 'mkcp-legacy', label: 'mKCP Legacy' }, + { value: 'xdns', label: 'xDNS' }, + { value: 'xicmp', label: 'xICMP' }, + { value: 'realm', label: 'Realm' }, + { value: 'header-custom', label: 'Header Custom' }, + { value: 'noise', label: 'Noise' }, + ]; return (
@@ -809,12 +920,20 @@ function UdpMaskItem({ getDeep(prev, [...absolutePath, 'type']) !== getDeep(curr, [...absolutePath, 'type'])} + shouldUpdate={(prev, curr) => + getDeep(prev, [...absolutePath, 'type']) !== getDeep(curr, [...absolutePath, 'type']) + } > {({ getFieldValue }) => { const type = getFieldValue([...absolutePath, 'type']) as string | undefined; if (type === 'salamander') { - return ; + return ( + + ); } if (type === 'mkcp-legacy') { return ( @@ -848,7 +967,11 @@ function UdpMaskItem({ if (type === 'xicmp') { return ( <> - + @@ -864,10 +987,20 @@ function UdpMaskItem({ - - TLS (optional) - + + TLS (optional) + + @@ -881,12 +1014,11 @@ function UdpMaskItem({ ]} /> - - methods.setValue('uuid', e.target.value)} /> -
{linkRows.length === 0 ? ( - {t('pages.clients.noExternalLinks')} - ) : linkRows.map(({ field, index }) => ( -
-
-
- - - - {t('enable')} -
- - - - -
-
- - - - ( - 0 ? dayjs(Number(expiryField.value)) : null} - onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)} - placeholder={t('pages.inbounds.leaveBlankToNeverExpire')} + + {t('pages.clients.noExternalLinks')} + + ) : ( + linkRows.map(({ field, index }) => ( +
+
+
+ + + + {t('enable')} +
+ + - )} - /> + + +
+
+ + + + ( + 0 + ? dayjs(Number(expiryField.value)) + : null + } + onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)} + placeholder={t('pages.inbounds.leaveBlankToNeverExpire')} + /> + )} + /> +
-
- ))} + )) + )}
-
{subscriptionRows.length === 0 ? ( - {t('pages.clients.noExternalSubscriptions')} - ) : subscriptionRows.map(({ field, index }) => ( -
-
-
- - - - {t('enable')} -
- - - - -
-
- - - - - - - ( - 0 ? dayjs(Number(expiryField.value)) : null} - onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)} - placeholder={t('pages.inbounds.leaveBlankToNeverExpire')} + + {t('pages.clients.noExternalSubscriptions')} + + ) : ( + subscriptionRows.map(({ field, index }) => ( +
+
+
+ + + + {t('enable')} +
+ + - )} - /> + + +
+
+ + + + + + + ( + 0 + ? dayjs(Number(expiryField.value)) + : null + } + onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)} + placeholder={t('pages.inbounds.leaveBlankToNeverExpire')} + /> + )} + /> +
+ + {field.lastFetchError + ? `${t('pages.clients.lastFetchError')}: ${field.lastFetchError}` + : field.lastFetchAt > 0 + ? `${t('pages.clients.lastFetchAt')}: ${dayjs(field.lastFetchAt).format('YYYY-MM-DD HH:mm:ss')}` + : t('pages.clients.neverFetched')} +
- - {field.lastFetchError - ? `${t('pages.clients.lastFetchError')}: ${field.lastFetchError}` - : field.lastFetchAt > 0 - ? `${t('pages.clients.lastFetchAt')}: ${dayjs(field.lastFetchAt).format('YYYY-MM-DD HH:mm:ss')}` - : t('pages.clients.neverFetched')} - -
- ))} + )) + )}
), @@ -1180,7 +1387,13 @@ reset: Number(values.reset) || 0, , - , , - , @@ -329,7 +409,11 @@ export default function ClientInfoModal({ {t('pages.clients.renewsUsed')} - = (traffic?.resetMax ?? 0) ? 'red' : 'blue'}> + = (traffic?.resetMax ?? 0) ? 'red' : 'blue' + } + > {traffic?.resetCount ?? 0} / {traffic?.resetMax} @@ -337,22 +421,30 @@ export default function ClientInfoModal({ )} {t('pages.inbounds.createdAt')} - {dateLabel(client.createdAt)} + + {dateLabel(client.createdAt)} + {t('pages.inbounds.updatedAt')} - {dateLabel(client.updatedAt)} + + {dateLabel(client.updatedAt)} + {client.group && ( {t('pages.clients.group')} - {client.group} + + {client.group} + )} {client.comment && ( {t('pages.clients.comment')} - {client.comment} + + {client.comment} + )} @@ -404,7 +496,9 @@ export default function ClientInfoModal({ <> {t('subscription.title')} {subJsonLink && (
- JSON + + JSON +
-
@@ -468,7 +608,9 @@ export default function ClientInfoModal({ {subClashLink && (
- CLASH + + CLASH +
-
@@ -513,13 +677,22 @@ export default function ClientInfoModal({ const canQr = !isPostQuantumLink(link); return (
- {parts - ? - : LINK} - {rowTitle} + {parts ? ( + + ) : ( + LINK + )} + + {rowTitle} +
- , - , } + extra={ + + } /> ) : ( @@ -1035,46 +1237,90 @@ export default function ClientsPage() { - } /> + } + /> } + content={ + + } > - } /> + } + /> } + content={ + + } > - } /> + } + /> } + content={ + + } > - } /> + } + /> } + content={ + + } > - } /> + } + /> - } /> + } + /> @@ -1087,7 +1333,12 @@ export default function ClientsPage() { title={
{selectedRowKeys.length === 0 ? ( - ) : ( @@ -1104,102 +1355,103 @@ export default function ClientsPage() { trigger={['click']} placement="bottomRight" menu={{ - items: selectedRowKeys.length > 0 - ? [ - { - key: 'attach', - icon: , - label: t('pages.clients.attach'), - onClick: () => setBulkAttachOpen(true), - }, - { - key: 'detach', - icon: , - label: t('pages.clients.detach'), - danger: true, - onClick: () => setBulkDetachOpen(true), - }, - { - key: 'addToGroup', - icon: , - label: t('pages.clients.addToGroup'), - onClick: () => setBulkGroupOpen(true), - }, - { - key: 'ungroup', - icon: , - label: t('pages.clients.ungroup'), - danger: true, - onClick: onBulkUngroup, - }, - { type: 'divider' as const }, - { - key: 'enable', - icon: , - label: t('pages.clients.enable'), - onClick: () => onBulkSetEnable(true), - }, - { - key: 'disable', - icon: , - label: t('pages.clients.disable'), - danger: true, - onClick: () => onBulkSetEnable(false), - }, - { - key: 'adjust', - icon: , - label: t('pages.clients.adjust'), - onClick: () => setBulkAdjustOpen(true), - }, - { - key: 'subLinks', - icon: , - label: t('pages.clients.subLinks'), - onClick: () => setSubLinksOpen(true), - }, - ] - : [ - { - key: 'bulk', - icon: , - label: t('pages.clients.bulk'), - onClick: () => setBulkAddOpen(true), - }, - { - key: 'export', - icon: , - label: t('pages.clients.exportClients'), - onClick: onExportClients, - }, - { - key: 'import', - icon: , - label: t('pages.clients.importClients'), - onClick: onImportClients, - }, - { - key: 'resetAll', - icon: , - label: t('pages.clients.resetAllTraffics'), - onClick: onResetAllTraffics, - }, - { type: 'divider' as const }, - { - key: 'delDepleted', - icon: , - label: t('pages.clients.delDepleted'), - danger: true, - onClick: onDelDepleted, - }, - { - key: 'delOrphans', - icon: , - label: t('pages.clients.delOrphans'), - danger: true, - onClick: onDeleteOrphans, - }, - ], + items: + selectedRowKeys.length > 0 + ? [ + { + key: 'attach', + icon: , + label: t('pages.clients.attach'), + onClick: () => setBulkAttachOpen(true), + }, + { + key: 'detach', + icon: , + label: t('pages.clients.detach'), + danger: true, + onClick: () => setBulkDetachOpen(true), + }, + { + key: 'addToGroup', + icon: , + label: t('pages.clients.addToGroup'), + onClick: () => setBulkGroupOpen(true), + }, + { + key: 'ungroup', + icon: , + label: t('pages.clients.ungroup'), + danger: true, + onClick: onBulkUngroup, + }, + { type: 'divider' as const }, + { + key: 'enable', + icon: , + label: t('pages.clients.enable'), + onClick: () => onBulkSetEnable(true), + }, + { + key: 'disable', + icon: , + label: t('pages.clients.disable'), + danger: true, + onClick: () => onBulkSetEnable(false), + }, + { + key: 'adjust', + icon: , + label: t('pages.clients.adjust'), + onClick: () => setBulkAdjustOpen(true), + }, + { + key: 'subLinks', + icon: , + label: t('pages.clients.subLinks'), + onClick: () => setSubLinksOpen(true), + }, + ] + : [ + { + key: 'bulk', + icon: , + label: t('pages.clients.bulk'), + onClick: () => setBulkAddOpen(true), + }, + { + key: 'export', + icon: , + label: t('pages.clients.exportClients'), + onClick: onExportClients, + }, + { + key: 'import', + icon: , + label: t('pages.clients.importClients'), + onClick: onImportClients, + }, + { + key: 'resetAll', + icon: , + label: t('pages.clients.resetAllTraffics'), + onClick: onResetAllTraffics, + }, + { type: 'divider' as const }, + { + key: 'delDepleted', + icon: , + label: t('pages.clients.delDepleted'), + danger: true, + onClick: onDelDepleted, + }, + { + key: 'delOrphans', + icon: , + label: t('pages.clients.delOrphans'), + danger: true, + onClick: onDeleteOrphans, + }, + ], }} >
@@ -1405,18 +1701,31 @@ export default function ClientsPage() { {filteredClients.map((row) => { const bucket = clientBucket(row); return ( -
+
toggleSelect(row.email, e.target.checked)} /> - {row.enable && bucket !== 'depleted' && isOnline(row.email) - ? - : } + {row.enable && bucket !== 'depleted' && isOnline(row.email) ? ( + + ) : ( + + )} {row.email} - {bucket === 'depleted' && {t('depleted')}} - {bucket === 'expiring' && {t('depletingSoon')}} + {bucket === 'depleted' && ( + + {t('depleted')} + + )} + {bucket === 'expiring' && ( + + {t('depletingSoon')} + + )}
{t('pages.clients.qrCode')}, + label: ( + <> + {t('pages.clients.qrCode')} + + ), onClick: () => onShowQr(row.email), }, { key: 'reset', - label: <> {t('pages.inbounds.resetTraffic')}, + label: ( + <> + {' '} + {t('pages.inbounds.resetTraffic')} + + ), onClick: () => onResetTraffic(row.email), }, { key: 'edit', - label: <> {t('edit')}, + label: ( + <> + {t('edit')} + + ), onClick: () => onEdit(row.email), }, { key: 'delete', danger: true, - label: <> {t('delete')}, + label: ( + <> + {t('delete')} + + ), onClick: () => onDelete(row.email), }, ], }} > -
@@ -1655,11 +1987,17 @@ export default function ClientsPage() { function bucketChipLabel(b: string, t: (k: string) => string): string { switch (b) { - case 'active': return t('subscription.active'); - case 'expiring': return t('depletingSoon'); - case 'depleted': return t('depleted'); - case 'deactive': return t('disabled'); - case 'online': return t('online'); - default: return b; + case 'active': + return t('subscription.active'); + case 'expiring': + return t('depletingSoon'); + case 'depleted': + return t('depleted'); + case 'deactive': + return t('disabled'); + case 'online': + return t('online'); + default: + return b; } } diff --git a/frontend/src/pages/clients/FilterDrawer.tsx b/frontend/src/pages/clients/FilterDrawer.tsx index 96e54f66e..3b0050016 100644 --- a/frontend/src/pages/clients/FilterDrawer.tsx +++ b/frontend/src/pages/clients/FilterDrawer.tsx @@ -52,10 +52,11 @@ export default function FilterDrawer({ } const inboundOptions = useMemo( - () => inbounds.map((ib) => ({ - value: ib.id, - label: formatInboundLabel(ib.tag, ib.remark), - })), + () => + inbounds.map((ib) => ({ + value: ib.id, + label: formatInboundLabel(ib.tag, ib.remark), + })), [inbounds], ); @@ -64,10 +65,7 @@ export default function FilterDrawer({ [protocols], ); - const groupOptions = useMemo( - () => groups.map((g) => ({ value: g, label: g })), - [groups], - ); + const groupOptions = useMemo(() => groups.map((g) => ({ value: g, label: g })), [groups]); // 0 is the "local panel" sentinel (inbounds without a nodeId) — see // ClientFilters.nodeIds (#4997). @@ -104,10 +102,7 @@ export default function FilterDrawer({ >
{t('status')}}> - patch('buckets', v as string[])} - > + patch('buckets', v as string[])}> {BUCKET_KEYS.map((k) => ( @@ -260,11 +255,17 @@ export default function FilterDrawer({ function bucketLabel(key: string, t: (k: string) => string): string { switch (key) { - case 'active': return t('subscription.active'); - case 'expiring': return t('depletingSoon'); - case 'depleted': return t('depleted'); - case 'deactive': return t('disabled'); - case 'online': return t('online'); - default: return key; + case 'active': + return t('subscription.active'); + case 'expiring': + return t('depletingSoon'); + case 'depleted': + return t('depleted'); + case 'deactive': + return t('disabled'); + case 'online': + return t('online'); + default: + return key; } } diff --git a/frontend/src/pages/clients/RowCells.tsx b/frontend/src/pages/clients/RowCells.tsx index 109cdc8d7..0bf202270 100644 --- a/frontend/src/pages/clients/RowCells.tsx +++ b/frontend/src/pages/clients/RowCells.tsx @@ -130,7 +130,9 @@ export const ClientInboundChips = memo(function ClientInboundChips({ const proto = (inboundsById[id]?.protocol || '').toLowerCase(); return ( - {label(id)} + + {label(id)} + ); }; @@ -146,7 +148,9 @@ export const ClientInboundChips = memo(function ClientInboundChips({ placement="bottomRight" content={
{overflow.map(chip)}
} > - +{overflow.length} + + +{overflow.length} + )} diff --git a/frontend/src/pages/clients/SubLinksModal.tsx b/frontend/src/pages/clients/SubLinksModal.tsx index 2aa93ed1b..012b42a9a 100644 --- a/frontend/src/pages/clients/SubLinksModal.tsx +++ b/frontend/src/pages/clients/SubLinksModal.tsx @@ -61,7 +61,12 @@ export default function SubLinksModal({ }, [emails, clients, enabled, jsonEnabled, subSettings]); const allText = useMemo( - () => rows.map((r) => (jsonEnabled ? `${r.email}\t${r.link}\t${r.jsonLink}` : `${r.email}\t${r.link}`)).join('\n'), + () => + rows + .map((r) => + jsonEnabled ? `${r.email}\t${r.link}\t${r.jsonLink}` : `${r.email}\t${r.link}`, + ) + .join('\n'), [rows, jsonEnabled], ); @@ -102,7 +107,9 @@ export default function SubLinksModal({ ellipsis: true, render: (link: string) => ( - {link} + + {link} + ), }, @@ -111,7 +118,13 @@ export default function SubLinksModal({ key: 'actions', width: 64, render: (_v, row) => ( - diff --git a/frontend/src/pages/clients/wireguardConfig.ts b/frontend/src/pages/clients/wireguardConfig.ts index 27196a0a4..705813269 100644 --- a/frontend/src/pages/clients/wireguardConfig.ts +++ b/frontend/src/pages/clients/wireguardConfig.ts @@ -4,7 +4,13 @@ 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); + return !!( + client.privateKey || + client.publicKey || + client.allowedIPs || + client.preSharedKey || + client.keepAlive + ); } export function findWireguardInbound( @@ -22,7 +28,11 @@ export function buildWireguardClientConfig( host = window.location.hostname, publicHost = '', ): string { - const endpointHost = resolveShareHost(inbound ?? {}, inbound?.nodeAddress ?? '', preferPublicHost(host, publicHost)); + const endpointHost = resolveShareHost( + inbound ?? {}, + inbound?.nodeAddress ?? '', + 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) : ''; @@ -39,6 +49,7 @@ export function buildWireguardClientConfig( 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}`); + if (client.keepAlive && client.keepAlive > 0) + lines.push(`PersistentKeepalive = ${client.keepAlive}`); return lines.join('\n'); } diff --git a/frontend/src/pages/groups/GroupAddClientsModal.tsx b/frontend/src/pages/groups/GroupAddClientsModal.tsx index fbc8056c6..5021c96c3 100644 --- a/frontend/src/pages/groups/GroupAddClientsModal.tsx +++ b/frontend/src/pages/groups/GroupAddClientsModal.tsx @@ -74,7 +74,11 @@ export default function GroupAddClientsModal({ width: 140, ellipsis: true, render: (g: string) => - g ? {g} : , + g ? ( + {g} + ) : ( + + ), }, { title: t('enable'), diff --git a/frontend/src/pages/groups/GroupsPage.tsx b/frontend/src/pages/groups/GroupsPage.tsx index 7631b34d9..040985dc0 100644 --- a/frontend/src/pages/groups/GroupsPage.tsx +++ b/frontend/src/pages/groups/GroupsPage.tsx @@ -57,7 +57,10 @@ import { } from '@/schemas/client'; import { parseMsg } from '@/utils/zodValidate'; -const ClientRecordListSchema = z.array(ClientRecordSchema).nullable().transform((v) => v ?? []); +const ClientRecordListSchema = z + .array(ClientRecordSchema) + .nullable() + .transform((v) => v ?? []); const SubLinksModal = lazy(() => import('../clients/SubLinksModal')); const ClientBulkAdjustModal = lazy(() => import('../clients/ClientBulkAdjustModal')); @@ -90,10 +93,14 @@ export default function GroupsPage() { const { isMobile } = useMediaQuery(); const [modal, modalContextHolder] = Modal.useModal(); const [messageApi, messageContextHolder] = message.useMessage(); - useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); + useEffect(() => { + setMessageInstance(messageApi); + }, [messageApi]); const queryClient = useQueryClient(); - const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({ list: false }); + const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({ + list: false, + }); const groupsQuery = useQuery({ queryKey: keys.clients.groups(), @@ -111,25 +118,33 @@ export default function GroupsPage() { const createMut = useMutation({ mutationFn: (body: { name: string }) => HttpUtil.post('/panel/api/clients/groups/create', body, JSON_HEADERS), - onSuccess: (msg) => { if (msg?.success) invalidate(); }, + onSuccess: (msg) => { + if (msg?.success) invalidate(); + }, }); const renameMut = useMutation({ mutationFn: (body: { oldName: string; newName: string }) => HttpUtil.post('/panel/api/clients/groups/rename', body, JSON_HEADERS), - onSuccess: (msg) => { if (msg?.success) invalidate(); }, + onSuccess: (msg) => { + if (msg?.success) invalidate(); + }, }); const deleteMut = useMutation({ mutationFn: (body: { name: string }) => HttpUtil.post('/panel/api/clients/groups/delete', body, JSON_HEADERS), - onSuccess: (msg) => { if (msg?.success) invalidate(); }, + onSuccess: (msg) => { + if (msg?.success) invalidate(); + }, }); const groupResetMut = useMutation({ mutationFn: (body: { name: string }) => HttpUtil.post('/panel/api/clients/groups/resetTraffic', body, JSON_HEADERS), - onSuccess: (msg) => { if (msg?.success) invalidate(); }, + onSuccess: (msg) => { + if (msg?.success) invalidate(); + }, }); const [createOpen, setCreateOpen] = useState(false); @@ -168,14 +183,8 @@ export default function GroupsPage() { () => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0), [groups], ); - const totalUpload = useMemo( - () => groups.reduce((acc, g) => acc + (g.up || 0), 0), - [groups], - ); - const totalDownload = useMemo( - () => groups.reduce((acc, g) => acc + (g.down || 0), 0), - [groups], - ); + const totalUpload = useMemo(() => groups.reduce((acc, g) => acc + (g.up || 0), 0), [groups]); + const totalDownload = useMemo(() => groups.reduce((acc, g) => acc + (g.down || 0), 0), [groups]); function openCreate() { setCreateName(''); @@ -209,7 +218,11 @@ export default function GroupsPage() { setRenameOpen(false); return; } - if (groups.some((g) => g.name.toLowerCase() === next.toLowerCase() && g.name !== renameTarget.name)) { + if ( + groups.some( + (g) => g.name.toLowerCase() === next.toLowerCase() && g.name !== renameTarget.name, + ) + ) { messageApi.error(t('pages.groups.renameCollision', { name: next })); return; } @@ -305,9 +318,11 @@ export default function GroupsPage() { messageApi.success(t('pages.groups.deleteClientsSuccess', { count: ok })); } else { const firstError = skipped[0]?.reason ?? msg?.msg ?? ''; - messageApi.warning(firstError - ? `${t('pages.groups.deleteClientsMixed', { ok, failed })} — ${firstError}` - : t('pages.groups.deleteClientsMixed', { ok, failed })); + messageApi.warning( + firstError + ? `${t('pages.groups.deleteClientsMixed', { ok, failed })} — ${firstError}` + : t('pages.groups.deleteClientsMixed', { ok, failed }), + ); } } }, @@ -404,10 +419,23 @@ export default function GroupsPage() { render: (_v, row) => ( - } + extra={ + + } /> ) : ( @@ -522,7 +558,12 @@ export default function GroupsPage() { hoverable title={
-
diff --git a/frontend/src/pages/hosts/HostFormModal.tsx b/frontend/src/pages/hosts/HostFormModal.tsx index 2573b9df7..d215ea98c 100644 --- a/frontend/src/pages/hosts/HostFormModal.tsx +++ b/frontend/src/pages/hosts/HostFormModal.tsx @@ -66,7 +66,8 @@ function defaultsFor(host: HostRecord | null): FormShape { sockoptParams: asString(host?.sockoptParams), finalMask: host?.finalMask ?? '', vlessRoute: host?.vlessRoute ?? '', - excludeFromSubTypes: (host?.excludeFromSubTypes as BulkAddHostValues['excludeFromSubTypes']) ?? [], + excludeFromSubTypes: + (host?.excludeFromSubTypes as BulkAddHostValues['excludeFromSubTypes']) ?? [], nodeGuids: host?.nodeGuids ?? [], mihomoIpVersion: host?.mihomoIpVersion as BulkAddHostValues['mihomoIpVersion'], mihomoX25519: host?.mihomoX25519 ?? false, @@ -74,7 +75,15 @@ function defaultsFor(host: HostRecord | null): FormShape { }; } -export default function HostFormModal({ open, mode, host, inboundOptions, existingHosts, save, onOpenChange }: HostFormModalProps) { +export default function HostFormModal({ + open, + mode, + host, + inboundOptions, + existingHosts, + save, + onOpenChange, +}: HostFormModalProps) { const { t } = useTranslation(); const { isMobile } = useMediaQuery(); const methods = useForm({ defaultValues: defaultsFor(host) }); @@ -95,22 +104,30 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi const { nodes } = useNodesQuery(); const inboundSelectOptions = useMemo( - () => inboundOptions.map((ib) => ({ - value: ib.id, - label: ib.remark || ib.tag || `#${ib.id}`, - })), + () => + inboundOptions.map((ib) => ({ + value: ib.id, + label: ib.remark || ib.tag || `#${ib.id}`, + })), [inboundOptions], ); const nodeSelectOptions = useMemo( - () => nodes - .filter((n) => n.guid) - .map((n) => ({ value: n.guid as string, label: n.name || n.remark || (n.guid as string) })), + () => + nodes + .filter((n) => n.guid) + .map((n) => ({ value: n.guid as string, label: n.name || n.remark || (n.guid as string) })), [nodes], ); - const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []); - const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []); + const alpnOptions = useMemo( + () => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), + [], + ); + const fpOptions = useMemo( + () => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), + [], + ); const hostOptions = useMemo(() => { const addresses = new Set(); @@ -139,7 +156,9 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi try { const res = await save(payload); if (res?.success) { - messageApi.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update')); + messageApi.success( + t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'), + ); onOpenChange(false); } else if (res?.msg) { messageApi.error(res.msg); @@ -181,13 +200,26 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi label: catTabLabel(, t('pages.hosts.sections.basic'), isMobile), children: ( <> - + - + - + - + - + + + ({ value: v, label: v }))} + options={['same', 'tls', 'none', 'reality'].map((v) => ({ + value: v, + label: v, + }))} /> {showTls && ( @@ -234,10 +299,18 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi - + - + @@ -253,13 +326,25 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi - + - + @@ -270,7 +355,11 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi { key: 'advanced', forceRender: true, - label: catTabLabel(, t('pages.hosts.sections.advanced'), isMobile), + label: catTabLabel( + , + t('pages.hosts.sections.advanced'), + isMobile, + ), children: ( , t('pages.hosts.sections.general'), isMobile), + label: catTabLabel( + , + t('pages.hosts.sections.general'), + isMobile, + ), children: ( <> @@ -288,14 +381,24 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi - + - + ({ value: v, label: v }))} + options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map( + (v) => ({ value: v, label: v }), + )} /> - + - + diff --git a/frontend/src/pages/hosts/HostList.tsx b/frontend/src/pages/hosts/HostList.tsx index 88d2524e9..260b6064f 100644 --- a/frontend/src/pages/hosts/HostList.tsx +++ b/frontend/src/pages/hosts/HostList.tsx @@ -56,8 +56,19 @@ export function sortHosts(hosts: HostRecord[]): HostRecord[] { export default function HostList(props: HostListProps) { const { t } = useTranslation(); const { - hosts, inboundOptions, loading, isMobile, selectedGroupIds, onSelectionChange, - onAdd, onEdit, onDelete, onToggleEnable, onMove, onBulkEnable, onBulkDelete, + hosts, + inboundOptions, + loading, + isMobile, + selectedGroupIds, + onSelectionChange, + onAdd, + onEdit, + onDelete, + onToggleEnable, + onMove, + onBulkEnable, + onBulkDelete, } = props; const inboundsMap = useMemo(() => { @@ -78,16 +89,43 @@ export default function HostList(props: HostListProps) { return ( - - + )}
diff --git a/frontend/src/pages/hosts/HostsPage.tsx b/frontend/src/pages/hosts/HostsPage.tsx index 048b1065c..c873d1989 100644 --- a/frontend/src/pages/hosts/HostsPage.tsx +++ b/frontend/src/pages/hosts/HostsPage.tsx @@ -1,6 +1,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Card, Col, ConfigProvider, Layout, Modal, Result, Row, Spin, Statistic, message } from 'antd'; +import { + Button, + Card, + Col, + ConfigProvider, + Layout, + Modal, + Result, + Row, + Spin, + Statistic, + message, +} from 'antd'; import { CheckCircleOutlined, GlobalOutlined, StopOutlined } from '@ant-design/icons'; import { useTheme } from '@/hooks/useTheme'; @@ -20,10 +32,13 @@ export default function HostsPage() { const { isMobile } = useMediaQuery(); const [modal, modalContextHolder] = Modal.useModal(); const [messageApi, messageContextHolder] = message.useMessage(); - useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); + useEffect(() => { + setMessageInstance(messageApi); + }, [messageApi]); const { hosts, loading, fetched, fetchError, refetch } = useHostsQuery(); - const { bulkCreate, update, remove, setEnable, reorder, bulkSetEnable, bulkDel } = useHostMutations(); + const { bulkCreate, update, remove, setEnable, reorder, bulkSetEnable, bulkDel } = + useHostMutations(); const { data: inboundOptions = [] } = useInboundOptions(); const [formOpen, setFormOpen] = useState(false); @@ -43,45 +58,60 @@ export default function HostsPage() { setFormOpen(true); }, []); - const onSave = useCallback(async (payload: BulkAddHostValues) => { - if (formMode === 'edit' && formHost?.groupId) { - return update(formHost.groupId, payload); - } - return bulkCreate(payload); - }, [formMode, formHost, update, bulkCreate]); + const onSave = useCallback( + async (payload: BulkAddHostValues) => { + if (formMode === 'edit' && formHost?.groupId) { + return update(formHost.groupId, payload); + } + return bulkCreate(payload); + }, + [formMode, formHost, update, bulkCreate], + ); - const onDelete = useCallback((host: HostRecord) => { - modal.confirm({ - title: t('pages.hosts.deleteConfirmTitle', { name: host.remark }), - okText: t('delete'), - okType: 'danger', - cancelText: t('cancel'), - onOk: async () => { - const msg = await remove(host.groupId); - if (msg?.success) messageApi.success(t('pages.hosts.toasts.delete')); - }, - }); - }, [modal, t, remove, messageApi]); + const onDelete = useCallback( + (host: HostRecord) => { + modal.confirm({ + title: t('pages.hosts.deleteConfirmTitle', { name: host.remark }), + okText: t('delete'), + okType: 'danger', + cancelText: t('cancel'), + onOk: async () => { + const msg = await remove(host.groupId); + if (msg?.success) messageApi.success(t('pages.hosts.toasts.delete')); + }, + }); + }, + [modal, t, remove, messageApi], + ); - const onToggleEnable = useCallback(async (host: HostRecord, next: boolean) => { - await setEnable(host.groupId, next); - }, [setEnable]); + const onToggleEnable = useCallback( + async (host: HostRecord, next: boolean) => { + await setEnable(host.groupId, next); + }, + [setEnable], + ); - const onMove = useCallback(async (host: HostRecord, dir: 'up' | 'down') => { - const sorted = sortHosts(hosts); - const idx = sorted.findIndex((h) => h.groupId === host.groupId); - const swapWith = dir === 'up' ? idx - 1 : idx + 1; - if (idx < 0 || swapWith < 0 || swapWith >= sorted.length) return; - const groupIds = sorted.map((h) => h.groupId); - [groupIds[idx], groupIds[swapWith]] = [groupIds[swapWith], groupIds[idx]]; - await reorder(groupIds); - }, [hosts, reorder]); + const onMove = useCallback( + async (host: HostRecord, dir: 'up' | 'down') => { + const sorted = sortHosts(hosts); + const idx = sorted.findIndex((h) => h.groupId === host.groupId); + const swapWith = dir === 'up' ? idx - 1 : idx + 1; + if (idx < 0 || swapWith < 0 || swapWith >= sorted.length) return; + const groupIds = sorted.map((h) => h.groupId); + [groupIds[idx], groupIds[swapWith]] = [groupIds[swapWith], groupIds[idx]]; + await reorder(groupIds); + }, + [hosts, reorder], + ); - const onBulkEnable = useCallback(async (enable: boolean) => { - if (selectedGroupIds.length === 0) return; - const msg = await bulkSetEnable(selectedGroupIds, enable); - if (msg?.success) setSelectedGroupIds([]); - }, [selectedGroupIds, bulkSetEnable]); + const onBulkEnable = useCallback( + async (enable: boolean) => { + if (selectedGroupIds.length === 0) return; + const msg = await bulkSetEnable(selectedGroupIds, enable); + if (msg?.success) setSelectedGroupIds([]); + }, + [selectedGroupIds, bulkSetEnable], + ); const onBulkDelete = useCallback(() => { if (selectedGroupIds.length === 0) return; @@ -129,7 +159,11 @@ export default function HostsPage() { status="error" title={t('somethingWentWrong')} subTitle={fetchError} - extra={} + extra={ + + } /> ) : ( @@ -147,14 +181,18 @@ export default function HostsPage() { } + prefix={ + + } /> } + prefix={ + + } /> diff --git a/frontend/src/pages/hosts/json-forms/HostFinalMaskForm.tsx b/frontend/src/pages/hosts/json-forms/HostFinalMaskForm.tsx index 5c47ecb70..89a7e94ab 100644 --- a/frontend/src/pages/hosts/json-forms/HostFinalMaskForm.tsx +++ b/frontend/src/pages/hosts/json-forms/HostFinalMaskForm.tsx @@ -25,7 +25,13 @@ function parseFinalMask(raw: string): FinalMaskStreamSettings { return { tcp: [], udp: [] }; } -export default function HostFinalMaskForm({ value = '', onChange }: { value?: string; onChange?: (next: string) => void }) { +export default function HostFinalMaskForm({ + value = '', + onChange, +}: { + value?: string; + onChange?: (next: string) => void; +}) { const [form] = Form.useForm(); const [initial] = useState(() => parseFinalMask(value)); const onChangeRef = useRef(onChange); diff --git a/frontend/src/pages/hosts/json-forms/HostMuxForm.tsx b/frontend/src/pages/hosts/json-forms/HostMuxForm.tsx index 809e5b3f6..aff829620 100644 --- a/frontend/src/pages/hosts/json-forms/HostMuxForm.tsx +++ b/frontend/src/pages/hosts/json-forms/HostMuxForm.tsx @@ -9,16 +9,29 @@ import { serializeOverride } from './helpers'; * the sub-JSON editor; the host stores '' (= inherit the inbound/global mux) * when the toggle is off, an explicit mux object when on. */ -const DEFAULT_MUX = { enabled: false, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' }; +const DEFAULT_MUX = { + enabled: false, + concurrency: 8, + xudpConcurrency: 16, + xudpProxyUDP443: 'reject', +}; -export default function HostMuxForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) { +export default function HostMuxForm({ + value, + onChange, +}: { + value?: string; + onChange?: (next: string) => void; +}) { return ( ((mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : '')} + serialize={(mux) => + (mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : '' + } /* protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate; a host's mux override is protocol-agnostic and should always be editable. */ render={() => } diff --git a/frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx b/frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx index 0b03d3c69..12f45ea5f 100644 --- a/frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx +++ b/frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx @@ -25,7 +25,13 @@ function serializeClientSockopt(sockopt: unknown): string { return serializeOverride(copy); } -export default function HostSockoptForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) { +export default function HostSockoptForm({ + value, + onChange, +}: { + value?: string; + onChange?: (next: string) => void; +}) { /* * Populate the dialerProxy dropdown with the panel's outbound tags (a host can * chain through one of the subscription's outbounds by tag). dialerProxy chains diff --git a/frontend/src/pages/inbounds/CloneInboundModal.tsx b/frontend/src/pages/inbounds/CloneInboundModal.tsx index e83aa96ed..f03136a36 100644 --- a/frontend/src/pages/inbounds/CloneInboundModal.tsx +++ b/frontend/src/pages/inbounds/CloneInboundModal.tsx @@ -34,21 +34,29 @@ export default function CloneInboundModal({ const [targets, setTargets] = useState([LOCAL_PANEL]); const [submitting, setSubmitting] = useState(false); - const targetOptions = useMemo(() => [ - { value: LOCAL_PANEL, label: t('pages.inbounds.localPanel'), disabled: false }, - ...(nodes || []).filter((n) => n.enable).map((n) => ({ - value: n.id, - // Only online nodes are deployable targets: nodes report `unknown` - // until their first heartbeat, and the backend refuses any status - // other than online. - label: `${n.name}${n.status === 'online' ? '' : ` (${n.status || 'offline'})`}`, - disabled: n.status !== 'online', - })), - ], [nodes, t]); + const targetOptions = useMemo( + () => [ + { value: LOCAL_PANEL, label: t('pages.inbounds.localPanel'), disabled: false }, + ...(nodes || []) + .filter((n) => n.enable) + .map((n) => ({ + value: n.id, + // Only online nodes are deployable targets: nodes report `unknown` + // until their first heartbeat, and the backend refuses any status + // other than online. + label: `${n.name}${n.status === 'online' ? '' : ` (${n.status || 'offline'})`}`, + disabled: n.status !== 'online', + })), + ], + [nodes, t], + ); // "Select all" must not pick targets the user can't pick manually — // offline nodes are disabled options in the dropdown. - const selectableOptions = useMemo(() => targetOptions.filter((o) => !o.disabled), [targetOptions]); + const selectableOptions = useMemo( + () => targetOptions.filter((o) => !o.disabled), + [targetOptions], + ); // Reset the selection when the dialog OPENS: pre-select the source // inbound's own node when it is a selectable target, otherwise the local @@ -75,17 +83,23 @@ export default function CloneInboundModal({ for (const target of targets) { const msg = await HttpUtil.post( '/panel/api/inbounds/add', - buildClonePayload(dbInbound, pickClonePort(portsInUse.get(target)), target === LOCAL_PANEL ? null : target), + buildClonePayload( + dbInbound, + pickClonePort(portsInUse.get(target)), + target === LOCAL_PANEL ? null : target, + ), { silent: true }, ); - results.push({ ok: !!msg?.success, reason: msg?.success ? '' : (msg?.msg || '') }); + results.push({ ok: !!msg?.success, reason: msg?.success ? '' : msg?.msg || '' }); } const okCount = results.filter((r) => r.ok).length; const failed = results.length - okCount; if (failed === 0) { - messageApi.success(okCount === 1 - ? t('pages.inbounds.toasts.inboundCreateSuccess') - : t('pages.inbounds.toasts.clonedMany', { count: okCount })); + messageApi.success( + okCount === 1 + ? t('pages.inbounds.toasts.inboundCreateSuccess') + : t('pages.inbounds.toasts.clonedMany', { count: okCount }), + ); } else { const firstError = results.find((r) => !r.ok)?.reason ?? ''; const base = t('pages.inbounds.toasts.clonedMixed', { ok: okCount, failed }); @@ -114,11 +128,7 @@ export default function CloneInboundModal({ {t('pages.inbounds.cloneConfirmContent')} - + strategy !== 'node' || nodeShareOptionAvailable) - .map((strategy) => ({ - value: strategy, - label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`), - }))} + options={SHARE_ADDR_STRATEGIES.filter( + (strategy) => strategy !== 'node' || nodeShareOptionAvailable, + ).map((strategy) => ({ + value: strategy, + label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`), + }))} /> {shareAddrStrategy === 'custom' && ( isValidShareAddrInput(String(value ?? '')) || t('pages.inbounds.form.shareAddrHelp'), @@ -627,7 +641,10 @@ export default function InboundFormModal({ @@ -636,7 +653,10 @@ export default function InboundFormModal({ @@ -716,7 +736,9 @@ export default function InboundFormModal({ const protocolTab = ( <> - {protocol === Protocols.WIREGUARD && } + {protocol === Protocols.WIREGUARD && ( + + )} {protocol === Protocols.TUN && } @@ -729,11 +751,22 @@ export default function InboundFormModal({ {protocol === Protocols.SHADOWSOCKS && } - {protocol === Protocols.VLESS && } + {protocol === Protocols.VLESS && ( + + )} {isFallbackHost && fallbacksCard} - {(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) - && network === 'tcp' && !isFallbackHost && ( + {(protocol === Protocols.VLESS || protocol === Protocols.TROJAN) && + network === 'tcp' && + !isFallbackHost && ( { - const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings']; + const ALL = [ + 'tcpSettings', + 'kcpSettings', + 'wsSettings', + 'grpcSettings', + 'httpupgradeSettings', + 'xhttpSettings', + ]; const current = (getV('streamSettings') as Record) ?? {}; const cleaned: Record = { ...current, network: next }; for (const k of ALL) { @@ -773,7 +813,9 @@ export default function InboundFormModal({ } else { const fm = cleaned.finalmask as Record | undefined; if (fm && Array.isArray(fm.udp)) { - const udp = (fm.udp as unknown[]).filter((m) => (m as { type?: string })?.type !== 'mkcp-legacy'); + const udp = (fm.udp as unknown[]).filter( + (m) => (m as { type?: string })?.type !== 'mkcp-legacy', + ); cleaned.finalmask = { ...fm, udp }; } } @@ -914,10 +956,11 @@ export default function InboundFormModal({ label: t('pages.inbounds.advanced.all'), children: ( <> -
- {t('pages.inbounds.advanced.allHelp')} -
- +
{t('pages.inbounds.advanced.allHelp')}
+ ), }, @@ -940,44 +983,48 @@ export default function InboundFormModal({ ), }, ...(streamEnabled - ? [{ - key: 'stream', - label: t('pages.inbounds.advanced.stream'), - children: ( - <> -
- {t('pages.inbounds.advanced.streamHelp')}{' '} - {'{ streamSettings: { ... } }'}. -
- - - ), - }] + ? [ + { + key: 'stream', + label: t('pages.inbounds.advanced.stream'), + children: ( + <> +
+ {t('pages.inbounds.advanced.streamHelp')}{' '} + {'{ streamSettings: { ... } }'}. +
+ + + ), + }, + ] : []), ...(sniffingSupported - ? [{ - key: 'sniffing', - label: t('pages.inbounds.advanced.sniffing'), - children: ( - <> -
- {t('pages.inbounds.advanced.sniffingHelp')}{' '} - {'{ sniffing: { ... } }'}. -
- - - ), - }] + ? [ + { + key: 'sniffing', + label: t('pages.inbounds.advanced.sniffing'), + children: ( + <> +
+ {t('pages.inbounds.advanced.sniffingHelp')}{' '} + {'{ sniffing: { ... } }'}. +
+ + + ), + }, + ] : []), ]} /> @@ -1010,33 +1057,75 @@ export default function InboundFormModal({ wrapperCol={{ sm: { span: 14 } }} labelWrap > - + diff --git a/frontend/src/pages/inbounds/form/SniffingTab.tsx b/frontend/src/pages/inbounds/form/SniffingTab.tsx index 9bc502590..168855823 100644 --- a/frontend/src/pages/inbounds/form/SniffingTab.tsx +++ b/frontend/src/pages/inbounds/form/SniffingTab.tsx @@ -11,11 +11,7 @@ export default function SniffingTab() { control={control} name="sniffing" render={({ field }) => ( - + )} /> ); diff --git a/frontend/src/pages/inbounds/form/advanced-editors.tsx b/frontend/src/pages/inbounds/form/advanced-editors.tsx index b19a9b691..7c6866f08 100644 --- a/frontend/src/pages/inbounds/form/advanced-editors.tsx +++ b/frontend/src/pages/inbounds/form/advanced-editors.tsx @@ -67,9 +67,10 @@ export function AdvancedSliceEditor({ setText(next); try { const parsed = JSON.parse(next); - const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record)[wrapKey] ?? {} - : parsed; + const toWrite = + wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? ((parsed as Record)[wrapKey] ?? {}) + : parsed; setValue(path, toWrite); lastEmitRef.current = JSON.stringify(wrapKey ? { [wrapKey]: toWrite } : toWrite, null, 2); } catch { @@ -146,7 +147,17 @@ export function AdvancedAllEditor({ setText(formStr); lastEmitRef.current = formStr; /* eslint-disable-next-line react-hooks/exhaustive-deps */ - }, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled, sniffingEnabled]); + }, [ + wListen, + wPort, + wProtocol, + wTag, + wSettings, + wSniffing, + wStream, + streamEnabled, + sniffingEnabled, + ]); return ( diff --git a/frontend/src/pages/inbounds/form/protocols/hysteria.tsx b/frontend/src/pages/inbounds/form/protocols/hysteria.tsx index 343a084fb..68f3b7dc9 100644 --- a/frontend/src/pages/inbounds/form/protocols/hysteria.tsx +++ b/frontend/src/pages/inbounds/form/protocols/hysteria.tsx @@ -13,9 +13,10 @@ export default function HysteriaFields() { const masq = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade' }) as | { type?: string } | undefined; - const masqType = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade.type' }) as - | string - | undefined; + const masqType = useWatch({ + control, + name: 'streamSettings.hysteriaSettings.masquerade.type', + }) as string | undefined; return ( <> {masq && ( <> - + )} {masqType === 'file' && ( - + )} @@ -103,16 +100,10 @@ export default function HysteriaFields() { > - + - + diff --git a/frontend/src/pages/inbounds/form/protocols/mixed.tsx b/frontend/src/pages/inbounds/form/protocols/mixed.tsx index 80fce4651..f6dbfcddd 100644 --- a/frontend/src/pages/inbounds/form/protocols/mixed.tsx +++ b/frontend/src/pages/inbounds/form/protocols/mixed.tsx @@ -17,11 +17,7 @@ export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) { ]} /> - + {mixedUdpOn && ( diff --git a/frontend/src/pages/inbounds/form/protocols/mtproto.tsx b/frontend/src/pages/inbounds/form/protocols/mtproto.tsx index b61bb27f2..47046c3b7 100644 --- a/frontend/src/pages/inbounds/form/protocols/mtproto.tsx +++ b/frontend/src/pages/inbounds/form/protocols/mtproto.tsx @@ -8,7 +8,9 @@ import { useOutboundTags } from '@/api/queries/useOutboundTags'; export default function MtprotoFields() { const { t } = useTranslation(); const { control } = useFormContext(); - const routeThroughXray = useWatch({ control, name: 'settings.routeThroughXray' }) as boolean | undefined; + const routeThroughXray = useWatch({ control, name: 'settings.routeThroughXray' }) as + | boolean + | undefined; const { data: outboundTags } = useOutboundTags(); return ( <> @@ -26,7 +28,10 @@ export default function MtprotoFields() { > - + - + - ({ value: m, label: m }))} /> {isSSWith2022 && ( @@ -57,11 +55,7 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro ]} /> - + diff --git a/frontend/src/pages/inbounds/form/protocols/tunnel.tsx b/frontend/src/pages/inbounds/form/protocols/tunnel.tsx index ea44922aa..ac965b3cd 100644 --- a/frontend/src/pages/inbounds/form/protocols/tunnel.tsx +++ b/frontend/src/pages/inbounds/form/protocols/tunnel.tsx @@ -8,13 +8,19 @@ export default function TunnelFields() { const { t } = useTranslation(); return ( <> - + - + (
-
{target}
+
+ {target} +
{row.ip ?
{row.ip}
: null}
diff --git a/frontend/src/pages/inbounds/form/security/reality.tsx b/frontend/src/pages/inbounds/form/security/reality.tsx index f57c7ff81..575d71a19 100644 --- a/frontend/src/pages/inbounds/form/security/reality.tsx +++ b/frontend/src/pages/inbounds/form/security/reality.tsx @@ -1,7 +1,19 @@ import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd'; +import { + Alert, + Button, + Collapse, + Descriptions, + Divider, + Form, + Input, + InputNumber, + Select, + Space, + Switch, +} from 'antd'; import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; @@ -52,9 +64,10 @@ export default function RealityForm({ * worth reading, so subject/issuer stay visible and only the verdict is added. */ const certSummary = (r: RealityScanResult) => { - const who = r.certSubject && r.certIssuer - ? `${r.certSubject} (${r.certIssuer})` - : r.certSubject || r.certIssuer; + const who = + r.certSubject && r.certIssuer + ? `${r.certSubject} (${r.certIssuer})` + : r.certSubject || r.certIssuer; if (!who) return '—'; return r.certValid ? who : `${who} — ${t('pages.inbounds.form.scanCertInvalid')}`; }; @@ -73,16 +86,17 @@ export default function RealityForm({ >
- + - ({ value: fp, label: fp }))} /> - - + {t('pages.inbounds.form.getNewSeed')} - + void; } -function CertRow({ index, total, saving, onRemove, setCertFromPanel, clearCertFiles }: CertRowProps) { +function CertRow({ + index, + total, + saving, + onRemove, + setCertFromPanel, + clearCertFiles, +}: CertRowProps) { const { t } = useTranslation(); const { control } = useFormContext(); - const useFile = useWatch({ control, name: `streamSettings.tlsSettings.certificates.${index}.useFile` }); - const usage = useWatch({ control, name: `streamSettings.tlsSettings.certificates.${index}.usage` }); + const useFile = useWatch({ + control, + name: `streamSettings.tlsSettings.certificates.${index}.useFile`, + }); + const usage = useWatch({ + control, + name: `streamSettings.tlsSettings.certificates.${index}.usage`, + }); return (
- - {t('pages.inbounds.certificatePath')} - - - {t('pages.inbounds.certificateContent')} - + {t('pages.inbounds.certificatePath')} + {t('pages.inbounds.certificateContent')} {total > 1 && ( @@ -83,11 +97,7 @@ function CertRow({ index, total, saving, onRemove, setCertFromPanel, clearCertFi - @@ -342,7 +357,10 @@ export default function TlsForm({ - + {t('pages.inbounds.form.getNewEchCert')} - + - + - + - + - + - + applyRealClientIpPreset(v as RealClientIpPreset)} options={[ { value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') }, - { value: 'cloudflare', label: t('pages.inbounds.form.realClientIpPresetCloudflare') }, + { + value: 'cloudflare', + label: t('pages.inbounds.form.realClientIpPresetCloudflare'), + }, { value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') }, ]} /> @@ -124,7 +129,10 @@ export default function SockoptForm({ title={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')} /> )} - + - + Promise export default function XhttpForm() { const { t } = useTranslation(); const { control, getValues, setValue } = useFormContext(); - const xhttpMode = useWatch({ control, name: 'streamSettings.xhttpSettings.mode' }) as string | undefined; - const xhttpObfsMode = !!useWatch({ control, name: 'streamSettings.xhttpSettings.xPaddingObfsMode' }); - const xhttpSessionIDPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.sessionIDPlacement' }) as string | undefined; - const xhttpSessionIDTable = useWatch({ control, name: 'streamSettings.xhttpSettings.sessionIDTable' }); - const xhttpSeqPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.seqPlacement' }) as string | undefined; - const xhttpUplinkPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.uplinkDataPlacement' }) as string | undefined; + const xhttpMode = useWatch({ control, name: 'streamSettings.xhttpSettings.mode' }) as + | string + | undefined; + const xhttpObfsMode = !!useWatch({ + control, + name: 'streamSettings.xhttpSettings.xPaddingObfsMode', + }); + const xhttpSessionIDPlacement = useWatch({ + control, + name: 'streamSettings.xhttpSettings.sessionIDPlacement', + }) as string | undefined; + const xhttpSessionIDTable = useWatch({ + control, + name: 'streamSettings.xhttpSettings.sessionIDTable', + }); + const xhttpSeqPlacement = useWatch({ + control, + name: 'streamSettings.xhttpSettings.seqPlacement', + }) as string | undefined; + const xhttpUplinkPlacement = useWatch({ + control, + name: 'streamSettings.xhttpSettings.uplinkDataPlacement', + }) as string | undefined; const enableXmux = !!useWatch({ control, name: 'streamSettings.xhttpSettings.enableXmux' }); function onXmuxToggle(checked: boolean) { @@ -60,7 +77,10 @@ export default function XhttpForm() { - + updateSetting({ smtpHost: e.target.value })} /> - - - } description={t('pages.settings.smtpPortDesc')}> - updateSetting({ smtpPort: v }))} /> - - - - updateSetting({ smtpUsername: e.target.value })} /> - - - - updateSetting({ smtpPassword: v })} - onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })} /> - - - - updateSetting({ smtpFrom: e.target.value })} /> - - - - updateSetting({ smtpFromName: e.target.value })} /> - - - - updateSetting({ smtpTo: e.target.value })} /> - - - - updateSetting({ smtpHost: e.target.value })} + /> + + + } + description={t('pages.settings.smtpPortDesc')} + > + updateSetting({ smtpPort: v }))} + /> + + + + updateSetting({ smtpUsername: e.target.value })} + /> + + + + updateSetting({ smtpPassword: v })} + onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })} + /> + + + + updateSetting({ smtpFrom: e.target.value })} + /> + + + + updateSetting({ smtpFromName: e.target.value })} + /> + + + + updateSetting({ smtpTo: e.target.value })} + /> + + + + updateSetting({ webListen: e.target.value })} /> - - - - updateSetting({ webDomain: e.target.value })} /> - - - } description={t('pages.settings.panelPortDesc')}> - updateSetting({ webPort: v }))} /> - - - - updateSetting({ webBasePath: sanitizePath(e.target.value) })} /> - - - } description={t('pages.settings.sessionMaxAgeDesc')}> - updateSetting({ sessionMaxAge: v }))} /> - - - - updateSetting({ trustedProxyCIDRs: e.target.value })} - /> - - - - updateSetting({ ipLimitAllowlist: e.target.value })} - /> - - - - - - - ), - }, - { - key: '2', - label: catTabLabel(, t('pages.settings.notifications'), isMobile), - children: ( - <> - } description={t('pages.settings.expireTimeDiffDesc')}> - updateSetting({ expireDiff: v }))} /> - - } description={t('pages.settings.trafficDiffDesc')}> - updateSetting({ trafficDiff: v }))} /> - - - ), - }, - { - key: '3', - label: catTabLabel(, t('pages.settings.certs'), isMobile), - children: ( - <> - - updateSetting({ webCertFile: e.target.value })} /> - - - updateSetting({ webKeyFile: e.target.value })} /> - - - ), - }, - { - key: '4', - label: catTabLabel(, t('pages.settings.externalTraffic'), isMobile), - children: ( - <> - - updateSetting({ externalTrafficInformEnable: v })} /> - - - updateSetting({ externalTrafficInformURI: e.target.value })} - /> - - - ), - }, - { - key: '5', - label: catTabLabel(, t('pages.settings.dateAndTime'), isMobile), - children: ( - <> - - updateSetting({ timeLocation: e.target.value })} /> - - - updateSetting({ ldapHost: e.target.value })} /> - - }> - updateSetting({ ldapPort: v }))} /> - - - updateSetting({ ldapUseTLS: v })} /> - - - updateSetting({ ldapInsecureSkipVerify: v })} - /> - - - updateSetting({ ldapBindDN: e.target.value })} /> - - - updateSetting({ ldapPassword: v })} - onClearArmedChange={(armed) => updateSetting({ clearLdapPassword: armed })} - /> - - - updateSetting({ ldapBaseDN: e.target.value })} /> - - - updateSetting({ ldapUserFilter: e.target.value })} /> - - - updateSetting({ ldapUserAttr: e.target.value })} /> - - - updateSetting({ ldapVlessField: e.target.value })} /> - - - updateSetting({ ldapFlagField: e.target.value })} /> - - - updateSetting({ ldapTruthyValues: e.target.value })} /> - - - updateSetting({ ldapInvertFlag: v })} /> - - - updateSetting({ ldapSyncCron: e.target.value })} /> - - - <> - updateSetting({ webListen: e.target.value })} /> - {inboundOptions.length === 0 && ( -
{t('pages.settings.ldap.noInbounds')}
- )} - -
- - updateSetting({ ldapAutoCreate: v })} /> - - - updateSetting({ ldapAutoDelete: v })} /> - - }> - updateSetting({ ldapDefaultTotalGB: v }))} /> - - }> - updateSetting({ ldapDefaultExpiryDays: v }))} /> - - }> - updateSetting({ ldapDefaultLimitIP: v }))} /> - - - ), - }, - ]} /> + + + + updateSetting({ webDomain: e.target.value })} + /> + + + } + description={t('pages.settings.panelPortDesc')} + > + updateSetting({ webPort: v }))} + /> + + + + updateSetting({ webBasePath: sanitizePath(e.target.value) })} + /> + + + + } + description={t('pages.settings.sessionMaxAgeDesc')} + > + updateSetting({ sessionMaxAge: v }))} + /> + + + + updateSetting({ trustedProxyCIDRs: e.target.value })} + /> + + + + updateSetting({ ipLimitAllowlist: e.target.value })} + /> + + + + + + + ), + }, + { + key: '2', + label: catTabLabel(, t('pages.settings.notifications'), isMobile), + children: ( + <> + } + description={t('pages.settings.expireTimeDiffDesc')} + > + updateSetting({ expireDiff: v }))} + /> + + + } + description={t('pages.settings.trafficDiffDesc')} + > + updateSetting({ trafficDiff: v }))} + /> + + + ), + }, + { + key: '3', + label: catTabLabel(, t('pages.settings.certs'), isMobile), + children: ( + <> + + updateSetting({ webCertFile: e.target.value })} + /> + + + updateSetting({ webKeyFile: e.target.value })} + /> + + + ), + }, + { + key: '4', + label: catTabLabel(, t('pages.settings.externalTraffic'), isMobile), + children: ( + <> + + updateSetting({ externalTrafficInformEnable: v })} + /> + + + updateSetting({ externalTrafficInformURI: e.target.value })} + /> + + + ), + }, + { + key: '5', + label: catTabLabel(, t('pages.settings.dateAndTime'), isMobile), + children: ( + <> + + updateSetting({ timeLocation: e.target.value })} + /> + + + updateSetting({ ldapHost: e.target.value })} + /> + + } + > + updateSetting({ ldapPort: v }))} + /> + + + updateSetting({ ldapUseTLS: v })} + /> + + + updateSetting({ ldapInsecureSkipVerify: v })} + /> + + + updateSetting({ ldapBindDN: e.target.value })} + /> + + + updateSetting({ ldapPassword: v })} + onClearArmedChange={(armed) => updateSetting({ clearLdapPassword: armed })} + /> + + + updateSetting({ ldapBaseDN: e.target.value })} + /> + + + updateSetting({ ldapUserFilter: e.target.value })} + /> + + + updateSetting({ ldapUserAttr: e.target.value })} + /> + + + updateSetting({ ldapVlessField: e.target.value })} + /> + + + updateSetting({ ldapFlagField: e.target.value })} + /> + + + updateSetting({ ldapTruthyValues: e.target.value })} + /> + + + updateSetting({ ldapInvertFlag: v })} + /> + + + updateSetting({ ldapSyncCron: e.target.value })} + /> + + + <> + updateUserField('oldUsername', e.target.value)} /> + , t('pages.settings.security.admin'), isMobile), + children: ( + <> + + updateUserField('oldUsername', e.target.value)} + /> + + + updateUserField('oldPassword', e.target.value)} + /> + + + updateUserField('newUsername', e.target.value)} + /> + + + updateUserField('newPassword', e.target.value)} + /> + +
+ + + +
+ + ), + }, + { + key: '2', + label: catTabLabel( + , + t('pages.settings.security.twoFactor'), + isMobile, + ), + children: ( + + - - updateUserField('oldPassword', e.target.value)} /> - - - updateUserField('newUsername', e.target.value)} /> - - - updateUserField('newPassword', e.target.value)} /> - -
- - - -
- - ), - }, - { - key: '2', - label: catTabLabel(, t('pages.settings.security.twoFactor'), isMobile), - children: ( - - - - ), - }, - { - key: '3', - label: catTabLabel(, t('pages.nodes.apiToken'), isMobile), - children: ( -
-
-

{t('pages.nodes.apiTokenHint')}

- -
- - {!apiTokens.length && !apiTokensLoading && ( - - )} - {apiTokens.map((row) => ( -
-
-
- {row.name} - {formatTokenDate(row.createdAt)} -
-
- toggleTokenEnabled(row)} /> - +
+ + {!apiTokens.length && !apiTokensLoading && ( + + )} + {apiTokens.map((row) => ( +
+
+
+ {row.name} + + {formatTokenDate(row.createdAt)} + +
+
+ toggleTokenEnabled(row)} + /> + +
-
- ))} - -
- ), - }, - ]} /> + ))} +
+
+ ), + }, + ]} + /> setCreateName(e.target.value)} onPressEnter={confirmCreateToken} /> @@ -366,12 +404,16 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }: cancelButtonProps={{ style: { display: 'none' } }} >

- {t('pages.settings.security.apiTokenCreatedNotice') - || 'Copy this token now. For security it is not stored in readable form and will not be shown again.'} + {t('pages.settings.security.apiTokenCreatedNotice') || + 'Copy this token now. For security it is not stored in readable form and will not be shown again.'}

{createdToken?.token} -
diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 909597cc2..8926cd7a6 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -35,7 +35,14 @@ interface ApiMsg { success?: boolean; } -const tabSlugs = ['general', 'security', 'telegram', 'email', 'subscription', 'subscription-formats']; +const tabSlugs = [ + 'general', + 'security', + 'telegram', + 'email', + 'subscription', + 'subscription-formats', +]; function isIp(h: string): boolean { if (typeof h !== 'string') return false; @@ -84,12 +91,10 @@ export default function SettingsPage() { const [entryIsIP, setEntryIsIP] = useState(false); useEffect(() => { - const host = window.location.hostname; setEntryHost(host); setEntryPort(window.location.port); setEntryIsIP(isIp(host)); - }, []); const [alertVisible, setAlertVisible] = useState(true); @@ -99,7 +104,7 @@ export default function SettingsPage() { function rebuildUrlAfterRestart(): string { const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = allSetting; - const newProtocol = (webCertFile || webKeyFile) ? 'https:' : 'http:'; + const newProtocol = webCertFile || webKeyFile ? 'https:' : 'http:'; let base = webBasePath ? webBasePath.replace(/^\//, '') : ''; if (base && !base.endsWith('/')) base += '/'; @@ -144,7 +149,7 @@ export default function SettingsPage() { onOk: async () => { setSpinning(true); try { - const msg = await HttpUtil.post('/panel/api/setting/restartPanel') as ApiMsg; + const msg = (await HttpUtil.post('/panel/api/setting/restartPanel')) as ApiMsg; if (!msg?.success) return; await PromiseUtil.sleep(5000); window.location.replace(rebuildUrlAfterRestart()); @@ -170,7 +175,11 @@ export default function SettingsPage() { if (allSetting.subEnable) { let subPath = allSetting.subPath; if (allSetting.subURI) { - try { subPath = new URL(allSetting.subURI).pathname; } catch { /* noop */ } + try { + subPath = new URL(allSetting.subURI).pathname; + } catch { + /* noop */ + } } if (subPath === '/sub/') { out.push(t('pages.settings.warnDefaultSubPath')); @@ -179,7 +188,11 @@ export default function SettingsPage() { if (allSetting.subJsonEnable) { let p = allSetting.subJsonPath; if (allSetting.subJsonURI) { - try { p = new URL(allSetting.subJsonURI).pathname; } catch { /* noop */ } + try { + p = new URL(allSetting.subJsonURI).pathname; + } catch { + /* noop */ + } } if (p === '/json/') { out.push(t('pages.settings.warnDefaultJsonPath')); @@ -197,12 +210,24 @@ export default function SettingsPage() { const categoryBody = useMemo(() => { switch (activeSlug) { - case 'security': return ; - case 'telegram': return ; - case 'email': return ; - case 'subscription': return ; - case 'subscription-formats': return ; - default: return ; + case 'security': + return ( + + ); + case 'telegram': + return ; + case 'email': + return ; + case 'subscription': + return ; + case 'subscription-formats': + return ; + default: + return ; } }, [activeSlug, allSetting, updateSetting, savePayload]); @@ -215,7 +240,12 @@ export default function SettingsPage() { - + {!fetched ? (
) : ( @@ -227,14 +257,16 @@ export default function SettingsPage() { closable={{ onClose: () => setAlertVisible(false) }} className="conf-alert" title={t('pages.settings.securityWarnings')} - description={( + description={ <> {t('pages.settings.panelExposed')}
    - {confAlerts.map((msg, i) =>
  • {msg}
  • )} + {confAlerts.map((msg, i) => ( +
  • {msg}
  • + ))}
- )} + } /> )} @@ -247,7 +279,12 @@ export default function SettingsPage() { - @@ -261,9 +298,7 @@ export default function SettingsPage() { - - {categoryBody} - + {categoryBody} diff --git a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx index eebb759d5..fac2db332 100644 --- a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx +++ b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx @@ -1,13 +1,6 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { - Card, - Input, - InputNumber, - Select, - Switch, - Tabs, -} from 'antd'; +import { Card, Input, InputNumber, Select, Switch, Tabs } from 'antd'; import { FileTextOutlined, NodeIndexOutlined, @@ -73,7 +66,10 @@ function readJson(raw: string, fallback: T): T { } } -export default function SubscriptionFormatsTab({ allSetting, updateSetting }: SubscriptionFormatsTabProps) { +export default function SubscriptionFormatsTab({ + allSetting, + updateSetting, +}: SubscriptionFormatsTabProps) { const { t } = useTranslation(); const { isMobile } = useMediaQuery(); @@ -81,7 +77,8 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su const directEnabled = allSetting.subJsonRules !== ''; const muxObj = useMemo( - () => (muxEnabled ? readJson(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX), + () => + muxEnabled ? readJson(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX, [allSetting.subJsonMux, muxEnabled], ); @@ -89,7 +86,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su updateSetting({ subJsonMux: v ? JSON.stringify(DEFAULT_MUX) : '' }); } - function setMuxField(key: K, value: typeof DEFAULT_MUX[K]) { + function setMuxField(key: K, value: (typeof DEFAULT_MUX)[K]) { const next = { ...muxObj, [key]: value }; updateSetting({ subJsonMux: JSON.stringify(next) }); } @@ -148,190 +145,267 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su } return ( - , t('pages.settings.panelSettings'), isMobile), - children: ( -
- {allSetting.subJsonEnable && ( - - - {t('pages.settings.subJsonEnableTitle')} - - )} + , t('pages.settings.panelSettings'), isMobile), + children: ( +
+ {allSetting.subJsonEnable && ( + + + {t('pages.settings.subJsonEnableTitle')} + + } + > + JSON {t('pages.settings.subPath')}} + description={t('pages.settings.subPathDesc')} + > + updateSetting({ subJsonPath: sanitizePath(e.target.value) })} + onBlur={() => + updateSetting({ subJsonPath: normalizePath(allSetting.subJsonPath) }) + } + /> + + JSON {t('pages.settings.subURI')}} + description={t('pages.settings.subURIDesc')} + > + updateSetting({ subJsonURI: e.target.value })} + /> + + + updateSetting({ subJsonAlwaysArray: value })} + /> + + + updateSetting({ subJsonAutoDetect: v })} + /> + + + updateSetting({ subJsonUserAgentRegex: value })} + /> + + + )} + {allSetting.subClashEnable && ( + + + {t('pages.settings.subClashEnableTitle')} + + } + > + Clash {t('pages.settings.subPath')}} + description={t('pages.settings.subPathDesc')} + > + + updateSetting({ subClashPath: sanitizePath(e.target.value) }) + } + onBlur={() => + updateSetting({ subClashPath: normalizePath(allSetting.subClashPath) }) + } + /> + + Clash {t('pages.settings.subURI')}} + description={t('pages.settings.subURIDesc')} + > + updateSetting({ subClashURI: e.target.value })} + /> + + + updateSetting({ subClashAutoDetect: v })} + /> + + + updateSetting({ subClashUserAgentRegex: value })} + /> + + + )} +
+ ), + }, + { + key: '2', + label: catTabLabel( + , + t('pages.settings.subFormats.finalMask'), + isMobile, + ), + children: ( + <> + + updateSetting({ subJsonFinalMask: v })} + /> + + ), + }, + { + key: '3', + label: catTabLabel(, t('pages.settings.mux'), isMobile), + children: ( + <> + - JSON {t('pages.settings.subPath')}} description={t('pages.settings.subPathDesc')}> - updateSetting({ subJsonPath: sanitizePath(e.target.value) })} - onBlur={() => updateSetting({ subJsonPath: normalizePath(allSetting.subJsonPath) })} - /> - - JSON {t('pages.settings.subURI')}} description={t('pages.settings.subURIDesc')}> - updateSetting({ subJsonURI: e.target.value })} - /> - - - updateSetting({ subJsonAlwaysArray: value })} /> - - - updateSetting({ subJsonAutoDetect: v })} /> - - - updateSetting({ subJsonUserAgentRegex: value })} - /> - -
- )} - {allSetting.subClashEnable && ( - - - {t('pages.settings.subClashEnableTitle')} - - )} + + + {muxEnabled && ( +
+ + setMuxField('concurrency', v))} + /> + + + setMuxField('xudpConcurrency', v))} + /> + + + updateSetting({ subClashPath: sanitizePath(e.target.value) })} - onBlur={() => updateSetting({ subClashPath: normalizePath(allSetting.subClashPath) })} - /> - - Clash {t('pages.settings.subURI')}} description={t('pages.settings.subURIDesc')}> - updateSetting({ subClashURI: e.target.value })} - /> - - - updateSetting({ subClashAutoDetect: v })} - /> - - - updateSetting({ subClashUserAgentRegex: value })} - /> - - - )} -
- ), - }, - { - key: '2', - label: catTabLabel(, t('pages.settings.subFormats.finalMask'), isMobile), - children: ( - <> - - updateSetting({ subJsonFinalMask: v })} - /> - - ), - }, - { - key: '3', - label: catTabLabel(, t('pages.settings.mux'), isMobile), - children: ( - <> - - - - {muxEnabled && ( -
- - setMuxField('concurrency', v))} /> - - - setMuxField('xudpConcurrency', v))} /> - - - - - {t('pages.settings.direct')} {t('domainName')}}> - + + + {t('pages.settings.direct')} {t('domainName')} + + } + > + updateSetting({ subListen: e.target.value })} /> - - - updateSetting({ subDomain: e.target.value })} /> - - } description={t('pages.settings.subPortDesc')}> - updateSetting({ subPort: v }))} /> - - - updateSetting({ subPath: sanitizePath(e.target.value) })} - onBlur={() => updateSetting({ subPath: normalizePath(allSetting.subPath) })} - /> - - - updateSetting({ subURI: e.target.value })} /> - - - ), - }, - { - key: '2', - label: catTabLabel(, t('pages.settings.information'), isMobile), - children: ( - <> - - updateSetting({ subEncrypt: v })} /> - - - updateSetting({ remarkTemplate: v })} - maxLength={256} - /> - - - updateSetting({ subShowIdentityOnAllLinks: v })} - /> - - - } description={t('pages.settings.subUpdatesDesc')}> - updateSetting({ subUpdates: v }))} /> - - - ), - }, - { - key: '3', - label: catTabLabel(, t('pages.settings.profile'), isMobile), - children: ( - <> - - updateSetting({ subTitle: v })} - metadataOnly - /> - - - updateSetting({ subSupportUrl: v })} - metadataOnly - /> - - - updateSetting({ subProfileUrl: v })} - metadataOnly - /> - - - updateSetting({ subAnnounce: v })} - multiline - rows={3} - metadataOnly - /> - - - {t('pages.settings.subThemeDirDesc')}{' '} - - {t('pages.settings.subThemeDirDocs')} - - + , t('pages.settings.panelSettings'), isMobile), + children: ( + <> + + updateSetting({ subEnable: v })} + /> + + + updateSetting({ subJsonEnable: v })} + /> + + + updateSetting({ subClashEnable: v })} + /> + + {(allSetting.subJsonEnable || allSetting.subClashEnable) && ( + navigate('/settings#subscription-formats')}> + {t('pages.settings.subFormatsTipAction')} + + } + /> )} - > - updateSetting({ subThemeDir: e.target.value })} /> - - - ), - }, - { - key: '4', - label: catTabLabel(, t('pages.settings.certs'), isMobile), - children: ( - <> - - updateSetting({ subCertFile: e.target.value })} /> - - - updateSetting({ subKeyFile: e.target.value })} /> - - - ), - }, - { - key: '5', - label: catTabLabel(, 'Happ', isMobile), - children: ( - <> - - updateSetting({ subEnableRouting: v })} /> - - - updateSetting({ subRoutingRules: e.target.value })} /> - - - updateSetting({ subHideSettings: v })} /> - - - ), - }, - { - key: '6', - label: catTabLabel(, 'Clash / Mihomo', isMobile), - children: ( - <> - - updateSetting({ subClashEnableRouting: v })} /> - - - updateSetting({ subClashRules: e.target.value })} - /> - - - ), - }, - { - key: '7', - label: catTabLabel(, 'Incy', isMobile), - children: ( - <> - - updateSetting({ subIncyEnableRouting: v })} /> - - - updateSetting({ subIncyRoutingRules: e.target.value })} /> - - - ), - }, - ]} /> + + updateSetting({ subListen: e.target.value })} + /> + + + updateSetting({ subDomain: e.target.value })} + /> + + } + description={t('pages.settings.subPortDesc')} + > + updateSetting({ subPort: v }))} + /> + + + updateSetting({ subPath: sanitizePath(e.target.value) })} + onBlur={() => updateSetting({ subPath: normalizePath(allSetting.subPath) })} + /> + + + updateSetting({ subURI: e.target.value })} + /> + + + ), + }, + { + key: '2', + label: catTabLabel(, t('pages.settings.information'), isMobile), + children: ( + <> + + updateSetting({ subEncrypt: v })} + /> + + + updateSetting({ remarkTemplate: v })} + maxLength={256} + /> + + + updateSetting({ subShowIdentityOnAllLinks: v })} + /> + + + } + description={t('pages.settings.subUpdatesDesc')} + > + updateSetting({ subUpdates: v }))} + /> + + + ), + }, + { + key: '3', + label: catTabLabel(, t('pages.settings.profile'), isMobile), + children: ( + <> + + updateSetting({ subTitle: v })} + metadataOnly + /> + + + updateSetting({ subSupportUrl: v })} + metadataOnly + /> + + + updateSetting({ subProfileUrl: v })} + metadataOnly + /> + + + updateSetting({ subAnnounce: v })} + multiline + rows={3} + metadataOnly + /> + + + {t('pages.settings.subThemeDirDesc')}{' '} + + {t('pages.settings.subThemeDirDocs')} + + + } + > + updateSetting({ subThemeDir: e.target.value })} + /> + + + ), + }, + { + key: '4', + label: catTabLabel(, t('pages.settings.certs'), isMobile), + children: ( + <> + + updateSetting({ subCertFile: e.target.value })} + /> + + + updateSetting({ subKeyFile: e.target.value })} + /> + + + ), + }, + { + key: '5', + label: catTabLabel(, 'Happ', isMobile), + children: ( + <> + + updateSetting({ subEnableRouting: v })} + /> + + + updateSetting({ subRoutingRules: e.target.value })} + /> + + + updateSetting({ subHideSettings: v })} + /> + + + ), + }, + { + key: '6', + label: catTabLabel(, 'Clash / Mihomo', isMobile), + children: ( + <> + + updateSetting({ subClashEnableRouting: v })} + /> + + + updateSetting({ subClashRules: e.target.value })} + /> + + + ), + }, + { + key: '7', + label: catTabLabel(, 'Incy', isMobile), + children: ( + <> + + updateSetting({ subIncyEnableRouting: v })} + /> + + + updateSetting({ subIncyRoutingRules: e.target.value })} + /> + + + ), + }, + ]} + /> ); } diff --git a/frontend/src/pages/settings/TelegramTab.tsx b/frontend/src/pages/settings/TelegramTab.tsx index 6f3600886..66445f900 100644 --- a/frontend/src/pages/settings/TelegramTab.tsx +++ b/frontend/src/pages/settings/TelegramTab.tsx @@ -39,7 +39,12 @@ function parseRunTime(raw: string): RunTime { const v = (raw ?? '').trim(); const m = v.match(EVERY_RE); if (m) { - return { mode: 'every', num: Math.max(1, Number(m[1]) || 1), unit: m[2].toLowerCase() as Unit, custom: '' }; + return { + mode: 'every', + num: Math.max(1, Number(m[1]) || 1), + unit: m[2].toLowerCase() as Unit, + custom: '', + }; } if ((MACROS as string[]).includes(v)) { return { mode: v as Macro, num: 1, unit: 'h', custom: '' }; @@ -60,17 +65,22 @@ function composeRunTime(s: RunTime): string { // edit (and one that the 6-field parser accepts). function toCrontab(s: RunTime): string { switch (s.mode) { - case '@hourly': return '0 0 * * * *'; - case '@daily': return '0 0 0 * * *'; - case '@weekly': return '0 0 0 * * 0'; - case '@monthly': return '0 0 0 1 * *'; + case '@hourly': + return '0 0 * * * *'; + case '@daily': + return '0 0 0 * * *'; + case '@weekly': + return '0 0 0 * * 0'; + case '@monthly': + return '0 0 0 1 * *'; case 'every': { const n = Math.max(1, s.num || 1); if (s.unit === 's') return `*/${n} * * * * *`; if (s.unit === 'm') return `0 */${n} * * * *`; return `0 0 */${n} * * *`; } - default: return s.custom; + default: + return s.custom; } } @@ -160,106 +170,168 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr setTestLoading(true); setTestResult(null); try { - const res = await HttpUtil.post('/panel/api/setting/testTgBot') as { success?: boolean; msg?: string }; + const res = (await HttpUtil.post('/panel/api/setting/testTgBot')) as { + success?: boolean; + msg?: string; + }; setTestResult({ success: !!res.success, msg: res.msg || '' }); } catch (e: unknown) { - setTestResult({ success: false, msg: e instanceof Error ? e.message : t('pages.settings.requestFailed') }); + setTestResult({ + success: false, + msg: e instanceof Error ? e.message : t('pages.settings.requestFailed'), + }); } finally { setTestLoading(false); } } const langOptions = useMemo( - () => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({ - value: l.value, - label: ( - <> - {l.icon} -   {l.name} - + () => + LanguageManager.supportedLanguages.map( + (l: { value: string; name: string; icon: string }) => ({ + value: l.value, + label: ( + <> + + {l.icon} + +   {l.name} + + ), + }), ), - })), [], ); return ( - , t('pages.settings.panelSettings'), isMobile), - children: ( - <> - - updateSetting({ tgBotEnable: v })} /> - - - - updateSetting({ tgBotToken: v })} - onClearArmedChange={(armed) => updateSetting({ clearTgBotToken: armed })} - /> - - - - updateSetting({ tgBotChatId: e.target.value })} /> - - - - updateSetting({ tgBotAPIServer: e.target.value })} /> - - - - - {testResult && ( - setTestResult(null) }} + , t('pages.settings.panelSettings'), isMobile), + children: ( + <> + + updateSetting({ tgBotEnable: v })} /> - )} - - - ), - }, - { - key: '2', - label: catTabLabel(, t('pages.settings.notifications'), isMobile), - children: ( - <> - - updateSetting({ tgRunTime: v })} /> - - - updateSetting({ tgBotBackup: v })} /> - + - - - - - ), - }, - ]} /> + + updateSetting({ tgBotToken: v })} + onClearArmedChange={(armed) => updateSetting({ clearTgBotToken: armed })} + /> + + + + updateSetting({ tgBotChatId: e.target.value })} + /> + + + + updateSetting({ tgBotAPIServer: e.target.value })} + /> + + + + + {testResult && ( + setTestResult(null) }} + /> + )} + + + ), + }, + { + key: '2', + label: catTabLabel(, t('pages.settings.notifications'), isMobile), + children: ( + <> + + updateSetting({ tgRunTime: v })} + /> + + + updateSetting({ tgBotBackup: v })} + /> + + + + + + + ), + }, + ]} + /> ); } diff --git a/frontend/src/pages/settings/TwoFactorModal.tsx b/frontend/src/pages/settings/TwoFactorModal.tsx index f20eb1611..567de0900 100644 --- a/frontend/src/pages/settings/TwoFactorModal.tsx +++ b/frontend/src/pages/settings/TwoFactorModal.tsx @@ -37,7 +37,7 @@ export default function TwoFactorModal({ useEffect(() => { if (!open) return; - + setEnteredCode(''); totpRef.current = null; setQrValue(''); @@ -53,7 +53,6 @@ export default function TwoFactorModal({ totpRef.current = totp; setQrValue(totp.toString()); } - }, [open, token]); function close(success: boolean, code = '') { @@ -65,7 +64,9 @@ export default function TwoFactorModal({ function onOk() { const codeOk = TotpCodeSchema.safeParse(enteredCode); if (!codeOk.success) { - messageApi.error(t(codeOk.error.issues[0]?.message ?? 'pages.settings.security.twoFactorModalError')); + messageApi.error( + t(codeOk.error.issues[0]?.message ?? 'pages.settings.security.twoFactorModalError'), + ); return; } if (type === 'confirm' && !token) { @@ -97,49 +98,66 @@ export default function TwoFactorModal({ title={title} closable onCancel={onCancel} - footer={[ - , - , - ]} - > - {type === 'set' ? ( - <> -

{t('pages.settings.security.twoFactorModalSteps')}

- -

{t('pages.settings.security.twoFactorModalFirstStep')}

-
+ {t('cancel')} + , +
- -

{t('pages.settings.security.twoFactorModalSecondStep')}

- setEnteredCode(e.target.value)} style={{ width: '100%' }} aria-label={t('twoFactorCode')} /> - - ) : ( - <> -

{description}

- setEnteredCode(e.target.value)} style={{ width: '100%' }} aria-label={t('twoFactorCode')} /> - - )} + + ) : ( + <> +

{description}

+ setEnteredCode(e.target.value)} + style={{ width: '100%' }} + aria-label={t('twoFactorCode')} + /> + + )} ); diff --git a/frontend/src/pages/settings/catTabLabel.tsx b/frontend/src/pages/settings/catTabLabel.tsx index be1cb2046..ddfab6fd3 100644 --- a/frontend/src/pages/settings/catTabLabel.tsx +++ b/frontend/src/pages/settings/catTabLabel.tsx @@ -6,9 +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) { - const labelledIcon = typeof text === 'string' && isValidElement(icon) - ? cloneElement(icon as ReactElement<{ 'aria-label'?: string }>, { 'aria-label': text }) - : icon; + const labelledIcon = + typeof text === 'string' && isValidElement(icon) + ? cloneElement(icon as ReactElement<{ 'aria-label'?: string }>, { 'aria-label': text }) + : icon; return {labelledIcon}; } return ( diff --git a/frontend/src/pages/settings/uriPath.ts b/frontend/src/pages/settings/uriPath.ts index 88c55bc1a..3b7e66784 100644 --- a/frontend/src/pages/settings/uriPath.ts +++ b/frontend/src/pages/settings/uriPath.ts @@ -2,7 +2,8 @@ export function sanitizePath(input: string): string { let out = ''; for (const ch of String(input ?? '')) { const code = ch.charCodeAt(0); - if (ch === ':' || ch === '*' || ch === ' ' || ch === '\\' || code < 0x20 || code === 0x7f) continue; + if (ch === ':' || ch === '*' || ch === ' ' || ch === '\\' || code < 0x20 || code === 0x7f) + continue; out += ch; } return out; diff --git a/frontend/src/pages/sub/SubPage.css b/frontend/src/pages/sub/SubPage.css index d3765c7f4..bd0072bd8 100644 --- a/frontend/src/pages/sub/SubPage.css +++ b/frontend/src/pages/sub/SubPage.css @@ -61,7 +61,9 @@ border-radius: 10px; background: rgba(0, 0, 0, 0.03); border: 1px solid rgba(0, 0, 0, 0.08); - transition: background 120ms ease, border-color 120ms ease; + transition: + background 120ms ease, + border-color 120ms ease; } .sub-link-row:hover { @@ -127,4 +129,3 @@ .toolbar-btn .anticon { font-size: 18px; } - diff --git a/frontend/src/pages/sub/SubPage.tsx b/frontend/src/pages/sub/SubPage.tsx index 82f981f0d..650512d06 100644 --- a/frontend/src/pages/sub/SubPage.tsx +++ b/frontend/src/pages/sub/SubPage.tsx @@ -72,8 +72,9 @@ const isUnlimited = totalByte <= 0 && expireMs === 0; const isActive = (() => { if (!enabled) return false; if (totalByte > 0) { - const usedByteCalc = Number(subData.usedByte || 0) - || (Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0)); + const usedByteCalc = + Number(subData.usedByte || 0) || + Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0); if (usedByteCalc >= totalByte) return false; } if (expireMs > 0 && Date.now() >= expireMs) return false; @@ -84,7 +85,9 @@ export default function SubPage() { const { t } = useTranslation(); const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme(); const [messageApi, messageContextHolder] = message.useMessage(); - useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); + useEffect(() => { + setMessageInstance(messageApi); + }, [messageApi]); const { isMobile } = useMediaQuery(576); const [lang, setLang] = useState(() => LanguageManager.getLanguage()); @@ -106,11 +109,14 @@ export default function SubPage() { } }, [isDark, isUltra, toggleTheme, toggleUltra]); - const copy = useCallback(async (value: string) => { - if (!value) return; - const ok = await ClipboardManager.copyText(value); - if (ok) messageApi.success(t('copied')); - }, [t, messageApi]); + const copy = useCallback( + async (value: string) => { + if (!value) return; + const ok = await ClipboardManager.copyText(value); + if (ok) messageApi.success(t('copied')); + }, + [t, messageApi], + ); const copyAll = useCallback(async () => { if (links.length === 0) return; @@ -155,13 +161,15 @@ export default function SubPage() { { key: 'status', label: t('subscription.status'), - children: !enabled - ? {t('subscription.inactive')} - : isUnlimited - ? {t('subscription.unlimited')} - : - {isActive ? t('subscription.active') : t('subscription.inactive')} - , + children: !enabled ? ( + {t('subscription.inactive')} + ) : isUnlimited ? ( + {t('subscription.unlimited')} + ) : ( + + {isActive ? t('subscription.active') : t('subscription.inactive')} + + ), }, { key: 'down', label: t('subscription.downloaded'), children: download }, { key: 'up', label: t('subscription.uploaded'), children: upload }, @@ -179,51 +187,62 @@ export default function SubPage() { items.push({ key: 'expiry', label: t('subscription.expiry'), - children: expireMs === 0 - ? t('subscription.noExpiry') - : IntlUtil.formatDate(expireMs, datepicker), + children: + expireMs === 0 ? t('subscription.noExpiry') : IntlUtil.formatDate(expireMs, datepicker), }); return items; }, [t]); - const androidMenuItems = useMemo(() => [ - { - key: 'android-v2box', - label: 'V2Box', - onClick: () => open(`v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`), - }, - { - key: 'android-v2rayng', - label: 'V2RayNG', - onClick: () => open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`), - }, - { key: 'android-singbox', label: 'Sing-box', onClick: () => copy(subUrl) }, - { key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) }, - { key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) }, - { key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) }, - { key: 'android-incy', label: 'Incy', onClick: () => open(`incy://add/${subUrl}`) }, - ], [copy, open]); + const androidMenuItems = useMemo( + () => [ + { + key: 'android-v2box', + label: 'V2Box', + onClick: () => + open( + `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`, + ), + }, + { + key: 'android-v2rayng', + label: 'V2RayNG', + onClick: () => open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`), + }, + { key: 'android-singbox', label: 'Sing-box', onClick: () => copy(subUrl) }, + { key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) }, + { key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) }, + { key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) }, + { key: 'android-incy', label: 'Incy', onClick: () => open(`incy://add/${subUrl}`) }, + ], + [copy, open], + ); - const iosMenuItems = useMemo(() => [ - { key: 'ios-shadowrocket', label: 'Shadowrocket', onClick: () => open(shadowrocketUrl) }, - { key: 'ios-v2box', label: 'V2Box', onClick: () => open(v2boxUrl) }, - { key: 'ios-streisand', label: 'Streisand', onClick: () => open(streisandUrl) }, - { key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) }, - { key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) }, - { key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) }, - { key: 'ios-incy', label: 'Incy', onClick: () => open(incyUrl) }, - ], [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl, incyUrl]); + const iosMenuItems = useMemo( + () => [ + { key: 'ios-shadowrocket', label: 'Shadowrocket', onClick: () => open(shadowrocketUrl) }, + { key: 'ios-v2box', label: 'V2Box', onClick: () => open(v2boxUrl) }, + { key: 'ios-streisand', label: 'Streisand', onClick: () => open(streisandUrl) }, + { key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) }, + { key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) }, + { key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) }, + { key: 'ios-incy', label: 'Incy', onClick: () => open(incyUrl) }, + ], + [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl, incyUrl], + ); const langMenuItems = useMemo( - () => (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map((l) => ({ - key: l.value, - label: ( - - - {l.name} - + () => + (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map( + (l) => ({ + key: l.value, + label: ( + + + {l.name} + + ), + }), ), - })), [], ); @@ -294,8 +313,10 @@ export default function SubPage() { /> {subUrl && (
)} {subJsonUrl && ( } > -
@@ -380,7 +445,9 @@ export default function SubPage() { {subClashUrl && ( } > -
@@ -444,62 +531,65 @@ 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://'); + const isWireguardLink = + link.startsWith('wireguard://') || link.startsWith('wg://'); return ( -
- {parts - ? - : LINK} - - {rowTitle} - -
-
- } - > -
+ } + > +
-
- {isWireguardLink && ( - - )} + {isWireguardLink && ( + + )} ); })} diff --git a/frontend/src/pages/xray/XrayPage.tsx b/frontend/src/pages/xray/XrayPage.tsx index 36d605228..1a4fe6b2d 100644 --- a/frontend/src/pages/xray/XrayPage.tsx +++ b/frontend/src/pages/xray/XrayPage.tsx @@ -30,7 +30,11 @@ import { propagateOutboundTagRename } from './basics/helpers'; import { RoutingTab } from './routing'; import { OutboundsTab } from './outbounds'; import { BalancersTab } from './balancers'; -import { cleanupOrphanedBalancerLoopbacks, ensureMissingBalancerLoopbacks, detectBalancerCycles } from './balancers/balancer-loopback'; +import { + cleanupOrphanedBalancerLoopbacks, + ensureMissingBalancerLoopbacks, + detectBalancerCycles, +} from './balancers/balancer-loopback'; import { DnsTab } from './dns'; import { WarpModal, NordModal } from './overrides'; import './XrayPage.css'; @@ -44,7 +48,9 @@ export default function XrayPage() { const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isMobile } = useMediaQuery(); const [messageApi, messageContextHolder] = message.useMessage(); - useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); + useEffect(() => { + setMessageInstance(messageApi); + }, [messageApi]); const xs = useXraySetting(); const { fetched, @@ -79,7 +85,12 @@ export default function XrayPage() { const [advSettings, setAdvSettings] = useState('xraySetting'); const location = useLocation(); const navigate = useNavigate(); - const pathSection = location.pathname === '/outbound' ? 'outbound' : location.pathname === '/routing' ? 'routing' : ''; + const pathSection = + location.pathname === '/outbound' + ? 'outbound' + : location.pathname === '/routing' + ? 'routing' + : ''; const sectionSlug = pathSection || location.hash.replace(/^#/, ''); const activeSection = SECTION_SLUGS.includes(sectionSlug) ? sectionSlug : 'basic'; @@ -111,7 +122,12 @@ export default function XrayPage() { tt.outbounds.push(outbound as never); }); } - function onResetOutbound(payload: { index: number; outbound: Record; oldTag?: string; newTag?: string }) { + function onResetOutbound(payload: { + index: number; + outbound: Record; + oldTag?: string; + newTag?: string; + }) { mutate((tt) => { if (!tt.outbounds || payload.index < 0) return; tt.outbounds[payload.index] = payload.outbound as never; @@ -146,10 +162,14 @@ export default function XrayPage() { if (!tpl) return ''; try { switch (advSettings) { - case 'inboundSettings': return JSON.stringify(tpl.inbounds || [], null, 2); - case 'outboundSettings': return JSON.stringify(tpl.outbounds || [], null, 2); - case 'routingRuleSettings': return JSON.stringify(tpl.routing?.rules || [], null, 2); - default: return ''; + case 'inboundSettings': + return JSON.stringify(tpl.inbounds || [], null, 2); + case 'outboundSettings': + return JSON.stringify(tpl.outbounds || [], null, 2); + case 'routingRuleSettings': + return JSON.stringify(tpl.routing?.rules || [], null, 2); + default: + return ''; } } catch { return ''; @@ -259,10 +279,7 @@ export default function XrayPage() { ); case 'dns': return ( - + ); case 'advanced': return ( @@ -312,7 +329,12 @@ export default function XrayPage() { - + {!fetched ? (
) : fetchError ? ( @@ -320,7 +342,11 @@ export default function XrayPage() { status="error" title={t('somethingWentWrong')} subTitle={fetchError} - extra={} + extra={ + + } /> ) : ( @@ -343,9 +369,7 @@ export default function XrayPage() { - - {sectionBody} - + {sectionBody} )} diff --git a/frontend/src/pages/xray/balancers/BalancerFormModal.tsx b/frontend/src/pages/xray/balancers/BalancerFormModal.tsx index 780e6f0e7..c91df29e4 100644 --- a/frontend/src/pages/xray/balancers/BalancerFormModal.tsx +++ b/frontend/src/pages/xray/balancers/BalancerFormModal.tsx @@ -9,10 +9,7 @@ import type { Path } from 'react-hook-form'; import { InputAddon } from '@/components/ui'; import { FormField } from '@/components/form/rhf'; import type { XraySettingsValue } from '@/hooks/useXraySetting'; -import { - BalancerFormSchema, - type BalancerFormValues, -} from '@/schemas/xray'; +import { BalancerFormSchema, type BalancerFormValues } from '@/schemas/xray'; import { BalancerStrategyTypeSchema, type BalancerStrategyType, @@ -95,7 +92,10 @@ export default function BalancerFormModal({ ); const cycleInfo = useMemo(() => { - const rules = (templateSettings?.routing?.rules || []) as Array<{ inboundTag?: string[]; balancerTag?: string }>; + const rules = (templateSettings?.routing?.rules || []) as Array<{ + inboundTag?: string[]; + balancerTag?: string; + }>; const resolveLoopback = (tag: string): string | null => { for (const r of rules) { if (Array.isArray(r.inboundTag) && r.inboundTag.includes(tag) && r.balancerTag) { @@ -135,9 +135,8 @@ export default function BalancerFormModal({ const wouldCreateCycle = !!cycleInfo[fallbackTag]; const fallbackOptions = useMemo(() => { - const options: Array<{ value: string; label: ReactNode; disabled?: boolean; title?: string }> = [ - { value: '', label: `(${t('none')})` }, - ]; + const options: Array<{ value: string; label: ReactNode; disabled?: boolean; title?: string }> = + [{ value: '', label: `(${t('none')})` }]; for (const tg of outboundTags) { options.push({ value: tg, label: tg }); } @@ -146,10 +145,14 @@ export default function BalancerFormModal({ options.push({ value: tg, disabled: !!cycle, - title: cycle ? t('pages.xray.balancer.cycleTooltip', { path: cycle.join(' → '), start: currentTag }) : undefined, + title: cycle + ? t('pages.xray.balancer.cycleTooltip', { path: cycle.join(' → '), start: currentTag }) + : undefined, label: ( - {t('pages.xray.rules.balancer')} + + {t('pages.xray.rules.balancer')} + {tg} ), @@ -215,13 +218,16 @@ export default function BalancerFormModal({ const errorMessage = fieldState.error?.message ? t(fieldState.error.message, { defaultValue: fieldState.error.message }) : ''; - const showDuplicate = !errorMessage && (submitAttempted || fieldState.isTouched) && duplicate; + const showDuplicate = + !errorMessage && (submitAttempted || fieldState.isTouched) && duplicate; return ( v ?? '', output: (v) => (typeof v === 'string' && v ? v : undefined) }} + transform={{ + input: (v) => v ?? '', + output: (v) => (typeof v === 'string' && v ? v : undefined), + }} > @@ -295,7 +304,13 @@ export default function BalancerFormModal({ label={t('pages.xray.balancer.tolerance')} transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }} > - + - - ) : ( - - - {hostsList.map((row, idx) => ( -
- { - const next = hostsList.map((r, i) => (i === idx ? { ...r, domain: e.target.value } : r)); - syncHosts(next); - }} - /> - { + const next = hostsList.map((r, i) => + i === idx ? { ...r, domain: e.target.value } : r, + ); + syncHosts(next); + }} + /> + - + @@ -518,7 +523,9 @@ export default function OutboundFormModal({ buttonStyle="solid" onChange={(e) => onSecurityChange(e.target.value as string)} > - {network !== 'hysteria' && {t('none')}} + {network !== 'hysteria' && ( + {t('none')} + )} {tlsAllowed && TLS} {realityAllowed && Reality} @@ -529,7 +536,9 @@ export default function OutboundFormModal({ {security === 'reality' && realityAllowed && } - {((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && ( + {((streamAllowed && network) || + !streamAllowed || + protocol === 'wireguard') && ( )} @@ -555,7 +564,11 @@ export default function OutboundFormModal({ key: '2', label: 'JSON', children: ( - + ([]); const [subsLoading, setSubsLoading] = useState(false); - const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, allowInsecure: false, prepend: false }); + const [newSub, setNewSub] = useState({ + remark: '', + url: '', + tagPrefix: '', + updateInterval: 600, + enabled: true, + allowPrivate: false, + allowInsecure: false, + prepend: false, + }); const [editingSubId, setEditingSubId] = useState(null); const [savingSub, setSavingSub] = useState(false); const [refreshingId, setRefreshingId] = useState(null); const [refreshingAll, setRefreshingAll] = useState(false); const [busyId, setBusyId] = useState(null); const [previewing, setPreviewing] = useState(false); - const [previewData, setPreviewData] = useState<{ tag?: string; protocol?: string }[] | null>(null); + const [previewData, setPreviewData] = useState<{ tag?: string; protocol?: string }[] | null>( + null, + ); // Convenience: expose hours/minutes for the interval input const intervalHours = Math.floor((newSub.updateInterval || 600) / 3600); @@ -184,7 +201,9 @@ export default function OutboundsTab({ function openAdd() { setEditingOutbound(null); setEditingIndex(null); - setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg)); + setExistingTags( + (templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg), + ); setModalOpen(true); } @@ -281,7 +300,11 @@ export default function OutboundsTab({ return; } const obj = parsed as { outbounds?: unknown }; - const list = Array.isArray(parsed) ? parsed : Array.isArray(obj?.outbounds) ? obj.outbounds : null; + const list = Array.isArray(parsed) + ? parsed + : Array.isArray(obj?.outbounds) + ? obj.outbounds + : null; if (!list) { messageApi.error(t('pages.xray.importInvalidJson')); return; @@ -305,7 +328,16 @@ export default function OutboundsTab({ setSubsLoading(false); } } - function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; allowInsecure?: boolean; prepend?: boolean }) { + function subBody(src: { + remark?: string; + url?: string; + tagPrefix?: string; + updateInterval?: number; + enabled?: boolean; + allowPrivate?: boolean; + allowInsecure?: boolean; + prepend?: boolean; + }) { return { remark: src.remark ?? '', url: src.url ?? '', @@ -318,7 +350,16 @@ export default function OutboundsTab({ }; } function resetSubForm() { - setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, allowInsecure: false, prepend: false }); + setNewSub({ + remark: '', + url: '', + tagPrefix: '', + updateInterval: 600, + enabled: true, + allowPrivate: false, + allowInsecure: false, + prepend: false, + }); setEditingSubId(null); setPreviewData(null); } @@ -343,12 +384,19 @@ export default function OutboundsTab({ } setSavingSub(true); try { - const url = editingSubId != null - ? `/panel/api/xray/outbound-subs/${editingSubId}` - : '/panel/api/xray/outbound-subs'; + const url = + editingSubId != null + ? `/panel/api/xray/outbound-subs/${editingSubId}` + : '/panel/api/xray/outbound-subs'; const r = await HttpUtil.post(url, subBody(newSub)); if (r?.success) { - messageApi.success(t(editingSubId != null ? 'pages.xray.outboundSub.toastUpdated' : 'pages.xray.outboundSub.toastAdded')); + messageApi.success( + t( + editingSubId != null + ? 'pages.xray.outboundSub.toastUpdated' + : 'pages.xray.outboundSub.toastAdded', + ), + ); const createdId = editingSubId == null ? r.obj?.id : undefined; resetSubForm(); await loadSubs(); @@ -371,7 +419,10 @@ export default function OutboundsTab({ setPreviewing(true); setPreviewData(null); try { - const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>('/panel/api/xray/outbound-subs/parse', { url: newSub.url, allowPrivate: newSub.allowPrivate }); + const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>( + '/panel/api/xray/outbound-subs/parse', + { url: newSub.url, allowPrivate: newSub.allowPrivate }, + ); if (r?.success && Array.isArray(r.obj)) { setPreviewData(r.obj); if (r.obj.length === 0) messageApi.info(t('pages.xray.outboundSub.previewEmpty')); @@ -387,7 +438,10 @@ export default function OutboundsTab({ async function toggleEnabled(sub: OutboundSub) { setBusyId(sub.id); try { - const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${sub.id}`, subBody({ ...sub, enabled: !sub.enabled })); + const r = await HttpUtil.post( + `/panel/api/xray/outbound-subs/${sub.id}`, + subBody({ ...sub, enabled: !sub.enabled }), + ); if (r?.success) { await loadSubs(); onRefreshXrayData?.(); @@ -436,7 +490,11 @@ export default function OutboundsTab({ setRefreshingAll(true); try { for (const s of subs) { - try { await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`); } catch { /* continue */ } + try { + await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`); + } catch { + /* continue */ + } } messageApi.success(t('pages.xray.outboundSub.toastRefreshed')); await loadSubs(); @@ -493,8 +551,19 @@ export default function OutboundsTab({ { key: 'warp', icon: , label: 'WARP', onClick: onShowWarp }, { key: 'nord', icon: , label: 'NordVPN', onClick: onShowNord }, { type: 'divider' }, - { key: 'import', icon: , label: t('pages.xray.importOutbounds'), onClick: () => setImportOpen(true) }, - { key: 'export', icon: , label: t('pages.xray.exportOutbounds'), disabled: outbounds.length === 0, onClick: exportOutbounds }, + { + key: 'import', + icon: , + label: t('pages.xray.importOutbounds'), + onClick: () => setImportOpen(true), + }, + { + key: 'export', + icon: , + label: t('pages.xray.exportOutbounds'), + disabled: outbounds.length === 0, + onClick: exportOutbounds, + }, ], }} > @@ -505,13 +574,23 @@ export default function OutboundsTab({ - setTestMode(e.target.value)} buttonStyle="solid" size="small"> + setTestMode(e.target.value)} + buttonStyle="solid" + size="small" + > TCP HTTP {t('pages.xray.outbound.modeRealDelay')} - - setNewSub({ ...newSub, remark: e.target.value })} placeholder={t('pages.xray.outboundSub.remarkPlaceholder')} /> + setNewSub({ ...newSub, remark: e.target.value })} + placeholder={t('pages.xray.outboundSub.remarkPlaceholder')} + /> - setNewSub({ ...newSub, url: e.target.value })} placeholder={t('pages.xray.outboundSub.urlPlaceholder')} /> + setNewSub({ ...newSub, url: e.target.value })} + placeholder={t('pages.xray.outboundSub.urlPlaceholder')} + /> - setNewSub({ ...newSub, tagPrefix: e.target.value })} placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')} /> + setNewSub({ ...newSub, tagPrefix: e.target.value })} + placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')} + /> @@ -629,42 +720,61 @@ export default function OutboundsTab({ value={intervalHours} onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))} style={{ width: 80 }} - /> {t('pages.xray.outboundSub.hours')} + />{' '} + {t('pages.xray.outboundSub.hours')} setIntervalHM(intervalHours, v))} style={{ width: 80 }} - /> {t('pages.xray.outboundSub.minutes')} + />{' '} + {t('pages.xray.outboundSub.minutes')}
{t('pages.xray.outboundSub.intervalHint')}
- setNewSub({ ...newSub, enabled: v })} /> + setNewSub({ ...newSub, enabled: v })} + /> - setNewSub({ ...newSub, allowPrivate: v })} /> + setNewSub({ ...newSub, allowPrivate: v })} + />
{t('pages.xray.outboundSub.allowPrivateHint')}
- setNewSub({ ...newSub, allowInsecure: v })} /> + setNewSub({ ...newSub, allowInsecure: v })} + />
{t('pages.hosts.hints.allowInsecure')}
- setNewSub({ ...newSub, prepend: v })} /> + setNewSub({ ...newSub, prepend: v })} + />
{t('pages.xray.outboundSub.prependHint')}
- )} @@ -711,8 +854,22 @@ export default function OutboundsTab({ width: 56, render: (_: unknown, r: OutboundSub, index: number) => ( - - - {t('pages.xray.warp.settings')} - -
- v ?? undefined }} - onAfterChange={(v) => fetchServers(v as number)} - > - ({ value: c.id, label: c.name }))]} - /> - - )} - - {filteredServers.length > 0 && ( - - + + +
+ ), + }, + { + key: 'key', + label: t('pages.xray.nord.privateKey'), + children: ( +
+ + + + +
+ ), + }, + ]} + /> ) : ( <> - {t('disabled')} - + + {t('pages.xray.warp.settings')} + +
+ v ?? undefined }} + onAfterChange={(v) => fetchServers(v as number)} + > + ({ value: c.id, label: c.name })), + ]} + /> + + )} + + {filteredServers.length > 0 && ( + + - -
- - {licenseError && ( - - )} -
-
- ), - }, - { - key: '2', - label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'), - children: ( -
- Number(v) }} + + +
+ + {licenseError && ( + + )} +
+
+ ), + }, + { + key: '2', + label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'), + children: ( +
- - - -
- ), - }, - ]} - /> + Number(v) }} + > + + + + + ), + }, + ]} + /> - {t('pages.xray.warp.accountInfo')} -
- - -
+ {t('pages.xray.warp.accountInfo')} +
+ + +
- {hasConfig && ( - <> - - - - - - - - - - - - - - - {warpConfig?.account && ( - <> - - - - - - - - - - - - - - - - - {warpConfig.account.usage != null && ( + {hasConfig && ( + <> +
{t('pages.xray.warp.deviceName')}{warpConfig?.name}
{t('pages.xray.warp.deviceModel')}{warpConfig?.model}
{t('pages.xray.warp.deviceEnabled')}{String(warpConfig?.enabled)}
{t('pages.xray.warp.accountType')}{warpConfig.account.account_type}
{t('pages.xray.warp.role')}{warpConfig.account.role}
{t('pages.xray.warp.warpPlusData')}{SizeFormatter.sizeFormat(warpConfig.account.premium_data)}
{t('pages.xray.warp.quota')}{SizeFormatter.sizeFormat(warpConfig.account.quota)}
+ + + + + + + + + + + + + + {warpConfig?.account && ( + <> - - + + - )} - - )} - -
{t('pages.xray.warp.deviceName')}{warpConfig?.name}
{t('pages.xray.warp.deviceModel')}{warpConfig?.model}
{t('pages.xray.warp.deviceEnabled')}{String(warpConfig?.enabled)}
{t('pages.xray.warp.usage')}{SizeFormatter.sizeFormat(warpConfig.account.usage)}{t('pages.xray.warp.accountType')}{warpConfig.account.account_type}
+ + {t('pages.xray.warp.role')} + {warpConfig.account.role} + + + {t('pages.xray.warp.warpPlusData')} + {SizeFormatter.sizeFormat(warpConfig.account.premium_data)} + + + {t('pages.xray.warp.quota')} + {SizeFormatter.sizeFormat(warpConfig.account.quota)} + + {warpConfig.account.usage != null && ( + + {t('pages.xray.warp.usage')} + {SizeFormatter.sizeFormat(warpConfig.account.usage)} + + )} + + )} + + - {t('pages.xray.outbound.outboundStatus')} - {warpOutboundIndex >= 0 ? ( - <> - {t('enabled')} - - - ) : ( - <> - {t('disabled')} - - - )} - - )} - - )} + {t('pages.xray.outbound.outboundStatus')} + {warpOutboundIndex >= 0 ? ( + <> + {t('enabled')} + + + ) : ( + <> + {t('disabled')} + + + )} + + )} + + )} diff --git a/frontend/src/pages/xray/reference-cleanup.ts b/frontend/src/pages/xray/reference-cleanup.ts index 25991ae32..ea5d6c6e4 100644 --- a/frontend/src/pages/xray/reference-cleanup.ts +++ b/frontend/src/pages/xray/reference-cleanup.ts @@ -36,7 +36,12 @@ export interface DeletionImpact { burst: boolean; } -const emptyImpact = (): DeletionImpact => ({ rules: [], balancers: [], observatory: false, burst: false }); +const emptyImpact = (): DeletionImpact => ({ + rules: [], + balancers: [], + observatory: false, + burst: false, +}); function ruleList(tt: XraySettingsValue): RuleObject[] { const r = tt.routing?.rules; @@ -159,7 +164,11 @@ function applyCleanup( for (const outbound of tt.outbounds) { const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } }) ?.streamSettings?.sockopt; - if (sockopt && typeof sockopt.dialerProxy === 'string' && removedOutbounds.has(sockopt.dialerProxy)) { + if ( + sockopt && + typeof sockopt.dialerProxy === 'string' && + removedOutbounds.has(sockopt.dialerProxy) + ) { delete sockopt.dialerProxy; } } diff --git a/frontend/src/pages/xray/routing/CriterionRow.tsx b/frontend/src/pages/xray/routing/CriterionRow.tsx index 78605a4f8..a167af173 100644 --- a/frontend/src/pages/xray/routing/CriterionRow.tsx +++ b/frontend/src/pages/xray/routing/CriterionRow.tsx @@ -2,7 +2,17 @@ import { Tooltip } from 'antd'; import { csv } from './helpers'; -export default function CriterionRow({ label, value, values, title }: { label: string; value?: string; values?: string[]; title: string }) { +export default function CriterionRow({ + label, + value, + values, + title, +}: { + label: string; + value?: string; + values?: string[]; + title: string; +}) { const parts = values ?? csv(value); if (parts.length === 0) return null; return ( diff --git a/frontend/src/pages/xray/routing/RouteTester.tsx b/frontend/src/pages/xray/routing/RouteTester.tsx index 4ccdfe7b0..703ce50ad 100644 --- a/frontend/src/pages/xray/routing/RouteTester.tsx +++ b/frontend/src/pages/xray/routing/RouteTester.tsx @@ -105,7 +105,9 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps) allowClear value={inboundTag} onChange={setInboundTag} - options={inboundTags.filter(Boolean).map((tag) => ({ label: formatInboundTag(tag, remarkByTag), value: tag }))} + options={inboundTags + .filter(Boolean) + .map((tag) => ({ label: formatInboundTag(tag, remarkByTag), value: tag }))} /> @@ -120,14 +122,21 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps) /> - - {result && ( - result.matched ? ( + {result && + (result.matched ? ( {t('pages.xray.routeTesterViaBalancer')}: {(result.groupTags || []).map((tag) => ( - {tag} + + {tag} + ))} )} @@ -148,8 +159,7 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps) /> ) : ( - ) - )} + ))}
); } diff --git a/frontend/src/pages/xray/routing/RoutingBasic.tsx b/frontend/src/pages/xray/routing/RoutingBasic.tsx index bd0d44e3d..e8bba5213 100644 --- a/frontend/src/pages/xray/routing/RoutingBasic.tsx +++ b/frontend/src/pages/xray/routing/RoutingBasic.tsx @@ -13,7 +13,13 @@ import { directSettings, ipv4Settings, } from '../basics/constants'; -import { getDefaultOutboundTag, ruleGetter, ruleSetter, setDefaultOutboundTag, syncOutbound } from '../basics/helpers'; +import { + getDefaultOutboundTag, + ruleGetter, + ruleSetter, + setDefaultOutboundTag, + syncOutbound, +} from '../basics/helpers'; interface RoutingBasicProps { templateSettings: XraySettingsValue | null; @@ -81,12 +87,14 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }: control={ mutate((tt) => { - const next = checked - ? [...blockedProtocols, ...BITTORRENT_PROTOCOLS] - : blockedProtocols.filter((d) => !BITTORRENT_PROTOCOLS.includes(d)); - ruleSetter(tt, 'blocked', 'protocol', next); - })} + onChange={(checked) => + mutate((tt) => { + const next = checked + ? [...blockedProtocols, ...BITTORRENT_PROTOCOLS] + : blockedProtocols.filter((d) => !BITTORRENT_PROTOCOLS.includes(d)); + ruleSetter(tt, 'blocked', 'protocol', next); + }) + } /> } /> @@ -135,10 +143,12 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }: value={directIPs} style={{ width: '100%' }} options={IPS_OPTIONS} - onChange={(v) => mutate((tt) => { - ruleSetter(tt, 'direct', 'ip', v); - syncOutbound(tt, 'direct', directSettings); - })} + onChange={(v) => + mutate((tt) => { + ruleSetter(tt, 'direct', 'ip', v); + syncOutbound(tt, 'direct', directSettings); + }) + } /> } /> @@ -152,10 +162,12 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }: value={directDomains} style={{ width: '100%' }} options={DOMAINS_OPTIONS} - onChange={(v) => mutate((tt) => { - ruleSetter(tt, 'direct', 'domain', v); - syncOutbound(tt, 'direct', directSettings); - })} + onChange={(v) => + mutate((tt) => { + ruleSetter(tt, 'direct', 'domain', v); + syncOutbound(tt, 'direct', directSettings); + }) + } /> } /> @@ -170,10 +182,12 @@ export default function RoutingBasic({ templateSettings, setTemplateSettings }: value={ipv4Domains} style={{ width: '100%' }} options={SERVICES_OPTIONS} - onChange={(v) => mutate((tt) => { - ruleSetter(tt, 'IPv4', 'domain', v); - syncOutbound(tt, 'IPv4', ipv4Settings); - })} + onChange={(v) => + mutate((tt) => { + ruleSetter(tt, 'IPv4', 'domain', v); + syncOutbound(tt, 'IPv4', ipv4Settings); + }) + } /> } /> diff --git a/frontend/src/pages/xray/routing/RoutingTab.css b/frontend/src/pages/xray/routing/RoutingTab.css index 33176d793..d920bafe1 100644 --- a/frontend/src/pages/xray/routing/RoutingTab.css +++ b/frontend/src/pages/xray/routing/RoutingTab.css @@ -112,7 +112,9 @@ border: 1px solid var(--ant-color-border); border-radius: 8px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); - transition: opacity 0.15s, box-shadow 0.15s; + transition: + opacity 0.15s, + box-shadow 0.15s; } .rule-list > .rule-card:not(:last-child)::after { diff --git a/frontend/src/pages/xray/routing/RoutingTab.tsx b/frontend/src/pages/xray/routing/RoutingTab.tsx index 1b54a6213..b79865993 100644 --- a/frontend/src/pages/xray/routing/RoutingTab.tsx +++ b/frontend/src/pages/xray/routing/RoutingTab.tsx @@ -51,7 +51,12 @@ export default function RoutingTab({ const [editingIndex, setEditingIndex] = useState(null); const [draggedIndex, setDraggedIndex] = useState(null); const [dropTargetIndex, setDropTargetIndex] = useState(null); - const dragRef = useRef<{ from: number | null; to: number | null; startY: number; moved: boolean }>({ + const dragRef = useRef<{ + from: number | null; + to: number | null; + startY: number; + moved: boolean; + }>({ from: null, to: null, startY: 0, @@ -120,11 +125,15 @@ export default function RoutingTab({ for (const ib of (templateSettings?.inbounds as Array<{ tag?: string }>) || []) push(ib?.tag); for (const tag of inboundTags || []) push(tag); for (const ob of templateSettings?.outbounds || []) { - const obx = ob as { reverse?: { tag?: string }; settings?: { reverse?: { tag?: string }; inboundTag?: string } }; + const obx = ob as { + reverse?: { tag?: string }; + settings?: { reverse?: { tag?: string }; inboundTag?: string }; + }; push(obx?.reverse?.tag || obx?.settings?.reverse?.tag || obx?.settings?.inboundTag); } push((templateSettings?.dns as { tag?: string } | undefined)?.tag); - for (const s of (templateSettings?.dns as { servers?: Array<{ tag?: string }> } | undefined)?.servers || []) { + for (const s of (templateSettings?.dns as { servers?: Array<{ tag?: string }> } | undefined) + ?.servers || []) { if (typeof s === 'object' && s?.tag) push(s.tag); } return out; @@ -222,9 +231,10 @@ export default function RoutingTab({ okText: t('delete'), okType: 'danger', cancelText: t('cancel'), - onOk: () => mutate((tt) => { - tt.routing?.rules?.splice(target, 1); - }), + onOk: () => + mutate((tt) => { + tt.routing?.rules?.splice(target, 1); + }), }); } @@ -262,7 +272,9 @@ export default function RoutingTab({ ev.preventDefault(); try { (ev.currentTarget as Element).setPointerCapture(ev.pointerId); - } catch { /* ignore */ } + } catch { + /* ignore */ + } dragRef.current = { from: idx, to: idx, startY: ev.clientY, moved: false }; setDraggedIndex(idx); setDropTargetIndex(idx); @@ -357,8 +369,19 @@ export default function RoutingTab({ trigger={['click']} menu={{ items: [ - { key: 'import', icon: , label: t('pages.xray.importRules'), onClick: () => setImportOpen(true) }, - { key: 'export', icon: , label: t('pages.xray.exportRules'), disabled: rules.length === 0, onClick: exportRules }, + { + key: 'import', + icon: , + label: t('pages.xray.importRules'), + onClick: () => setImportOpen(true), + }, + { + key: 'export', + icon: , + label: t('pages.xray.exportRules'), + disabled: rules.length === 0, + onClick: exportRules, + }, ], }} > @@ -394,7 +417,10 @@ export default function RoutingTab({ if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) { classes.push(i > draggedIndex ? 'drop-after' : 'drop-before'); } - return { className: classes.join(' '), 'data-row-key': i } as React.HTMLAttributes; + return { + className: classes.join(' '), + 'data-row-key': i, + } as React.HTMLAttributes; }} /> )} diff --git a/frontend/src/pages/xray/routing/RuleCardList.tsx b/frontend/src/pages/xray/routing/RuleCardList.tsx index f63e3d58a..3b2f40107 100644 --- a/frontend/src/pages/xray/routing/RuleCardList.tsx +++ b/frontend/src/pages/xray/routing/RuleCardList.tsx @@ -13,7 +13,14 @@ import { } from '@ant-design/icons'; import { useInboundOptions } from '@/api/queries/useInboundOptions'; -import { buildRemarkByTag, chipPreview, inboundTagChipPreview, inboundTagsDisplayTitle, isApiRule, ruleCriteriaChips } from './helpers'; +import { + buildRemarkByTag, + chipPreview, + inboundTagChipPreview, + inboundTagsDisplayTitle, + isApiRule, + ruleCriteriaChips, +} from './helpers'; import type { RuleRow } from './types'; interface RuleCardListProps { @@ -51,7 +58,9 @@ export default function RuleCardList({
draggedIndex ? 'drop-after' : ''} ${ rule.enabled === false ? 'rule-disabled' : '' }`} @@ -68,14 +77,54 @@ export default function RuleCardList({ trigger={['click']} menu={{ items: [ - { key: 'edit', label: <> {t('edit')}, onClick: () => openEdit(index) }, - { key: 'up', label: <> {t('pages.inbounds.form.moveUp')}, disabled: index === 0, onClick: () => moveUp(index) }, - { key: 'down', label: <> {t('pages.inbounds.form.moveDown')}, disabled: index === rows.length - 1, onClick: () => moveDown(index) }, - { key: 'del', danger: true, label: <> {t('delete')}, onClick: () => confirmDelete(index) }, + { + key: 'edit', + label: ( + <> + {t('edit')} + + ), + onClick: () => openEdit(index), + }, + { + key: 'up', + label: ( + <> + {t('pages.inbounds.form.moveUp')} + + ), + disabled: index === 0, + onClick: () => moveUp(index), + }, + { + key: 'down', + label: ( + <> + {t('pages.inbounds.form.moveDown')} + + ), + disabled: index === rows.length - 1, + onClick: () => moveDown(index), + }, + { + key: 'del', + danger: true, + label: ( + <> + {t('delete')} + + ), + onClick: () => confirmDelete(index), + }, ], }} > -