Ver Fonte

Move to TypeScript 7 and the oxc toolchain (oxlint + oxfmt) (#6262)

* chore(frontend,docs): move to TypeScript 7 and replace ESLint with oxlint

TypeScript 7 is the native Go port and ships no programmatic compiler
API, so typescript-eslint cannot run at all: it peer-pins
typescript >=4.8.4 <6.1.0 (canary too) and hard-crashes with
"typescript-eslint does not support TS 7.0". Upstream support is
tracked in typescript-eslint#10940 and targets TS >=7.1.

Rather than wait, or carry Microsoft's side-by-side alias (which keeps
a second TS 6 install alive purely to feed the linter), both projects
move to oxlint, which never depended on the TypeScript API.

Typecheck drops from ~9.7s to ~2.2s and 167 packages leave frontend/.

oxlint has no no-restricted-syntax, so the #6121/#6127 cleared-
InputNumber guard is reimplemented as a JS plugin in
frontend/tools/oxlint/. It was verified to still fire in
pages/settings/** and pages/xray/** and to stay exempt in *Modal.tsx.

The type-aware @deprecated sweep survives too, as
`npm run lint:deprecated`: oxlint's type-aware mode runs on
oxlint-tsgolint, which drives the TS 7 typescript-go checker, so the
TS 7 move is what makes it possible.

Behaviour is preserved rather than tightened. jsx-a11y/prefer-tag-over-role
is off in both configs because it was never part of the recommended sets
ESLint actually ran, and oxlint honours the existing eslint-disable
comments, so no source churn was needed.

Two real fixes fell out of the stricter linting:
- outbound-link-parser.test.ts used `out?.streamSettings` behind an `as`
  cast, which hid the optional chain from ESLint and would throw on a
  null parse; the rest of the file already used `out!`.
- InputAddon's conditional role/tabIndex/onKeyDown is genuinely
  accessible but oxlint cannot evaluate it, so it gets a scoped disable.

* chore(docs): replace Prettier with oxfmt

oxfmt is the oxc project's Prettier-compatible formatter, so this pairs
with the oxlint move and drops the last JS-based tool from the docs
toolchain.

The swap is behaviour-preserving. Running Prettier and oxfmt over the
same files, with the existing .prettierrc.json settings migrated via
`oxfmt --migrate=prettier`, produces byte-identical output on every
file. (Comparing them outside the project directory is misleading:
Prettier silently falls back to its defaults when it cannot find its
config, which looks like a mismatch but is not one.)

The 18 files reformatted here were already failing `pnpm format:check`
before this change — Prettier wanted the exact same edits. The check is
not part of docs-ci.yml, which is why the drift went unnoticed.

.prettierignore becomes ignorePatterns in .oxfmtrc.json, keeping the
deliberate MDX exclusion: reflowing MDX prose merges headings into
paragraphs and collapses lists inside Steps/Callout components. Both
that and the generated fumadocs-openapi reference output were verified
untouched.

oxfmt is pinned to 0.63.0 rather than latest. pnpm 11's built-in
minimumReleaseAge policy rejects same-day releases, and 0.64.0 would
have made pnpm silently append 20 waiver lines to pnpm-workspace.yaml.

* style(frontend): adopt oxfmt and format src

frontend/ has never had a formatter, so this reformats 344 of 497 files
in src/. The change is purely whitespace, quoting and line wrapping —
no logic is touched. It is kept in its own commit so it does not bury
the TypeScript 7 / oxlint migration or the git blame for the code
itself.

Settings match docs/ and the code as it was already written: single
quotes, semicolons, trailing commas, 2-space indent, 100 columns. That
was measured rather than assumed — src/ was already uniformly
single-quoted and 2-space indented, with p90 line length at 75.

Formatting is scoped to src/ (mirroring `oxlint src`) and
.oxfmtrc.json ignores src/generated. Both matter: `make gen-check`
compares src/generated and public/openapi.json, and
`make msw-worker-check` byte-compares public/mockServiceWorker.js
against the installed MSW runtime, so reformatting any of them breaks
the gate.

Reflowing also moves `eslint-disable-next-line` comments off the line
they guard, which broke two suppressions that had been silently
correct before:
- clone-inbound-modal.test.tsx: the object literal became multi-line,
  leaving `} as any;` four lines below its no-explicit-any disable.
- ClientsPage.tsx: the useMemo dependency array moved onto its own
  line, out from under its exhaustive-deps disable.
Both comments were relocated onto the line they actually guard, and
verified to still suppress by removing them and watching the errors
return.

* ci: enforce formatting in CI and make verify

Adding oxfmt in the previous two commits gave both projects a formatter
but nothing that checks it, which is how docs/ had already drifted to 18
unformatted files: docs-ci.yml runs typecheck, lint, test and build, but
never format:check, so Prettier's complaints were only ever visible to
whoever ran it by hand.

Wire `format:check` into the frontend job in ci.yml and the docs job in
docs-ci.yml, and add a `format-check` target to `make verify` so the
local gate keeps mirroring CI as the Makefile header promises.

Verified the step actually bites rather than passing vacuously: adding
a badly formatted line to a source file in each project makes both
`make format-check` and `pnpm format:check` fail, and reverting it makes
them pass again.

No workflow referenced ESLint or Prettier by name — they all invoke the
package scripts — so the tooling swap needed no other CI changes.

* ci: trigger CI on Makefile changes

The path filters listed **.go, go.mod, go.sum, frontend/**, .nvmrc and
ci.yml itself, but not the Makefile — so a change to the canonical task
runner that ci.yml is meant to mirror could land without any job
running. The previous commit, which edits both, only triggers because
it happens to touch ci.yml too.

* fix(frontend): replace deprecated Ant Design 6 APIs in the geo components

`npm run lint:deprecated` reported five uses of props Ant Design 6 has
deprecated. All five are gone, and the matching runtime warnings no
longer appear in the test output.

Tag `bordered={false}` becomes `variant="filled"` and Space `direction`
becomes `orientation`; both are the one-to-one replacements named in
antd's own deprecation messages, and `direction`/`orientation` share the
same Orientation type.

Input `addonAfter` is the one that is not a rename. It becomes a
`Space.Compact block` wrapping the Input and the browse Button, which is
antd's documented migration. `block` keeps the field filling its form
row as the addon did. Note this is a deliberate visual change: the
button used to be a borderless `type="text"` icon sitting inside the
addon's grey box, and is now a regular button whose border joins the
input. The tooltip, aria-label, ref, id and onBlur wiring are unchanged,
so the react-hook-form binding in RuleFormModal and the existing tests
still address it the same way.

Only these five were deprecated. The other `bordered` props in the tree
sit on QRCode, Table, Descriptions and Alert, where the prop is not
deprecated, and these were the only two Space `direction` uses in the
codebase.

* fix(frontend): restore lint rules lost in the oxlint migration, and test the guard

Addresses the review on #6262.

The frontend config re-enabled only no-explicit-any and no-unused-vars
and left the rest of tseslint's recommended set to oxlint's correctness
category. It does not cover all of it. Confirmed by linting one probe
file against both configs: docs/ (which enumerates the rules) reports
all nine, frontend/ reported four. So ban-ts-comment,
no-empty-object-type, no-namespace, no-require-imports and
no-unsafe-function-type had silently stopped being enforced — a `//
@ts-ignore` or a `namespace` block would have landed unflagged. The ten
rules are now mirrored from docs/.oxlintrc.json, and src/ still passes.

The #6121/#6127 guard was 57 lines of hand-written AST walking with no
test. It now has one: fixtures for the three banned shapes plus an
onNumber()-wrapped control, asserting the rule fires three times and
that .oxlintrc.json still wires it to the right paths. Verified it fails
for the right reason by making walk() enumerate nothing, which is the
silent-death mode the review described — the traversal depends on
Object.keys() seeing AST children as own enumerable properties.

The fixtures deliberately violate the rule, so their oxlint config is
named guard.oxlintrc.json rather than .oxlintrc.json: oxlint discovers
nested configs by directory, which would otherwise turn the fixtures
into three lint errors. The test passes it explicitly with -c.

Also from the review:
- lint and format now cover tools/ as well as src/, so the one piece of
  hand-written lint logic in the repo is no longer the least covered
  file in it.
- lint-staged runs oxfmt before oxlint --fix. Formatting became a hard
  CI gate in this PR while the hook only ran the linter, so a commit
  could pass the hook and fail CI on formatting alone.
- .oxfmtrc.json ignores public/, so the artefacts that make gen-check
  and make msw-worker-check byte-compare stay safe even if oxfmt is
  invoked without a path argument.
- The MDX and generated-reference rationales that .prettierignore
  carried are back as comments in docs/.oxfmtrc.json — oxlint and oxfmt
  both accept JSONC, so relocating them was unnecessary.

Not applied: the review also suggested restoring ../internal/web/dist to
the ignore lists. Both tools reject `..` patterns outright ("patterns
are resolved within the config file's directory"), and being outside
frontend/ it is unreachable anyway.
Sanaei há 11 horas atrás
pai
commit
92fb94d856
100 ficheiros alterados com 3714 adições e 1887 exclusões
  1. 5 0
      .github/workflows/ci.yml
  2. 3 0
      .github/workflows/docs-ci.yml
  3. 3 2
      CLAUDE.md
  4. 5 4
      CONTRIBUTING.md
  5. 8 4
      Makefile
  6. 21 0
      docs/.oxfmtrc.json
  7. 42 0
      docs/.oxlintrc.json
  8. 0 10
      docs/.prettierignore
  9. 0 7
      docs/.prettierrc.json
  10. 3 3
      docs/CONTRIBUTING.md
  11. 16 16
      docs/README.md
  12. 101 95
      docs/architecture.md
  13. 6 1
      docs/components/tools/api-request-builder.tsx
  14. 72 11
      docs/components/tools/routing-builder.tsx
  15. 38 7
      docs/components/tools/subscription-builder.tsx
  16. 22 22
      docs/custom-subscription-templates.md
  17. 0 21
      docs/eslint.config.mjs
  18. 8 1
      docs/lib/layout.shared.tsx
  19. 2 1
      docs/lib/site-i18n.ts
  20. 20 6
      docs/lib/xray/api-client.test.ts
  21. 6 1
      docs/lib/xray/outbounds.test.ts
  22. 5 1
      docs/lib/xray/outbounds.ts
  23. 6 1
      docs/lib/xray/routing.test.ts
  24. 4 1
      docs/lib/xray/routing.ts
  25. 10 2
      docs/lib/xray/subscription.ts
  26. 12 5
      docs/lib/xray/telegram.test.ts
  27. 4 1
      docs/lib/xray/telegram.ts
  28. 6 8
      docs/package.json
  29. 332 444
      docs/pnpm-lock.yaml
  30. 10 10
      docs/real-client-ip.md
  31. 14 0
      frontend/.oxfmtrc.json
  32. 72 0
      frontend/.oxlintrc.json
  33. 1 1
      frontend/CLAUDE.md
  34. 18 11
      frontend/README.md
  35. 0 89
      frontend/eslint.config.js
  36. 0 26
      frontend/eslint.deprecated.config.js
  37. 858 297
      frontend/package-lock.json
  38. 12 12
      frontend/package.json
  39. 3 1
      frontend/src/api/http-init.ts
  40. 26 10
      frontend/src/api/queries/useAllSettings.ts
  41. 3 1
      frontend/src/api/queries/useFactoryDefaults.ts
  42. 3 1
      frontend/src/api/queries/useFail2banStatusQuery.ts
  43. 11 2
      frontend/src/api/queries/useGeodata.ts
  44. 31 12
      frontend/src/api/queries/useHostMutations.ts
  45. 29 14
      frontend/src/api/queries/useNodeMutations.ts
  46. 6 2
      frontend/src/api/queries/useOutboundTags.ts
  47. 3 1
      frontend/src/api/queries/useStatusQuery.ts
  48. 2 1
      frontend/src/api/queryKeys.ts
  49. 15 4
      frontend/src/api/websocket.ts
  50. 5 2
      frontend/src/components/clients/ClientCardComment.tsx
  51. 1 2
      frontend/src/components/clients/ClientSpeedTag.tsx
  52. 4 1
      frontend/src/components/clients/ClientTrafficCell.stories.tsx
  53. 8 2
      frontend/src/components/clients/ClientTrafficCell.tsx
  54. 17 5
      frontend/src/components/clients/ConfigBlock.stories.tsx
  55. 12 6
      frontend/src/components/clients/ConfigBlock.tsx
  56. 3 1
      frontend/src/components/feedback/PromptModal.stories.tsx
  57. 5 1
      frontend/src/components/feedback/PromptModal.tsx
  58. 7 2
      frontend/src/components/feedback/TextModal.stories.tsx
  59. 44 32
      frontend/src/components/feedback/TextModal.tsx
  60. 2 2
      frontend/src/components/form/DateTimePicker.css
  61. 3 1
      frontend/src/components/form/DateTimePicker.stories.tsx
  62. 4 1
      frontend/src/components/form/DateTimePicker.tsx
  63. 19 4
      frontend/src/components/form/HeaderMapEditor.stories.tsx
  64. 10 6
      frontend/src/components/form/HeaderMapEditor.tsx
  65. 3 2
      frontend/src/components/form/JsonEditor.tsx
  66. 12 2
      frontend/src/components/form/RemarkTemplateField.stories.tsx
  67. 25 4
      frontend/src/components/form/RemarkTemplateField.tsx
  68. 7 2
      frontend/src/components/form/RemarkVarPicker.stories.tsx
  69. 37 22
      frontend/src/components/form/RemarkVarPicker.tsx
  70. 16 4
      frontend/src/components/form/SelectAllClearButtons.stories.tsx
  71. 1 5
      frontend/src/components/form/SelectAllClearButtons.tsx
  72. 23 6
      frontend/src/components/form/rhf/FormField.stories.tsx
  73. 3 1
      frontend/src/components/form/rhf/useZodForm.ts
  74. 509 106
      frontend/src/components/geodata/GeoBrowserModal.stories.tsx
  75. 78 20
      frontend/src/components/geodata/GeoBrowserModal.tsx
  76. 65 20
      frontend/src/components/geodata/GeoTokenInput.stories.tsx
  77. 27 21
      frontend/src/components/geodata/GeoTokenInput.tsx
  78. 4 1
      frontend/src/components/ui/DefaultSettingTag.tsx
  79. 10 1
      frontend/src/components/ui/InputAddon.tsx
  80. 14 4
      frontend/src/components/ui/SettingListItem.tsx
  81. 8 3
      frontend/src/components/ui/notifications/EmailNotifications.stories.tsx
  82. 43 14
      frontend/src/components/ui/notifications/EmailNotifications.tsx
  83. 5 1
      frontend/src/components/ui/notifications/NotificationCard.tsx
  84. 1 5
      frontend/src/components/ui/notifications/NotificationEvent.tsx
  85. 36 7
      frontend/src/components/ui/notifications/NotificationGroup.stories.tsx
  86. 10 2
      frontend/src/components/ui/notifications/NotificationGroup.tsx
  87. 3 1
      frontend/src/components/ui/notifications/NotificationHeader.stories.tsx
  88. 29 4
      frontend/src/components/ui/notifications/NotificationHeader.tsx
  89. 61 7
      frontend/src/components/ui/notifications/NotificationLayout.stories.tsx
  90. 7 1
      frontend/src/components/ui/notifications/NotificationLayout.tsx
  91. 8 2
      frontend/src/components/ui/notifications/TelegramNotifications.stories.tsx
  92. 43 14
      frontend/src/components/ui/notifications/TelegramNotifications.tsx
  93. 5 1
      frontend/src/components/ui/notifications/types.ts
  94. 12 3
      frontend/src/components/utility/LazyMount.stories.tsx
  95. 4 1
      frontend/src/components/viz/Sparkline.stories.tsx
  96. 25 7
      frontend/src/components/viz/Sparkline.tsx
  97. 387 235
      frontend/src/hooks/useClients.ts
  98. 8 3
      frontend/src/hooks/useServerDraft.ts
  99. 157 115
      frontend/src/hooks/useXraySetting.ts
  100. 7 2
      frontend/src/i18n/react.ts

+ 5 - 0
.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

+ 3 - 0
.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
 

+ 3 - 2
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

+ 5 - 4
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` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged.
 5. Open the PR against `main` with a brief description of what changed and how to test it.
 

+ 8 - 4
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"

+ 21 - 0
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"
+  ]
+}

+ 42 - 0
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"
+  }
+}

+ 0 - 10
docs/.prettierignore

@@ -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

+ 0 - 7
docs/.prettierrc.json

@@ -1,7 +0,0 @@
-{
-  "semi": true,
-  "singleQuote": true,
-  "trailingComma": "all",
-  "printWidth": 100,
-  "tabWidth": 2
-}

+ 3 - 3
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

+ 16 - 16
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.
 

+ 101 - 95
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 |
-
-To change *when* something runs, edit `startTask()`. To change *what* it does, edit the job file.
+| 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.
 
 ### 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/<resource>.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/<area>/`, `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/<resource>.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/<area>/`, `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)

+ 6 - 1
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';

+ 72 - 11
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) => (
           <div key={i} className="rounded-xl border p-3">
             <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
-              <TextField label="Domain (comma)" value={r.domain} onChange={(v) => patchRule(i, { domain: v })} placeholder="geosite:google, example.com" />
-              <TextField label="IP (comma)" value={r.ip} onChange={(v) => patchRule(i, { ip: v })} placeholder="geoip:cn, 1.1.1.1" />
-              <TextField label="Port" value={r.port} onChange={(v) => patchRule(i, { port: v })} placeholder="443 or 1000-2000" />
-              <SelectField label="Network" value={r.network} onChange={(v) => patchRule(i, { network: v })} options={NETWORKS} />
-              <TextField label="Inbound tag (comma)" value={r.inboundTag} onChange={(v) => patchRule(i, { inboundTag: v })} placeholder="optional" />
-              <SelectField label="Target kind" value={r.targetKind} onChange={(v) => patchRule(i, { targetKind: v as 'outbound' | 'balancer' })} options={TARGET_KINDS} />
-              <TextField label="Target tag" value={r.targetTag} onChange={(v) => patchRule(i, { targetTag: v })} />
+              <TextField
+                label="Domain (comma)"
+                value={r.domain}
+                onChange={(v) => patchRule(i, { domain: v })}
+                placeholder="geosite:google, example.com"
+              />
+              <TextField
+                label="IP (comma)"
+                value={r.ip}
+                onChange={(v) => patchRule(i, { ip: v })}
+                placeholder="geoip:cn, 1.1.1.1"
+              />
+              <TextField
+                label="Port"
+                value={r.port}
+                onChange={(v) => patchRule(i, { port: v })}
+                placeholder="443 or 1000-2000"
+              />
+              <SelectField
+                label="Network"
+                value={r.network}
+                onChange={(v) => patchRule(i, { network: v })}
+                options={NETWORKS}
+              />
+              <TextField
+                label="Inbound tag (comma)"
+                value={r.inboundTag}
+                onChange={(v) => patchRule(i, { inboundTag: v })}
+                placeholder="optional"
+              />
+              <SelectField
+                label="Target kind"
+                value={r.targetKind}
+                onChange={(v) => patchRule(i, { targetKind: v as 'outbound' | 'balancer' })}
+                options={TARGET_KINDS}
+              />
+              <TextField
+                label="Target tag"
+                value={r.targetTag}
+                onChange={(v) => patchRule(i, { targetTag: v })}
+              />
             </div>
             <div className="mt-2 flex justify-end">
               <button

+ 38 - 7
docs/components/tools/subscription-builder.tsx

@@ -79,7 +79,15 @@ export function SubscriptionBuilder() {
     setClients((prev) => prev.map((c, j) => (i === j ? { ...c, ...p } : c)));
   }
 
-  const urlInput: SubUrlInput = { scheme, host, port: Number(port), subPath, jsonPath, subId, behindProxy };
+  const urlInput: SubUrlInput = {
+    scheme,
+    host,
+    port: Number(port),
+    subPath,
+    jsonPath,
+    subId,
+    behindProxy,
+  };
   const urls = buildSubscriptionUrls(urlInput);
   const subClients = clients.filter((c) => c.address.trim()).map(toClient);
 
@@ -159,16 +167,33 @@ export function SubscriptionBuilder() {
                 onChange={(v) => patch(i, { protocol: v as ClientProtocol })}
                 options={PROTOCOLS}
               />
-              <TextField label="Remark" value={c.remark} onChange={(v) => patch(i, { remark: v })} />
-              <TextField label="Address" value={c.address} onChange={(v) => patch(i, { address: v })} />
-              <TextField label="Port" value={c.port} onChange={(v) => patch(i, { port: v })} inputMode="numeric" />
+              <TextField
+                label="Remark"
+                value={c.remark}
+                onChange={(v) => patch(i, { remark: v })}
+              />
+              <TextField
+                label="Address"
+                value={c.address}
+                onChange={(v) => patch(i, { address: v })}
+              />
+              <TextField
+                label="Port"
+                value={c.port}
+                onChange={(v) => patch(i, { port: v })}
+                inputMode="numeric"
+              />
               <TextField
                 label={c.protocol === 'vless' || c.protocol === 'vmess' ? 'UUID (id)' : 'Password'}
                 value={c.credential}
                 onChange={(v) => patch(i, { credential: v })}
               />
               {c.protocol === 'ss' ? (
-                <TextField label="Method" value={c.method} onChange={(v) => patch(i, { method: v })} />
+                <TextField
+                  label="Method"
+                  value={c.method}
+                  onChange={(v) => patch(i, { method: v })}
+                />
               ) : null}
               <SelectField
                 label="Transport"
@@ -200,9 +225,15 @@ export function SubscriptionBuilder() {
       </div>
 
       <div className="mt-4 grid grid-cols-1 gap-4">
-        <OutputBlock label="Subscription links (decoded body)" value={buildShareLinks(subClients).join('\n')} />
+        <OutputBlock
+          label="Subscription links (decoded body)"
+          value={buildShareLinks(subClients).join('\n')}
+        />
         <OutputBlock label="Base64 body" value={buildBase64Subscription(subClients)} />
-        <OutputBlock label="JSON subscription (preview)" value={buildJsonSubscription(subClients)} />
+        <OutputBlock
+          label="JSON subscription (preview)"
+          value={buildJsonSubscription(subClients)}
+        />
       </div>
     </ToolFrame>
   );

+ 22 - 22
docs/custom-subscription-templates.md

@@ -22,28 +22,28 @@ The panel uses standard Go `html/template` to render the subscription page.
 
 When rendering the template, the following variables are injected into the template context (`{{ .variable }}`):
 
-* `{{ .sId }}`: Subscription ID (UUID).
-* `{{ .enabled }}`: Whether the subscription/client is enabled (boolean).
-* `{{ .isOnline }}`: Whether the subscription's client has a live connection right now (boolean). Computed from the panel's online-client tracking (local Xray plus any remote nodes) at render time.
-* `{{ .download }}`: Formatted download traffic (e.g. "2.5 GB").
-* `{{ .upload }}`: Formatted upload traffic.
-* `{{ .total }}`: Formatted total traffic limit.
-* `{{ .used }}`: Formatted used traffic (download + upload).
-* `{{ .remained }}`: Formatted remaining traffic.
-* `{{ .expire }}`: Expiration time as an int64 Unix timestamp in **seconds** (`0` means never). Multiply by 1000 for a JavaScript `Date`.
-* `{{ .lastOnline }}`: Last online time as an int64 Unix timestamp in **milliseconds** (`0` means never seen).
-* `{{ .downloadByte }}`: Download traffic in exact bytes (int64).
-* `{{ .uploadByte }}`: Upload traffic in exact bytes (int64).
-* `{{ .totalByte }}`: Total traffic limit in exact bytes (int64).
-* `{{ .subUrl }}`: The URL of the subscription page.
-* `{{ .subJsonUrl }}`: The URL for the JSON configuration of the subscription.
-* `{{ .subClashUrl }}`: The URL for the Clash/Mihomo configuration.
-* `{{ .subTitle }}`: The subscription title configured in the panel (Subscription → Information). Useful for page branding/headings. May be empty.
-* `{{ .subSupportUrl }}`: The support URL configured in the panel. Useful for a "Contact support" link. May be empty.
-* `{{ .links }}`: A list (slice) of string configurations (VMess, VLESS, etc. URLs). You can loop through them using `{{ range .links }} ... {{ end }}`.
-* `{{ .emails }}`: A list (slice) of client emails, parallel to `links` — the email at index *i* owns the link at index *i*. May contain duplicates when one client has several links.
-* `{{ .announce }}`: The announcement text configured in the panel (Settings → Subscription → Announce). May be empty.
-* `{{ .datepicker }}`: Current calendar format used by the panel (e.g. "gregorian" or "jalali").
+- `{{ .sId }}`: Subscription ID (UUID).
+- `{{ .enabled }}`: Whether the subscription/client is enabled (boolean).
+- `{{ .isOnline }}`: Whether the subscription's client has a live connection right now (boolean). Computed from the panel's online-client tracking (local Xray plus any remote nodes) at render time.
+- `{{ .download }}`: Formatted download traffic (e.g. "2.5 GB").
+- `{{ .upload }}`: Formatted upload traffic.
+- `{{ .total }}`: Formatted total traffic limit.
+- `{{ .used }}`: Formatted used traffic (download + upload).
+- `{{ .remained }}`: Formatted remaining traffic.
+- `{{ .expire }}`: Expiration time as an int64 Unix timestamp in **seconds** (`0` means never). Multiply by 1000 for a JavaScript `Date`.
+- `{{ .lastOnline }}`: Last online time as an int64 Unix timestamp in **milliseconds** (`0` means never seen).
+- `{{ .downloadByte }}`: Download traffic in exact bytes (int64).
+- `{{ .uploadByte }}`: Upload traffic in exact bytes (int64).
+- `{{ .totalByte }}`: Total traffic limit in exact bytes (int64).
+- `{{ .subUrl }}`: The URL of the subscription page.
+- `{{ .subJsonUrl }}`: The URL for the JSON configuration of the subscription.
+- `{{ .subClashUrl }}`: The URL for the Clash/Mihomo configuration.
+- `{{ .subTitle }}`: The subscription title configured in the panel (Subscription → Information). Useful for page branding/headings. May be empty.
+- `{{ .subSupportUrl }}`: The support URL configured in the panel. Useful for a "Contact support" link. May be empty.
+- `{{ .links }}`: A list (slice) of string configurations (VMess, VLESS, etc. URLs). You can loop through them using `{{ range .links }} ... {{ end }}`.
+- `{{ .emails }}`: A list (slice) of client emails, parallel to `links` — the email at index _i_ owns the link at index _i_. May contain duplicates when one client has several links.
+- `{{ .announce }}`: The announcement text configured in the panel (Settings → Subscription → Announce). May be empty.
+- `{{ .datepicker }}`: Current calendar format used by the panel (e.g. "gregorian" or "jalali").
 
 ## Live Status JSON (`?format=info`)
 

+ 0 - 21
docs/eslint.config.mjs

@@ -1,21 +0,0 @@
-import coreWebVitals from 'eslint-config-next/core-web-vitals';
-import typescript from 'eslint-config-next/typescript';
-
-/** @type {import('eslint').Linter.Config[]} */
-const config = [
-  {
-    ignores: [
-      '.next/**',
-      '.source/**',
-      'out/**',
-      'node_modules/**',
-      'next-env.d.ts',
-      // Generated API reference pages (fumadocs-openapi output)
-      'content/docs/**/reference/api/**',
-    ],
-  },
-  ...coreWebVitals,
-  ...typescript,
-];
-
-export default config;

+ 8 - 1
docs/lib/layout.shared.tsx

@@ -3,7 +3,14 @@ import { Heart } from 'lucide-react';
 import { Logo } from '@/components/logo';
 import { TelegramIcon } from '@/components/icons';
 import { DocsThemeSwitch } from '@/components/theme-switch';
-import { appName, productRepoUrl, telegramChannel, telegramChannelUrl, donateUrl, siteUrl } from './shared';
+import {
+  appName,
+  productRepoUrl,
+  telegramChannel,
+  telegramChannelUrl,
+  donateUrl,
+  siteUrl,
+} from './shared';
 import { getSiteMessages } from './site-i18n';
 
 // Build locale-aware shared layout options. With `hideLocale: 'default-locale'`,

+ 2 - 1
docs/lib/site-i18n.ts

@@ -222,7 +222,8 @@ const zh: SiteMessages = {
     },
     {
       title: '自托管且可脚本化',
-      description: '单个 Go 二进制文件或 Docker 镜像、SQLite/PostgreSQL 后端,以及用于自动化的完整 REST API。',
+      description:
+        '单个 Go 二进制文件或 Docker 镜像、SQLite/PostgreSQL 后端,以及用于自动化的完整 REST API。',
     },
   ],
   licenseBefore: '基于 ',

+ 20 - 6
docs/lib/xray/api-client.test.ts

@@ -31,7 +31,7 @@ const base = {
 describe('buildCurl', () => {
   it('GET emits the Bearer header, a single-quoted URL, and no body flag', () => {
     const cmd = buildCurl({ ...base, method: 'GET' });
-    expect(cmd).toContain("-X GET");
+    expect(cmd).toContain('-X GET');
     expect(cmd).toContain("-H 'Authorization: Bearer TKN'");
     expect(cmd).toContain("'https://panel.example.com:2053/panel/api/inbounds/list'");
     expect(cmd).not.toContain('--data');
@@ -39,14 +39,23 @@ describe('buildCurl', () => {
   });
 
   it('POST with a body emits --data and a JSON content type', () => {
-    const cmd = buildCurl({ ...base, method: 'POST', path: '/panel/api/inbounds/add', body: '{"up":0}' });
+    const cmd = buildCurl({
+      ...base,
+      method: 'POST',
+      path: '/panel/api/inbounds/add',
+      body: '{"up":0}',
+    });
     expect(cmd).toContain('-X POST');
-    expect(cmd).toContain("--data '{\"up\":0}'");
-    expect(cmd).toContain("Content-Type: application/json");
+    expect(cmd).toContain('--data \'{"up":0}\'');
+    expect(cmd).toContain('Content-Type: application/json');
   });
 
   it('POST without a body omits --data', () => {
-    const cmd = buildCurl({ ...base, method: 'POST', path: '/panel/api/inbounds/resetAllTraffics' });
+    const cmd = buildCurl({
+      ...base,
+      method: 'POST',
+      path: '/panel/api/inbounds/resetAllTraffics',
+    });
     expect(cmd).not.toContain('--data');
   });
 });
@@ -60,7 +69,12 @@ describe('buildFetchSnippet', () => {
   });
 
   it('POST with a body includes a JSON.stringify body', () => {
-    const snip = buildFetchSnippet({ ...base, method: 'POST', path: '/panel/api/inbounds/add', body: '{"up":0}' });
+    const snip = buildFetchSnippet({
+      ...base,
+      method: 'POST',
+      path: '/panel/api/inbounds/add',
+      body: '{"up":0}',
+    });
     expect(snip).toContain("method: 'POST'");
     expect(snip).toContain('body: JSON.stringify(');
   });

+ 6 - 1
docs/lib/xray/outbounds.test.ts

@@ -160,7 +160,12 @@ describe('buildOutbound — wireguard & warp', () => {
     const ob = buildOutbound({
       kind: 'wireguard',
       tag: 'wg',
-      wireguard: { secretKey: 'sk', address: ['10.0.0.2/32'], publicKey: 'pk', endpoint: 'host:51820' },
+      wireguard: {
+        secretKey: 'sk',
+        address: ['10.0.0.2/32'],
+        publicKey: 'pk',
+        endpoint: 'host:51820',
+      },
     });
     const s = ob.settings as Record<string, unknown>;
     expect(s.secretKey).toBe('sk');

+ 5 - 1
docs/lib/xray/outbounds.ts

@@ -162,7 +162,11 @@ function buildSettings(o: OutboundInput): Record<string, unknown> {
         ],
       };
     case 'trojan':
-      return { servers: [{ address: s?.address ?? '', port: toPort(s?.port), password: s?.password ?? '' }] };
+      return {
+        servers: [
+          { address: s?.address ?? '', port: toPort(s?.port), password: s?.password ?? '' },
+        ],
+      };
     case 'shadowsocks':
       return {
         servers: [

+ 6 - 1
docs/lib/xray/routing.test.ts

@@ -18,7 +18,12 @@ describe('buildBalancer', () => {
   });
 
   it('includes fallbackTag when set', () => {
-    const b = buildBalancer({ tag: 'lb', selector: ['a'], strategy: 'random', fallbackTag: 'direct' });
+    const b = buildBalancer({
+      tag: 'lb',
+      selector: ['a'],
+      strategy: 'random',
+      fallbackTag: 'direct',
+    });
     expect(b.fallbackTag).toBe('direct');
   });
 });

+ 4 - 1
docs/lib/xray/routing.ts

@@ -121,7 +121,10 @@ export function buildRouting(input: RoutingInput): Record<string, unknown> {
   if (input.observatory) {
     Object.assign(out, buildObservatory(input.observatory));
   } else if (input.balancers.some((b) => b.strategy === 'leastLoad')) {
-    Object.assign(out, buildObservatory({ mode: 'burst', subjectSelector: uniqueSelectors(input.balancers) }));
+    Object.assign(
+      out,
+      buildObservatory({ mode: 'burst', subjectSelector: uniqueSelectors(input.balancers) }),
+    );
   } else if (input.balancers.some((b) => b.strategy === 'leastPing')) {
     Object.assign(
       out,

+ 10 - 2
docs/lib/xray/subscription.ts

@@ -214,12 +214,20 @@ function proxyOutbound(c: SubClient): Record<string, unknown> {
       };
       break;
     case 'trojan':
-      settings = { servers: [{ address: c.address, port: c.port, password: c.password ?? '', level: 8 }] };
+      settings = {
+        servers: [{ address: c.address, port: c.port, password: c.password ?? '', level: 8 }],
+      };
       break;
     case 'ss':
       settings = {
         servers: [
-          { address: c.address, port: c.port, password: c.password ?? '', level: 8, method: c.method || '' },
+          {
+            address: c.address,
+            port: c.port,
+            password: c.password ?? '',
+            level: 8,
+            method: c.method || '',
+          },
         ],
       };
       break;

+ 12 - 5
docs/lib/xray/telegram.test.ts

@@ -36,7 +36,10 @@ describe('parseAdminIds', () => {
   });
 
   it('accepts negative group ids and captures invalid entries', () => {
-    expect(parseAdminIds('-1001234567, abc, 42')).toEqual({ ids: [-1001234567, 42], invalid: ['abc'] });
+    expect(parseAdminIds('-1001234567, abc, 42')).toEqual({
+      ids: [-1001234567, 42],
+      invalid: ['abc'],
+    });
   });
 
   it('returns empty for blank input', () => {
@@ -78,9 +81,9 @@ describe('telegramApiBase', () => {
 
 describe('renderMessageTemplate', () => {
   it('substitutes known variables', () => {
-    expect(renderMessageTemplate('Host {{host}} up {{uptime}}', { host: 'srv', uptime: '3d' })).toBe(
-      'Host srv up 3d',
-    );
+    expect(
+      renderMessageTemplate('Host {{host}} up {{uptime}}', { host: 'srv', uptime: '3d' }),
+    ).toBe('Host srv up 3d');
   });
 
   it('leaves unknown variables literal', () => {
@@ -90,7 +93,11 @@ describe('renderMessageTemplate', () => {
 
 describe('buildBotConfigSummary', () => {
   it('emits the panel settings keys with admin ids joined', () => {
-    const s = buildBotConfigSummary({ token: VALID_TOKEN, adminIds: '111, 222', runTime: '@daily' });
+    const s = buildBotConfigSummary({
+      token: VALID_TOKEN,
+      adminIds: '111, 222',
+      runTime: '@daily',
+    });
     expect(s.tgBotEnable).toBe(true);
     expect(s.tgBotToken).toBe(VALID_TOKEN);
     expect(s.tgBotChatId).toBe('111,222');

+ 4 - 1
docs/lib/xray/telegram.ts

@@ -43,7 +43,10 @@ export function validateBotToken(token: string): TokenValidation {
 export function parseAdminIds(raw: string): AdminIdsResult {
   const ids: number[] = [];
   const invalid: string[] = [];
-  for (const part of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
+  for (const part of raw
+    .split(',')
+    .map((s) => s.trim())
+    .filter(Boolean)) {
     // Telegram chat ids are integers; group/channel ids are negative.
     if (/^-?\d+$/.test(part)) ids.push(Number(part));
     else invalid.push(part);

+ 6 - 8
docs/package.json

@@ -11,9 +11,9 @@
     "postinstall": "fumadocs-mdx",
     "gen:api": "node scripts/gen-openapi.ts",
     "typecheck": "fumadocs-mdx && next typegen && tsc --noEmit",
-    "lint": "eslint .",
-    "format": "prettier --write .",
-    "format:check": "prettier --check .",
+    "lint": "oxlint .",
+    "format": "oxfmt .",
+    "format:check": "oxfmt --check .",
     "test": "vitest run",
     "test:watch": "vitest"
   },
@@ -41,13 +41,11 @@
     "@types/node": "^26.2.0",
     "@types/react": "^19.2.18",
     "@types/react-dom": "^19.2.4",
-    "eslint": "^9.39.5",
-    "eslint-config-next": "16.3.0",
-    "eslint-plugin-react": "^7.37.5",
+    "oxfmt": "0.63.0",
+    "oxlint": "1.78.0",
     "postcss": "^8.5.26",
-    "prettier": "^3.9.6",
     "tailwindcss": "^4.3.3",
-    "typescript": "6.0.3",
+    "typescript": "7.0.2",
     "vitest": "^4.1.10"
   },
   "packageManager": "[email protected]+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1"

Diff do ficheiro suprimidas por serem muito extensas
+ 332 - 444
docs/pnpm-lock.yaml


+ 10 - 10
docs/real-client-ip.md

@@ -14,11 +14,11 @@ list, and multi-node sync — so once it is set, everything downstream just work
 Open an inbound → **Transport / Stream Settings** → enable **Sockopt** → use the
 **Real client IP** preset selector:
 
-| Preset | What it does | Use for |
-|---|---|---|
-| **Off / direct** | Clears both fields. | Inbound reachable directly by clients. |
-| **Cloudflare CDN** | Sets `sockopt.trustedXForwardedFor = ["CF-Connecting-IP"]`. | WebSocket / HTTPUpgrade / XHTTP behind Cloudflare's CDN (orange cloud). |
-| **L4 relay / Spectrum (PROXY)** | Sets `acceptProxyProtocol = true`. | An L4 tunnel/relay in front, or Cloudflare **Spectrum**. |
+| Preset                          | What it does                                                | Use for                                                                 |
+| ------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------- |
+| **Off / direct**                | Clears both fields.                                         | Inbound reachable directly by clients.                                  |
+| **Cloudflare CDN**              | Sets `sockopt.trustedXForwardedFor = ["CF-Connecting-IP"]`. | WebSocket / HTTPUpgrade / XHTTP behind Cloudflare's CDN (orange cloud). |
+| **L4 relay / Spectrum (PROXY)** | Sets `acceptProxyProtocol = true`.                          | An L4 tunnel/relay in front, or Cloudflare **Spectrum**.                |
 
 The raw `Proxy Protocol` switch and `Trusted X-Forwarded-For` list stay visible below the preset
 selector for manual / advanced tuning — the presets just fill them in for you.
@@ -65,16 +65,16 @@ and XHTTP; **not** on mKCP. The front must be configured to send the header, e.g
 
 ## Transport support matrix
 
-| Mechanism | TCP/RAW | mKCP | WebSocket | gRPC | HTTPUpgrade | XHTTP |
-|---|:--:|:--:|:--:|:--:|:--:|:--:|
-| `trustedXForwardedFor` (header) | – | – | ✅ | – | ✅ | ✅ |
-| `acceptProxyProtocol` (PROXY)   | ✅ | – | ✅ | ✅ | ✅ | ✅ |
+| Mechanism                       | TCP/RAW | mKCP | WebSocket | gRPC | HTTPUpgrade | XHTTP |
+| ------------------------------- | :-----: | :--: | :-------: | :--: | :---------: | :---: |
+| `trustedXForwardedFor` (header) |       |    |        |    |          |     |
+| `acceptProxyProtocol` (PROXY)   |      |    |         |    |          |     |
 
 The form shows a warning when you select a preset that the current transport cannot honor.
 
 > **Use one, not both.** `acceptProxyProtocol` and `trustedXForwardedFor` are independent — the
 > first reads the real IP from the L4 PROXY header, the second from an HTTP request header. On
-> WebSocket / HTTPUpgrade / XHTTP, xray applies the HTTP header *last*, so a stale
+> WebSocket / HTTPUpgrade / XHTTP, xray applies the HTTP header _last_, so a stale
 > `trustedXForwardedFor` would override (and defeat) a PROXY-protocol setup. The presets are
 > mutually exclusive and clear the other field for you; only mix them by hand if you know your
 > upstream chain needs it.

+ 14 - 0
frontend/.oxfmtrc.json

@@ -0,0 +1,14 @@
+{
+  "$schema": "./node_modules/oxfmt/configuration_schema.json",
+  "semi": true,
+  "singleQuote": true,
+  "trailingComma": "all",
+  "printWidth": 100,
+  "tabWidth": 2,
+  "ignorePatterns": [
+    "node_modules",
+    "src/generated",
+    "public",
+    "tools/oxlint/__fixtures__"
+  ]
+}

+ 72 - 0
frontend/.oxlintrc.json

@@ -0,0 +1,72 @@
+{
+  "$schema": "./node_modules/oxlint/configuration_schema.json",
+  "ignorePatterns": [
+    "node_modules/**"
+  ],
+  "plugins": [
+    "typescript",
+    "react",
+    "jsx-a11y"
+  ],
+  "jsPlugins": [
+    "./tools/oxlint/input-number-guard.mjs"
+  ],
+  "categories": {
+    "correctness": "error"
+  },
+  "env": {
+    "browser": true,
+    "es2022": true
+  },
+  "rules": {
+    "typescript/no-explicit-any": "error",
+    "typescript/no-unused-vars": [
+      "warn",
+      {
+        "argsIgnorePattern": "^_",
+        "varsIgnorePattern": "^_",
+        "caughtErrorsIgnorePattern": "^_"
+      }
+    ],
+    "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",
+    "no-empty": [
+      "error",
+      {
+        "allowEmptyCatch": true
+      }
+    ],
+    "react-hooks/rules-of-hooks": "error",
+    "react-hooks/exhaustive-deps": "error",
+    "jsx-a11y/no-autofocus": "off",
+    "input-number/no-synthetic-clear": "off",
+    "jsx-a11y/prefer-tag-over-role": "off"
+  },
+  "overrides": [
+    {
+      "files": [
+        "src/pages/settings/**/*.tsx",
+        "src/pages/xray/**/*.tsx"
+      ],
+      "rules": {
+        "input-number/no-synthetic-clear": "error"
+      }
+    },
+    {
+      "files": [
+        "src/pages/xray/**/*Modal.tsx"
+      ],
+      "rules": {
+        "input-number/no-synthetic-clear": "off"
+      }
+    }
+  ]
+}

+ 1 - 1
frontend/CLAUDE.md

@@ -33,7 +33,7 @@ The `@` import alias maps to `src/`.
 - Function components + hooks only; no class components.
 - Comments in committed TS/TSX: 2 lines MAX per comment block, spent on the
   *why* a name cannot hold (same rule as root CLAUDE.md). HTML comments are fine.
-- TS strict; `no-explicit-any` is an error. Build forms with `useZodForm` +
+- TS strict; oxlint's `typescript/no-explicit-any` is an error. Build forms with `useZodForm` +
   `FormField` from `@/components/form/rhf` (wrap the tree in `FormProvider`);
   validate through the `zodResolver` or per-field
   `rules={{ validate: rhfZodValidate(Schema.shape.field) }}` — messages are Zod

+ 18 - 11
frontend/README.md

@@ -33,7 +33,10 @@ production-style links work without round-tripping through Go.
 | `npm run build` | Regenerates OpenAPI + Zod, then builds into `../internal/web/dist/` |
 | `npm run preview` | Serve the built bundle locally |
 | `npm run typecheck` | `tsc --noEmit` (strict, no emit) |
-| `npm run lint` | ESLint flat config (`@typescript-eslint` + `react-hooks`) |
+| `npm run lint` | oxlint over `src/` + `tools/` (`.oxlintrc.json`) |
+| `npm run lint:deprecated` | Type-aware sweep for JSDoc `@deprecated` APIs (on demand) |
+| `npm run format` | oxfmt (`.oxfmtrc.json`) — rewrites `src/` + `tools/` in place |
+| `npm run format:check` | oxfmt in check mode (no writes) |
 | `npm run test` | Vitest single run (schema fixtures, link parsers, …) |
 | `npm run test:watch` | Vitest watch mode |
 | `npm run storybook` | Storybook dev server on `:6006` (component workbench + autodocs) |
@@ -41,8 +44,8 @@ production-style links work without round-tripping through Go.
 | `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` |
 | `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` |
 
-CI runs `typecheck`, `lint`, `test`, `build`, and `build-storybook` on
-every PR (see `../.github/workflows/ci.yml`).
+CI runs `typecheck`, `lint`, `format:check`, `test`, `build`, and
+`build-storybook` on every PR (see `../.github/workflows/ci.yml`).
 
 ### One-off: scan for deprecated APIs
 
@@ -51,12 +54,13 @@ with the JSDoc `@deprecated` tag (AntD prop renames, Zod renames,
 removed Web APIs, etc.):
 
 ```sh
-npx eslint --config eslint.deprecated.config.js src
+npm run lint:deprecated
 ```
 
-It's a type-aware ESLint run against `eslint.deprecated.config.js`
-and is not wired into `npm run lint` because typed linting triples
-the wall-clock time.
+It is oxlint's type-aware mode (`oxlint-tsgolint`, which drives the
+TypeScript 7 `typescript-go` checker) narrowed to `no-deprecated`, and
+is not wired into `npm run lint` because typed linting needs a full
+type-check pass.
 
 ## Production build
 
@@ -85,9 +89,12 @@ normal network requests.
 frontend/
 ├── index.html, login.html, subpage.html  # 3 Vite entries
 ├── tsconfig.json
-├── eslint.config.js
-├── eslint.deprecated.config.js           # On-demand type-aware lint config that flags
-│                                         #   usages of APIs marked with JSDoc @deprecated
+├── .oxlintrc.json                        # oxlint config (replaces the ESLint flat config)
+├── .oxfmtrc.json                         # oxfmt config (Prettier-compatible settings)
+├── tools/oxlint/
+│   └── input-number-guard.mjs            # oxlint JS plugin: the #6121/#6127 cleared-
+│                                         #   InputNumber guard (oxlint has no
+│                                         #   no-restricted-syntax)
 ├── vitest.config.ts
 ├── vite.config.js
 ├── .storybook/                           # Storybook config (main.ts, preview.tsx)
@@ -155,7 +162,7 @@ Patterns:
   - Wire request: `Schema.parse(payload)` inside `mutationFn` — throws,
     because a malformed payload here is always a developer bug
 - **No `.loose()` or `[key: string]: any`** in production schemas.
-  `@typescript-eslint/no-explicit-any: error` is enforced.
+  `typescript/no-explicit-any: error` is enforced by oxlint.
 
 ## Form pattern (Pattern A)
 

+ 0 - 89
frontend/eslint.config.js

@@ -1,89 +0,0 @@
-import js from '@eslint/js';
-import tseslint from 'typescript-eslint';
-import reactHooks from 'eslint-plugin-react-hooks';
-import jsxA11y from 'eslint-plugin-jsx-a11y';
-import globals from 'globals';
-
-export default [
-  { ignores: ['node_modules/**', '../internal/web/dist/**'] },
-  js.configs.recommended,
-  ...tseslint.configs.recommended.map((config) => ({
-    ...config,
-    files: ['**/*.{ts,tsx}'],
-  })),
-  {
-    files: ['**/*.{ts,tsx}'],
-    plugins: {
-      'react-hooks': reactHooks,
-    },
-    languageOptions: {
-      ecmaVersion: 2022,
-      sourceType: 'module',
-      globals: {
-        ...globals.browser,
-      },
-    },
-    rules: {
-      ...reactHooks.configs.recommended.rules,
-      '@typescript-eslint/no-unused-vars': ['warn', {
-        argsIgnorePattern: '^_',
-        varsIgnorePattern: '^_',
-        caughtErrorsIgnorePattern: '^_',
-      }],
-      // Zod migration goal (Step 7): every production module is held to
-      // strict no-explicit-any. The two legacy class files at the bottom
-      // of the rule list keep their existing file-level eslint-disable
-      // until DBInbound is migrated off Inbound.toInbound() — see the
-      // migration spec Non-Goals section.
-      '@typescript-eslint/no-explicit-any': 'error',
-      'no-empty': ['error', { allowEmptyCatch: true }],
-      'react-hooks/set-state-in-effect': 'off',
-      'react-hooks/purity': 'off',
-      'react-hooks/react-compiler': 'off',
-      'react-hooks/preserve-manual-memoization': 'off',
-      'react-hooks/immutability': 'off',
-      'react-hooks/refs': 'off',
-    },
-  },
-  {
-    files: ['**/*.tsx'],
-    plugins: { 'jsx-a11y': jsxA11y },
-    rules: {
-      ...jsxA11y.flatConfigs.recommended.rules,
-      'jsx-a11y/no-autofocus': 'off',
-    },
-  },
-  {
-    // The settings and xray pages write numeric InputNumber changes straight
-    // into state, so a null-collapsing handler (`Number(v) || N`, or the
-    // ternary `typeof v === 'number' ? v : N`) turns a cleared field into a
-    // stored N — the cleared-port bug, #6121. Handlers here go through
-    // onNumber() (src/utils/onNumber.ts) instead. Known limit: a handler
-    // extracted into a variable and passed as onChange={handler} is not
-    // matched; the inline shapes below are the ones that drift in practice.
-    files: ['src/pages/settings/**/*.tsx', 'src/pages/xray/**/*.tsx'],
-    rules: {
-      'no-restricted-syntax': ['error', {
-        selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="||"] > CallExpression[callee.name="Number"]',
-        message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
-      }, {
-        selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] ConditionalExpression[test.left.operator="typeof"][alternate.type="Literal"]',
-        message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
-      }, {
-        selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="??"][right.type="Literal"]',
-        message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
-      }],
-    },
-  },
-  {
-    // The xray form modals (OutboundFormModal, BalancerFormModal,
-    // DnsServerModal, WarpModal, …) stage values behind Zod validation like
-    // the clients/inbounds modals do, and some of their fields carry a
-    // deliberate clear-means-zero semantic — the direct-write rule above
-    // does not apply to them.
-    files: ['src/pages/xray/**/*Modal.tsx'],
-    rules: {
-      'no-restricted-syntax': 'off',
-    },
-  },
-];

+ 0 - 26
frontend/eslint.deprecated.config.js

@@ -1,26 +0,0 @@
-import tseslint from 'typescript-eslint';
-import reactHooks from 'eslint-plugin-react-hooks';
-
-export default [
-  { ignores: ['node_modules/**', '../internal/web/dist/**', 'src/generated/**'] },
-  {
-    files: ['**/*.{ts,tsx}'],
-    plugins: {
-      '@typescript-eslint': tseslint.plugin,
-      'react-hooks': reactHooks,
-    },
-    languageOptions: {
-      parser: tseslint.parser,
-      parserOptions: {
-        projectService: true,
-        tsconfigRootDir: import.meta.dirname,
-      },
-    },
-    rules: {
-      '@typescript-eslint/no-deprecated': 'warn',
-    },
-    linterOptions: {
-      reportUnusedDisableDirectives: 'off',
-    },
-  },
-];

Diff do ficheiro suprimidas por serem muito extensas
+ 858 - 297
frontend/package-lock.json


+ 12 - 12
frontend/package.json

@@ -12,7 +12,10 @@
     "dev": "vite",
     "build": "npm run gen:api && vite build",
     "preview": "vite preview",
-    "lint": "eslint src",
+    "lint": "oxlint src tools",
+    "lint:deprecated": "oxlint --type-aware -A all -D typescript/no-deprecated src",
+    "format": "oxfmt src tools",
+    "format:check": "oxfmt --check src tools",
     "typecheck": "tsc --noEmit",
     "test": "vitest run",
     "test:watch": "vitest",
@@ -24,7 +27,10 @@
     "prepare": "cd .. && husky frontend/.husky || true"
   },
   "lint-staged": {
-    "src/**/*.{ts,tsx}": "eslint --fix"
+    "src/**/*.{ts,tsx}": [
+      "oxfmt",
+      "oxlint --fix"
+    ]
   },
   "dependencies": {
     "@ant-design/icons": "^6.3.2",
@@ -50,7 +56,6 @@
     "zod": "^4.4.3"
   },
   "devDependencies": {
-    "@eslint/js": "^10.0.1",
     "@storybook/addon-a11y": "^10.5.7",
     "@storybook/addon-docs": "^10.5.7",
     "@storybook/addon-vitest": "^10.5.7",
@@ -63,25 +68,20 @@
     "@vitejs/plugin-react": "^6.0.5",
     "@vitest/browser-playwright": "4.1.10",
     "@vitest/coverage-v8": "^4.1.10",
-    "eslint": "^10.8.1",
-    "eslint-plugin-jsx-a11y": "^6.10.2",
-    "eslint-plugin-react-hooks": "^7.1.1",
-    "globals": "^17.11.0",
     "husky": "^9.1.7",
     "jsdom": "^30.0.1",
     "lint-staged": "^17.3.0",
     "msw": "^2.15.0",
+    "oxfmt": "0.63.0",
+    "oxlint": "1.78.0",
+    "oxlint-tsgolint": "^7.0.2001",
     "playwright": "^1.62.1",
     "storybook": "^10.5.7",
-    "typescript": "6.0.3",
-    "typescript-eslint": "^8.67.0",
+    "typescript": "7.0.2",
     "vite": "8.2.1",
     "vitest": "^4.1.10"
   },
   "overrides": {
-    "eslint-plugin-jsx-a11y": {
-      "eslint": "$eslint"
-    },
     "dompurify": "^3.4.11",
     "react-copy-to-clipboard": "^5.1.1",
     "react-inspector": "^9.0.0",

+ 3 - 1
frontend/src/api/http-init.ts

@@ -79,7 +79,9 @@ function encodeForm(data: unknown): string {
       return;
     }
     if (typeof value === 'object') {
-      Object.entries(value as Record<string, unknown>).forEach(([k, v]) => append(`${key}[${k}]`, v));
+      Object.entries(value as Record<string, unknown>).forEach(([k, v]) =>
+        append(`${key}[${k}]`, v),
+      );
       return;
     }
     parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);

+ 26 - 10
frontend/src/api/queries/useAllSettings.ts

@@ -4,7 +4,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { HttpUtil, Msg } from '@/utils';
 import { parseMsg } from '@/utils/zodValidate';
 import { AllSetting } from '@/models/setting';
-import { AllSettingResponseSchema, AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
+import {
+  AllSettingResponseSchema,
+  AllSettingSchema,
+  type AllSettingInput,
+} from '@/schemas/setting';
 import { keys } from '@/api/queryKeys';
 import { useServerDraft } from '@/hooks/useServerDraft';
 
@@ -39,22 +43,34 @@ export function useAllSettings() {
   );
   const allSetting = draft ?? server;
 
-  const updateSetting = useCallback((patch: Partial<AllSetting>) => {
-    setDraft((prev) => {
-      const next = new AllSetting(prev ?? server);
-      Object.assign(next, patch);
-      return next;
-    });
-  }, [server, setDraft]);
+  const updateSetting = useCallback(
+    (patch: Partial<AllSetting>) => {
+      setDraft((prev) => {
+        const next = new AllSetting(prev ?? server);
+        Object.assign(next, patch);
+        return next;
+      });
+    },
+    [server, setDraft],
+  );
 
   const saveMut = useMutation({
-    mutationFn: async ({ payload, saved }: { payload: SettingSavePayload; saved?: AllSetting }): Promise<SettingSaveResult> => {
+    mutationFn: async ({
+      payload,
+      saved,
+    }: {
+      payload: SettingSavePayload;
+      saved?: AllSetting;
+    }): Promise<SettingSaveResult> => {
       const next = { ...payload };
       const body = AllSettingSchema.partial().safeParse(next);
       if (!body.success) {
         console.warn('[zod] setting/update body failed validation', body.error.issues);
       }
-      const msg = await HttpUtil.post('/panel/api/setting/update', body.success ? { ...next, ...body.data } : next);
+      const msg = await HttpUtil.post(
+        '/panel/api/setting/update',
+        body.success ? { ...next, ...body.data } : next,
+      );
       return { msg, saved };
     },
     onSuccess: ({ msg, saved }) => {

+ 3 - 1
frontend/src/api/queries/useFactoryDefaults.ts

@@ -6,7 +6,9 @@ import { FactoryDefaultsSchema, type FactoryDefaults } from '@/schemas/setting';
 import { keys } from '@/api/queryKeys';
 
 async function fetchFactoryDefaults(): Promise<FactoryDefaults> {
-  const msg = await HttpUtil.post('/panel/api/setting/factoryDefaults', undefined, { silent: true });
+  const msg = await HttpUtil.post('/panel/api/setting/factoryDefaults', undefined, {
+    silent: true,
+  });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch factory defaults');
   const validated = parseMsg(msg, FactoryDefaultsSchema, 'setting/factoryDefaults');
   const parsed = FactoryDefaultsSchema.safeParse(validated.obj);

+ 3 - 1
frontend/src/api/queries/useFail2banStatusQuery.ts

@@ -18,7 +18,9 @@ const FAIL_OPEN_STATUS: Fail2banStatus = {
 };
 
 async function fetchFail2banStatus(): Promise<Fail2banStatus> {
-  const msg = await HttpUtil.get<Fail2banStatus>('/panel/api/server/fail2banStatus', undefined, { silent: true });
+  const msg = await HttpUtil.get<Fail2banStatus>('/panel/api/server/fail2banStatus', undefined, {
+    silent: true,
+  });
   if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch fail2ban status');
   return { ...FAIL_OPEN_STATUS, ...msg.obj };
 }

+ 11 - 2
frontend/src/api/queries/useGeodata.ts

@@ -2,7 +2,12 @@ import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
 import { z } from 'zod';
 
 import { keys } from '@/api/queryKeys';
-import { GeoCategoryPageSchema, GeoEntryPageSchema, GeoFileSchema, GeodataTokenIssueSchema } from '@/generated/zod';
+import {
+  GeoCategoryPageSchema,
+  GeoEntryPageSchema,
+  GeoFileSchema,
+  GeodataTokenIssueSchema,
+} from '@/generated/zod';
 import type { GeoCategoryPage, GeoEntryPage, GeoFile, GeodataTokenIssue } from '@/generated/types';
 import { HttpUtil } from '@/utils';
 import { parseMsg } from '@/utils/zodValidate';
@@ -28,7 +33,11 @@ async function fetchGeodataFiles(): Promise<GeoFile[]> {
 }
 
 async function fetchGeodataCategories(file: string, query: string): Promise<GeoCategoryPage> {
-  const msg = await HttpUtil.get('/panel/api/xray/geodata/categories', { file, q: query }, { silent: true });
+  const msg = await HttpUtil.get(
+    '/panel/api/xray/geodata/categories',
+    { file, q: query },
+    { silent: true },
+  );
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata categories');
   const validated = parseMsg(msg, GeoCategoryPageSchema, 'xray/geodata/categories');
   return validated.obj ?? EMPTY_CATEGORY_PAGE;

+ 31 - 12
frontend/src/api/queries/useHostMutations.ts

@@ -11,50 +11,69 @@ export function useHostMutations() {
   const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.hosts.root() });
 
   const bulkCreateMut = useMutation({
-    mutationFn: (payload: BulkAddHostValues) => HttpUtil.post('/panel/api/hosts/bulk/add', payload, JSON_HEADERS),
-    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+    mutationFn: (payload: BulkAddHostValues) =>
+      HttpUtil.post('/panel/api/hosts/bulk/add', payload, JSON_HEADERS),
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
   });
 
   const updateMut = useMutation({
     mutationFn: ({ groupId, payload }: { groupId: string; payload: BulkAddHostValues }) =>
       HttpUtil.post(`/panel/api/hosts/update/${groupId}`, payload, JSON_HEADERS),
-    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
   });
 
   const removeMut = useMutation({
     mutationFn: (groupId: string) => HttpUtil.post(`/panel/api/hosts/del/${groupId}`),
-    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
   });
 
   const setEnableMut = useMutation({
     mutationFn: ({ groupId, enable }: { groupId: string; enable: boolean }) =>
       HttpUtil.post(`/panel/api/hosts/setEnable/${groupId}`, { enable }),
-    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
   });
 
   const reorderMut = useMutation({
-    mutationFn: (groupIds: string[]) => HttpUtil.post('/panel/api/hosts/reorder', { ids: groupIds }, JSON_HEADERS),
-    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+    mutationFn: (groupIds: string[]) =>
+      HttpUtil.post('/panel/api/hosts/reorder', { ids: groupIds }, JSON_HEADERS),
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
   });
 
   const bulkEnableMut = useMutation({
     mutationFn: ({ groupIds, enable }: { groupIds: string[]; enable: boolean }) =>
       HttpUtil.post('/panel/api/hosts/bulk/setEnable', { ids: groupIds, enable }, JSON_HEADERS),
-    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
   });
 
   const bulkDelMut = useMutation({
-    mutationFn: (groupIds: string[]) => HttpUtil.post('/panel/api/hosts/bulk/del', { ids: groupIds }, JSON_HEADERS),
-    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+    mutationFn: (groupIds: string[]) =>
+      HttpUtil.post('/panel/api/hosts/bulk/del', { ids: groupIds }, JSON_HEADERS),
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
   });
 
   return {
     bulkCreate: (payload: BulkAddHostValues) => bulkCreateMut.mutateAsync(payload),
-    update: (groupId: string, payload: BulkAddHostValues) => updateMut.mutateAsync({ groupId, payload }),
+    update: (groupId: string, payload: BulkAddHostValues) =>
+      updateMut.mutateAsync({ groupId, payload }),
     remove: (groupId: string) => removeMut.mutateAsync(groupId),
     setEnable: (groupId: string, enable: boolean) => setEnableMut.mutateAsync({ groupId, enable }),
     reorder: (groupIds: string[]) => reorderMut.mutateAsync(groupIds),
-    bulkSetEnable: (groupIds: string[], enable: boolean) => bulkEnableMut.mutateAsync({ groupIds, enable }),
+    bulkSetEnable: (groupIds: string[], enable: boolean) =>
+      bulkEnableMut.mutateAsync({ groupIds, enable }),
     bulkDel: (groupIds: string[]) => bulkDelMut.mutateAsync(groupIds),
   };
 }

+ 29 - 14
frontend/src/api/queries/useNodeMutations.ts

@@ -30,27 +30,33 @@ export function useNodeMutations() {
   };
 
   const createMut = useMutation({
-    mutationFn: (payload: Partial<NodeRecord>) =>
-      HttpUtil.post('/panel/api/nodes/add', payload),
-    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+    mutationFn: (payload: Partial<NodeRecord>) => HttpUtil.post('/panel/api/nodes/add', payload),
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
   });
 
   const updateMut = useMutation({
     mutationFn: ({ id, payload }: { id: number; payload: Partial<NodeRecord> }) =>
       HttpUtil.post(`/panel/api/nodes/update/${id}`, payload),
-    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
   });
 
   const removeMut = useMutation({
-    mutationFn: (id: number) =>
-      HttpUtil.post(`/panel/api/nodes/del/${id}`),
-    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+    mutationFn: (id: number) => HttpUtil.post(`/panel/api/nodes/del/${id}`),
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
   });
 
   const setEnableMut = useMutation({
     mutationFn: ({ id, enable }: { id: number; enable: boolean }) =>
       HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }),
-    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
   });
 
   const probeMut = useMutation({
@@ -58,15 +64,23 @@ export function useNodeMutations() {
       const raw = await HttpUtil.post(`/panel/api/nodes/probe/${id}`);
       return parseMsg(raw, ProbeResultSchema, 'nodes/probe');
     },
-    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
   });
 
   const updatePanelsMut = useMutation({
     mutationFn: ({ ids, dev }: { ids: number[]; dev: boolean }) =>
-      HttpUtil.post<NodeUpdateResult[]>('/panel/api/nodes/updatePanel', { ids, dev }, {
-        headers: { 'Content-Type': 'application/json' },
-      }),
-    onSuccess: (msg) => { if (msg?.success) invalidate(); },
+      HttpUtil.post<NodeUpdateResult[]>(
+        '/panel/api/nodes/updatePanel',
+        { ids, dev },
+        {
+          headers: { 'Content-Type': 'application/json' },
+        },
+      ),
+    onSuccess: (msg) => {
+      if (msg?.success) invalidate();
+    },
   });
 
   return {
@@ -75,7 +89,8 @@ export function useNodeMutations() {
     remove: (id: number) => removeMut.mutateAsync(id),
     setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
     probe: (id: number) => probeMut.mutateAsync(id),
-    updatePanels: (ids: number[], dev: boolean): Promise<Msg<NodeUpdateResult[]>> => updatePanelsMut.mutateAsync({ ids, dev }),
+    updatePanels: (ids: number[], dev: boolean): Promise<Msg<NodeUpdateResult[]>> =>
+      updatePanelsMut.mutateAsync({ ids, dev }),
     testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
       const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
       return parseMsg(raw, ProbeResultSchema, 'nodes/test');

+ 6 - 2
frontend/src/api/queries/useOutboundTags.ts

@@ -26,7 +26,9 @@ export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
       }
       // Balancers are valid routing targets too — injectMtprotoEgress emits a
       // balancerTag rule when the chosen tag names a balancer.
-      const balancers = (data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined)?.balancers;
+      const balancers = (
+        data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined
+      )?.balancers;
       for (const b of balancers ?? []) {
         if (b?.tag) tags.add(b.tag);
       }
@@ -61,7 +63,9 @@ export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) {
         if (t) outbounds.add(t);
       }
       const balancers: string[] = [];
-      const bal = (data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined)?.balancers;
+      const bal = (
+        data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined
+      )?.balancers;
       for (const b of bal ?? []) {
         if (b?.tag && !outbounds.has(b.tag)) balancers.push(b.tag);
       }

+ 3 - 1
frontend/src/api/queries/useStatusQuery.ts

@@ -26,7 +26,9 @@ export function useStatusQuery() {
   });
 
   const status = useMemo(() => query.data ?? new Status(), [query.data]);
-  const refresh = async () => { await query.refetch(); };
+  const refresh = async () => {
+    await query.refetch();
+  };
 
   return {
     status,

+ 2 - 1
frontend/src/api/queryKeys.ts

@@ -41,7 +41,8 @@ export const keys = {
     geodata: {
       root: () => ['xray', 'geodata'] as const,
       files: () => ['xray', 'geodata', 'files'] as const,
-      categories: (file: string, query: string) => ['xray', 'geodata', 'categories', file, query] as const,
+      categories: (file: string, query: string) =>
+        ['xray', 'geodata', 'categories', file, query] as const,
       entries: (file: string, code: string, query: string, offset: number, limit: number) =>
         ['xray', 'geodata', 'entries', file, code, query, offset, limit] as const,
     },

+ 15 - 4
frontend/src/api/websocket.ts

@@ -35,7 +35,10 @@ export class WebSocketClient {
   }
 
   connect(): void {
-    if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
+    if (
+      this.ws &&
+      (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)
+    ) {
       return;
     }
     this.shouldReconnect = true;
@@ -48,7 +51,9 @@ export class WebSocketClient {
     this.#cancelReconnect();
     this.reconnectAttempts = 0;
     if (this.ws) {
-      try { this.ws.close(1000, 'client disconnect'); } catch {}
+      try {
+        this.ws.close(1000, 'client disconnect');
+      } catch {}
       this.ws = null;
     }
     this.isConnected = false;
@@ -130,7 +135,9 @@ export class WebSocketClient {
       const byteLen = new Blob([data]).size;
       if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
         console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
-        try { this.ws?.close(1009, 'message too big'); } catch {}
+        try {
+          this.ws?.close(1009, 'message too big');
+        } catch {}
         return;
       }
     }
@@ -141,7 +148,11 @@ export class WebSocketClient {
       console.error('WebSocket: invalid JSON message', err);
       return;
     }
-    if (!message || typeof message !== 'object' || typeof (message as { type?: unknown }).type !== 'string') {
+    if (
+      !message ||
+      typeof message !== 'object' ||
+      typeof (message as { type?: unknown }).type !== 'string'
+    ) {
       console.error('WebSocket: malformed message envelope');
       return;
     }

+ 5 - 2
frontend/src/components/clients/ClientCardComment.tsx

@@ -3,7 +3,10 @@ type ClientCardCommentProps = {
   className?: string;
 };
 
-export default function ClientCardComment({ comment, className = 'client-card-comment' }: ClientCardCommentProps) {
+export default function ClientCardComment({
+  comment,
+  className = 'client-card-comment',
+}: ClientCardCommentProps) {
   if (!comment) return null;
 
   return (
@@ -11,4 +14,4 @@ export default function ClientCardComment({ comment, className = 'client-card-co
       {comment}
     </span>
   );
-}
+}

+ 1 - 2
frontend/src/components/clients/ClientSpeedTag.tsx

@@ -23,8 +23,7 @@ export function ClientSpeedTag({ speed, tableCell = false }: ClientSpeedTagProps
       style={tableCell ? SPEED_TAG_STYLE : undefined}
     >
       ↑ {SizeFormatter.speedFormat(speed.up)}
-      {' / '}
-      ↓ {SizeFormatter.speedFormat(speed.down)}
+      {' / '}↓ {SizeFormatter.speedFormat(speed.down)}
     </Tag>
   );
 }

+ 4 - 1
frontend/src/components/clients/ClientTrafficCell.stories.tsx

@@ -31,7 +31,10 @@ const meta = {
     down: { description: 'Downloaded bytes counted against the client.' },
     total: { description: 'Traffic quota in bytes; 0 or less renders as unlimited.' },
     enabled: { description: 'Grays the bar out when the client is disabled.' },
-    trafficDiff: { description: 'Headroom in bytes below the quota at which the bar shifts from green to orange.' },
+    trafficDiff: {
+      description:
+        'Headroom in bytes below the quota at which the bar shifts from green to orange.',
+    },
     compact: { description: 'Smaller bar and tighter layout for dense table rows.' },
   },
 } satisfies Meta<typeof ClientTrafficCell>;

+ 8 - 2
frontend/src/components/clients/ClientTrafficCell.tsx

@@ -60,7 +60,9 @@ const ClientTrafficCell = memo(function ClientTrafficCell({
     'client-traffic-cell',
     compact ? 'is-compact' : '',
     display.isUnlimited ? 'is-unlimited' : '',
-  ].filter(Boolean).join(' ');
+  ]
+    .filter(Boolean)
+    .join(' ');
 
   return (
     <Popover content={popover} trigger={['hover', 'click']} placement="top">
@@ -77,7 +79,11 @@ const ClientTrafficCell = memo(function ClientTrafficCell({
         />
         <span className="client-traffic-cell-limit">
           {display.isUnlimited ? (
-            <span className="client-traffic-cell-infinity" role="img" aria-label={t('subscription.unlimited')}>
+            <span
+              className="client-traffic-cell-infinity"
+              role="img"
+              aria-label={t('subscription.unlimited')}
+            >
               <InfinityIcon />
             </span>
           ) : (

+ 17 - 5
frontend/src/components/clients/ConfigBlock.stories.tsx

@@ -17,8 +17,13 @@ const meta = {
     },
   },
   argTypes: {
-    label: { description: 'Protocol/type badge shown on the panel header (e.g. `vless`, `trojan`).' },
-    text: { description: 'The config or share-link text to display, copy, download, and encode as a QR code.' },
+    label: {
+      description: 'Protocol/type badge shown on the panel header (e.g. `vless`, `trojan`).',
+    },
+    text: {
+      description:
+        'The config or share-link text to display, copy, download, and encode as a QR code.',
+    },
     fileName: { description: 'File name used when downloading the text.' },
     qrRemark: { description: 'Optional remark embedded in the QR panel; falls back to `label`.' },
     showQr: { description: 'Whether to show the QR-code action button.' },
@@ -31,8 +36,9 @@ export default meta;
 
 type Story = StoryObj<typeof meta>;
 
-const sampleLink = 'vless://[email protected]:443'
-  + '?type=ws&security=tls&path=%2Fpath#example-node';
+const sampleLink =
+  'vless://[email protected]:443' +
+  '?type=ws&security=tls&path=%2Fpath#example-node';
 
 export const Collapsed: Story = {
   args: { label: 'vless', text: sampleLink, fileName: 'client-config.txt' },
@@ -58,5 +64,11 @@ export const Expanded: Story = {
 };
 
 export const WithoutQr: Story = {
-  args: { label: 'trojan', text: sampleLink, fileName: 'client-config.txt', showQr: false, tagColor: 'geekblue' },
+  args: {
+    label: 'trojan',
+    text: sampleLink,
+    fileName: 'client-config.txt',
+    showQr: false,
+    tagColor: 'geekblue',
+  },
 };

+ 12 - 6
frontend/src/components/clients/ConfigBlock.tsx

@@ -70,12 +70,18 @@ export default function ConfigBlock({
         className="config-block"
         collapsible="header"
         defaultActiveKey={defaultOpen ? ['cfg'] : []}
-        items={[{
-          key: 'cfg',
-          label: <Tag color={tagColor} style={{ margin: 0, fontWeight: 600, letterSpacing: '0.3px' }}>{label}</Tag>,
-          extra: actions,
-          children: <code className="config-block-text">{text}</code>,
-        }]}
+        items={[
+          {
+            key: 'cfg',
+            label: (
+              <Tag color={tagColor} style={{ margin: 0, fontWeight: 600, letterSpacing: '0.3px' }}>
+                {label}
+              </Tag>
+            ),
+            extra: actions,
+            children: <code className="config-block-text">{text}</code>,
+          },
+        ]}
       />
     </>
   );

+ 3 - 1
frontend/src/components/feedback/PromptModal.stories.tsx

@@ -39,7 +39,9 @@ function InputDemo() {
   const [value, setValue] = useState('');
   return (
     <>
-      <Button type="primary" onClick={() => setOpen(true)}>Rename client</Button>
+      <Button type="primary" onClick={() => setOpen(true)}>
+        Rename client
+      </Button>
       <div style={{ marginTop: 12 }}>Last confirmed: {value || '—'}</div>
       <PromptModal
         open={open}

+ 5 - 1
frontend/src/components/feedback/PromptModal.tsx

@@ -71,7 +71,11 @@ export default function PromptModal({
         <JsonEditor value={value} onChange={setValue} minHeight="240px" maxHeight="60vh" />
       ) : type === 'textarea' ? (
         <Input.TextArea
-          ref={(el) => { 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)}

+ 7 - 2
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.' },
   },

+ 44 - 32
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 && (
-            <Button icon={<DownloadOutlined />} onClick={download}>{fileName}</Button>
-          )}
-          <Button type="primary" icon={<CopyOutlined />} onClick={copy}>{t('copy')}</Button>
-        </>
-      )}
-    >
-      {tabs && tabs.length > 0 && (
-        <Tabs
-          activeKey={activeTab?.key}
-          onChange={setActiveKey}
-          items={tabs.map((tab) => ({ key: tab.key, label: tab.label }))}
-        />
-      )}
-      {json ? (
-        <JsonEditor value={activeContent} readOnly minHeight="240px" maxHeight="60vh" />
-      ) : (
-        <Input.TextArea
-          aria-label={title}
-          value={activeContent}
-          readOnly
-          autoSize={{ minRows: 10, maxRows: 20 }}
-          style={{
-            fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
-            fontSize: 12,
-            overflowY: 'auto',
-          }}
-        />
-      )}
+        footer={
+          <>
+            {fileName && (
+              <Button icon={<DownloadOutlined />} onClick={download}>
+                {fileName}
+              </Button>
+            )}
+            <Button type="primary" icon={<CopyOutlined />} onClick={copy}>
+              {t('copy')}
+            </Button>
+          </>
+        }
+      >
+        {tabs && tabs.length > 0 && (
+          <Tabs
+            activeKey={activeTab?.key}
+            onChange={setActiveKey}
+            items={tabs.map((tab) => ({ key: tab.key, label: tab.label }))}
+          />
+        )}
+        {json ? (
+          <JsonEditor value={activeContent} readOnly minHeight="240px" maxHeight="60vh" />
+        ) : (
+          <Input.TextArea
+            aria-label={title}
+            value={activeContent}
+            readOnly
+            autoSize={{ minRows: 10, maxRows: 20 }}
+            style={{
+              fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
+              fontSize: 12,
+              overflowY: 'auto',
+            }}
+          />
+        )}
       </Modal>
     </>
   );

+ 2 - 2
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,

+ 3 - 1
frontend/src/components/form/DateTimePicker.stories.tsx

@@ -17,7 +17,9 @@ function ClientExpiryDemo() {
     <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
       <DateTimePicker value={value} onChange={setValue} placeholder="Expiry date" />
       <Typography.Text type="secondary">
-        {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)'}
       </Typography.Text>
     </div>
   );

+ 4 - 1
frontend/src/components/form/DateTimePicker.tsx

@@ -90,7 +90,10 @@ export default function DateTimePicker({
 
   if (datepicker === 'jalalian') {
     return (
-      <div ref={jalaliRef} className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}${value ? '' : ' jdp-empty'}`}>
+      <div
+        ref={jalaliRef}
+        className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}${value ? '' : ' jdp-empty'}`}
+      >
         <PersianDateTimePicker
           key={clearNonce}
           value={value ? value.valueOf() : null}

+ 19 - 4
frontend/src/components/form/HeaderMapEditor.stories.tsx

@@ -17,9 +17,17 @@ const meta = {
     },
   },
   argTypes: {
-    mode: { description: 'Wire shape: `v1` = string per name, `v2` = string[] per name (repeatable headers).' },
-    value: { description: 'Header map in the wire shape matching `mode`; converted to editable rows internally.' },
-    onChange: { description: 'Called with the rebuilt wire-shape map after every row edit, add, or remove.' },
+    mode: {
+      description:
+        'Wire shape: `v1` = string per name, `v2` = string[] per name (repeatable headers).',
+    },
+    value: {
+      description:
+        'Header map in the wire shape matching `mode`; converted to editable rows internally.',
+    },
+    onChange: {
+      description: 'Called with the rebuilt wire-shape map after every row edit, add, or remove.',
+    },
   },
 } satisfies Meta<typeof HeaderMapEditor>;
 
@@ -63,7 +71,14 @@ function WireShapeDemo() {
   return (
     <div style={{ maxWidth: 560 }}>
       <HeaderMapEditor mode="v2" value={value} onChange={setValue} />
-      <pre style={{ marginTop: 16, padding: 12, borderRadius: 8, background: 'rgba(128, 128, 128, 0.12)' }}>
+      <pre
+        style={{
+          marginTop: 16,
+          padding: 12,
+          borderRadius: 8,
+          background: 'rgba(128, 128, 128, 0.12)',
+        }}
+      >
         {JSON.stringify(value ?? {}, null, 2)}
       </pre>
     </div>

+ 10 - 6
frontend/src/components/form/HeaderMapEditor.tsx

@@ -24,10 +24,7 @@ import { InputAddon } from '@/components/ui';
 
 export type HeaderMapMode = 'v1' | 'v2';
 
-export type HeaderMapValue =
-  | Record<string, string>
-  | Record<string, string[]>
-  | undefined;
+export type HeaderMapValue = Record<string, string> | Record<string, string[]> | undefined;
 
 interface HeaderRow {
   name: string;
@@ -55,7 +52,10 @@ function mapToRows(value: HeaderMapValue): HeaderRow[] {
   return out;
 }
 
-function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record<string, string> | Record<string, string[]> {
+function rowsToMap(
+  rows: HeaderRow[],
+  mode: HeaderMapMode,
+): Record<string, string> | Record<string, string[]> {
   if (mode === 'v1') {
     const map: Record<string, string> = {};
     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 })}
           />
-          <Button aria-label={t('remove')} icon={<MinusOutlined />} onClick={() => removeRow(idx)} />
+          <Button
+            aria-label={t('remove')}
+            icon={<MinusOutlined />}
+            onClick={() => removeRow(idx)}
+          />
         </Space.Compact>
       ))}
       <Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}>

+ 3 - 2
frontend/src/components/form/JsonEditor.tsx

@@ -45,8 +45,9 @@ function buildDarkTheme({ bg, panelBg, activeBg, border, selection }: DarkPalett
       },
       '.cm-activeLine': { backgroundColor: activeBg },
       '.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
-      '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
-        { backgroundColor: selection },
+      '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
+        backgroundColor: selection,
+      },
       '.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
       '.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
       '.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },

+ 12 - 2
frontend/src/components/form/RemarkTemplateField.stories.tsx

@@ -17,7 +17,10 @@ const meta = {
     },
   },
   argTypes: {
-    value: { description: 'Current template string; any {{VAR}} token enables the live preview below the input.' },
+    value: {
+      description:
+        'Current template string; any {{VAR}} token enables the live preview below the input.',
+    },
     onChange: { description: 'Called with the updated template on typing or token insertion.' },
     maxLength: { description: 'Maximum template length; picker insertions are clamped to it.' },
     placeholder: { description: 'Placeholder shown while the template is empty.' },
@@ -30,7 +33,14 @@ type Story = StoryObj<typeof meta>;
 
 function InteractiveDemo() {
   const [value, setValue] = useState('{{STATUS_EMOJI}} {{INBOUND}}-{{EMAIL}} | {{TRAFFIC_LEFT}}');
-  return <RemarkTemplateField value={value} onChange={setValue} maxLength={256} placeholder="{{INBOUND}}-{{EMAIL}}" />;
+  return (
+    <RemarkTemplateField
+      value={value}
+      onChange={setValue}
+      maxLength={256}
+      placeholder="{{INBOUND}}-{{EMAIL}}"
+    />
+  );
 }
 
 export const Empty: Story = {

+ 25 - 4
frontend/src/components/form/RemarkTemplateField.tsx

@@ -5,7 +5,12 @@ import type { TextAreaRef } from 'antd/es/input/TextArea';
 import { CodeOutlined } from '@ant-design/icons';
 import { useTranslation } from 'react-i18next';
 
-import { hasRemarkTokens, previewRemark, SUBSCRIPTION_METADATA_VARIABLES, wrapToken } from '@/lib/remark/remarkVariables';
+import {
+  hasRemarkTokens,
+  previewRemark,
+  SUBSCRIPTION_METADATA_VARIABLES,
+  wrapToken,
+} from '@/lib/remark/remarkVariables';
 import RemarkVarPicker from './RemarkVarPicker';
 
 interface RemarkTemplateFieldProps {
@@ -24,7 +29,15 @@ interface RemarkTemplateFieldProps {
  * (insert-at-caret) and a live, sample-based preview of the expanded result.
  * Used for subscription text fields that support Remark Template variables.
  */
-export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder, multiline = false, rows, metadataOnly = false }: RemarkTemplateFieldProps) {
+export default function RemarkTemplateField({
+  value = '',
+  onChange,
+  maxLength,
+  placeholder,
+  multiline = false,
+  rows,
+  metadataOnly = false,
+}: RemarkTemplateFieldProps) {
   const { t } = useTranslation();
   const inputRef = useRef<InputRef>(null);
   const textAreaRef = useRef<TextAreaRef>(null);
@@ -60,7 +73,13 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
       title={t('pages.hosts.remarkVars.title')}
     >
       <Tooltip title={t('pages.hosts.remarkVars.title')}>
-        <Button type="text" size="small" icon={<CodeOutlined />} aria-label={t('pages.hosts.remarkVars.title')} style={{ marginInlineEnd: -7 }} />
+        <Button
+          type="text"
+          size="small"
+          icon={<CodeOutlined />}
+          aria-label={t('pages.hosts.remarkVars.title')}
+          style={{ marginInlineEnd: -7 }}
+        />
       </Tooltip>
     </Popover>
   );
@@ -92,7 +111,9 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
       {hasRemarkTokens(value) && (
         <div style={{ fontSize: 12, marginTop: 4, opacity: 0.7 }}>
           {t('pages.hosts.remarkVars.preview')}:{' '}
-          <span style={{ fontFamily: 'monospace' }}>{previewRemark(value, variables, metadataOnly) || '—'}</span>
+          <span style={{ fontFamily: 'monospace' }}>
+            {previewRemark(value, variables, metadataOnly) || '—'}
+          </span>
         </div>
       )}
     </div>

+ 7 - 2
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<typeof RemarkVarPicker>;
 
@@ -29,7 +32,9 @@ export default meta;
 type Story = StoryObj<typeof meta>;
 
 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 (
     <div style={{ maxWidth: 520 }}>
       <Input

+ 37 - 22
frontend/src/components/form/RemarkVarPicker.tsx

@@ -15,35 +15,50 @@ interface RemarkVarPickerProps {
  * RemarkVarPicker is the grouped, tooltipped chip list of {{VAR}} tokens used by
  * the global remark-template field.
  */
-export default function RemarkVarPicker({ onPick, variables = REMARK_VARIABLES }: RemarkVarPickerProps) {
+export default function RemarkVarPicker({
+  onPick,
+  variables = REMARK_VARIABLES,
+}: RemarkVarPickerProps) {
   const { t } = useTranslation();
   return (
     <div style={{ maxWidth: 460, maxHeight: 'min(70vh, 640px)', overflowY: 'auto' }}>
       <Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
         {t('pages.hosts.remarkVars.intro')}
       </Typography.Paragraph>
-      {REMARK_VAR_GROUPS.filter((group) => variables.some((v) => v.group === group)).map((group) => (
-        <div key={group} style={{ marginBottom: 8 }}>
-          <div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', opacity: 0.6, marginBottom: 4 }}>
-            {t(`pages.hosts.remarkVars.groups.${group}`)}
+      {REMARK_VAR_GROUPS.filter((group) => variables.some((v) => v.group === group)).map(
+        (group) => (
+          <div key={group} style={{ marginBottom: 8 }}>
+            <div
+              style={{
+                fontSize: 11,
+                fontWeight: 600,
+                textTransform: 'uppercase',
+                opacity: 0.6,
+                marginBottom: 4,
+              }}
+            >
+              {t(`pages.hosts.remarkVars.groups.${group}`)}
+            </div>
+            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
+              {variables
+                .filter((v) => v.group === group)
+                .map((v) => (
+                  <Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
+                    <Tag
+                      role="button"
+                      tabIndex={0}
+                      onClick={() => onPick(v.token)}
+                      onKeyDown={activateOnKey(() => onPick(v.token))}
+                      style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }}
+                    >
+                      {wrapToken(v.token)}
+                    </Tag>
+                  </Tooltip>
+                ))}
+            </div>
           </div>
-          <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
-            {variables.filter((v) => v.group === group).map((v) => (
-              <Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
-                <Tag
-                  role="button"
-                  tabIndex={0}
-                  onClick={() => onPick(v.token)}
-                  onKeyDown={activateOnKey(() => onPick(v.token))}
-                  style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }}
-                >
-                  {wrapToken(v.token)}
-                </Tag>
-              </Tooltip>
-            ))}
-          </div>
-        </div>
-      ))}
+        ),
+      )}
     </div>
   );
 }

+ 16 - 4
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<typeof SelectAllClearButtons>;
 

+ 1 - 5
frontend/src/components/form/SelectAllClearButtons.tsx

@@ -35,11 +35,7 @@ export default function SelectAllClearButtons<T extends string | number = number
       >
         {selectAllLabel ?? t('pages.clients.selectAllInbounds')}
       </Button>
-      <Button
-        size="small"
-        disabled={value.length === 0}
-        onClick={() => onChange([])}
-      >
+      <Button size="small" disabled={value.length === 0} onClick={() => onChange([])}>
         {clearLabel ?? t('pages.clients.clearAllInbounds')}
       </Button>
     </div>

+ 23 - 6
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 (
     <FormProvider {...methods}>
       <Form layout="vertical" style={{ maxWidth: 360 }}>
-        <FormField name="email" label="Email" tooltip="Unique identifier used to match client traffic" required>
+        <FormField
+          name="email"
+          label="Email"
+          tooltip="Unique identifier used to match client traffic"
+          required
+        >
           <Input placeholder="[email protected]" />
         </FormField>
         <FormField name="flow" label="Flow" extra="Only applies to VLESS over raw TLS">
@@ -96,7 +111,9 @@ function TrafficDemo() {
         >
           <InputNumber min={0} style={{ width: '100%' }} />
         </FormField>
-        <Typography.Text type="secondary">Form state: {totalBytes.toLocaleString()} bytes</Typography.Text>
+        <Typography.Text type="secondary">
+          Form state: {totalBytes.toLocaleString()} bytes
+        </Typography.Text>
       </Form>
     </FormProvider>
   );

+ 3 - 1
frontend/src/components/form/rhf/useZodForm.ts

@@ -7,7 +7,9 @@ export function useZodForm<TFieldValues extends FieldValues>(
   schema: z.ZodType<TFieldValues>,
   options?: Omit<UseFormProps<TFieldValues>, 'resolver'>,
 ): UseFormReturn<TFieldValues> {
-  const resolver = zodResolver(schema as z.ZodType<TFieldValues, TFieldValues>) as Resolver<TFieldValues>;
+  const resolver = zodResolver(
+    schema as z.ZodType<TFieldValues, TFieldValues>,
+  ) as Resolver<TFieldValues>;
   return useForm<TFieldValues>({
     mode: 'onSubmit',
     reValidateMode: 'onChange',

+ 509 - 106
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<string, GeoEntry[]> = {
   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<string, string[]> = {
 };
 
 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<string, GeoEntry[]> = {
   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<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> = {
-  'geosite.dat': { categories: SITE_CATEGORIES, entries: SITE_ENTRIES },
-  'geoip.dat': { categories: IP_CATEGORIES, entries: IP_ENTRIES },
-};
+const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> =
+  {
+    '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 (
-    <Space direction="vertical" size={12}>
+    <Space orientation="vertical" size={12}>
       <Space size={8}>
         <Button onClick={() => setOpen(true)}>Open geo browser</Button>
         <Typography.Text code>{value || 'no rule yet'}</Typography.Text>
@@ -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.' },

+ 78 - 20
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<string | undefined>(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 && (
               <span className="geo-attrs">
                 {category.attributes.map((attribute) => (
-                  <Tag key={attribute} bordered={false}>
+                  <Tag key={attribute} variant="filled">
                     @{attribute}
                   </Tag>
                 ))}
@@ -199,7 +228,7 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
         dataIndex: 'kind',
         width: 88,
         render: (entryKind: string) => (
-          <Tag bordered={false} className={`geo-kind geo-kind-${entryKind}`}>
+          <Tag variant="filled" className={`geo-kind geo-kind-${entryKind}`}>
             {entryKind}
           </Tag>
         ),
@@ -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 && <Alert type="error" showIcon title={t('pages.xray.geoBrowser.loadFailed')} className="mb-12" />}
+      {filesQuery.isError && (
+        <Alert
+          type="error"
+          showIcon
+          title={t('pages.xray.geoBrowser.loadFailed')}
+          className="mb-12"
+        />
+      )}
 
       {!filesQuery.isError && !filesQuery.isLoading && files.length === 0 ? (
         <Empty
@@ -253,7 +296,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
             <span>
               {t('pages.xray.geoBrowser.noFiles')}
               <br />
-              <Typography.Text type="secondary">{t('pages.xray.geoBrowser.noFilesHint')}</Typography.Text>
+              <Typography.Text type="secondary">
+                {t('pages.xray.geoBrowser.noFilesHint')}
+              </Typography.Text>
             </span>
           }
         />
@@ -279,7 +324,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
               allowClear
             />
             <Button
-              onClick={() => toggle([...new Set([...selectedCodes, ...visibleCategories.map((c) => c.code)])])}
+              onClick={() =>
+                toggle([...new Set([...selectedCodes, ...visibleCategories.map((c) => c.code)])])
+              }
               disabled={visibleCategories.length === 0}
             >
               {`${t('pages.xray.geoBrowser.selectFound')} (${visibleCategories.length.toLocaleString()})`}
@@ -296,7 +343,11 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
                 rowKey="code"
                 columns={categoryColumns}
                 dataSource={visibleCategories}
-                loading={filesQuery.isLoading || categoriesQuery.isLoading || categoriesQuery.isPlaceholderData}
+                loading={
+                  filesQuery.isLoading ||
+                  categoriesQuery.isLoading ||
+                  categoriesQuery.isPlaceholderData
+                }
                 pagination={false}
                 scroll={{ y: CATEGORY_SCROLL_HEIGHT }}
                 locale={{ emptyText: t('pages.xray.geoBrowser.noMatches') }}
@@ -308,7 +359,8 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
                 }}
                 onRow={(category) => ({
                   onClick: (event) => {
-                    if ((event.target as HTMLElement).closest('.ant-table-selection-column')) return;
+                    if ((event.target as HTMLElement).closest('.ant-table-selection-column'))
+                      return;
                     setActiveCode(category.code);
                     clearEntryFilter();
                   },
@@ -369,7 +421,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
                 </>
               ) : (
                 <div className="geo-placeholder">
-                  <Typography.Text type="secondary">{t('pages.xray.geoBrowser.pickCategory')}</Typography.Text>
+                  <Typography.Text type="secondary">
+                    {t('pages.xray.geoBrowser.pickCategory')}
+                  </Typography.Text>
                 </div>
               )}
             </div>
@@ -377,7 +431,9 @@ export default function GeoBrowserModal({ open, kind, value, onApply, onClose }:
 
           <div className="geo-footer">
             {selected.length === 0 ? (
-              <Typography.Text type="secondary">{t('pages.xray.geoBrowser.emptySelection')}</Typography.Text>
+              <Typography.Text type="secondary">
+                {t('pages.xray.geoBrowser.emptySelection')}
+              </Typography.Text>
             ) : (
               <>
                 <Space size={4} wrap className="geo-chips">
@@ -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}
                     </Tag>

+ 65 - 20
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<string, GeoEntry[]> = {
   '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<string, GeoEntry[]> = {
   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<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> = {
-  'geosite.dat': { categories: categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES), entries: SITE_ENTRIES },
-  'geoip.dat': { categories: categoriesOf(IP_ENTRIES), entries: IP_ENTRIES },
-};
+const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> =
+  {
+    '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 (
-    <Space direction="vertical" size={4} style={{ width: 460 }}>
+    <Space orientation="vertical" size={4} style={{ width: 460 }}>
       <label htmlFor={id}>{rest.kind === 'ip' ? 'Target IP' : 'Target domain'}</label>
       <GeoTokenInput {...rest} id={id} value={current} onChange={setCurrent} />
     </Space>
@@ -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();
   },
 };

+ 27 - 21
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<InputRef>;
 }
 
-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<GeodataTokenIssue[]>([]);
@@ -72,25 +80,23 @@ export default function GeoTokenInput({ value = '', onChange, onBlur, kind, plac
 
   return (
     <>
-      <Input
-        ref={ref}
-        id={id}
-        value={value}
-        placeholder={placeholder}
-        onChange={(event) => onChange?.(event.target.value)}
-        onBlur={onBlur}
-        addonAfter={
-          <Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
-            <Button
-              type="text"
-              size="small"
-              icon={<DatabaseOutlined />}
-              aria-label={t('pages.xray.geoBrowser.openTooltip')}
-              onClick={() => setBrowsing(true)}
-            />
-          </Tooltip>
-        }
-      />
+      <Space.Compact block>
+        <Input
+          ref={ref}
+          id={id}
+          value={value}
+          placeholder={placeholder}
+          onChange={(event) => onChange?.(event.target.value)}
+          onBlur={onBlur}
+        />
+        <Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
+          <Button
+            icon={<DatabaseOutlined />}
+            aria-label={t('pages.xray.geoBrowser.openTooltip')}
+            onClick={() => setBrowsing(true)}
+          />
+        </Tooltip>
+      </Space.Compact>
       {groupByReason(issues).map(([reason, tokens]) => (
         <Typography.Text key={reason} type="warning" className="geo-unknown-hint">
           {t(REASON_KEYS[reason] ?? REASON_KEYS.categoryMissing, { tokens: tokens.join(', ') })}

+ 4 - 1
frontend/src/components/ui/DefaultSettingTag.tsx

@@ -8,7 +8,10 @@ import { useFactoryDefaults } from '@/api/queries/useFactoryDefaults';
  * default?", not "has the user ever saved this key?" — a stored 2096 and a
  * fallback 2096 behave identically, so they read identically.
  */
-export function matchesFactoryDefault(current: unknown, factoryDefault: string | undefined): boolean {
+export function matchesFactoryDefault(
+  current: unknown,
+  factoryDefault: string | undefined,
+): boolean {
   if (factoryDefault === undefined) return false;
   if (typeof current === 'number') {
     const parsed = Number(factoryDefault);

+ 10 - 1
frontend/src/components/ui/InputAddon.tsx

@@ -10,8 +10,17 @@ interface InputAddonProps {
   ariaLabel?: string;
 }
 
-export default function InputAddon({ children, className = '', style, onClick, ariaLabel }: InputAddonProps) {
+export default function InputAddon({
+  children,
+  className = '',
+  style,
+  onClick,
+  ariaLabel,
+}: InputAddonProps) {
   return (
+    // oxlint cannot see through the conditional role/tabIndex/onKeyDown below,
+    // which is exactly what makes the clickable variant accessible.
+    // oxlint-disable-next-line jsx-a11y/no-static-element-interactions
     <span
       className={`input-addon ${className}`.trim()}
       style={style}

+ 14 - 4
frontend/src/components/ui/SettingListItem.tsx

@@ -1,4 +1,11 @@
-import { cloneElement, Fragment, isValidElement, useId, type ReactElement, type ReactNode } from 'react';
+import {
+  cloneElement,
+  Fragment,
+  isValidElement,
+  useId,
+  type ReactElement,
+  type ReactNode,
+} from 'react';
 import { Col, Row } from 'antd';
 import './SettingListItem.css';
 
@@ -22,9 +29,12 @@ export default function SettingListItem({
   const padding = paddings === 'small' ? '10px 20px' : '20px';
   const titleId = useId();
   const node = control ?? children;
-  const labelledNode = title && isValidElement(node) && node.type !== Fragment
-    ? cloneElement(node as ReactElement<{ 'aria-labelledby'?: string }>, { 'aria-labelledby': titleId })
-    : node;
+  const labelledNode =
+    title && isValidElement(node) && node.type !== Fragment
+      ? cloneElement(node as ReactElement<{ 'aria-labelledby'?: string }>, {
+          'aria-labelledby': titleId,
+        })
+      : node;
   return (
     <div className="setting-list-item" style={{ padding }}>
       <Row gutter={[8, 16]} style={{ width: '100%' }}>

+ 8 - 3
frontend/src/components/ui/notifications/EmailNotifications.stories.tsx

@@ -23,7 +23,8 @@ const meta = {
         'Panel settings snapshot; smtpEnabledEvents holds the selected event keys and smtpCpu/smtpMemory the alert threshold percentages.',
     },
     updateSetting: {
-      description: 'Receives a partial settings patch when an event is toggled or a threshold input changes.',
+      description:
+        'Receives a partial settings patch when an event is toggled or a threshold input changes.',
     },
   },
 } satisfies Meta<typeof EmailNotifications>;
@@ -56,7 +57,9 @@ export const SystemThresholdAlerts: Story = {
   args: placeholderArgs,
   render: () => (
     <StatefulDemo
-      initial={new AllSetting({ smtpEnabledEvents: 'cpu.high,memory.high', smtpCpu: 85, smtpMemory: 90 })}
+      initial={
+        new AllSetting({ smtpEnabledEvents: 'cpu.high,memory.high', smtpCpu: 85, smtpMemory: 90 })
+      }
     />
   ),
 };
@@ -64,7 +67,9 @@ export const SystemThresholdAlerts: Story = {
 export const InfrastructureOnly: Story = {
   args: placeholderArgs,
   render: () => (
-    <StatefulDemo initial={new AllSetting({ smtpEnabledEvents: 'outbound.down,node.down,node.up,xray.crash' })} />
+    <StatefulDemo
+      initial={new AllSetting({ smtpEnabledEvents: 'outbound.down,node.down,node.up,xray.crash' })}
+    />
   ),
 };
 

+ 43 - 14
frontend/src/components/ui/notifications/EmailNotifications.tsx

@@ -1,5 +1,11 @@
 import { InputNumber } from 'antd';
-import { CloudServerOutlined, ThunderboltOutlined, DesktopOutlined, DashboardOutlined, SafetyOutlined } from '@ant-design/icons';
+import {
+  CloudServerOutlined,
+  ThunderboltOutlined,
+  DesktopOutlined,
+  DashboardOutlined,
+  SafetyOutlined,
+} from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import { NotificationLayout } from './NotificationLayout';
 import { NotificationGroup } from './NotificationGroup';
@@ -15,7 +21,15 @@ const GROUPS: NotificationGroupConfig[] = [
         label: 'eventOutboundDown',
         settingKey: 'outboundDownThreshold',
         extra: ({ value, onChange, ariaLabel }) => (
-          <InputNumber size="small" min={1} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
+          <InputNumber
+            size="small"
+            min={1}
+            max={100}
+            value={value}
+            onChange={onChange}
+            aria-label={ariaLabel}
+            style={{ width: 80 }}
+          />
         ),
       },
       { key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
@@ -24,9 +38,7 @@ const GROUPS: NotificationGroupConfig[] = [
   {
     icon: <ThunderboltOutlined />,
     title: 'eventGroupXray',
-    events: [
-      { key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' },
-    ],
+    events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }],
   },
   {
     icon: <DesktopOutlined />,
@@ -45,7 +57,15 @@ const GROUPS: NotificationGroupConfig[] = [
         label: 'eventCPUHigh',
         settingKey: 'smtpCpu',
         extra: ({ value, onChange, ariaLabel }) => (
-          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
+          <InputNumber
+            size="small"
+            min={0}
+            max={100}
+            value={value}
+            onChange={onChange}
+            aria-label={ariaLabel}
+            style={{ width: 80 }}
+          />
         ),
       },
       {
@@ -53,7 +73,15 @@ const GROUPS: NotificationGroupConfig[] = [
         label: 'eventMemoryHigh',
         settingKey: 'smtpMemory',
         extra: ({ value, onChange, ariaLabel }) => (
-          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
+          <InputNumber
+            size="small"
+            min={0}
+            max={100}
+            value={value}
+            onChange={onChange}
+            aria-label={ariaLabel}
+            style={{ width: 80 }}
+          />
         ),
       },
     ],
@@ -61,9 +89,7 @@ const GROUPS: NotificationGroupConfig[] = [
   {
     icon: <SafetyOutlined />,
     title: 'eventGroupSecurity',
-    events: [
-      { key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' },
-    ],
+    events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }],
   },
 ];
 
@@ -74,12 +100,15 @@ interface Props {
 
 export function EmailNotifications({ allSetting, updateSetting }: Props) {
   const events = allSetting.smtpEnabledEvents || '';
-  const selected = events ? events.split(',').map((s) => s.trim()).filter(Boolean) : [];
+  const selected = events
+    ? events
+        .split(',')
+        .map((s) => s.trim())
+        .filter(Boolean)
+    : [];
 
   function toggle(key: string) {
-    const next = selected.includes(key)
-      ? selected.filter((e) => e !== key)
-      : [...selected, key];
+    const next = selected.includes(key) ? selected.filter((e) => e !== key) : [...selected, key];
     updateSetting({ smtpEnabledEvents: next.join(',') });
   }
 

+ 5 - 1
frontend/src/components/ui/notifications/NotificationCard.tsx

@@ -13,7 +13,11 @@ export function NotificationCard({ icon, title, extra, children }: Props) {
     <Card
       size="small"
       variant="outlined"
-      title={<span>{icon} {title}</span>}
+      title={
+        <span>
+          {icon} {title}
+        </span>
+      }
       extra={extra}
       style={{ borderWidth: 1 }}
     >

+ 1 - 5
frontend/src/components/ui/notifications/NotificationEvent.tsx

@@ -16,11 +16,7 @@ export function NotificationEvent({ label, checked, onToggle, children }: Props)
       <Checkbox checked={checked} onChange={onToggle}>
         {t(label)}
       </Checkbox>
-      {checked && children && (
-        <div style={{ paddingLeft: 24, marginTop: 4 }}>
-          {children}
-        </div>
-      )}
+      {checked && children && <div style={{ paddingLeft: 24, marginTop: 4 }}>{children}</div>}
     </div>
   );
 }

+ 36 - 7
frontend/src/components/ui/notifications/NotificationGroup.stories.tsx

@@ -16,7 +16,15 @@ const systemGroup: NotificationGroupConfig = {
       label: 'eventCPUHigh',
       settingKey: 'tgCpu',
       extra: ({ value, onChange, ariaLabel }) => (
-        <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
+        <InputNumber
+          size="small"
+          min={0}
+          max={100}
+          value={value}
+          onChange={onChange}
+          aria-label={ariaLabel}
+          style={{ width: 80 }}
+        />
       ),
     },
     {
@@ -24,7 +32,15 @@ const systemGroup: NotificationGroupConfig = {
       label: 'eventMemoryHigh',
       settingKey: 'tgMemory',
       extra: ({ value, onChange, ariaLabel }) => (
-        <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
+        <InputNumber
+          size="small"
+          min={0}
+          max={100}
+          value={value}
+          onChange={onChange}
+          aria-label={ariaLabel}
+          style={{ width: 80 }}
+        />
       ),
     },
   ],
@@ -53,12 +69,21 @@ const meta = {
     },
   },
   argTypes: {
-    config: { description: 'Group definition: icon, `pages.settings` title key, and the event rows to render.' },
+    config: {
+      description:
+        'Group definition: icon, `pages.settings` title key, and the event rows to render.',
+    },
     selected: { description: 'Enabled event keys; drives each checkbox and the header count.' },
     onToggle: { description: 'Called with the event key when a single checkbox is clicked.' },
-    onToggleAll: { description: 'Called with every event key in the group when the master checkbox is clicked.' },
-    allSetting: { description: 'Panel settings snapshot; threshold values such as `tgCpu` are read from it.' },
-    updateSetting: { description: 'Called with a partial settings patch when a threshold input changes.' },
+    onToggleAll: {
+      description: 'Called with every event key in the group when the master checkbox is clicked.',
+    },
+    allSetting: {
+      description: 'Panel settings snapshot; threshold values such as `tgCpu` are read from it.',
+    },
+    updateSetting: {
+      description: 'Called with a partial settings patch when a threshold input changes.',
+    },
   },
 } satisfies Meta<typeof NotificationGroup>;
 
@@ -77,7 +102,11 @@ function Demo() {
         setSelected((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]))
       }
       onToggleAll={(keys) =>
-        setSelected((prev) => (keys.every((k) => prev.includes(k)) ? prev.filter((k) => !keys.includes(k)) : [...new Set([...prev, ...keys])]))
+        setSelected((prev) =>
+          keys.every((k) => prev.includes(k))
+            ? prev.filter((k) => !keys.includes(k))
+            : [...new Set([...prev, ...keys])],
+        )
       }
       allSetting={settings}
       updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}

+ 10 - 2
frontend/src/components/ui/notifications/NotificationGroup.tsx

@@ -15,7 +15,14 @@ interface Props {
   updateSetting: (patch: Partial<AllSetting>) => void;
 }
 
-export function NotificationGroup({ config, selected, onToggle, onToggleAll, allSetting, updateSetting }: Props) {
+export function NotificationGroup({
+  config,
+  selected,
+  onToggle,
+  onToggleAll,
+  allSetting,
+  updateSetting,
+}: Props) {
   const { t } = useTranslation();
 
   const count = config.events.filter((e) => selected.includes(e.key)).length;
@@ -49,7 +56,8 @@ export function NotificationGroup({ config, selected, onToggle, onToggleAll, all
             onToggle={() => onToggle(event.key)}
           >
             {event.extra?.({
-              value: Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0,
+              value:
+                Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0,
               onChange: (v) => updateSetting({ [event.settingKey]: v }),
               ariaLabel: t(`pages.settings.${event.label}`),
             })}

+ 3 - 1
frontend/src/components/ui/notifications/NotificationHeader.stories.tsx

@@ -22,7 +22,9 @@ const meta = {
     total: { description: 'Total number of events the group offers.' },
     allSelected: { description: 'Checks the master checkbox when every event is selected.' },
     indeterminate: { description: 'Shows the dash state when only some events are selected.' },
-    onToggleAll: { description: 'Called when the master checkbox is clicked to select or clear all events.' },
+    onToggleAll: {
+      description: 'Called when the master checkbox is clicked to select or clear all events.',
+    },
   },
 } satisfies Meta<typeof NotificationHeader>;
 

+ 29 - 4
frontend/src/components/ui/notifications/NotificationHeader.tsx

@@ -10,19 +10,44 @@ interface Props {
   onToggleAll: () => void;
 }
 
-function MasterCheckbox({ checked, indeterminate, onChange }: { checked: boolean; indeterminate: boolean; onChange: () => void }) {
+function MasterCheckbox({
+  checked,
+  indeterminate,
+  onChange,
+}: {
+  checked: boolean;
+  indeterminate: boolean;
+  onChange: () => void;
+}) {
   const { t } = useTranslation();
   const ref = useRef<HTMLInputElement>(null);
   useEffect(() => {
     if (ref.current) ref.current.indeterminate = indeterminate;
   }, [indeterminate]);
-  return <input ref={ref} type="checkbox" aria-label={t('pages.clients.selectAll')} checked={checked} onChange={onChange} style={{ cursor: 'pointer' }} />;
+  return (
+    <input
+      ref={ref}
+      type="checkbox"
+      aria-label={t('pages.clients.selectAll')}
+      checked={checked}
+      onChange={onChange}
+      style={{ cursor: 'pointer' }}
+    />
+  );
 }
 
-export function NotificationHeader({ count, total, allSelected, indeterminate, onToggleAll }: Props) {
+export function NotificationHeader({
+  count,
+  total,
+  allSelected,
+  indeterminate,
+  onToggleAll,
+}: Props) {
   return (
     <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
-      <Tag>{count}/{total}</Tag>
+      <Tag>
+        {count}/{total}
+      </Tag>
       <MasterCheckbox checked={allSelected} indeterminate={indeterminate} onChange={onToggleAll} />
     </span>
   );

+ 61 - 7
frontend/src/components/ui/notifications/NotificationLayout.stories.tsx

@@ -20,7 +20,15 @@ function OutboundGroup() {
     <NotificationCard
       icon={<CloudServerOutlined />}
       title="Outbound"
-      extra={<NotificationHeader count={1} total={2} allSelected={false} indeterminate onToggleAll={noop} />}
+      extra={
+        <NotificationHeader
+          count={1}
+          total={2}
+          allSelected={false}
+          indeterminate
+          onToggleAll={noop}
+        />
+      }
     >
       <Space orientation="vertical" size={8} style={{ width: '100%' }}>
         <NotificationEvent label="Outbound went down" checked onToggle={noop} />
@@ -35,7 +43,15 @@ function XrayGroup() {
     <NotificationCard
       icon={<ThunderboltOutlined />}
       title="Xray"
-      extra={<NotificationHeader count={1} total={1} allSelected indeterminate={false} onToggleAll={noop} />}
+      extra={
+        <NotificationHeader
+          count={1}
+          total={1}
+          allSelected
+          indeterminate={false}
+          onToggleAll={noop}
+        />
+      }
     >
       <Space orientation="vertical" size={8} style={{ width: '100%' }}>
         <NotificationEvent label="Xray crashed" checked onToggle={noop} />
@@ -49,7 +65,15 @@ function NodeGroup() {
     <NotificationCard
       icon={<DesktopOutlined />}
       title="Nodes"
-      extra={<NotificationHeader count={0} total={2} allSelected={false} indeterminate={false} onToggleAll={noop} />}
+      extra={
+        <NotificationHeader
+          count={0}
+          total={2}
+          allSelected={false}
+          indeterminate={false}
+          onToggleAll={noop}
+        />
+      }
     >
       <Space orientation="vertical" size={8} style={{ width: '100%' }}>
         <NotificationEvent label="Node went offline" checked={false} onToggle={noop} />
@@ -64,14 +88,36 @@ function SystemGroup() {
     <NotificationCard
       icon={<DashboardOutlined />}
       title="System"
-      extra={<NotificationHeader count={2} total={2} allSelected indeterminate={false} onToggleAll={noop} />}
+      extra={
+        <NotificationHeader
+          count={2}
+          total={2}
+          allSelected
+          indeterminate={false}
+          onToggleAll={noop}
+        />
+      }
     >
       <Space orientation="vertical" size={8} style={{ width: '100%' }}>
         <NotificationEvent label="CPU usage above threshold (%)" checked onToggle={noop}>
-          <InputNumber size="small" min={0} max={100} defaultValue={80} aria-label="CPU usage threshold percent" style={{ width: 80 }} />
+          <InputNumber
+            size="small"
+            min={0}
+            max={100}
+            defaultValue={80}
+            aria-label="CPU usage threshold percent"
+            style={{ width: 80 }}
+          />
         </NotificationEvent>
         <NotificationEvent label="Memory usage above threshold (%)" checked onToggle={noop}>
-          <InputNumber size="small" min={0} max={100} defaultValue={90} aria-label="Memory usage threshold percent" style={{ width: 80 }} />
+          <InputNumber
+            size="small"
+            min={0}
+            max={100}
+            defaultValue={90}
+            aria-label="Memory usage threshold percent"
+            style={{ width: 80 }}
+          />
         </NotificationEvent>
       </Space>
     </NotificationCard>
@@ -83,7 +129,15 @@ function SecurityGroup() {
     <NotificationCard
       icon={<SafetyOutlined />}
       title="Security"
-      extra={<NotificationHeader count={1} total={1} allSelected indeterminate={false} onToggleAll={noop} />}
+      extra={
+        <NotificationHeader
+          count={1}
+          total={1}
+          allSelected
+          indeterminate={false}
+          onToggleAll={noop}
+        />
+      }
     >
       <Space orientation="vertical" size={8} style={{ width: '100%' }}>
         <NotificationEvent label="Panel login attempt" checked onToggle={noop} />

+ 7 - 1
frontend/src/components/ui/notifications/NotificationLayout.tsx

@@ -6,7 +6,13 @@ interface Props {
 
 export function NotificationLayout({ children }: Props) {
   return (
-    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
+    <div
+      style={{
+        display: 'grid',
+        gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
+        gap: 12,
+      }}
+    >
       {children}
     </div>
   );

+ 8 - 2
frontend/src/components/ui/notifications/TelegramNotifications.stories.tsx

@@ -18,8 +18,14 @@ const meta = {
     },
   },
   argTypes: {
-    allSetting: { description: 'Panel settings snapshot; reads `tgEnabledEvents` plus the `tgCpu`/`tgMemory` thresholds.' },
-    updateSetting: { description: 'Called with a partial settings patch when an event toggle or threshold changes.' },
+    allSetting: {
+      description:
+        'Panel settings snapshot; reads `tgEnabledEvents` plus the `tgCpu`/`tgMemory` thresholds.',
+    },
+    updateSetting: {
+      description:
+        'Called with a partial settings patch when an event toggle or threshold changes.',
+    },
   },
 } satisfies Meta<typeof TelegramNotifications>;
 

+ 43 - 14
frontend/src/components/ui/notifications/TelegramNotifications.tsx

@@ -1,5 +1,11 @@
 import { InputNumber } from 'antd';
-import { CloudServerOutlined, ThunderboltOutlined, DesktopOutlined, DashboardOutlined, SafetyOutlined } from '@ant-design/icons';
+import {
+  CloudServerOutlined,
+  ThunderboltOutlined,
+  DesktopOutlined,
+  DashboardOutlined,
+  SafetyOutlined,
+} from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
 import { NotificationLayout } from './NotificationLayout';
 import { NotificationGroup } from './NotificationGroup';
@@ -15,7 +21,15 @@ const GROUPS: NotificationGroupConfig[] = [
         label: 'eventOutboundDown',
         settingKey: 'outboundDownThreshold',
         extra: ({ value, onChange, ariaLabel }) => (
-          <InputNumber size="small" min={1} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
+          <InputNumber
+            size="small"
+            min={1}
+            max={100}
+            value={value}
+            onChange={onChange}
+            aria-label={ariaLabel}
+            style={{ width: 80 }}
+          />
         ),
       },
       { key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
@@ -24,9 +38,7 @@ const GROUPS: NotificationGroupConfig[] = [
   {
     icon: <ThunderboltOutlined />,
     title: 'eventGroupXray',
-    events: [
-      { key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' },
-    ],
+    events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }],
   },
   {
     icon: <DesktopOutlined />,
@@ -45,7 +57,15 @@ const GROUPS: NotificationGroupConfig[] = [
         label: 'eventCPUHigh',
         settingKey: 'tgCpu',
         extra: ({ value, onChange, ariaLabel }) => (
-          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
+          <InputNumber
+            size="small"
+            min={0}
+            max={100}
+            value={value}
+            onChange={onChange}
+            aria-label={ariaLabel}
+            style={{ width: 80 }}
+          />
         ),
       },
       {
@@ -53,7 +73,15 @@ const GROUPS: NotificationGroupConfig[] = [
         label: 'eventMemoryHigh',
         settingKey: 'tgMemory',
         extra: ({ value, onChange, ariaLabel }) => (
-          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
+          <InputNumber
+            size="small"
+            min={0}
+            max={100}
+            value={value}
+            onChange={onChange}
+            aria-label={ariaLabel}
+            style={{ width: 80 }}
+          />
         ),
       },
     ],
@@ -61,9 +89,7 @@ const GROUPS: NotificationGroupConfig[] = [
   {
     icon: <SafetyOutlined />,
     title: 'eventGroupSecurity',
-    events: [
-      { key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' },
-    ],
+    events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }],
   },
 ];
 
@@ -74,12 +100,15 @@ interface Props {
 
 export function TelegramNotifications({ allSetting, updateSetting }: Props) {
   const events = allSetting.tgEnabledEvents || '';
-  const selected = events ? events.split(',').map((s) => s.trim()).filter(Boolean) : [];
+  const selected = events
+    ? events
+        .split(',')
+        .map((s) => s.trim())
+        .filter(Boolean)
+    : [];
 
   function toggle(key: string) {
-    const next = selected.includes(key)
-      ? selected.filter((e) => e !== key)
-      : [...selected, key];
+    const next = selected.includes(key) ? selected.filter((e) => e !== key) : [...selected, key];
     updateSetting({ tgEnabledEvents: next.join(',') });
   }
 

+ 5 - 1
frontend/src/components/ui/notifications/types.ts

@@ -4,7 +4,11 @@ export interface NotificationEventConfig {
   key: string;
   label: string;
   settingKey: string;
-  extra?: (props: { value: number; onChange: (v: number | null) => void; ariaLabel: string }) => ReactNode;
+  extra?: (props: {
+    value: number;
+    onChange: (v: number | null) => void;
+    ariaLabel: string;
+  }) => ReactNode;
 }
 
 export interface NotificationGroupConfig {

+ 12 - 3
frontend/src/components/utility/LazyMount.stories.tsx

@@ -18,7 +18,9 @@ const meta = {
     },
   },
   argTypes: {
-    when: { description: 'Children mount the first time this becomes true and stay mounted afterwards.' },
+    when: {
+      description: 'Children mount the first time this becomes true and stay mounted afterwards.',
+    },
     fallback: { description: 'Suspense fallback shown while a React.lazy child is still loading.' },
     children: { description: 'Content to mount on demand, typically a lazily imported modal.' },
   },
@@ -53,7 +55,8 @@ function OnDemandDemo() {
         </Card>
       </LazyMount>
       <Typography.Text type="secondary">
-        The card mounts the first time the switch turns on and stays mounted after turning it off; the mount time never changes.
+        The card mounts the first time the switch turns on and stays mounted after turning it off;
+        the mount time never changes.
       </Typography.Text>
     </Space>
   );
@@ -67,7 +70,13 @@ const xrayConfigSnippet = JSON.stringify(
         protocol: 'vless',
         port: 443,
         settings: {
-          clients: [{ id: 'b831381d-6324-4d53-ad4f-8cda48b30811', email: '[email protected]', flow: 'xtls-rprx-vision' }],
+          clients: [
+            {
+              id: 'b831381d-6324-4d53-ad4f-8cda48b30811',
+              email: '[email protected]',
+              flow: 'xtls-rprx-vision',
+            },
+          ],
           decryption: 'none',
         },
         streamSettings: { network: 'tcp', security: 'reality' },

+ 4 - 1
frontend/src/components/viz/Sparkline.stories.tsx

@@ -2,7 +2,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
 
 import Sparkline from './Sparkline';
 
-const wave = Array.from({ length: 48 }, (_, i) => 45 + Math.round(28 * Math.sin(i / 4) + (i % 5) * 3));
+const wave = Array.from(
+  { length: 48 },
+  (_, i) => 45 + Math.round(28 * Math.sin(i / 4) + (i % 5) * 3),
+);
 const inverse = wave.map((v) => Math.max(0, 100 - v));
 
 const meta = {

+ 25 - 7
frontend/src/components/viz/Sparkline.tsx

@@ -85,7 +85,10 @@ function hexToRgba(color: string, alpha: number): string {
   const trimmed = color.trim();
   const fn = trimmed.match(/^rgba?\(([^)]+)\)$/i);
   if (fn) {
-    const parts = fn[1].split(/[,/]\s*|\s+/).filter(Boolean).map(Number);
+    const parts = fn[1]
+      .split(/[,/]\s*|\s+/)
+      .filter(Boolean)
+      .map(Number);
     if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) {
       const baseAlpha = parts.length > 3 && Number.isFinite(parts[3]) ? parts[3] : 1;
       return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${baseAlpha * alpha})`;
@@ -94,7 +97,11 @@ function hexToRgba(color: string, alpha: number): string {
   }
   let h = trimmed;
   if (h.startsWith('#')) h = h.slice(1);
-  if (h.length === 3) h = h.split('').map((c) => c + c).join('');
+  if (h.length === 3)
+    h = h
+      .split('')
+      .map((c) => c + c)
+      .join('');
   if (h.length !== 6) return trimmed;
   const int = Number.parseInt(h, 16);
   if (Number.isNaN(int)) return trimmed;
@@ -110,11 +117,14 @@ function cssVar(el: HTMLElement, name: string, fallback: string): string {
 }
 
 function parseDash(dash: string, dpr: number): number[] {
-  return dash.trim().split(/\s+/).map((n) => (Number(n) || 0) * dpr);
+  return dash
+    .trim()
+    .split(/\s+/)
+    .map((n) => (Number(n) || 0) * dpr);
 }
 
 function dprOf(u: uPlot): number {
-  return u.width > 0 ? u.ctx.canvas.width / u.width : (uPlot.pxRatio || 1);
+  return u.width > 0 ? u.ctx.canvas.width / u.width : uPlot.pxRatio || 1;
 }
 
 export default function Sparkline(props: SparklineProps) {
@@ -427,7 +437,9 @@ export default function Sparkline(props: SparklineProps) {
       }
       const pt = v.points[idx];
       const fmt = p.tooltipFormatter ?? p.yFormatter ?? ((x: number) => String(x));
-      const label = p.tooltipLabelFormatter ? p.tooltipLabelFormatter(String(pt.label)) : String(pt.label);
+      const label = p.tooltipLabelFormatter
+        ? p.tooltipLabelFormatter(String(pt.label))
+        : String(pt.label);
       const multi = hasSeries2 || hasSeries3;
 
       tooltipEl.textContent = '';
@@ -567,7 +579,11 @@ export default function Sparkline(props: SparklineProps) {
   }, []);
 
   return (
-    <div className="sparkline-container" role={ariaSummary ? 'img' : undefined} aria-label={ariaSummary || undefined}>
+    <div
+      className="sparkline-container"
+      role={ariaSummary ? 'img' : undefined}
+      aria-label={ariaSummary || undefined}
+    >
       {extremaPoints && (
         <div className="sparkline-extrema" aria-hidden="true">
           <span className="extrema-item" style={{ color: maxColor }}>
@@ -581,7 +597,9 @@ export default function Sparkline(props: SparklineProps) {
       {showLegend && legendItems.length > 0 && (
         <div className="sparkline-legend" aria-hidden="true">
           {legendItems.map((s) => (
-            <span key={s.name} className="extrema-item" style={{ color: s.color }}>● {s.name}</span>
+            <span key={s.name} className="extrema-item" style={{ color: s.color }}>
+              ● {s.name}
+            </span>
           ))}
         </div>
       )}

+ 387 - 235
frontend/src/hooks/useClients.ts

@@ -80,9 +80,16 @@ export interface ClientQueryParams {
 
 const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
 const DEFAULT_SUMMARY: ClientsSummary = {
-  total: 0, active: 0,
-  onlineCount: 0, depletedCount: 0, expiringCount: 0, deactiveCount: 0,
-  online: [], depleted: [], expiring: [], deactive: [],
+  total: 0,
+  active: 0,
+  onlineCount: 0,
+  depletedCount: 0,
+  expiringCount: 0,
+  deactiveCount: 0,
+  online: [],
+  depleted: [],
+  expiring: [],
+  deactive: [],
 };
 
 export interface ClientSpeedEntry {
@@ -129,7 +136,9 @@ function buildQS(p: ClientQueryParams): string {
 
 async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageResponse> {
   const qs = buildQS(params);
-  const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
+  const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, {
+    silent: true,
+  });
   if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
   const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged', { strict: true });
   if (!validated.obj) throw new Error('Empty clients response');
@@ -144,7 +153,9 @@ async function fetchInboundOptions(): Promise<InboundOption[]> {
 }
 
 async function fetchDefaults(): Promise<Record<string, unknown>> {
-  const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, { silent: true });
+  const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, {
+    silent: true,
+  });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
   const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
   return validated.obj || {};
@@ -173,24 +184,25 @@ export function useClients(options: UseClientsOptions = {}) {
   const setQuery = useCallback((next: ClientQueryParams) => {
     setQueryState((prev) => {
       if (
-        prev
-        && prev.page === next.page
-        && prev.pageSize === next.pageSize
-        && (prev.search ?? '') === (next.search ?? '')
-        && (prev.filter ?? '') === (next.filter ?? '')
-        && (prev.protocol ?? '') === (next.protocol ?? '')
-        && (prev.inbound ?? '') === (next.inbound ?? '')
-        && (prev.sort ?? '') === (next.sort ?? '')
-        && (prev.order ?? '') === (next.order ?? '')
-        && (prev.expiryFrom ?? 0) === (next.expiryFrom ?? 0)
-        && (prev.expiryTo ?? 0) === (next.expiryTo ?? 0)
-        && (prev.usageFrom ?? 0) === (next.usageFrom ?? 0)
-        && (prev.usageTo ?? 0) === (next.usageTo ?? 0)
-        && (prev.autoRenew ?? '') === (next.autoRenew ?? '')
-        && (prev.hasTgId ?? '') === (next.hasTgId ?? '')
-        && (prev.hasComment ?? '') === (next.hasComment ?? '')
-        && (prev.group ?? '') === (next.group ?? '')
-      ) return prev;
+        prev &&
+        prev.page === next.page &&
+        prev.pageSize === next.pageSize &&
+        (prev.search ?? '') === (next.search ?? '') &&
+        (prev.filter ?? '') === (next.filter ?? '') &&
+        (prev.protocol ?? '') === (next.protocol ?? '') &&
+        (prev.inbound ?? '') === (next.inbound ?? '') &&
+        (prev.sort ?? '') === (next.sort ?? '') &&
+        (prev.order ?? '') === (next.order ?? '') &&
+        (prev.expiryFrom ?? 0) === (next.expiryFrom ?? 0) &&
+        (prev.expiryTo ?? 0) === (next.expiryTo ?? 0) &&
+        (prev.usageFrom ?? 0) === (next.usageFrom ?? 0) &&
+        (prev.usageTo ?? 0) === (next.usageTo ?? 0) &&
+        (prev.autoRenew ?? '') === (next.autoRenew ?? '') &&
+        (prev.hasTgId ?? '') === (next.hasTgId ?? '') &&
+        (prev.hasComment ?? '') === (next.hasComment ?? '') &&
+        (prev.group ?? '') === (next.group ?? '')
+      )
+        return prev;
       return next;
     });
   }, []);
@@ -251,24 +263,27 @@ export function useClients(options: UseClientsOptions = {}) {
   const onlines = useMemo(() => onlinesQuery.data ?? [], [onlinesQuery.data]);
 
   const defaults = defaultsQuery.data ?? {};
-  const subSettings: SubSettings = useMemo(() => ({
-    enable: !!defaults.subEnable,
-    subURI: (defaults.subURI as string) || '',
-    subJsonURI: (defaults.subJsonURI as string) || '',
-    subJsonEnable: !!defaults.subJsonEnable,
-    subClashURI: (defaults.subClashURI as string) || '',
-    subClashEnable: !!defaults.subClashEnable,
-    publicHost: (defaults.subDomain as string) || (defaults.webDomain as string) || '',
-  }), [
-    defaults.subEnable,
-    defaults.subURI,
-    defaults.subJsonURI,
-    defaults.subJsonEnable,
-    defaults.subClashURI,
-    defaults.subClashEnable,
-    defaults.subDomain,
-    defaults.webDomain,
-  ]);
+  const subSettings: SubSettings = useMemo(
+    () => ({
+      enable: !!defaults.subEnable,
+      subURI: (defaults.subURI as string) || '',
+      subJsonURI: (defaults.subJsonURI as string) || '',
+      subJsonEnable: !!defaults.subJsonEnable,
+      subClashURI: (defaults.subClashURI as string) || '',
+      subClashEnable: !!defaults.subClashEnable,
+      publicHost: (defaults.subDomain as string) || (defaults.webDomain as string) || '',
+    }),
+    [
+      defaults.subEnable,
+      defaults.subURI,
+      defaults.subJsonURI,
+      defaults.subJsonEnable,
+      defaults.subClashURI,
+      defaults.subClashEnable,
+      defaults.subDomain,
+      defaults.webDomain,
+    ],
+  );
 
   const ipLimitEnable = !!defaults.ipLimitEnable;
   const tgBotEnable = !!defaults.tgBotEnable;
@@ -284,17 +299,14 @@ export function useClients(options: UseClientsOptions = {}) {
   const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
   const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
 
-  const invalidateAll = useCallback(
-    () => {
-      markLocalInvalidate();
-      return Promise.all([
-        queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
-        queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
-        queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
-      ]);
-    },
-    [queryClient],
-  );
+  const invalidateAll = useCallback(() => {
+    markLocalInvalidate();
+    return Promise.all([
+      queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
+      queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
+      queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
+    ]);
+  }, [queryClient]);
 
   const refresh = useCallback(async () => {
     await invalidateAll();
@@ -311,25 +323,33 @@ export function useClients(options: UseClientsOptions = {}) {
   const createMut = useMutation({
     mutationFn: (payload: unknown) =>
       HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS),
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const bulkAddToGroupMut = useMutation({
     mutationFn: (body: { emails: string[]; group: string }) =>
       HttpUtil.post('/panel/api/clients/groups/bulkAdd', body, JSON_HEADERS),
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const bulkRemoveFromGroupMut = useMutation({
     mutationFn: (body: { emails: string[] }) =>
       HttpUtil.post('/panel/api/clients/groups/bulkRemove', body, JSON_HEADERS),
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const updateMut = useMutation({
     mutationFn: ({ email, client }: { email: string; client: unknown }) =>
       HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const removeMut = useMutation({
@@ -339,15 +359,22 @@ export function useClients(options: UseClientsOptions = {}) {
         : `/panel/api/clients/del/${encodeURIComponent(email)}`;
       return HttpUtil.post(url);
     },
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const bulkDeleteMut = useMutation({
-    mutationFn: async (payload: { emails: string[]; keepTraffic?: boolean }): Promise<Msg<BulkDeleteResult>> => {
+    mutationFn: async (payload: {
+      emails: string[];
+      keepTraffic?: boolean;
+    }): Promise<Msg<BulkDeleteResult>> => {
       const raw = await HttpUtil.post('/panel/api/clients/bulkDel', payload, JSON_HEADERS);
       return parseMsg(raw, BulkDeleteResultSchema, 'clients/bulkDel');
     },
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const bulkCreateMut = useMutation({
@@ -355,70 +382,121 @@ export function useClients(options: UseClientsOptions = {}) {
       const raw = await HttpUtil.post('/panel/api/clients/bulkCreate', payloads, JSON_HEADERS);
       return parseMsg(raw, BulkCreateResultSchema, 'clients/bulkCreate');
     },
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const bulkAdjustMut = useMutation({
-    mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number; flow: string }): Promise<Msg<BulkAdjustResult>> => {
+    mutationFn: async (payload: {
+      emails: string[];
+      addDays: number;
+      addBytes: number;
+      flow: string;
+    }): Promise<Msg<BulkAdjustResult>> => {
       const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
       return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
     },
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const bulkSetEnableMut = useMutation({
-    mutationFn: async (payload: { emails: string[]; enable: boolean }): Promise<Msg<BulkSetEnableResult>> => {
-      const path = payload.enable ? '/panel/api/clients/bulkEnable' : '/panel/api/clients/bulkDisable';
+    mutationFn: async (payload: {
+      emails: string[];
+      enable: boolean;
+    }): Promise<Msg<BulkSetEnableResult>> => {
+      const path = payload.enable
+        ? '/panel/api/clients/bulkEnable'
+        : '/panel/api/clients/bulkDisable';
       const raw = await HttpUtil.post(path, { emails: payload.emails }, JSON_HEADERS);
-      return parseMsg(raw, BulkSetEnableResultSchema, payload.enable ? 'clients/bulkEnable' : 'clients/bulkDisable');
+      return parseMsg(
+        raw,
+        BulkSetEnableResultSchema,
+        payload.enable ? 'clients/bulkEnable' : 'clients/bulkDisable',
+      );
+    },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
     },
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
   const attachMut = useMutation({
     mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
-      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, { ...JSON_HEADERS, silentSuccess: true }),
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+      HttpUtil.post(
+        `/panel/api/clients/${encodeURIComponent(email)}/attach`,
+        { inboundIds },
+        { ...JSON_HEADERS, silentSuccess: true },
+      ),
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const setExternalLinksMut = useMutation({
     mutationFn: ({ email, externalLinks }: { email: string; externalLinks: ExternalLinkInput[] }) =>
-      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`, { externalLinks }, { ...JSON_HEADERS, silentSuccess: true }),
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+      HttpUtil.post(
+        `/panel/api/clients/${encodeURIComponent(email)}/externalLinks`,
+        { externalLinks },
+        { ...JSON_HEADERS, silentSuccess: true },
+      ),
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const bulkAttachMut = useMutation({
-    mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkAttachResult>> => {
+    mutationFn: async (payload: {
+      emails: string[];
+      inboundIds: number[];
+    }): Promise<Msg<BulkAttachResult>> => {
       const raw = await HttpUtil.post('/panel/api/clients/bulkAttach', payload, JSON_HEADERS);
       return parseMsg(raw, BulkAttachResultSchema, 'clients/bulkAttach');
     },
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const detachMut = useMutation({
     mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
-      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, { ...JSON_HEADERS, silentSuccess: true }),
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+      HttpUtil.post(
+        `/panel/api/clients/${encodeURIComponent(email)}/detach`,
+        { inboundIds },
+        { ...JSON_HEADERS, silentSuccess: true },
+      ),
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
-
   const bulkDetachMut = useMutation({
-    mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkDetachResult>> => {
+    mutationFn: async (payload: {
+      emails: string[];
+      inboundIds: number[];
+    }): Promise<Msg<BulkDetachResult>> => {
       const raw = await HttpUtil.post('/panel/api/clients/bulkDetach', payload, JSON_HEADERS);
       return parseMsg(raw, BulkDetachResultSchema, 'clients/bulkDetach');
     },
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const resetTrafficMut = useMutation({
     mutationFn: (email: string) =>
       HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`),
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const resetAllTrafficsMut = useMutation({
     mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics'),
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const delDepletedMut = useMutation({
@@ -426,7 +504,9 @@ export function useClients(options: UseClientsOptions = {}) {
       const raw = await HttpUtil.post('/panel/api/clients/delDepleted');
       return parseMsg(raw, DelDepletedResultSchema, 'clients/delDepleted');
     },
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const delOrphansMut = useMutation({
@@ -434,7 +514,9 @@ export function useClients(options: UseClientsOptions = {}) {
       const raw = await HttpUtil.post('/panel/api/clients/delOrphans');
       return parseMsg(raw, DelDepletedResultSchema, 'clients/delOrphans');
     },
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const importClientsMut = useMutation({
@@ -442,76 +524,137 @@ export function useClients(options: UseClientsOptions = {}) {
       const raw = await HttpUtil.post('/panel/api/clients/import', { data }, JSON_HEADERS);
       return parseMsg(raw, BulkCreateResultSchema, 'clients/import');
     },
-    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+    onSuccess: (msg) => {
+      if (msg?.success) invalidateAll();
+    },
   });
 
   const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]);
-  const update = useCallback((email: string, client: unknown) => {
-    if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
-    return updateMut.mutateAsync({ email, client });
-  }, [updateMut]);
-  const remove = useCallback((email: string, keepTraffic = false) => {
-    if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
-    return removeMut.mutateAsync({ email, keepTraffic });
-  }, [removeMut]);
-  const bulkDelete = useCallback((emails: string[], keepTraffic = false) => {
-    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkDeleteResult>);
-    return bulkDeleteMut.mutateAsync({ emails, keepTraffic });
-  }, [bulkDeleteMut]);
-  const bulkCreate = useCallback((payloads: unknown[]) => {
-    if (!Array.isArray(payloads) || payloads.length === 0) return Promise.resolve(null as unknown as Msg<BulkCreateResult>);
-    return bulkCreateMut.mutateAsync(payloads);
-  }, [bulkCreateMut]);
-  const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number, flow = '') => {
-    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
-    return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow });
-  }, [bulkAdjustMut]);
-  const bulkEnable = useCallback((emails: string[]) => {
-    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
-    return bulkSetEnableMut.mutateAsync({ emails, enable: true });
-  }, [bulkSetEnableMut]);
-  const bulkDisable = useCallback((emails: string[]) => {
-    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
-    return bulkSetEnableMut.mutateAsync({ emails, enable: false });
-  }, [bulkSetEnableMut]);
-  const bulkAddToGroup = useCallback((emails: string[], group: string) => {
-    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
-    return bulkAddToGroupMut.mutateAsync({ emails, group });
-  }, [bulkAddToGroupMut]);
-  const bulkRemoveFromGroup = useCallback((emails: string[]) => {
-    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
-    return bulkRemoveFromGroupMut.mutateAsync({ emails });
-  }, [bulkRemoveFromGroupMut]);
-  const attach = useCallback((email: string, inboundIds: number[]) => {
-    if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
-    return attachMut.mutateAsync({ email, inboundIds });
-  }, [attachMut]);
-  const setExternalLinks = useCallback((email: string, externalLinks: ExternalLinkInput[]) => {
-    if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
-    return setExternalLinksMut.mutateAsync({ email, externalLinks });
-  }, [setExternalLinksMut]);
-  const bulkAttach = useCallback((emails: string[], inboundIds: number[]) => {
-    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
-    if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
-    return bulkAttachMut.mutateAsync({ emails, inboundIds });
-  }, [bulkAttachMut]);
-  const detach = useCallback((email: string, inboundIds: number[]) => {
-    if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
-    return detachMut.mutateAsync({ email, inboundIds });
-  }, [detachMut]);
-  const bulkDetach = useCallback((emails: string[], inboundIds: number[]) => {
-    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
-    if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
-    return bulkDetachMut.mutateAsync({ emails, inboundIds });
-  }, [bulkDetachMut]);
-  const resetTraffic = useCallback((client: ClientRecord) => {
-    if (!client?.email) return Promise.resolve(null as unknown as Msg<unknown>);
-    return resetTrafficMut.mutateAsync(client.email);
-  }, [resetTrafficMut]);
-  const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);
+  const update = useCallback(
+    (email: string, client: unknown) => {
+      if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
+      return updateMut.mutateAsync({ email, client });
+    },
+    [updateMut],
+  );
+  const remove = useCallback(
+    (email: string, keepTraffic = false) => {
+      if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
+      return removeMut.mutateAsync({ email, keepTraffic });
+    },
+    [removeMut],
+  );
+  const bulkDelete = useCallback(
+    (emails: string[], keepTraffic = false) => {
+      if (!Array.isArray(emails) || emails.length === 0)
+        return Promise.resolve(null as unknown as Msg<BulkDeleteResult>);
+      return bulkDeleteMut.mutateAsync({ emails, keepTraffic });
+    },
+    [bulkDeleteMut],
+  );
+  const bulkCreate = useCallback(
+    (payloads: unknown[]) => {
+      if (!Array.isArray(payloads) || payloads.length === 0)
+        return Promise.resolve(null as unknown as Msg<BulkCreateResult>);
+      return bulkCreateMut.mutateAsync(payloads);
+    },
+    [bulkCreateMut],
+  );
+  const bulkAdjust = useCallback(
+    (emails: string[], addDays: number, addBytes: number, flow = '') => {
+      if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
+      return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow });
+    },
+    [bulkAdjustMut],
+  );
+  const bulkEnable = useCallback(
+    (emails: string[]) => {
+      if (!Array.isArray(emails) || emails.length === 0)
+        return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
+      return bulkSetEnableMut.mutateAsync({ emails, enable: true });
+    },
+    [bulkSetEnableMut],
+  );
+  const bulkDisable = useCallback(
+    (emails: string[]) => {
+      if (!Array.isArray(emails) || emails.length === 0)
+        return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
+      return bulkSetEnableMut.mutateAsync({ emails, enable: false });
+    },
+    [bulkSetEnableMut],
+  );
+  const bulkAddToGroup = useCallback(
+    (emails: string[], group: string) => {
+      if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
+      return bulkAddToGroupMut.mutateAsync({ emails, group });
+    },
+    [bulkAddToGroupMut],
+  );
+  const bulkRemoveFromGroup = useCallback(
+    (emails: string[]) => {
+      if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
+      return bulkRemoveFromGroupMut.mutateAsync({ emails });
+    },
+    [bulkRemoveFromGroupMut],
+  );
+  const attach = useCallback(
+    (email: string, inboundIds: number[]) => {
+      if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
+      return attachMut.mutateAsync({ email, inboundIds });
+    },
+    [attachMut],
+  );
+  const setExternalLinks = useCallback(
+    (email: string, externalLinks: ExternalLinkInput[]) => {
+      if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
+      return setExternalLinksMut.mutateAsync({ email, externalLinks });
+    },
+    [setExternalLinksMut],
+  );
+  const bulkAttach = useCallback(
+    (emails: string[], inboundIds: number[]) => {
+      if (!Array.isArray(emails) || emails.length === 0)
+        return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
+      if (!Array.isArray(inboundIds) || inboundIds.length === 0)
+        return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
+      return bulkAttachMut.mutateAsync({ emails, inboundIds });
+    },
+    [bulkAttachMut],
+  );
+  const detach = useCallback(
+    (email: string, inboundIds: number[]) => {
+      if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
+      return detachMut.mutateAsync({ email, inboundIds });
+    },
+    [detachMut],
+  );
+  const bulkDetach = useCallback(
+    (emails: string[], inboundIds: number[]) => {
+      if (!Array.isArray(emails) || emails.length === 0)
+        return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
+      if (!Array.isArray(inboundIds) || inboundIds.length === 0)
+        return Promise.resolve(null as unknown as Msg<BulkDetachResult>);
+      return bulkDetachMut.mutateAsync({ emails, inboundIds });
+    },
+    [bulkDetachMut],
+  );
+  const resetTraffic = useCallback(
+    (client: ClientRecord) => {
+      if (!client?.email) return Promise.resolve(null as unknown as Msg<unknown>);
+      return resetTrafficMut.mutateAsync(client.email);
+    },
+    [resetTrafficMut],
+  );
+  const resetAllTraffics = useCallback(
+    () => resetAllTrafficsMut.mutateAsync(),
+    [resetAllTrafficsMut],
+  );
   const delDepleted = useCallback(() => delDepletedMut.mutateAsync(), [delDepletedMut]);
   const delOrphans = useCallback(() => delOrphansMut.mutateAsync(), [delOrphansMut]);
-  const importClients = useCallback((data: string) => importClientsMut.mutateAsync(data), [importClientsMut]);
+  const importClients = useCallback(
+    (data: string) => importClientsMut.mutateAsync(data),
+    [importClientsMut],
+  );
   // Fetch the exported clients so the page can show them in a CodeMirror viewer
   // (Copy / Download), rather than triggering an immediate browser download.
   const exportClients = useCallback(async (): Promise<unknown[] | null> => {
@@ -520,104 +663,113 @@ export function useClients(options: UseClientsOptions = {}) {
     return Array.isArray(msg.obj) ? msg.obj : [];
   }, []);
 
-  const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
-    if (!client?.email) return null;
-    const full = await hydrate(client.email);
-    const base = full?.client;
-    if (!base) return null;
-    const payload: Record<string, unknown> = {
-      email: base.email,
-      subId: base.subId,
-      id: base.uuid,
-      password: base.password,
-      auth: base.auth,
-      flow: base.flow || '',
-      security: base.security || 'auto',
-      totalGB: base.totalGB || 0,
-      expiryTime: base.expiryTime || 0,
-      limitIp: base.limitIp || 0,
-      limitHwid: base.limitHwid || 0,
-      tgId: Number(base.tgId) || 0,
-      reset: Number(base.reset) || 0,
-      resetDay: Number(base.resetDay) || 0,
-      resetMax: Number(base.resetMax) || 0,
-      group: base.group || '',
-      comment: base.comment || '',
-      enable: !!enable,
-    };
-    if (base.reverse?.tag) {
-      payload.reverse = { tag: base.reverse.tag };
-    }
-    return update(client.email, payload);
-  }, [hydrate, update]);
+  const setEnable = useCallback(
+    async (client: ClientRecord, enable: boolean) => {
+      if (!client?.email) return null;
+      const full = await hydrate(client.email);
+      const base = full?.client;
+      if (!base) return null;
+      const payload: Record<string, unknown> = {
+        email: base.email,
+        subId: base.subId,
+        id: base.uuid,
+        password: base.password,
+        auth: base.auth,
+        flow: base.flow || '',
+        security: base.security || 'auto',
+        totalGB: base.totalGB || 0,
+        expiryTime: base.expiryTime || 0,
+        limitIp: base.limitIp || 0,
+        limitHwid: base.limitHwid || 0,
+        tgId: Number(base.tgId) || 0,
+        reset: Number(base.reset) || 0,
+        resetDay: Number(base.resetDay) || 0,
+        resetMax: Number(base.resetMax) || 0,
+        group: base.group || '',
+        comment: base.comment || '',
+        enable: !!enable,
+      };
+      if (base.reverse?.tag) {
+        payload.reverse = { tag: base.reverse.tag };
+      }
+      return update(client.email, payload);
+    },
+    [hydrate, update],
+  );
 
   // WS-driven in-place merges. Page wires these via useWebSocket; the bridge
   // covers coarse 'invalidate' and 'inbounds' events centrally.
   const queryRef = useRef(query);
   queryRef.current = query;
 
-  const applyTrafficEvent = useCallback((payload: unknown) => {
-    if (!payload || typeof payload !== 'object') return;
-    const p = payload as {
-      onlineClients?: string[];
-      clientTraffics?: { email: string; up: number; down: number }[];
-    };
-    if (Array.isArray(p.onlineClients)) {
-      queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
-    }
-    if (Array.isArray(p.clientTraffics)) {
-      // Xray reports a row per client whether or not it moved a byte, so most of
-      // this map used to be zeros. A missing entry and a zero entry render
-      // identically (isActiveSpeed treats both as inactive), so the zeros are
-      // dropped and an unchanged result returns the previous object — which lets
-      // React bail out of the update instead of re-rendering the table.
-      const next: Record<string, ClientSpeedEntry> = {};
-      for (const ct of p.clientTraffics) {
-        if (!ct || !ct.email) continue;
-        const up = ct.up || 0;
-        const down = ct.down || 0;
-        if (up === 0 && down === 0) continue;
-        next[ct.email] = {
-          up: up / TRAFFIC_POLL_INTERVAL_S,
-          down: down / TRAFFIC_POLL_INTERVAL_S,
-        };
+  const applyTrafficEvent = useCallback(
+    (payload: unknown) => {
+      if (!payload || typeof payload !== 'object') return;
+      const p = payload as {
+        onlineClients?: string[];
+        clientTraffics?: { email: string; up: number; down: number }[];
+      };
+      if (Array.isArray(p.onlineClients)) {
+        queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
       }
-      setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
-    }
-  }, [queryClient]);
+      if (Array.isArray(p.clientTraffics)) {
+        // Xray reports a row per client whether or not it moved a byte, so most of
+        // this map used to be zeros. A missing entry and a zero entry render
+        // identically (isActiveSpeed treats both as inactive), so the zeros are
+        // dropped and an unchanged result returns the previous object — which lets
+        // React bail out of the update instead of re-rendering the table.
+        const next: Record<string, ClientSpeedEntry> = {};
+        for (const ct of p.clientTraffics) {
+          if (!ct || !ct.email) continue;
+          const up = ct.up || 0;
+          const down = ct.down || 0;
+          if (up === 0 && down === 0) continue;
+          next[ct.email] = {
+            up: up / TRAFFIC_POLL_INTERVAL_S,
+            down: down / TRAFFIC_POLL_INTERVAL_S,
+          };
+        }
+        setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
+      }
+    },
+    [queryClient],
+  );
 
-  const applyClientStatsEvent = useCallback((payload: unknown) => {
-    if (!payload || typeof payload !== 'object') return;
-    const p = payload as { clients?: ClientStatRow[] };
-    if (!Array.isArray(p.clients) || p.clients.length === 0) return;
-    const active = queryRef.current;
-    if (!active) return;
-    const byEmail = new Map<string, ClientTraffic>();
-    for (const row of p.clients) {
-      if (row && row.email) byEmail.set(row.email, row);
-    }
-    queryClient.setQueryData<ClientPageResponse>(keys.clients.list(active), (prev) => {
-      if (!prev) return prev;
-      let touched = false;
-      const next = prev.items.slice();
-      for (let i = 0; i < next.length; i++) {
-        const row = next[i];
-        const upd = byEmail.get(row?.email);
-        if (!upd) continue;
-        const merged: ClientTraffic = { ...(row.traffic || {}) };
-        if (typeof upd.up === 'number') merged.up = upd.up;
-        if (typeof upd.down === 'number') merged.down = upd.down;
-        if (typeof upd.total === 'number') merged.total = upd.total;
-        if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
-        if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
-        if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
-        next[i] = { ...row, traffic: merged };
-        touched = true;
+  const applyClientStatsEvent = useCallback(
+    (payload: unknown) => {
+      if (!payload || typeof payload !== 'object') return;
+      const p = payload as { clients?: ClientStatRow[] };
+      if (!Array.isArray(p.clients) || p.clients.length === 0) return;
+      const active = queryRef.current;
+      if (!active) return;
+      const byEmail = new Map<string, ClientTraffic>();
+      for (const row of p.clients) {
+        if (row && row.email) byEmail.set(row.email, row);
       }
-      if (!touched) return prev;
-      return { ...prev, items: next };
-    });
-  }, [queryClient]);
+      queryClient.setQueryData<ClientPageResponse>(keys.clients.list(active), (prev) => {
+        if (!prev) return prev;
+        let touched = false;
+        const next = prev.items.slice();
+        for (let i = 0; i < next.length; i++) {
+          const row = next[i];
+          const upd = byEmail.get(row?.email);
+          if (!upd) continue;
+          const merged: ClientTraffic = { ...(row.traffic || {}) };
+          if (typeof upd.up === 'number') merged.up = upd.up;
+          if (typeof upd.down === 'number') merged.down = upd.down;
+          if (typeof upd.total === 'number') merged.total = upd.total;
+          if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
+          if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
+          if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
+          next[i] = { ...row, traffic: merged };
+          touched = true;
+        }
+        if (!touched) return prev;
+        return { ...prev, items: next };
+      });
+    },
+    [queryClient],
+  );
 
   useEffect(() => {
     queryRef.current = query;

+ 8 - 3
frontend/src/hooks/useServerDraft.ts

@@ -1,6 +1,10 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 
-export function useServerDraft<T>(server: T | undefined, clone: (value: T) => T, equals: (left: T, right: T) => boolean) {
+export function useServerDraft<T>(
+  server: T | undefined,
+  clone: (value: T) => T,
+  equals: (left: T, right: T) => boolean,
+) {
   const cloneRef = useRef(clone);
   const equalsRef = useRef(equals);
   cloneRef.current = clone;
@@ -17,8 +21,9 @@ export function useServerDraft<T>(server: T | undefined, clone: (value: T) => T,
     if (server === undefined) return;
     const currentDraft = draftRef.current;
     const currentBaseline = baselineRef.current;
-    const isDirty = currentDraft !== undefined
-      && (currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline));
+    const isDirty =
+      currentDraft !== undefined &&
+      (currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline));
     setBaseline(server);
     if (isDirty && !equalsRef.current(currentDraft, server)) return;
     setDraft(cloneRef.current(server));

+ 157 - 115
frontend/src/hooks/useXraySetting.ts

@@ -26,7 +26,10 @@ function normalizeOutboundTestUrl(url: string) {
 }
 
 export function isUdpOutbound(outbound: unknown): boolean {
-  const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
+  const o = outbound as
+    | { protocol?: string; streamSettings?: { network?: string } }
+    | null
+    | undefined;
   const p = o?.protocol;
   const n = o?.streamSettings?.network;
   return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
@@ -90,7 +93,8 @@ type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
 export async function fetchXrayConfig(): Promise<XrayConfigPayload> {
   const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
-  if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
+  if (typeof msg.obj !== 'string')
+    throw new Error('Malformed xray config response: expected string');
   let parsed: unknown;
   try {
     parsed = JSON.parse(msg.obj);
@@ -107,7 +111,9 @@ export async function fetchXrayConfig(): Promise<XrayConfigPayload> {
 }
 
 async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
-  const msg = await HttpUtil.get('/panel/api/xray/getOutboundsTraffic', undefined, { silent: true });
+  const msg = await HttpUtil.get('/panel/api/xray/getOutboundsTraffic', undefined, {
+    silent: true,
+  });
   if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
   const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
   return Array.isArray(validated.obj) ? validated.obj : [];
@@ -137,10 +143,14 @@ export function useXraySetting(): UseXraySettingResult {
   const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
   const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
   const [subscriptionOutboundTags, setSubscriptionOutboundTags] = useState<string[]>([]);
-  const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
+  const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>(
+    {},
+  );
   // Subscription outbounds aren't in templateSettings.outbounds, so their test
   // results are keyed by tag rather than by index.
-  const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
+  const [subscriptionTestStates, setSubscriptionTestStates] = useState<
+    Record<string, OutboundTestState>
+  >({});
   const [testingAll, setTestingAll] = useState(false);
 
   const syncingRef = useRef(false);
@@ -167,8 +177,9 @@ export function useXraySetting(): UseXraySettingResult {
     setClientReverseTags(obj.clientReverseTags || []);
     setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
     setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
-    const isDirty = savedXraySettingRef.current !== xraySettingRef.current
-      || savedOutboundTestUrlRef.current !== normalizeOutboundTestUrl(outboundTestUrlRef.current);
+    const isDirty =
+      savedXraySettingRef.current !== xraySettingRef.current ||
+      savedOutboundTestUrlRef.current !== normalizeOutboundTestUrl(outboundTestUrlRef.current);
     if (isDirty) return;
     syncingRef.current = true;
     setXraySettingState(pretty);
@@ -242,8 +253,7 @@ export function useXraySetting(): UseXraySettingResult {
   });
 
   const resetTrafficMut = useMutation({
-    mutationFn: (tag: string) =>
-      HttpUtil.post('/panel/api/xray/resetOutboundsTraffic', { tag }),
+    mutationFn: (tag: string) => HttpUtil.post('/panel/api/xray/resetOutboundsTraffic', { tag }),
     onSuccess: (msg) => {
       if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
     },
@@ -262,9 +272,18 @@ export function useXraySetting(): UseXraySettingResult {
     },
   });
 
-  const saveAll = useCallback(async () => { await saveMut.mutateAsync(); }, [saveMut]);
-  const resetOutboundsTraffic = useCallback(async (tag: string) => { await resetTrafficMut.mutateAsync(tag); }, [resetTrafficMut]);
-  const resetToDefault = useCallback(async () => { await resetDefaultMut.mutateAsync(); }, [resetDefaultMut]);
+  const saveAll = useCallback(async () => {
+    await saveMut.mutateAsync();
+  }, [saveMut]);
+  const resetOutboundsTraffic = useCallback(
+    async (tag: string) => {
+      await resetTrafficMut.mutateAsync(tag);
+    },
+    [resetTrafficMut],
+  );
+  const resetToDefault = useCallback(async () => {
+    await resetDefaultMut.mutateAsync();
+  }, [resetDefaultMut]);
 
   const spinning = saveMut.isPending || resetDefaultMut.isPending;
 
@@ -285,7 +304,9 @@ export function useXraySetting(): UseXraySettingResult {
         const msg = parseMsg(raw, OutboundTestResultListSchema, 'xray/testOutbounds');
         if (!msg?.success || !Array.isArray(msg.obj)) return failAll(msg?.msg || 'Unknown error');
         const list = msg.obj;
-        return outbounds.map((_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode });
+        return outbounds.map(
+          (_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode },
+        );
       } catch (e) {
         return failAll(String(e));
       }
@@ -325,113 +346,134 @@ export function useXraySetting(): UseXraySettingResult {
     [postOutboundTestBatch],
   );
 
-  const testAllOutbounds = useCallback(async (mode = 'tcp') => {
-    // Template outbounds key their results by index (outboundTestStates);
-    // subscription outbounds aren't in the template, so they key by tag
-    // (subscriptionTestStates). Both go through the same probe endpoint.
-    const templateList = templateSettingsRef.current?.outbounds || [];
-    const subList = (subscriptionOutboundsRef.current || []) as Array<{ tag?: string; protocol?: string }>;
-    if ((templateList.length === 0 && subList.length === 0) || testingAll) return;
-    setTestingAll(true);
-    try {
-      type TcpEntry =
-        | { kind: 'tpl'; index: number; outbound: unknown }
-        | { kind: 'sub'; tag: string; outbound: unknown };
-      const tcpQueue: TcpEntry[] = [];
-      // HTTP batches stay homogeneous (all template or all subscription) so a
-      // tag shared between a template and a subscription outbound can't collide
-      // inside one batch, and each batch's results route to one state map.
-      const probeMode = mode === 'real' ? 'real' : 'http';
-      const httpTplQueue: { index: number; outbound: unknown }[] = [];
-      const httpSubQueue: { tag: string; outbound: unknown }[] = [];
-      const enqueue = (ob: { tag?: string; protocol?: string }, kind: 'tpl' | 'sub', index: number, tag: string) => {
-        const proto = ob?.protocol;
-        if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
-        // freedom ("direct") and dns aren't proxies — skip them in every mode.
-        if (proto === 'freedom' || proto === 'dns') return;
-        if (kind === 'sub' && !tag) return;
-        const toHttp = mode !== 'tcp' || isUdpOutbound(ob);
-        if (kind === 'tpl') {
-          if (toHttp) httpTplQueue.push({ index, outbound: ob });
-          else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
-        } else if (toHttp) {
-          httpSubQueue.push({ tag, outbound: ob });
-        } else {
-          tcpQueue.push({ kind: 'sub', tag, outbound: ob });
-        }
-      };
-      templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, ''));
-      subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : ''));
-
-      // TCP probes are dial-only and cheap server-side; per-item requests
-      // keep results landing one by one, each routed to its own state map.
-      const runTcpLane = async () => {
-        const queue = [...tcpQueue];
-        const worker = async () => {
-          while (queue.length > 0) {
-            const item = queue.shift();
-            if (!item) break;
-            if (item.kind === 'sub') await testSubscriptionOutbound(item.tag, item.outbound, mode);
-            else await testOutbound(item.index, item.outbound, mode);
+  const testAllOutbounds = useCallback(
+    async (mode = 'tcp') => {
+      // Template outbounds key their results by index (outboundTestStates);
+      // subscription outbounds aren't in the template, so they key by tag
+      // (subscriptionTestStates). Both go through the same probe endpoint.
+      const templateList = templateSettingsRef.current?.outbounds || [];
+      const subList = (subscriptionOutboundsRef.current || []) as Array<{
+        tag?: string;
+        protocol?: string;
+      }>;
+      if ((templateList.length === 0 && subList.length === 0) || testingAll) return;
+      setTestingAll(true);
+      try {
+        type TcpEntry =
+          | { kind: 'tpl'; index: number; outbound: unknown }
+          | { kind: 'sub'; tag: string; outbound: unknown };
+        const tcpQueue: TcpEntry[] = [];
+        // HTTP batches stay homogeneous (all template or all subscription) so a
+        // tag shared between a template and a subscription outbound can't collide
+        // inside one batch, and each batch's results route to one state map.
+        const probeMode = mode === 'real' ? 'real' : 'http';
+        const httpTplQueue: { index: number; outbound: unknown }[] = [];
+        const httpSubQueue: { tag: string; outbound: unknown }[] = [];
+        const enqueue = (
+          ob: { tag?: string; protocol?: string },
+          kind: 'tpl' | 'sub',
+          index: number,
+          tag: string,
+        ) => {
+          const proto = ob?.protocol;
+          if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
+          // freedom ("direct") and dns aren't proxies — skip them in every mode.
+          if (proto === 'freedom' || proto === 'dns') return;
+          if (kind === 'sub' && !tag) return;
+          const toHttp = mode !== 'tcp' || isUdpOutbound(ob);
+          if (kind === 'tpl') {
+            if (toHttp) httpTplQueue.push({ index, outbound: ob });
+            else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
+          } else if (toHttp) {
+            httpSubQueue.push({ tag, outbound: ob });
+          } else {
+            tcpQueue.push({ kind: 'sub', tag, outbound: ob });
           }
         };
-        await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
-      };
-      // HTTP probes go out as chunked batches — one temp xray spawn per
-      // chunk instead of one per outbound, with results landing per chunk.
-      const runTplHttpLane = async () => {
-        for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
-          const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
-          setOutboundTestStates((prev) => {
-            const next = { ...prev };
-            for (const item of chunk) next[item.index] = { testing: true, result: null, mode: probeMode };
-            return next;
-          });
-          const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), probeMode);
-          setOutboundTestStates((prev) => {
-            const next = { ...prev };
-            chunk.forEach((item, i) => {
-              next[item.index] = { testing: false, result: results[i] };
+        templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, ''));
+        subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : ''));
+
+        // TCP probes are dial-only and cheap server-side; per-item requests
+        // keep results landing one by one, each routed to its own state map.
+        const runTcpLane = async () => {
+          const queue = [...tcpQueue];
+          const worker = async () => {
+            while (queue.length > 0) {
+              const item = queue.shift();
+              if (!item) break;
+              if (item.kind === 'sub')
+                await testSubscriptionOutbound(item.tag, item.outbound, mode);
+              else await testOutbound(item.index, item.outbound, mode);
+            }
+          };
+          await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
+        };
+        // HTTP probes go out as chunked batches — one temp xray spawn per
+        // chunk instead of one per outbound, with results landing per chunk.
+        const runTplHttpLane = async () => {
+          for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
+            const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
+            setOutboundTestStates((prev) => {
+              const next = { ...prev };
+              for (const item of chunk)
+                next[item.index] = { testing: true, result: null, mode: probeMode };
+              return next;
             });
-            return next;
-          });
-        }
-      };
-      const runSubHttpLane = async () => {
-        for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) {
-          const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
-          setSubscriptionTestStates((prev) => {
-            const next = { ...prev };
-            for (const item of chunk) next[item.tag] = { testing: true, result: null, mode: probeMode };
-            return next;
-          });
-          const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), probeMode);
-          setSubscriptionTestStates((prev) => {
-            const next = { ...prev };
-            chunk.forEach((item, i) => {
-              next[item.tag] = { testing: false, result: results[i] };
+            const results = await postOutboundTestBatch(
+              chunk.map((c) => c.outbound),
+              probeMode,
+            );
+            setOutboundTestStates((prev) => {
+              const next = { ...prev };
+              chunk.forEach((item, i) => {
+                next[item.index] = { testing: false, result: results[i] };
+              });
+              return next;
             });
-            return next;
-          });
-        }
-      };
-      // HTTP batches must not overlap: the backend serialises them with a
-      // non-blocking lock and rejects a second concurrent batch ("Another
-      // outbound test is already running"). Run the template and subscription
-      // HTTP lanes one after the other; TCP probes don't take that lock, so
-      // they still run alongside.
-      const runHttpLane = async () => {
-        await runTplHttpLane();
-        await runSubHttpLane();
-      };
-      await Promise.all([runTcpLane(), runHttpLane()]);
-    } finally {
-      setTestingAll(false);
-    }
-  }, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
+          }
+        };
+        const runSubHttpLane = async () => {
+          for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) {
+            const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
+            setSubscriptionTestStates((prev) => {
+              const next = { ...prev };
+              for (const item of chunk)
+                next[item.tag] = { testing: true, result: null, mode: probeMode };
+              return next;
+            });
+            const results = await postOutboundTestBatch(
+              chunk.map((c) => c.outbound),
+              probeMode,
+            );
+            setSubscriptionTestStates((prev) => {
+              const next = { ...prev };
+              chunk.forEach((item, i) => {
+                next[item.tag] = { testing: false, result: results[i] };
+              });
+              return next;
+            });
+          }
+        };
+        // HTTP batches must not overlap: the backend serialises them with a
+        // non-blocking lock and rejects a second concurrent batch ("Another
+        // outbound test is already running"). Run the template and subscription
+        // HTTP lanes one after the other; TCP probes don't take that lock, so
+        // they still run alongside.
+        const runHttpLane = async () => {
+          await runTplHttpLane();
+          await runSubHttpLane();
+        };
+        await Promise.all([runTcpLane(), runHttpLane()]);
+      } finally {
+        setTestingAll(false);
+      }
+    },
+    [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch],
+  );
 
-  const saveDisabled = savedXraySetting === xraySetting
-    && savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
+  const saveDisabled =
+    savedXraySetting === xraySetting &&
+    savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
 
   const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
 

+ 7 - 2
frontend/src/i18n/react.ts

@@ -16,7 +16,10 @@ function moduleKeyFor(code: string): string {
 }
 
 let active: string = LanguageManager.getLanguage();
-if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
+if (
+  active !== FALLBACK &&
+  !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))
+) {
   active = FALLBACK;
 }
 
@@ -29,7 +32,9 @@ export async function readyI18n() {
     returnNull: false,
   });
   if (active !== FALLBACK) {
-    const loader = lazyModules[moduleKeyFor(active)] as (() => Promise<{ default: Record<string, unknown> }>) | undefined;
+    const loader = lazyModules[moduleKeyFor(active)] as
+      | (() => Promise<{ default: Record<string, unknown> }>)
+      | undefined;
     if (loader) {
       const mod = await loader();
       const messages = (mod.default ?? mod) as Record<string, unknown>;

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff