* fix(clients): fall back to inbound scan when ClientRecord is missing
DeleteByEmail looked up the email in client_records and returned the
raw "record not found" gorm error when nothing matched, even though
the client could still live inside an inbound's settings.clients JSON
(legacy entries that SyncInbound never picked up, or rows deleted out
from under a stale inbound). The user-visible delete then fails
mysteriously while xray happily keeps serving the client.
When GetRecordByEmail returns ErrRecordNotFound, walk inbounds whose
settings JSON references the email and run DelInboundClientByEmail on
each. The traffic / IP rows are cleaned up at the end unless keepTraffic
is set. If no inbound carries the email either, surface a clear
"client %q not found in any inbound or client record" error instead.
* chore(logging): include request + caller context in jsonMsgObj warnings
The generic "X-UI: Something went wrong. Error: record not found" log
gave no clue about which endpoint, client, or controller line emitted
it. Prepend a context block:
[POST /panel/api/clients/del/ADMIN ip=109.124.234.127
handler=controller.(*ClientController).delete client.go:146]
Handler frame is located by scanning the stack for the first caller
outside util.go, so it points at the right controller method whether
the path went through jsonMsg, jsonObj, or jsonMsgObj directly.
* fix(clients): tolerate orphan client_inbounds rows in Delete
DeleteByEmail's previous fix only covered the case where GetRecordByEmail
returned ErrRecordNotFound. When the ClientRecord exists but a client_inbounds
row points to an inbound that has been removed out-of-band (failed mid-delete,
manual SQL, pre-SyncInbound migration), Delete bubbled the raw gorm
"record not found" from inboundSvc.GetInbound and aborted before any cleanup
ran — leaving the client un-deletable through the UI/API.
Match the tolerance bulkDelInboundClients already has: when GetInbound
returns gorm.ErrRecordNotFound for a join row, log a warning and continue.
The unconditional Delete(&model.ClientInbound{}) later in the function then
removes the stale row, and the ClientRecord delete succeeds.
* fix(schemas): accept empty-string fingerprint on externalProxy
The External Proxy form offers a "Default" option with value '' for the
uTLS fingerprint dropdown, but UtlsFingerprintSchema.optional() rejects
empty strings (only undefined or a valid enum member). Saving an inbound
with externalProxy rows failed with `expected one of "360"|"chrome"|...`.
Preprocess '' to undefined before the optional enum, matching the existing
pattern used for VmessSecuritySchema.
* chore(logging): drop noisy orphan client_inbounds warning
Per-row WARNINGs spammed logs whenever a client referenced multiple
already-deleted inbounds. The continue keeps the orphan-tolerant
behavior; just no longer announces each skipped row.
* feat(clients): per-client VMess security in client form
Restores the VMess `security` selector on the client form (auto, aes-128-gcm,
chacha20-poly1305, none, zero) and surfaces it only when at least one attached
inbound is VMess. The value rides into the share link via the existing
`scy=` field in genVmessLink; the panel persists it on ClientRecord and in
the inbound's settings.clients so the link generator can read it back.
Adds the pages.clients.vmessSecurity i18n key in en-US and fa-IR.
* fix(xray-config): strip panel-only fields from inbound config
Two fields the panel stores but Xray doesn't accept on the inbound side:
- VMess clients[].security — panel persists it so the share-link generator
can write `scy=...`, but xray's vmess inbound spec has no per-client
security. The field was leaking into the inbound JSON pushed to xray-core.
- VLESS settings.encryption — per the xray spec the inbound only takes
`decryption`; `encryption` is for the matching client outbound. The panel
keeps it for operator reference, but it must not appear in the inbound
payload.
Add two strip helpers next to HealShadowsocksClientMethods and wire them
into GenXrayInboundConfig via a per-protocol switch, so both local and
remote runtime paths get the cleaned config.
* chore(db): backend-aware pool sizes with env overrides
Per-backend defaults:
- Postgres: 25 max open / 25 max idle. Matching idle to open removes
pool churn under bursts (Postgres handles concurrency at the server,
idle connections are cheap).
- SQLite: 1 max open / 1 max idle. Single-writer model means a wider
cap just queues behind busy_timeout; tight cap is honest.
Both back ends share ConnMaxLifetime=1h and ConnMaxIdleTime=30m so
stale connections (vault rotation, pgbouncer drops, load-balancer
idle eviction) rotate out without operator intervention.
Operators can override either default at boot via:
XUI_DB_MAX_OPEN_CONNS=...
XUI_DB_MAX_IDLE_CONNS=...
envInt parses these; missing/empty/non-positive values fall back to
the per-backend default.
* fix(schemas): accept boolean acceptProxyProtocol on TCP stream
TcpStreamSettingsSchema declared `acceptProxyProtocol: z.literal(true).optional()`,
so saving an inbound where the AntD Switch sat in the off state failed
validation with `Invalid input` because the Switch always emits a plain
boolean.
Switch to `z.boolean().default(false)` — same shape ws/sockopt/httpupgrade
already use, and matches the actual wire payload (golden fixtures and
other settings blocks all store `acceptProxyProtocol: false`).
Snapshots for stream.test and inbound-full.test pick up the new defaulted
field on TCP fixtures.
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
settingsisz.discriminatedUnion('protocol', […]), same for stream and security. - Three validation layers, non-overlapping:
- API boundary:
parseMsg(msg, schema, ctx)inside TanStack QueryqueryFn— 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)insidemutationFn— throws, because a malformed payload here is always a developer bug
- API boundary:
- No
.loose()or[key: string]: anyin production schemas.@typescript-eslint/no-explicit-any: erroris 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.
- Add the page component under
src/pages/<page>/. - Register it in
src/routes.tsxunder the/panel/...tree. - If you need a brand-new top-level bundle (login-style standalone
page), add the HTML at
frontend/<page>.html, an entry atsrc/entries/<page>.tsx, and register it inrollupOptions.inputinvite.config.js. Then add the Go controller call toserveDistPage(c, "<page>.html").