3x-ui/frontend
MHSanaei 66f5026356
feat(clients): compact link + inbound rows in the info modal and table
ClientInfoModal — Copy URL section reskinned:
- Each link is a single row: [PROTOCOL] [remark] [copy] [QR]
  instead of a card with the raw 200-char URL printed inline
- Remark is parsed per-protocol — VMess pulls it from the
  base64-JSON `ps` field, the rest from the `#fragment`
- The row title strips the client email suffix so the same
  string isn't repeated three times in the modal; the QR
  popover still uses the full remark (it's the QR's own name
  for the download file)
- QR button opens an inline Popover with the existing QrPanel,
  size 220, destroyed on close
- Subscription section uses the same row layout (SUB / JSON
  tags, clickable subId, copy + QR actions)
- New per-protocol Tag colors so the protocol is identifiable
  at a glance

ClientInfoModal — Attached inbounds + ClientsPage table column:
- Chip format changed from `${remark} (${proto}:${port})` to
  just `${proto}:${port}` — when an admin attaches 5 inbounds
  to one client the remark was repeated 5 times and wrapped onto
  two lines
- Only the first inbound chip is shown; the rest collapse into
  a `+N` chip that opens a Popover with the full list (remark
  included). INBOUND_CHIP_LIMIT = 1
- Per-protocol Tag colors
- Tooltip on each chip shows the full `${remark}
  (${proto}:${port})`
- Table column pinned to width: 170 so the row doesn't reserve
  the old 300px of whitespace next to the compact chip

Comment row in the info table is always shown now (renders `-`
when unset) so the layout doesn't jump per-client.

VmessSecuritySchema gets a preprocess pass that maps legacy
`security: ""` (persisted on pre-enum-lock VMess inbounds) back
to `'auto'`. z.enum's `.default()` only fires on a missing
field, not on an empty string — without this, old rows fail
validation with "expected one of aes-128-gcm|chacha20-poly1305|
auto|none|zero". `z.infer` is taken from the raw enum so the
inferred type stays the union, not `unknown`.

i18n adds a `more` key (en-US + fa-IR) used by the overflow
chip label.
2026-05-27 02:36:44 +02:00
..
public feat(clients): server-side bulk create/delete with per-inbound batching 2026-05-27 00:20:52 +02:00
scripts refactor(frontend): port api-docs/endpoints to TypeScript 2026-05-25 15:29:26 +02:00
src feat(clients): compact link + inbound rows in the info modal and table 2026-05-27 02:36:44 +02:00
.gitignore Vue3 migration (#4198) 2026-05-09 17:47:35 +02:00
eslint.config.js chore(frontend): enforce no-explicit-any: error + add typecheck/test to CI 2026-05-26 12:31:01 +02:00
eslint.deprecated.config.js docs(frontend): refresh README + simplify deprecated-scan config 2026-05-27 01:51:50 +02:00
index.html feat(inbound): Advanced XHTTP and external TLS proxy settings (#4491) 2026-05-24 21:54:26 +02:00
login.html Frontend rewrite: React + TypeScript with AntD v6 (#4498) 2026-05-23 15:21:45 +02:00
package-lock.json chore(frontend): bump deps + refresh lockfile 2026-05-27 02:00:30 +02:00
package.json chore(frontend): bump deps + refresh lockfile 2026-05-27 02:00:30 +02:00
README.md docs(frontend): refresh README + simplify deprecated-scan config 2026-05-27 01:51:50 +02:00
subpage.html Frontend rewrite: React + TypeScript with AntD v6 (#4498) 2026-05-23 15:21:45 +02:00
tsconfig.json Frontend rewrite: React + TypeScript with AntD v6 (#4498) 2026-05-23 15:21:45 +02:00
vite.config.js fix(vite): bypass es-toolkit CJS shim for recharts deep imports 2026-05-25 17:33:20 +02:00
vitest.config.ts refactor(frontend): extract genVmessLink to lib/xray/inbound-link.ts 2026-05-26 00:07:36 +02:00

3x-ui frontend

React 19 + Ant Design 6 + TypeScript + Vite 8. Three SPA bundles — index.html (admin panel SPA, all /panel/* routes), login.html (login + 2FA), and subpage.html (public subscription viewer). All three are built into ../web/dist/ and embedded into the Go binary via embed.FS.

State is split between local useState, TanStack Query for server state, and useTheme / useWebSocket contexts. Form validation, API parsing, and the xray config model all run through a single shared Zod schema tree (see Schemas).

Dev

npm install
npm run dev

Vite serves on http://localhost:5173/. API calls and /panel/* routes proxy to the Go panel at http://localhost:2053/, so start the Go panel first (go run main.go) and then Vite. The proxy auto-rewrites /panel, /panel/settings, /panel/inbounds, /panel/xray to the matching Vite-served HTML, so the sidebar's production-style links work without round-tripping through Go.

Scripts

Command What
npm run dev Vite dev server with API + WS proxy to Go
npm run build Regenerates OpenAPI + Zod, then builds into ../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 test Vitest single run (schema fixtures, link parsers, …)
npm run test:watch Vitest watch mode
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, and build on every PR (see ../.github/workflows/ci.yml).

One-off: scan for deprecated APIs

Run this command to sweep the codebase for usages of APIs marked with the JSDoc @deprecated tag (AntD prop renames, Zod renames, removed Web APIs, etc.):

npx eslint --config eslint.deprecated.config.js src

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.

Production build

npm run build

Outputs to ../web/dist/ (HTML at the root, hashed JS/CSS under assets/). manualChunks splits AntD, icons, codemirror, and react-query into separate vendor bundles to keep the per-page initial JS small. The Go binary embeds this directory at compile time and web/controller/dist.go serves the per-page HTML.

Layout

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
├── vitest.config.ts
├── vite.config.js
├── scripts/
│   └── build-openapi.mjs                 # endpoints.ts → openapi.json
└── src/
    ├── entries/         # Per-page bootstrap (createRoot + render)
    ├── main.tsx         # Shared root for the admin SPA (index.html)
    ├── routes.tsx       # react-router routes mounted under /panel/
    ├── pages/           # One folder per route, page component + helpers
    │   ├── index/, login/, inbounds/, clients/, xray/, nodes/,
    │   ├── settings/, api-docs/, sub/
    ├── layouts/         # AdminLayout (sidebar + header + outlet)
    ├── components/      # Cross-page React components
    ├── hooks/           # useClients, useTheme, useWebSocket, …
    ├── api/             # Axios + CSRF interceptor, TanStack Query bridge,
    │                    #   WebSocket client + queryClient.ts
    ├── i18n/            # react-i18next init (locales in web/translation/)
    ├── lib/xray/        # Pure functions: link generation, defaults,
    │                    #   form ⇄ wire adapters, protocol capabilities
    ├── schemas/         # Zod source-of-truth (see "Schemas" below)
    ├── generated/       # Code-generated zod + ts types from Go
    │                    #   (DO NOT hand-edit — regenerated by gen:zod)
    ├── models/          # Thin legacy types still in transit
    │                    #   (DBInbound, Status, AllSetting, reality-targets)
    ├── styles/          # Shared CSS modules
    ├── test/            # Vitest specs + golden fixtures
    │   ├── *.test.ts
    │   ├── __snapshots__/
    │   └── golden/fixtures/  # Per-(protocol × network × security) JSON
    └── utils/           # HttpUtil, ClipboardManager, SizeFormatter, …

Schemas

src/schemas/ is the single source of truth for the xray configuration model. Every API response is parsed through it, every form field is validated against it, and TypeScript types are inferred via z.infer<typeof X> — never hand-written.

schemas/
├── primitives/      # Atomic reusable schemas (port, protocol, sniffing, …)
├── api/             # Backend response shapes (e.g. SlimInboundSchema)
├── forms/           # User-facing form shapes (narrower than api/)
├── protocols/
│   ├── inbound/     # Per-protocol settings (vmess, vless, trojan, …)
│   ├── outbound/
│   ├── stream/      # Network transports (tcp, ws, grpc, xhttp, kcp, …)
│   └── security/    # TLS, Reality, none
├── client.ts, dns.ts, routing.ts, setting.ts, status.ts, xray.ts
└── _envelope.ts     # Generic `Msg<T>` envelope wrapper

Patterns:

  • Discriminated unions for polymorphic data — inbound settings is z.discriminatedUnion('protocol', […]), same for stream and security.
  • Three validation layers, non-overlapping:
    • API boundary: parseMsg(msg, schema, ctx) inside TanStack Query queryFn — warn-only in prod, throws in dev
    • Form input: antdRule(schema.shape.field) on every <Form.Item> — blocks submit + per-field inline error
    • 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.

Form pattern (Pattern A)

All non-trivial modals use this single pattern:

const [form] = Form.useForm<InboundFormValues>();

const onFinish = async () => {
  const values = await form.validateFields();
  await createInbound.mutateAsync(values);
};

<Form form={form} onFinish={onFinish}>
  <Form.Item
    name="port"
    label="Port"
    rules={[antdRule(InboundFormSchema.shape.port, t)]}
  >
    <InputNumber min={1} max={65535} />
  </Form.Item>
</Form>

No safeParse-on-submit handlers, no useRef<any> for form references, no inline z.string().min(1) in rules. Conditional fields use <Form.Item dependencies={...} shouldUpdate> with the nested protocol schema.

Testing

Vitest runs everything under src/test/. Schemas have golden fixture suites — one JSON per (protocol × network × security) combination round-tripped through schema.parse → link generator → snapshot. Regenerate snapshots after intentional changes:

npx vitest run -u

Fixtures live in src/test/golden/fixtures/ and are auto-discovered via import.meta.glob.

Adding a new page

Most new routes go inside the admin SPA (index.html) via routes.tsx — no new HTML or Vite entry needed.

  1. Add the page component under src/pages/<page>/.
  2. Register it in src/routes.tsx under the /panel/... tree.
  3. If you need a brand-new top-level bundle (login-style standalone page), add the HTML at frontend/<page>.html, an entry at src/entries/<page>.tsx, and register it in rollupOptions.input in vite.config.js. Then add the Go controller call to serveDistPage(c, "<page>.html").