Commit Graph

10 Commits

Author SHA1 Message Date
Sanaei
3f787ae169 feat: complete Zod migration of frontend + bulk client batching (#4599)
* feat(frontend): add Zod runtime validation at API boundary

Introduces Zod 4 schemas for response validation on the three highest-traffic
endpoints (server/status, nodes/list, setting/all) and a Zod->AntD form rule
adapter, replacing the duplicated per-file ApiMsg<T> interfaces. Validation
runs safeParse with console.warn + raw-payload fallback so backend drift never
breaks the UI for users.

Login form switches to schema-driven rules as the proof-of-life for the
adapter. Class-based models stay untouched; remaining query/mutation hooks
and form modals will migrate in follow-ups.

* feat(frontend): extend Zod validation to remaining query/mutation hooks

Adds Zod schemas for client/inbound/xray/node-probe endpoints and wires
useNodeMutations, useClients, useInbounds, useXraySetting, useDatepicker
through parseMsg. Drops the duplicated per-file ApiMsg<T> interfaces and
the local ClientRecord / OutboundTrafficRow / XraySettingsValue / DefaultsPayload
declarations in favour of schema-inferred types re-exported from the
new src/schemas/ modules.

API boundary now validates: clients list/paged, clients onlines,
clients lastOnline, clients get/hydrate, inbounds slim, inbounds get,
inbounds options, defaultSettings, xray config, xray outbounds traffic,
xray testOutbound, xray getXrayResult, getDefaultJsonConfig, nodes probe,
nodes test. Mutation responses that consume obj (bulkAdjust, delDepleted,
nodes probe / test) get response validation; pass-through mutations stay
agnostic. NodeFormModal type-aligned to Msg<ProbeResult>.

* fix(frontend): allow null slices in client/summary schemas

Go's encoding/json emits nil []T as null, not []. The initial
ClientPageResponseSchema and ClientHydrateSchema rejected null
inboundIds / summary.online / summary.depleted / etc., causing
[zod] warnings on every empty list.

Add nullableStringArray / nullableNumberArray helpers that accept
null and transform to [] so consuming code keeps seeing arrays.
Mark ClientRecord.traffic and .reverse nullable too (reverse is
explicitly null in MarshalJSON when storage is empty).

* fix(vite): treat /panel/xray as SPA page, not API root

The dev-server bypass classified /panel/xray as an API path because
the PANEL_API_PREFIXES matcher did `stripped === prefix.replace(/\/$/, '')`,
which made the bare path collide with the SPA route of the same name
(see web/controller/xui.go: g.GET("/xray", a.panelSPA)).

On reload, /panel/xray got proxied to the Go backend instead of being
served by Vite. The backend returned the embedded built index.html
with hashed asset names that the dev server doesn't have, so every
asset 404'd.

Prefix-only match for trailing-slash entries fixes it: panel/xray/...
still routes to the API, but panel/xray itself reaches the SPA branch.

* feat(frontend): drive form validation from Zod schemas

NodeFormModal — full conversion to AntD Form.useForm with antdRule
on every required field. Inline field errors replace the single
'fillRequired' toast. testConnection now runs validateFields(['address','port'])
before sending.

ClientFormModal and ClientBulkAddModal — minimal conversion: keep the
existing useState-driven controlled-component pattern, but replace the
hand-rolled `if (!form.x)` checks with schema.safeParse(form). The
schema is the single source of truth for required-ness and types;
ClientCreateFormSchema layers on the create-only `inboundIds.min(1)` rule.

New schemas (in src/schemas/):
  NodeFormSchema (node.ts)
  ClientFormSchema / ClientCreateFormSchema (client.ts)
  ClientBulkAddFormSchema (client.ts)

Other 16+ form modals stay on the current pattern — the antdRule adapter
ships from the first Zod pass for opportunistic migration as forms are
touched.

* chore(frontend): silence swagger-ui-react peer-dep warnings on React 19

swagger-ui-react@5.32.6 bundles three deps whose declared peer ranges
predate React 19:

  react-copy-to-clipboard@5.1.0 (peer 15-18)
  react-debounce-input@3.3.0     (peer 15-18, unmaintained)
  react-inspector@6.0.2          (peer 16-18)

For the first two, the actual code is React-19 compatible - only the
metadata is stale. Resolve via npm overrides:

- react-copy-to-clipboard bumped to ^5.1.1 (peer is open-ended >=15.3.0
  in that release).
- react-inspector bumped to ^9.0.0 (^8 was a broken publish per its own
  deprecation notice).
- react-debounce-input is wedged on 3.3.0 with no maintained successor
  on npm. Use the nested-override syntax to satisfy its react peer:

    "react-debounce-input": { "react": "^19.0.0" }

  That tells npm to use our React 19 for the package's peer dependency,
  which silences the warning without changing the package version.

* fix(vite): bypass es-toolkit CJS shim for recharts deep imports

The Nodes page (and any other recharts-using route) crashed in dev and
prod with TypeError: require_isUnsafeProperty is not a function.

Root cause: es-toolkit's package.json exports './compat/*' only via a
default condition pointing at the CJS shims under compat/<name>.js.
Those shims use a require_X.Y access pattern that Vite's optimizer
(Rolldown in Vite 8) and the production Rolldown build both mishandle,
losing the named-export accessor and calling the namespace object as
a function. recharts imports a dozen of these subpaths with default-
import syntax, so every chart path tripped the bug.

The matching ESM build at dist/compat/<category>/<name>.mjs is fine,
but it only carries a named export. Recharts uses default imports.

Plug a small Rollup-compatible plugin (enforce: 'pre') in front of
the resolver: any 'es-toolkit/compat/<name>' request becomes a virtual
module that imports the named symbol from the right .mjs file and
re-exports it as both default and named. The plugin is registered as
a top-level plugin (for the prod build) and via the new Vite 8
optimizeDeps.rolldownOptions.plugins (for the dev pre-bundler), so
both pipelines pick it up consistently.

* feat(frontend): migrate five secondary form modals to Zod schemas

Apply the schema + safeParse-on-submit pattern (introduced for
ClientFormModal / ClientBulkAddModal) to five more forms:

- ClientBulkAdjustModal: ClientBulkAdjustFormSchema enforces 'at least
  one of addDays / addGB is non-zero' via .refine(), replacing the
  ad-hoc days+gb check.
- BalancerFormModal: BalancerFormSchema covers tag and selector
  required-ness; the duplicate-tag check stays inline since it needs
  the otherTags prop. Per-field validateStatus now reads from the
  parsed issues map.
- RuleFormModal: RuleFormSchema captures the form shape (no required
  fields - every property is optional by design). safeParse short-
  circuits if anything is structurally wrong.
- CustomGeoFormModal: CustomGeoFormSchema folds the regex alias rule
  and the http(s) URL validation (including URL parse) into the
  schema, replacing a 20-line validate() function.
- TwoFactorModal: TotpCodeSchema (z.string().regex(/^\d{6}$/)) drives
  both the disabled-state of the OK button and the safeParse gate
  before the TOTP comparison.

Schemas live alongside the matching API schemas:
- ClientBulkAdjustFormSchema in schemas/client.ts
- BalancerFormSchema / RuleFormSchema / CustomGeoFormSchema in schemas/xray.ts
- TotpCodeSchema in schemas/login.ts (next to LoginFormSchema)

No UX change for valid inputs.

* feat(frontend): block invalid settings saves with Zod pre-save check

Tighten AllSettingSchema with the actual valid ranges and patterns:

- webPort / subPort / ldapPort: integer 1-65535
- pageSize: integer 1-1000
- sessionMaxAge: integer >= 1
- tgCpu: integer 0-100 (percentage)
- subUpdates: integer 1-168 (hours)
- expireDiff / trafficDiff / ldapDefault*: non-negative integers
- webBasePath / subPath / subJsonPath / subClashPath: must start with /

The existing useAllSettings save path runs AllSettingSchema.partial()
through safeParse and logs drift without blocking. SettingsPage now
adds a stronger gate before the mutation: run the full schema against
the draft and, on failure, surface the first issue (field path +
message) via the existing messageApi.error so the user actually sees
what's wrong instead of silently sending bad data to the backend.

Use cases caught: port out of range, negative quota, sub path missing
leading slash, page size set to 0, tgCpu > 100.

* feat(frontend): schema-guard Inbound and Outbound form submits

The two largest forms in the panel send to the backend without ever
checking their own port range or required-ness. Schema-gate the
top-level fields so obviously bad payloads stop at the client.

InboundFormModal: InboundFormSchema (port 1-65535 int, non-empty
protocol, the rest of the keys present) runs as a safeParse just
before the HttpUtil.post in submit(). The 2000+ lines of protocol-
specific subform code stay untouched - that's a separate effort and
the existing per-protocol logic (e.g. canEnableStream, isFallbackHost)
already gates most of the structural correctness.

OutboundFormModal: OutboundTagSchema (trim + min 1) replaces the
hand-rolled `if (!ob.tag?.trim()) messageApi.error('Tag is required')`
check. The duplicateTag check stays inline because it needs the
existingTags prop.

Both schemas emit i18n keys for messages with a defaultValue fallback,
matching the pattern in BalancerFormModal and SettingsPage.

* feat(backend): gate request bodies with go-playground/validator

Add a generic BindAndValidate helper in web/middleware that wraps gin's
content-aware binder with an explicit validator.Struct call and emits a
structured `entity.Msg{Obj: ValidationPayload{Issues...}}` on failure so
the frontend can map each issue to an i18n key.

Tag the user-facing fields on model.Inbound, model.Node, and
entity.AllSetting with the range/enum constraints they were previously
relying on hand-rolled CheckValid logic (or nothing) to enforce, and
wire the helper into the inbound/node/settings controllers that bind
those structs directly. Promotes validator/v10 from indirect to direct
require, plus six unit tests covering valid payloads, range violations,
enum violations, malformed JSON, in-place binding, and JSON-only strict
mode.

This is PR1 of a planned end-to-end Zod rollout — controllers using
local form structs (custom_geo, setEnable, fallbacks, client) keep
their existing handling and will be migrated as their schemas firm up.

* feat(codegen): Go-first tool emitting Zod schemas and TS types

Add tools/openapigen — a single-binary Go program that walks the
exported structs in database/model, web/entity, and xray via go/parser
and emits two committed artifacts under frontend/src/generated:

  - zod.ts   shared Zod schemas keyed off `validate:` tags (ports get
             .min(1).max(65535), Inbound.protocol becomes a z.enum,
             Node.scheme too, etc.)
  - types.ts plain TS interfaces inferred from the same walk, so
             consumers can import Inbound without dragging Zod along

The walker flattens embedded structs (AllSettingView.AllSetting),
honors json:"-" and omitempty, and accepts per-struct overrides so
the JSON-string-inside-JSON columns (Inbound.Settings/StreamSettings/
Sniffing, ClientRecord.Reverse, InboundClientIps.Ips) render as
z.unknown() instead of leaking the DB-storage type into the API
contract. Type aliases like model.Protocol are emitted as TS aliases
and Zod schemas in their own right.

Wires `npm run gen:zod` in frontend/package.json so the generator can
be re-run without leaving the frontend tree. The existing openapi.json
build (gen:api) is left alone for now; migrating the OpenAPI surface
to this generator is a follow-up.

PR2 of the planned Zod end-to-end rollout.

* refactor(frontend): tighten HttpUtil generics from any to unknown

Switch the class-level default on Msg<T> and the per-method defaults on
HttpUtil.get/post/postWithModal from `any` to `unknown`, so callers that
don't pass an explicit T get a narrowed response that must be schema-
checked or type-cast before its shape is trusted.

Drops the four file-level eslint-disable comments these defaults
required. Fixes the nine direct `.obj.field` consumers that surfaced
(IndexPage, XrayMetricsModal, NordModal, WarpModal, LogModal,
VersionModal, XrayLogModal, CustomGeoSection) by giving each call site
the explicit T it should have had from the start — typically a small
ad-hoc shape, sometimes a string for the JSON-text-in-Msg.obj pattern
used by NordModal/WarpModal/Xray nord/warp endpoints.

PR3 of the planned Zod end-to-end rollout — schemas/inbound.ts and
schemas/client.ts loose() removal stays parked until the protocol
schemas land in Phase 3 to avoid silently dropping fields.

* feat(frontend): protocol-leaf Zod schemas with discriminated unions

Stand up schemas/primitives (Port, Flow, Protocol, Sniffing) and per-protocol
leaf schemas for all 10 inbound and 13 outbound xray protocols. The leaves
omit any inner `protocol` literal — the discriminator lives at the parent
level so consumers narrow on `.protocol` without redundant projection. Wire
shape is preserved per protocol: vmess outbound stays in `vnext[]`, trojan
and shadowsocks outbound in `servers[]`, vless outbound flat, http/socks
outbound in `servers[].users[]`.

Cross-protocol atoms (port, flow, sniffing dest, protocol enum) live in
primitives. Protocol-specific enums (vmess security, ss method/network,
hysteria version, freedom domain strategy, dns rule action) stay with their
leaves. Tagged-wrapper `z.discriminatedUnion('protocol', [...])` composes
both InboundSettingsSchema and OutboundSettingsSchema; existing class-based
models in src/models/ are untouched and will be retired in Step 3 once the
golden-file safety net is in place.

* feat(frontend): stream and security Zod families with discriminated unions

Stand up the remaining Step 2 families. NetworkSettingsSchema is a
6-branch DU on `network` covering tcp/kcp/ws/grpc/httpupgrade/xhttp, with
asymmetric per-network wire keys (tcpSettings, wsSettings, ...) preserved
exactly so fixtures round-trip byte-identical. SecuritySettingsSchema is a
3-branch DU on `security` covering none/tls/reality. TLS certs use a
file-vs-inline union; uTLS fingerprints are shared between TLS and Reality
via a single primitive enum.

Hysteria-as-network, finalmask, and sockopt are not in the plan's Step 2
inventory and are deferred to Step 6 (Tighten) - they're orthogonal extras
on the stream root, not network-discriminated branches.

Resolves a Security identifier collision in protocols/index.ts by
re-exporting the type alias as SecurityKind (the `Security` name is taken
by the namespace re-export).

* test(frontend): vitest harness with golden-file fixtures for inbound protocols

Stand up Phase 3 safety net before the models/ rewrite. The harness loads
JSON fixtures via Vite's import.meta.glob, parses each through
InboundSettingsSchema (the tagged-wrapper DU), and snapshots the canonical
parsed shape. Snapshots stay byte-stable across the upcoming class-to-
pure-function extraction, catching any normalization drift.

Six representative inbound fixtures cover the high-traffic protocols:
vless, vmess, trojan, shadowsocks (2022-blake3 multi-user), wireguard,
hysteria2. Stream and security branches plus the remaining protocols
(http, mixed, tunnel, hysteria) follow in subsequent turns.

Uses /// <reference types="vite/client" /> instead of @types/node so we
avoid pulling in another type package; import.meta.glob is enough to walk
the fixtures directory at compile time.

Adds vitest 4.1.7 as the only new dev dependency. test/test:watch scripts
land in package.json; a standalone vitest.config.ts keeps the production
vite.config.js (which reads from sqlite via DatabaseSync) out of the test
runner.

* test(frontend): broaden golden coverage to remaining inbounds + stream + security DUs

Round out Step 3b. Four more inbound fixtures complete the protocol set
(http with two accounts, mixed with socks-style auth, tunnel with a port
map, hysteria v1). Two parallel test files cover the other DUs:
stream.test.ts walks tcp/ws/grpc fixtures through NetworkSettingsSchema,
and security.test.ts walks none/tls/reality through SecuritySettingsSchema.

Snapshot count is now 16 across three test files. The reality fixture
locks in the array form of serverNames/shortIds (the panel class stores
them comma-joined internally but they ship as arrays on the wire). The
TLS fixture pins the file-vs-inline cert DU on the file branch.

Stream coverage for httpupgrade/xhttp/kcp and security mixed-with-stream
combos follow in the next turn, alongside the shadow harness.

* test(frontend): shadow-parse harness asserting legacy class and Zod converge

Add Step 3c's safety net: for every inbound golden fixture, run the raw
payload through both pipelines —

  legacy:  Inbound.Settings.fromJson(protocol, raw.settings).toJson()
  zod:     InboundSettingsSchema.parse(raw).settings

— canonicalize each (recursively sort keys, drop empty arrays / null /
undefined), and assert byte-equality. This locks the wire shape across the
upcoming class-to-pure-function extraction in Step 3d. Any normalization
drift introduced by the rewrite trips an assertion here before it can
reach users.

Two ergonomic wrinkles handled inline:
  - The legacy class lumps hysteria + hysteria2 onto a single
    HysteriaSettings (no hysteria2 case in the dispatch table); the test
    routes hysteria2 fixtures through the HYSTERIA branch.
  - Empty arrays in Zod's output (e.g. fallbacks: [] from a .default([]))
    are treated as equivalent to the legacy class's omit-when-empty
    behavior. Same wire state, different syntactic surface.

All 26 tests across 4 test files pass on first run.

* refactor(frontend): extract toHeaders + toV2Headers to lib/xray/headers.ts

First Step 3d extraction. The XrayCommonClass static helpers
toHeaders/toV2Headers are pure data shape conversions with no class
hierarchy needs, so they move to a standalone module that callers can
import without dragging in models/inbound.ts. The new module exports
HeaderEntry + V2HeaderMap as named types so consumers stop reaching into
the legacy class for type shapes.

A new test file (headers.test.ts) asserts byte-equality with the legacy
XrayCommonClass.toHeaders / .toV2Headers across 18 cases — null /
undefined / primitive inputs, single-string headers, array-valued
headers, duplicate names, empty-name and empty-value filtering, both
arr=true (TCP request/response shape) and arr=false (WS / xHTTP / sockopt
shape). Drift between the legacy and new impls fails these tests, so the
follow-up call-site swap stays safe.

Callers (TcpStreamSettings, WsStreamSettings, HTTPUpgradeStreamSettings,
TunnelSettings, etc.) still go through XrayCommonClass for now — those
swaps land alongside class-method extractions in subsequent turns.

Suite is now 44 tests across 5 files; typecheck + lint clean.

* refactor(frontend): extract createDefault*Client factories to lib/xray

Next Step 3d slice. Five plain-object factories — Vless, Vmess, Trojan,
Shadowsocks, Hysteria — replace the legacy
`new Inbound.<Protocol>Settings.<Protocol>(...)` constructor chain and the
ClientBase XrayCommonClass machinery. Each factory takes an optional
seed; missing random fields (id, password, auth, email, subId) fall
through to RandomUtil at call time. Forms can hand-pick a UUID; tests
pass deterministic seeds so the suite never touches window.crypto.

Tests double-verify each factory: a snapshot locks the exact shape, and
the matching Zod ClientSchema.parse(out) must equal `out` — no missing
defaults, no stray fields, type-narrowed end-to-end.

Discovered: VmessClientSchema and VlessClientSchema enforce z.uuid()
format, so the test seeds use real-shape UUIDs.

Suite: 49 tests across 6 files; typecheck + lint clean. Outbound and
inbound-settings factories follow in subsequent turns alongside the
toShareLink extraction.

* refactor(frontend): add createDefault*InboundSettings factories for all 10 protocols

Round out Step 3d's settings factory set. Ten plain-object factories
(vless / vmess / trojan / shadowsocks / hysteria / hysteria2 / http /
mixed / tunnel / wireguard) replace the legacy
`new Inbound.<X>Settings(protocol)` constructors. Each returns a Zod-
parsable wire shape with schema defaults applied — no class instance.
Forms (Step 4) and InboundsPage clone (Step 5) call these factories
directly once the swap lands.

Three factories take a seed for random fields:
  - shadowsocks: method-dependent password length via
    RandomUtil.randomShadowsocksPassword(method)
  - hysteria: explicit `version` override (defaults to 2, matching
    the legacy panel constructor — v1 is opt-in)
  - wireguard: secretKey from Wireguard.generateKeypair().privateKey

Tests double-verify each factory the same way as the client factories:
snapshot the shape, then Zod parse round-trip to confirm no missing
defaults or stray fields.

Suite: 59 tests across 6 files; typecheck + lint clean. Outbound
factories and the toShareLink extraction follow next.

* refactor(frontend): add getHeaderValue wire-shape lookup to lib/xray/headers

Tiny piece of the toShareLink scaffold. The legacy Inbound.getHeader(obj,
name) iterated the panel's internal HeaderEntry[] form; the new
getHeaderValue reads the Record<string, string|string[]> map our Zod
schemas store on the wire. Case-insensitive, returns '' on miss to match
the legacy fallback so link-generator call sites stay simple.

For repeated-name maps (TCP/WS-style string[] values) the first value
wins — matches the legacy iteration order so the share URL's Host hint
stays deterministic.

Five unit tests cover undefined/null/empty inputs, case folding,
string-valued and array-valued matches, empty-array edge case, and
missing-key fallback. Suite: 64 tests across 6 files; typecheck + lint
clean.

This unblocks the next slice: per-protocol link generators (genVmessLink
etc.) take a typed inbound + client and call getHeaderValue against the
ws/httpupgrade/xhttp/tcp.request header maps.

* feat(frontend): stream extras + full InboundSchema with DU intersection

Step 3d's last scaffolding piece before link generators. Three new
stream-extras schemas land alongside the network/security DUs:

  - finalmask: TcpMask[] + UdpMask[] + QuicParams. Mask `settings` stays
    record<string, unknown> for now — there are 13 UDP mask types and 3
    TCP mask types with distinct per-type setting shapes, and modeling
    them all as DUs would dwarf the rest of stream/ without buying
    anything the shadow harness doesn't already catch. Tightened in
    Step 6.
  - sockopt: 17 socket-tuning knobs (TCP keepalive, TFO, mark, tproxy,
    mptcp, dialer proxy, IPv6-only, congestion). `interfaceName` field
    matches the panel class naming; serializers rename to `interface` on
    the wire.
  - external-proxy: rows ship per inbound describing edge fronts (CDN
    mirrors). Used by link generators to fan out share URLs.

schemas/api/inbound.ts composes the top-level wire shape with
intersection-of-DUs:

  StreamSettingsSchema = NetworkSettingsSchema
    .and(SecuritySettingsSchema)
    .and(StreamExtrasSchema)

  InboundSchema = InboundCoreSchema.and(InboundSettingsSchema)

A fixture (vless-ws-tls.json) exercises the full shape — protocol DU,
network DU, security DU, and TLS cert file branch in one round trip.
The snapshot pins the canonical parsed form so the upcoming link
extractor consumes typed input with no class hierarchy underneath.

Suite: 65 tests across 7 files; typecheck + lint clean. Zod 4
intersection-of-DUs works.

* refactor(frontend): extract genVmessLink to lib/xray/inbound-link.ts

First link generator to leave the class hierarchy. genVmessLink takes a
typed Inbound + client args and returns the base64-encoded vmess://
URL. Internal helpers (buildXhttpExtra, applyXhttpExtraToObj,
applyFinalMaskToObj, applyExternalProxyTLSObj, serializeFinalMask,
hasShareableFinalMaskValue, externalProxyAlpn) port across from
XrayCommonClass — same logic, rewritten to read the Zod schemas'
Record<string, string> headers instead of the legacy HeaderEntry[].

Parity test (inbound-link.test.ts) loads each vmess fixture in
golden/fixtures/inbound-full, parses it with InboundSchema for the new
pure fn AND constructs LegacyInbound.fromJson(raw) for the class method,
then asserts the URLs match byte-for-byte. Drift between the two impls
fails here before the call sites in pages/inbounds/* get swapped.

Adds a small test setup file that aliases globalThis.window to globalThis
so Base64.encode's window.btoa works under Node — keeps the test env at
'node' and avoids pulling jsdom as a new dep.

A first vmess-tcp-tls full-inbound fixture pins the round-trip path.

Suite: 67 tests across 8 files; typecheck + lint clean. Five more link
generators (vless/trojan/ss/hysteria/wireguard) plus the orchestrator
(toShareLink, genAllLinks) follow in subsequent turns.

* test(frontend): refresh inbound-full snapshot with vmess-tcp-tls fixture

* refactor(frontend): extract genVlessLink to lib/xray/inbound-link

Second link generator. genVlessLink builds the
vless://<uuid>@<host>:<port>?<query>#<remark> share URL from a typed
Inbound + client args, dispatching on streamSettings.network for the
network-specific knobs and on streamSettings.security for the
TLS/Reality knobs. Three param-style helpers move alongside the obj-
style ones already in this file:

  - applyXhttpExtraToParams — writes path/host/mode/x_padding_bytes and
    the JSON extra blob into URLSearchParams
  - applyFinalMaskToParams — writes the fm payload when shareable
  - applyExternalProxyTLSParams — overrides sni/fp/alpn when an external
    proxy entry is supplied and security is tls

A vless-tcp-reality fixture lands alongside the existing vless-ws-tls
one, so the parity test now exercises both security branches.

Discovered a latent legacy bug while writing parity: the old class
stored realitySettings.serverNames as a comma-joined string and gated
SNI on `!ObjectUtil.isArrEmpty(serverNames)`, which always returns true
for strings — so SNI was never written into Reality share URLs.
Existing clients rely on the omission (they pull SNI from
realitySettings.target instead). We preserve the omission here to keep
this extraction byte-stable; an inline comment marks the spot for a
separate intentional fix.

Suite: 70 tests across 8 files; typecheck + lint clean.

* refactor(frontend): extract genTrojanLink + genShadowsocksLink to lib/xray

Third and fourth link generators. genTrojanLink mirrors genVlessLink's
shape (URLSearchParams + network/security branches + remark hash) minus
the encryption/flow VLESS-isms. genShadowsocksLink shares the same query
construction but base64-encodes the userinfo portion as method:password
or method:settingsPw:clientPw depending on whether SS-2022 is in
single-user or multi-user mode.

Three reusable helpers move out of the per-protocol functions:
  - writeNetworkParams: the per-network switch that all param-style
    links share (tcp http header / kcp mtu+tti / ws path+host /
    grpc serviceName+authority / httpupgrade / xhttp extras)
  - writeTlsParams: fingerprint/alpn/ech/sni
  - writeRealityParams: pbk/sid/spx/pqv (preserves the SNI-omission
    legacy parity quirk noted in the genVlessLink commit)

genVmessLink stays with its inline switch — it builds a JSON obj instead
of URLSearchParams and has per-network quirks (kcp emits mtu+tti at
the obj root, grpc maps multiMode to obj.type='multi') that don't
factor cleanly through the shared writer.

Two new full-inbound fixtures (trojan-ws-tls, shadowsocks-tcp-2022)
plus matching parity tests bring the suite to 74 tests across 8 files;
typecheck + lint clean.

* refactor(frontend): extract genHysteriaLink + Wireguard link/config to lib/xray

Fifth and sixth link generators. genHysteriaLink builds the v1/v2
share URL (scheme picked from settings.version), copying TLS knobs into
the query, surfacing the salamander obfs password from
finalmask.udp[type=salamander] when present, and writing the broader
finalmask payload under `fm` like the other links.

Legacy parity note: the old genHysteriaLink read
stream.tls.settings.allowInsecure, which isn't a field on
TlsStreamSettings.Settings — the guard always evaluated false and the
`insecure` param never made it into the URL. We omit it here to stay
byte-stable.

genWireguardLink and genWireguardConfig take a typed
WireguardInboundSettings + peer index and:

  - link: wireguard://<peerPriv>@host:port?publickey=&address=&mtu=#remark
  - config: the .conf text WireGuard clients consume directly

Both derive the server pubKey from settings.secretKey via
Wireguard.generateKeypair at call time — Zod stores only secretKey on
the wire (pubKey is computed). The Wireguard utility is pure JS (X25519
over Float64Array), so it runs fine under node + the window polyfill we
added with the vmess extraction.

Two new full-inbound fixtures (hysteria-v1-tls, wireguard-server) plus
matching parity tests bring the suite to 78 tests across 8 files;
typecheck + lint clean.

Hysteria2 (protocol literal) parity stays deferred — the legacy
class has no HYSTERIA2 dispatch case, so it can't round-trip a
hysteria2 fixture without a protocol remap. Same trick the shadow
harness uses; revisit in the orchestrator commit.

* refactor(frontend): extract share-link orchestrator to lib/xray/inbound-link

Last slice of Step 3d. Five orchestrator exports compose the per-
protocol generators into the public surface the panel consumes:

  - resolveAddr(inbound, hostOverride, fallbackHostname): picks the
    address that goes into share/sub URLs. Browser `location.hostname`
    is no longer a hidden dependency — callers pass it in (or any other
    fallback they want).
  - getInboundClients(inbound): protocol-aware clients accessor.
    Mirrors the legacy `Inbound.clients` getter, including the SS
    quirk where 2022-blake3-chacha20 single-user inbounds report null
    (no client loop) and everything else returns the clients array.
  - genLink: per-protocol dispatcher matching legacy Inbound.genLink.
  - genAllLinks: per-client fanout. Builds the remarkModel-formatted
    remark (separator + 'i'/'e'/'o' field picker) and iterates
    streamSettings.externalProxy when present.
  - genInboundLinks: top-level \r\n-joined link block. Loops per
    client for clientful protocols, single-shots SS for non-multi-user,
    and delegates to genWireguardConfigs for wireguard. Returns ''
    for http/mixed/tunnel (no share URL at all).

Plus genWireguardLinks / genWireguardConfigs fanouts which iterate
peers and append index-suffixed remarks.

Parity test exercises every full-inbound fixture against legacy
Inbound.genInboundLinks. Skips hysteria2 (no legacy dispatch case;
that bridge belongs in a separate intentional commit alongside the
form modal swap). Suite: 89 tests across 8 files; typecheck + lint
clean.

Next: Step 4 form modal migrations. Forms can now drop
`new Inbound.Settings.getSettings(protocol)` in favor of the
createDefault*InboundSettings factories, and InboundsPage clone can
swap to genInboundLinks. Models/ deletion follows in Step 5 once all
call sites are off the class.

* refactor(frontend): swap InboundsPage clone fallback off Inbound.Settings.getSettings

First Step 4 call-site swap. createDefaultInboundSettings(protocol) lands
in lib/xray/inbound-defaults — a protocol-aware dispatch over the 10
per-protocol settings factories already in this module. Returns a Zod-
parsable plain object instead of a class instance, so callers that just
need the wire-shape JSON can drop the class hierarchy without touching
the broader form modals.

InboundsPage's clone path used Inbound.Settings.getSettings(p).toString()
as the fallback when settings JSON parsing failed. That's now
createDefaultInboundSettings + JSON.stringify, with a final '{}' guard
for unknown protocols (legacy returned null and .toString() crashed —
we just emit empty settings instead). The Inbound import on this file
is now unused and removed.

The 2 remaining getSettings call sites in InboundFormModal aren't safe
to swap in isolation — the form mutates the returned class instance
through methods like .addClient() and .toJson() across ~2000 lines of
JSX. Those land with the full Pattern A rewrite of InboundFormModal,
which the plan budgets at multiple days on its own.

Suite: 89 tests across 8 files; typecheck + lint clean.

* refactor(frontend): lift Protocols + TLS_FLOW_CONTROL consts to schemas/primitives

Step 4b. The Protocols and TLS_FLOW_CONTROL enums on models/inbound.ts
were dragging five page files into that 3,300-line module just to read
literal string constants. Lifting them to schemas/primitives lets those
pages drop the @/models/inbound import entirely.

  - schemas/primitives/protocol.ts now exports a Protocols const map
    alongside the existing ProtocolSchema. TUN stays in the const for
    parity (legacy panel deployments may have saved TUN inbounds) even
    though the Go validator no longer accepts it as a new write.
  - schemas/primitives/flow.ts now exports TLS_FLOW_CONTROL. The
    empty-string default isn't keyed because the legacy never had a
    NONE entry — call sites compare against the two real flow values.

Updated five consumers:
  - useInbounds.ts: TRACKED_PROTOCOLS now annotated readonly string[]
    so .includes(string) keeps narrowing through the array literal
  - QrCodeModal.tsx, InboundInfoModal.tsx: Protocols
  - ClientFormModal.tsx, ClientBulkAddModal.tsx: TLS_FLOW_CONTROL

Suite: 89 tests across 8 files; typecheck + lint clean.

models/inbound.ts is now imported by:
  - InboundFormModal.tsx (heavy use of Inbound class + getSettings)
  - test/inbound-link.test.ts + test/shadow.test.ts + test/headers.test.ts
    (intentional — these are parity tests against the legacy class)

OutboundFormModal still imports from models/outbound. Both form modals
are the multi-day Pattern A rewrites the plan scopes separately.

* refactor(frontend): lift OutboundProtocols + OutboundDomainStrategies to schemas/primitives

Moves the two outbound-side consts out of models/outbound.ts and into
schemas/primitives/outbound-protocol.ts. Renames the export to
OutboundProtocols to disambiguate from the inbound Protocols const
(different key casing — PascalCase vs ALL CAPS — and partly different
member set, so they cannot share a single const).

OutboundsTab.tsx keeps its 15+ Protocols.X call sites by aliasing
the import. FinalMaskForm.tsx and BasicsTab.tsx swap directly.
Drops a stale `as string[]` cast in BasicsTab that no longer fits
the new readonly-tuple typing.

After this commit only the two big form modals
(InboundFormModal/OutboundFormModal) plus three intentional parity
tests still import from @/models/.

* refactor(frontend): lift outbound option dictionaries to schemas/primitives

Adds schemas/primitives/options.ts with UTLS_FINGERPRINT, ALPN_OPTION,
SNIFFING_OPTION, USERS_SECURITY, MODE_OPTION (all identical between
models/inbound.ts and models/outbound.ts) plus the outbound-only
WireguardDomainStrategy, Address_Port_Strategy, and DNSRuleActions.

OutboundFormModal now pulls 9 consts from primitives. Only `Outbound`
(the class) and `SSMethods` (whose inbound/outbound versions diverge by
2 legacy aliases — keep the picker open for the Pattern A rewrite) still
come from @/models/outbound.

Drops three stale `as string[]` casts on what are now readonly tuples.

* refactor(frontend): swap InboundFormModal option dicts to schemas/primitives

Extends primitives/options.ts with the five inbound-only option dicts
(TLS_VERSION_OPTION, TLS_CIPHER_OPTION, USAGE_OPTION,
DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION) and lifts InboundFormModal
off @/models/inbound for 10 of its 12 imports. Only the Inbound class
and SSMethods (inbound vs outbound versions diverge by 2 entries) still
come from @/models/.

Widens NODE_ELIGIBLE_PROTOCOLS Set element type to string since the new
primitives const exposes a narrow literal union that `.has(arbitraryString)`
would otherwise reject.

* feat(frontend): InboundFormValues schema for Pattern A rewrite

Foundation for the InboundFormModal rewrite. Mirrors the wire Inbound
shape (intersection of core fields + protocol settings DU + stream/security
DUs) plus the DB-side fields (up/down/total/trafficReset/nodeId/...) that
flow through DBInbound rather than the xray config slice.

InboundStreamFormSchema is exported separately so individual sub-form
sections can rule against just the stream portion when needed.

FallbackRowSchema is co-located here even though fallbacks save via a
distinct endpoint after the main POST — they belong to the same form
state from the user's perspective.

No modal changes in this commit. Foundation only; subsequent turns swap
the modal's `inboundRef`/`dbFormRef` mutable-class state for
Form.useForm<InboundFormValues>().

* feat(frontend): adapter between raw inbound rows and InboundFormValues

Adds lib/xray/inbound-form-adapter.ts with rawInboundToFormValues and
formValuesToWirePayload. The pair is the data boundary the upcoming
Pattern A modal will use: it consumes the DB row shape (settings et al.
as string OR object — coerced internally), hands the modal typed
InboundFormValues, and on submit reverses the trip to a wire payload
with the three JSON-stringified slices the Go endpoints expect.

No dependency on the legacy Inbound/DBInbound classes — the coerce step
is inlined so the adapter survives the eventual models/ deletion.

Adds 10 Vitest cases covering string vs object inputs, the optional
streamSettings/nodeId fields, trafficReset coercion, and a raw-to-payload
-to-raw round-trip equality.

* feat(frontend): protocol capability predicates as pure functions

Adds lib/xray/protocol-capabilities.ts with the seven predicates the
modals call: canEnableTls, canEnableReality, canEnableTlsFlow,
canEnableStream, canEnableVisionSeed, isSS2022, isSSMultiUser. Each
takes a minimal slice of an InboundFormValues, no class instance.

The legacy isSSMultiUser returns true on non-shadowsocks protocols too
(method getter resolves to "" which != blake3-chacha20-poly1305). The
new function preserves this quirk and documents it inline; callers all
narrow on protocol === shadowsocks before checking, so the surprising
return value never surfaces.

Parity harness in test/protocol-capabilities.test.ts crosses each of
the 10 golden fixtures with 14 stream configurations (network × security)
and asserts each predicate matches the legacy class method — 140 cases,
all green.

* feat(frontend): outbound settings factories + dispatcher

Adds lib/xray/outbound-defaults.ts parallel to inbound-defaults.ts:
13 createDefault*OutboundSettings factories (one per outbound protocol)
plus the createDefaultOutboundSettings(protocol) dispatcher mirroring
Outbound.Settings.getSettings's contract — non-null on each known
protocol, null otherwise.

The factory output matches the legacy `new Outbound.<X>Settings()` start
state: required-by-schema fields the user fills in via the form
(address, port, password, id, peer publicKey/endpoint) come back as
empty stubs. Wireguard alone seeds secretKey via the X25519 generator;
the rest expose blank fields. This is the same behavior the
OutboundFormModal relies on for protocol-change resets.

Shadowsocks defaults to 2022-blake3-aes-128-gcm rather than the legacy
undefined — the Select snaps to the first option anyway, so the
coherent default keeps the modal from rendering an empty picker.

Tests cover three layers:
- exact-shape snapshots per factory (13 cases)
- Zod schema acceptance after sensible stub fill-in (13 cases)
- dispatcher non-null per known protocol + null for the unknown (14 cases)

* feat(frontend): InboundFormModal.new.tsx skeleton (Pattern A)

First commit of the sibling-file modal rewrite. The new modal mounts
Form.useForm<InboundFormValues>, hydrates via rawInboundToFormValues on
open (edit) or buildAddModeValues (add), runs validateFields + safeParse
on submit, and posts the formValuesToWirePayload result. No tabs yet —
the modal body shows a WIP placeholder.

The file is not imported anywhere; the existing InboundFormModal.tsx
remains the one InboundsPage renders. Build, lint, and 280 tests stay
green. Subsequent commits add the basic / sniffing / protocol / stream /
security / advanced / fallbacks sections; the atomic import swap in
InboundsPage.tsx lands last.

* feat(frontend): basic tab on InboundFormModal.new.tsx (Pattern A)

First real section of the sibling-file rewrite. Wires AntD Form.Items
to InboundFormValues paths for the basic tab — enable, remark, deployTo
(when protocol is node-eligible), protocol, listen, port, totalGB,
trafficReset, expireDate.

The port input gets a per-field antdRule against
InboundFormBaseSchema.shape.port — the spec's Pattern A reference. The
intersection-typed InboundFormSchema has no .shape accessor, so per-field
rules pull from the underlying ZodObject components.

totalGB and expireDate are bytes/timestamp on the wire but a GB number /
dayjs picker in the UI. Both use shouldUpdate-closure children that read
form state and call setFieldValue on user input — no transient
form-only fields, no DU-shape surprises at submit time.

Protocol-change cascade lives in Form's onValuesChange: pick a new
protocol and the settings DU branch is reset to
createDefaultInboundSettings(next); a non-node-eligible protocol also
clears nodeId.

Modal still renders a single-tab Tabs container. Sniffing tab is next.

* feat(frontend): sniffing tab on InboundFormModal.new.tsx (Pattern A)

Second section of the sibling-file rewrite. Wires the six sniffing
sub-fields to nested form paths ['sniffing', 'enabled'], ['sniffing',
'destOverride'], etc. Uses Form.useWatch on the enabled flag to drive
conditional rendering of the dependent fields — the same gate the
legacy modal expressed via `ib.sniffing.enabled &&`.

Checkbox.Group renders one Checkbox per SNIFFING_OPTION entry. The two
exclusion lists use Select mode="tags" so the user can paste comma-
separated IP/CIDR or domain rules.

No transient form state, no class methods — every field maps directly
to a wire-shape path in InboundFormValues.

Protocol tab is next.

* feat(frontend): protocol tab VLESS auth on InboundFormModal.new.tsx

Adds the protocol tab to the sibling-file rewrite — currently only the
VLESS section, which lays out decryption/encryption inputs and the three
buttons that drive them: Get New x25519, Get New mlkem768, Clear.

getNewVlessEnc + clearVlessEnc are ported from the legacy modal as
pure setFieldValue paths into ['settings', 'decryption'] /
['settings', 'encryption'] — no class methods, no inboundRef. The
matchesVlessAuth helper mirrors the legacy fuzzy label-matching so the
backend response shape stays the only source of truth.

selectedVlessAuth derives the displayed auth label from the encryption
string via Form.useWatch — same heuristic as the legacy modal
(.length > 300 → mlkem768, otherwise x25519).

Tab spread is conditional: the protocol tab only appears when
protocol === 'vless' right now. As more protocol sections land
(shadowsocks, http/mixed, tunnel, tun, wireguard) the condition will
widen to cover each one.

* feat(frontend): protocol tab Shadowsocks section (Pattern A)

Adds the Shadowsocks sub-form: method picker (from SSMethodSchema's
seven schema-aligned options), conditional password input gated on
isSS2022, network picker (tcp/udp/tcp,udp), ivCheck toggle.

Method change cascades through the Select's onChange — regenerating
the inbound-level password via RandomUtil.randomShadowsocksPassword.
The shadowsockses[] multi-user list reset is deferred until the
clients-management section lands.

Uses isSS2022 from lib/xray/protocol-capabilities to gate the password
field exactly the way the legacy modal did — keeps the form behavior
identical without referencing the legacy class.

SSMethodSchema.options drives the Select rather than the legacy
SSMethods const (which the inbound modal pulled from models/inbound.ts).
This commits to the schema-aligned 7-entry list for inbound; the
outbound divergence (9 entries with legacy aliases) is still pending
in OutboundFormModal — defer the UX decision to that rewrite.

* feat(frontend): protocol tab HTTP and Mixed sections (Pattern A)

Adds the HTTP and Mixed sub-forms. Both share an accounts list — first
Form.List usage in the rewrite. Each row binds via [field.name, 'user']
/ [field.name, 'pass'] under the parent ['settings', 'accounts'] path,
so the wire shape stays exactly what HttpInboundSettingsSchema and
MixedInboundSettingsSchema validate.

HTTP-only: allowTransparent Switch.
Mixed-only: auth Select (noauth/password), udp Switch, conditional ip
Input gated on the udp value via Form.useWatch.

Tab visibility widens to include http + mixed alongside vless +
shadowsocks. The string cast on the includes-check keeps the frozen
Protocols const's narrow union from rejecting the broader protocol
string at the call site.

* feat(frontend): protocol tab Tunnel section (Pattern A)

Adds the Tunnel sub-form: rewriteAddress + rewritePort, allowedNetwork
picker (tcp/udp/tcp,udp), Form.List-driven portMap with name/value
pairs, and the followRedirect Switch.

portMap is the second Form.List in the rewrite — same shape as the
HTTP/Mixed accounts list but with name/value rather than user/pass.
The wire shape stays `settings.portMap: { name, value }[]` exactly.

Tab visibility widens to Tunnel.

* feat(frontend): protocol tab TUN section (Pattern A)

Adds the TUN sub-form: interface name, MTU, four primitive-array
Form.Lists (gateway, dns, autoSystemRoutingTable), userLevel,
autoOutboundsInterface.

Primitive Form.Lists bind each row's Input directly to `field.name`
(no inner key) — distinct from the object-row Form.Lists that bind to
`[field.name, 'fieldKey']`.

The Form.useWatch('protocol') return type comes from the schema's
protocol enum which excludes 'tun' (TUN is in the legacy Protocols
const for data parity but never accepted by the wire validator). Cast
to string at the source so per-section comparisons against
Protocols.TUN typecheck. Why: legacy DB rows with protocol === 'tun'
still need to render; widening here keeps reads from rejecting them.

Tab visibility widens to TUN.

* feat(frontend): protocol tab Wireguard section (Pattern A)

Adds the Wireguard sub-form: server secretKey input with regen icon,
derived disabled public-key display, mtu, noKernelTun toggle, and a
Form.List of peers — each peer having its own privateKey (regen icon),
publicKey, preSharedKey, allowedIPs (nested Form.List for the string
array), keepAlive.

pubKey is purely derived (computed via Wireguard.generateKeypair from
the watched secretKey) and is NOT stored in the form value — the schema
omits it from the wire shape on purpose. The disabled display shows the
live derivation without polluting form state.

regenInboundWg generates a fresh keypair and writes only the
secretKey path; pubKey re-derives automatically. regenWgPeerKeypair
writes both privateKey and publicKey at the peer's path index.

The preSharedKey wire-shape name is used instead of the legacy class's
internal psk — matches WireguardInboundPeerSchema.

Tab visibility widens to Wireguard.

* feat(frontend): stream tab skeleton with TCP + KCP (Pattern A)

Opens the stream tab on the sibling-file rewrite. Tab visibility is
driven by canEnableStream from lib/xray/protocol-capabilities — same
gate the legacy modal used, now schema-aware.

Transmission picker (network select) is hidden for HYSTERIA since
that protocol's network is implicit. onNetworkChange clears any stale
per-network settings keys (tcpSettings/kcpSettings/...) and seeds an
empty object for the new branch so AntD Form.Items don't read from
undefined nested paths.

TCP section: acceptProxyProtocol Switch (literal-true-optional on the
wire — the form stores true/false but Zod's strip behavior keeps
false-as-omission round-trips clean) plus an HTTP-camouflage toggle
that flips header.type between 'none' and 'http'. The full HTTP
camouflage request/response sub-form lands in a follow-up commit.

KCP section: six numeric knobs (mtu, tti, upCap, downCap,
cwndMultiplier, maxSendingWindow).

WS / gRPC / HTTPUpgrade / XHTTP / external-proxy / sockopt / hysteria
stream / FinalMaskForm hookup all still pending.

* feat(frontend): stream tab WS + gRPC + HTTPUpgrade sections (Pattern A)

Adds the three medium-complexity network branches to the stream tab.
Plain Form.Item paths into the corresponding *Settings keys — no
Form.List wrappers since these schemas don't have arrays at the top
level.

WS: acceptProxyProtocol, host, path, heartbeatPeriod
gRPC: serviceName, authority, multiMode
HTTPUpgrade: acceptProxyProtocol, host, path

Header editing is deferred to a later commit — WsHeaderMap is a
Record<string,string> on the wire, V2HeaderMap a Record<string,string[]>,
and the form needs an array-of-{name,value} UI that converts on edit.
Worth building once and reusing across WS, HTTPUpgrade, XHTTP, TCP
request/response, and Hysteria masquerade headers.

XHTTP + external-proxy + sockopt + hysteria stream + finalmask hookup
still pending.

* feat(frontend): stream tab XHTTP section (Pattern A)

XHTTP is the heaviest network branch — 19 fields rendered conditionally
on mode, xPaddingObfsMode, and the three *Placement selectors. Each
gates its dependent field set via Form.useWatch.

Field structure mirrors the legacy XHTTPStreamSettings form 1:1:
- mode picker (auto / packet-up / stream-up / stream-one)
- packet-up adds scMaxBufferedPosts + scMaxEachPostBytes; stream-up
  adds scStreamUpServerSecs
- serverMaxHeaderBytes, xPaddingBytes, uplinkHTTPMethod (with the
  packet-up gate on the GET option)
- xPaddingObfsMode unlocks xPadding{Key,Header,Placement,Method}
- sessionPlacement / seqPlacement each unlock their respective Key
  field when set to anything other than 'path'
- packet-up mode additionally unlocks uplinkDataPlacement, and that
  in turn unlocks uplinkDataKey when the placement is not 'body'
- noSSEHeader Switch at the tail

XHTTP headers editor still pending (same WsHeaderMap as WS — will be
unified in the header-editor extraction commit).

* feat(frontend): stream tab external-proxy + sockopt sections (Pattern A)

External Proxy: Switch driven by externalProxy array length. Toggling
on seeds one row with the window hostname + the inbound's current port;
toggling off clears the array. Each row is a Form.List item with
forceTls/dest/port/remark inline, and a nested SNI/Fingerprint/ALPN
row that conditionally renders on forceTls === 'tls' via a
shouldUpdate-closure that watches the per-row forceTls path.

Sockopt: Switch driven by whether the sockopt object exists in form
state. Toggling on calls SockoptStreamSettingsSchema.parse({}) so every
default the schema declares (mark=0, tproxy='off', domainStrategy='UseIP',
tcpcongestion='bbr', etc.) flows into the form; toggling off sets to
undefined.

Renders the seventeen sockopt fields directly bound to
['streamSettings', 'sockopt', X] paths. Option lists pull from the
primitives const dictionaries (UTLS_FINGERPRINT, ALPN_OPTION,
DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION) rather than the
schema's .options to keep one source of truth for UI label strings.

* feat(frontend): security tab base + TLS section (Pattern A)

Adds the security tab to the sibling-file rewrite. Visibility is paired
with the stream tab — both gated on canEnableStream. The security
selector is itself disabled when canEnableTls is false, and the reality
option only appears when canEnableReality is true, mirroring the legacy
modal's Radio.Group guards.

onSecurityChange clears the previous branch's *Settings key and seeds
the new branch from the schema's parsed defaults (the same trick the
sockopt toggle uses). The security selector itself is rendered via a
shouldUpdate closure so the on-change handler can write the cleaned
streamSettings shape atomically without racing AntD's per-field sync.

TLS section: serverName (the wire field — the legacy class calls it
sni internally), cipherSuites (with the 13 named suites from
TLS_CIPHER_OPTION), min/max version pair, uTLS fingerprint, ALPN
multi-select, plus the three policy Switches.

TLS certificates list, ECH controls, the full Reality sub-form, and
the four API-call buttons (genRealityKeypair / genMldsa65 / getNewEchCert
/ randomizers) land in a follow-up commit.

* feat(frontend): security tab Reality + ECH + mldsa65 controls (Pattern A)

Adds the Reality sub-form and the four API-call buttons that drive
the server-generated material:

- genRealityKeypair calls /panel/api/server/getNewX25519Cert and writes
  the result into ['streamSettings', 'realitySettings', 'privateKey']
  and the nested settings.publicKey path.
- genMldsa65 calls /panel/api/server/getNewmldsa65 for the
  post-quantum seed/verify pair.
- getNewEchCert calls /panel/api/server/getNewEchCert with the current
  serverName and writes echServerKeys + settings.echConfigList.
- randomizeRealityTarget seeds target + serverNames from the random
  reality-targets pool.
- randomizeShortIds calls RandomUtil.randomShortIds (comma-joined
  string) and splits into the schema's string[] form.

Reality fields are bound directly to schema paths — show/xver/target,
maxTimediff, min/max ClientVer, the settings.{publicKey, fingerprint,
spiderX, mldsa65Verify} nested subtree, plus the array fields
(serverNames, shortIds) rendered as Select mode="tags" since both ship
as string[] on the wire.

TLS certificates list (Form.List with the useFile DU) still pending —
that's a chunky sub-form on its own.

* feat(frontend): security tab TLS certificates list (Pattern A)

Closes out the security tab: a Form.List of certificates that toggles
between TlsCertFileSchema (certificateFile + keyFile string paths) and
TlsCertInlineSchema (certificate + key as string arrays per the wire
shape) via a per-row useFile boolean.

useFile is a transient form-only field — not part of TlsCertSchema.
Zod's default-strip behavior drops it during InboundFormSchema parse
on submit, leaving only the matching wire branch's keys populated.
Whichever side the user wasn't on stays empty, so Zod's union picks
the populated branch.

For inline certs the TextAreas use normalize + getValueProps to convert
between the wire-side string[] and the multi-line text the user types.
Each line becomes one array element, matching the legacy class's
`cert.split('\n')` toJson convention.

Per-row buildChain is conditionally rendered when usage === 'issue' —
a shouldUpdate-closure watches the specific path so the toggle
re-renders inline without listening to unrelated form changes.

Security tab is now functionally complete. Advanced JSON tab,
Fallbacks card, and the atomic swap in InboundsPage are next.

* feat(frontend): advanced JSON tab on InboundFormModal.new.tsx (Pattern A)

Adds the advanced JSON tab. Each sub-tab (settings / streamSettings /
sniffing) renders an AdvancedSliceEditor — a small CodeMirror-backed
JsonEditor that holds a local text buffer and forwards parsed JSON to
form state on every valid edit.

Invalid JSON sits silently in the local buffer; once the user finishes
balancing braces / quoting, the next valid parse pushes through to the
form. No stamping ref, no apply-on-tab-switch ceremony — the form is
the single source of truth.

The buffer seeds once from form state on mount. The Modal's
destroyOnHidden means each open is a fresh editor instance, so external
form mutations during a single open session can't desync the editor
either.

The streamSettings sub-tab is omitted when streamEnabled is false
(matching the legacy modal's behavior for protocols like Http / Mixed
that have no stream layer).

* feat(frontend): fallbacks card on InboundFormModal.new.tsx (Pattern A)

Adds the fallbacks card rendered inside the protocol tab whenever the
current values describe a fallback host — VLESS or Trojan on tcp with
tls or reality security. The protocol tab visibility widens to include
Trojan in that exact case (it has no other protocol sub-form).

Fallbacks live in a useState alongside the form rather than inside form
values, mirroring the legacy modal: fallbacks save via a distinct
endpoint (/panel/api/inbounds/{id}/fallbacks) after the main inbound
POST, not as part of the inbound payload. loadFallbacks runs on open
for edit-mode VLESS/Trojan; saveFallbacks runs after a successful POST
inside the submit handler.

Each row: child picker (filtered down to other inbounds), then four
inline edits for SNI / ALPN / path / xver. Add adds an empty row;
delete pulls the row from state.

Quick-Add-All, the rederive-from-child helper, and the per-row up/down
movers are deferred — the basic add/edit/remove cycle is what the modal
actually needs to function.

* feat(frontend): atomic swap InboundFormModal to Pattern A

Deletes the 2261-line class-mutation modal and renames the
1900-line sibling rewrite into its place. InboundsPage.tsx already
imports the file by path so no consumer change is needed — the swap
is one file delete plus one file rename. Build, lint, and 280 tests
stay green.

What the new modal covers end-to-end:
- Basic (enable / remark / nodeId / protocol / listen / port /
  totalGB / trafficReset / expireDate)
- Sniffing (enabled / destOverride / metadataOnly / routeOnly /
  ipsExcluded / domainsExcluded)
- Protocol per DU branch: VLESS (decryption/encryption + buttons),
  Shadowsocks (method/password/network/ivCheck), HTTP + Mixed
  (accounts list + per-protocol toggles), Tunnel (rewrite + portMap +
  followRedirect), TUN (interface/mtu + four primitive lists +
  userLevel/autoInterface), Wireguard (secretKey + derived pubKey +
  peers list with nested allowedIPs)
- Stream per network: TCP base, KCP, WS, gRPC, HTTPUpgrade, XHTTP
  (the 22-field one), plus external-proxy and sockopt extras
- Security: TLS (SNI/cipher/version/uTLS/ALPN/policy switches +
  certificates list with file/inline toggle + ECH controls), Reality
  (every field + the four API-call buttons), none
- Advanced JSON (settings / streamSettings / sniffing live editors
  that round-trip into form state on every valid parse)
- Fallbacks (load on open for VLESS/Trojan TLS-or-Reality TCP hosts;
  save through the secondary endpoint after the main POST succeeds)

Known regressions vs the legacy modal, all reachable via Advanced JSON
until backfilled in follow-up commits:
- Hysteria stream sub-form (masquerade / udpIdleTimeout / version) —
  schema gap; the existing inbound DU has no hysteria stream branch
- FinalMaskForm hookup — the component is still class-shape coupled
- HeaderMapEditor — TCP request/response headers, WS / HTTPUpgrade /
  XHTTP headers, Hysteria masquerade headers all need a shared editor
- TCP HTTP camouflage request/response body (version, method, path
  list, headers, status, reason) — only the on/off toggle is wired
- Fallbacks polish — up/down move, quick-add-all, rederive-from-child,
  the per-row advanced-toggle / proxy-tag chips

No reference to @/models/inbound's Inbound class anywhere in the new
modal — only @/models/dbinbound (out of scope) and
@/models/reality-targets (out of scope). The protocol-capabilities
predicates and the rawInboundToFormValues + formValuesToWirePayload
adapters carry every behavior the class used to provide.

* fix(frontend): finish InboundFormModal rename after atomic swap

The atomic-swap commit landed the new file but the exported function was
still named InboundFormModalNew. Rename to match the file.

* feat(frontend): outbound form schema + wire adapter foundation

Lay the groundwork for OutboundFormModal's Pattern A rewrite:

- schemas/forms/outbound-form.ts: discriminated-union form values across
  all 12 outbound protocols, with flat per-protocol settings shapes that
  match the legacy class fields (vmess vnext / trojan-ss-socks-http
  servers / wireguard csv address-reserved all flattened).

- lib/xray/outbound-form-adapter.ts: rawOutboundToFormValues converts
  wire-shape outbound JSON to typed form values; formValuesToWirePayload
  re-nests on submit. Replaces the Outbound.fromJson/toJson dependency
  the modal currently has on the legacy class hierarchy.

- test/outbound-form-adapter.test.ts: 15 round-trip cases covering each
  protocol's wire quirks (vmess vnext flatten, vless reverse-wrap,
  wireguard csv↔array, blackhole response wrap, DNS rule normalization,
  mux gating).

* feat(frontend): OutboundFormModal.new.tsx skeleton (Pattern A)

Sibling .new.tsx file with the Modal shell, Tabs (Basic/JSON), Form.useForm
hydration via rawOutboundToFormValues, and the submit pipeline that calls
formValuesToWirePayload before onConfirm. Tag uniqueness check is wired in.

Protocol-specific sub-forms, stream, security, sockopt, and mux sections
are deferred to subsequent commits — accessible via the JSON tab in the
meantime. The InboundsPage continues to render the legacy modal until the
atomic swap at the end.

Also: rawOutboundToFormValues now returns streamSettings as undefined
when the wire payload omits it, so Form.useForm doesn't receive a value
that does not match the NetworkSettings discriminated union.

* feat(frontend): OutboundFormModal.new.tsx vmess/vless/trojan/ss sections

- Shared connect-target sub-block (address + port) for the six protocols
  whose form schema carries them flat at settings root.
- VMess: id + security Select (USERS_SECURITY).
- VLESS: id + encryption + flow + reverseTag (reverse-sniffing slice and
  Vision testpre/testseed come in a later commit).
- Trojan: password.
- Shadowsocks: password + method Select (SSMethodSchema) + UoT switch +
  UoT version.

onValuesChange cascade: when the user picks a different protocol, the
adapter re-seeds the settings sub-object to the new protocol's defaults
so leftover fields from the previous protocol do not bleed through.

* feat(frontend): OutboundFormModal.new.tsx socks/http/hysteria/loopback/blackhole/wireguard sections

- SOCKS / HTTP: user + pass at settings root.
- Hysteria: read-only version=2 (the actual transport knobs live on
  stream.hysteria, added with the stream tab).
- Loopback: inboundTag.
- Blackhole: response type Select with empty/none/http options.
- Wireguard: address (csv) + secretKey (with regenerate icon) + derived
  pubKey + domain strategy + MTU + workers + no-kernel-tun + reserved
  (csv) + peers Form.List with nested allowedIPs sub-list.

Wireguard regenerate icon uses Wireguard.generateKeypair() and writes
both keys to the form via setFieldValue — preserves the legacy UX of
the SyncOutlined inline-icon next to the privateKey label.

* feat(frontend): OutboundFormModal.new.tsx DNS + Freedom + VLESS reverse-sniffing

- DNS: rewriteNetwork (udp/tcp Select) + rewriteAddress + rewritePort +
  userLevel + rules Form.List (action/qtype/domain).

- Freedom: domainStrategy + redirect + Fragment Switch with conditional
  4-field sub-block (legacy 'enable Fragment' UX preserved — Switch sets
  all four fields to populated defaults, off-state empties them all out
  so the adapter strips them on submit) + Noises Form.List (rand/base64/
  str/hex types, packet/delay/applyTo per row) + Final Rules Form.List
  with conditional block-delay sub-field.

- VLESS reverse-sniffing slice: rendered only when reverseTag is set
  (matches the legacy modal's nested conditional). All six fields wired
  to the form state with appropriate widgets (Switch / Select multi /
  Select tags).

* feat(frontend): OutboundFormModal.new.tsx stream tab (TCP/KCP/WS/gRPC/HTTPUpgrade)

Wire the stream sub-form into the Pattern A modal:

- newStreamSlice(network) helper bootstraps the per-network DU branch
  with Xray defaults (mtu=1350, tti=20, uplinkCapacity=5, etc.).
- streamSettings is seeded once when the protocol supports streams
  but the form has no slice yet (new outbound + protocol switch).
- onNetworkChange swaps the sub-key and preserves security when the
  new network still supports it, else snaps back to 'none'.
- Per-network sub-forms wired:
    TCP: HTTP camouflage Switch (sets header.type = 'http' / 'none')
    KCP: 6 numeric tuning fields
    WS: host + path + heartbeat
    gRPC: service name + authority + multi-mode switch
    HTTPUpgrade: host + path
    XHTTP: host + path + mode + padding bytes (advanced fields via JSON)

Security radio, TLS/Reality sub-forms, sockopt, and mux still pending.

* feat(frontend): OutboundFormModal.new.tsx security tab (TLS + Reality + Flow)

- onSecurityChange cascade: swaps tlsSettings/realitySettings sub-key
  matching the DU branch, seeding the new sub-form with empty/default
  fields so the UI does not reference undefined values.

- Flow Select rendered when canEnableTlsFlow is true (VLESS + TCP +
  TLS/Reality). Moved from the basic VLESS section so it only appears
  in the relevant security context — matches the legacy modal UX.

- Security Radio (none / TLS / Reality) gated by canEnableTls and
  canEnableReality pure-function predicates from
  lib/xray/protocol-capabilities.

- TLS sub-form: 6 outbound-specific fields (SNI/uTLS/ALPN/ECH/
  verifyPeerCertByName/pinnedPeerCertSha256) matching the legacy
  TlsStreamSettings flat shape (no certificates list — outbound is
  client-side).

- Reality sub-form: 6 fields (SNI/uTLS/shortId/spiderX/publicKey/
  mldsa65Verify). publicKey + mldsa65Verify get TextAreas to handle
  the long base64 strings.

* feat(frontend): OutboundFormModal.new.tsx sockopt + mux sections

- Sockopts: Switch toggles streamSettings.sockopt between undefined and
  a populated default object (17 fields with sane bbr/UseIP defaults).
  Only the 8 most-used fields are rendered (dialer proxy, domain
  strategy, keep alive interval, TFO, MPTCP, penetrate, mark, interface).
  The remaining sockopt knobs (acceptProxyProtocol, tcpUserTimeout,
  tcpcongestion, tcpKeepAliveIdle, tcpMaxSeg, tcpWindowClamp, V6Only,
  trustedXForwardedFor, tproxy) are still in the wire payload — edit
  them via the JSON tab.

- Mux: gated by isMuxAllowed(protocol, flow, network) — VMess/VLESS/
  Trojan/SS/HTTP/SOCKS, no flow set, no xhttp transport. Sub-fields
  (concurrency / xudpConcurrency / xudpProxyUDP443) only render when
  enabled is true.

- Sockopt section visible only when streamAllowed AND network is set —
  non-stream protocols (freedom/blackhole/dns/loopback) still edit
  sockopt via the JSON tab.

* feat(frontend): atomic swap OutboundFormModal to Pattern A

Delete the legacy 1473-line class-based OutboundFormModal.tsx and replace
it with the new Pattern A modal (Form.useForm + antdRule + per-protocol
discriminated-union form values + wire adapter).

Net diff: legacy file gone, function renamed from OutboundFormModalNew
to OutboundFormModal so the existing OutboundsTab import resolves
unchanged.

What is migrated:
  - All 12 protocols (vmess/vless/trojan/ss/socks/http/wireguard/
    hysteria/freedom/blackhole/dns/loopback)
  - Stream tab with TCP/KCP/WS/gRPC/HTTPUpgrade + partial XHTTP
  - Security tab with TLS + Reality + Flow gating
  - Sockopt + Mux sections (gated by isMuxAllowed)
  - JSON tab with bidirectional bridge to form state
  - Tag uniqueness check
  - VLESS reverse-sniffing slice
  - Freedom fragment/noises/finalRules
  - DNS rewrite + rules list
  - Wireguard peers + nested allowedIPs sub-list
  - Wireguard secret/public key regeneration

Deferred to follow-up commits (still accessible via the JSON tab):
  - XHTTP advanced fields (xmux, sequence/session placement, padding obfs)
  - Hysteria stream transport sub-form
  - TCP HTTP camouflage host/path body
  - WS/HTTPUpgrade/XHTTP headers map editor
  - Remaining sockopt knobs (tcpUserTimeout, tcpcongestion, tcpKeepAliveIdle,
    tcpMaxSeg, tcpWindowClamp, V6Only, trustedXForwardedFor, tproxy,
    acceptProxyProtocol)
  - VLESS Vision testpre/testseed
  - Reality API helpers (random target, x25519/mldsa65 generate-import)
  - Link import (vmess:// vless:// etc → outbound)
  - FinalMaskForm hookup (deferred from inbound rewrite too)

* test(frontend): convert legacy-class parity tests to snapshot baselines

With the inbound/outbound modal rewrites complete, the cross-check
against the legacy Inbound class has served its purpose. The new
pure-function / Zod-schema paths are the source of truth for production
code; the parity assertions were the migration safety net.

Convert the three parity test files to snapshot-based regression tests:

- headers.test.ts: toHeaders + toV2Headers run against snapshots
  captured at the close of the migration (when both new and legacy
  were verified byte-equal).
- protocol-capabilities.test.ts: 140 cases (10 fixtures × 14 stream
  shapes) snapshot the predicate-result tuple. Was: parity vs legacy
  Inbound.canEnableX() class methods.
- inbound-link.test.ts: per-protocol genXxxLink + genInboundLinks
  orchestrator output is snapshotted. Was: byte-equality vs legacy
  Inbound.genXxxLink() methods.

Also delete shadow.test.ts — its purpose was a dual-parse drift
detector (Inbound.Settings.fromJson vs InboundSettingsSchema.parse).
inbound-full.test.ts already snapshots the Zod parse output, which
covers the same ground without the legacy dependency.

models/inbound.ts and models/outbound.ts stay in the tree for now —
DBInbound still consumes Inbound via its toInbound() method, and
DBInbound migration is out of scope per the migration spec
('Do NOT migrate Status, DBInbound, or AllSetting...'). No
production page imports from @/models/inbound or @/models/outbound
directly anymore.

* chore(frontend): enforce no-explicit-any: error + add typecheck/test to CI

Step 7 of the Zod migration: lock the migration's gains in place via
lint + CI enforcement.

- eslint.config.js: `@typescript-eslint/no-explicit-any` set to error.
  Verified locally — zero violations in src/, with the only file-level
  disables being src/models/inbound.ts and src/models/outbound.ts
  (kept for DBInbound's toInbound() consumer; their migration is out
  of spec scope).

- .github/workflows/ci.yml: add Typecheck and Test steps to the
  frontend job, between Lint and Build. PRs now have to pass
  tsc --noEmit and the full vitest suite (285 tests + 172 snapshots)
  before build runs.

Migration scoreboard (vs the spec):
  Step 1 primitives + barrels         done
  Step 2 protocol leaf + DUs          done
  Step 3 pure-fn extraction           done
  Step 4 form modals -> Pattern A     done (Inbound + Outbound)
  Step 5 delete models/ files         DEFERRED (DBInbound still uses
                                      Inbound; spec marks DBInbound
                                      migration out of scope)
  Step 6 tighten .loose() / unknown   DEFERRED (invasive, separate PR)
  Step 7 lint + CI enforcement        done (this commit)

Production code paths now have no direct dependency on the legacy
Inbound or Outbound classes.

* feat(frontend): OutboundFormModal deferred features (Vision seed / TCP host+path / WG pubKey derive)

Three small wins from the post-atomic-swap deferred list:

- VLESS Vision testpre + testseed: shown only when flow ===
  'xtls-rprx-vision' (mirrors the legacy canEnableVisionSeed gate).
  testseed binds to a Select mode='tags' with a normalize() that
  coerces strings to positive integers and drops invalid entries.

- TCP HTTP camouflage host + path: when the TCP HTTP camouflage
  Switch is on, surface two inputs that read/write directly into
  streamSettings.tcpSettings.header.request.headers.Host and .path.
  Both fields are string[] on the wire; normalize + getValueProps
  translate to/from comma-joined strings in the UI (one entry per
  host or path the user wants camouflaged).

- Wireguard pubKey auto-derive: Form.useWatch on settings.secretKey
  + useEffect that runs Wireguard.generateKeypair(secret).publicKey
  on every change and writes the result into the disabled pubKey
  display field. Matches the legacy modal's per-keystroke derive.

* feat(frontend): symmetric TCP HTTP host/path + extra sockopt knobs

OutboundFormModal:
- Sockopt section gains 5 common-but-rarely-tweaked knobs:
  acceptProxyProtocol, tproxy (off/redirect/tproxy), tcpcongestion
  (bbr/cubic/reno), V6Only, tcpUserTimeout. The remaining sockopt
  fields (tcpKeepAliveIdle, tcpMaxSeg, tcpWindowClamp,
  trustedXForwardedFor) are still edit-via-JSON; they are deeply
  tunable and not commonly touched.

InboundFormModal:
- TCP HTTP camouflage gains host + path inputs symmetric to the
  outbound side. Switch ON seeds request with sensible defaults
  (version 1.1, method GET, path ['/'], empty headers). The two
  inputs use the same normalize/getValueProps comma-string ↔
  string[] dance the outbound side uses, so the wire shape stays
  identical to what xray-core expects.

* feat(frontend): HeaderMapEditor reusable component + wire WS/HTTPUpgrade headers

Add a single reusable header-map editor that handles the two wire
shapes Xray uses:

- v1: { name: 'value' } — used by WS / HTTPUpgrade / Hysteria
  masquerade. One value per name.
- v2: { name: ['value1', 'value2'] } — used by TCP HTTP camouflage.
  Each header can repeat (RFC 7230 §3.2.2).

Internal state is always a flat list of {name, value} rows regardless
of mode; conversion to/from the wire shape happens at the value /
onChange boundary so consumers bind straight to a Form.Item with no
extra transforms.

Wired into:
- InboundFormModal: WS Headers, HTTPUpgrade Headers
- OutboundFormModal: WS Headers, HTTPUpgrade Headers

XHTTP headers are already in a list-of-rows wire shape (different
from these two), so they keep their bespoke editor. Hysteria
masquerade is still deferred until the Hysteria stream sub-form
lands.

* feat(frontend): Hysteria stream sub-form (schema branch + outbound UI)

Add the 7th branch to NetworkSettingsSchema for Hysteria transport.

schemas/protocols/stream/hysteria.ts:
- HysteriaStreamSettingsSchema covers the full wire shape: version=2,
  auth, congestion (''|'brutal'), up/down bandwidth strings, optional
  udphop sub-object for port-hopping, receive-window tuning fields,
  maxIdleTimeout, keepAlivePeriod, disablePathMTUDiscovery.

schemas/protocols/stream/index.ts:
- NetworkSchema gains 'hysteria'.
- NetworkSettingsSchema gains the 7th branch
  { network: 'hysteria', hysteriaSettings: HysteriaStreamSettingsSchema }.

OutboundFormModal.tsx:
- NETWORK_OPTIONS keeps the 6 standard transports for non-hysteria
  protocols; when protocol === 'hysteria', a 7th option is appended
  (matches the legacy [...NETWORKS, 'hysteria'] gate).
- newStreamSlice handles the 'hysteria' case with sensible defaults
  matching the legacy HysteriaStreamSettings constructor.
- New sub-form when network === 'hysteria': 8 common fields (auth,
  congestion, up, down, udphop Switch + 3 nested fields when on,
  maxIdleTimeout, keepAlivePeriod, disablePathMTUDiscovery).
- Receive-window tuning fields are still edit-via-JSON (rarely
  touched + would clutter the form).

* feat(frontend): fallbacks polish — move up/down + Add all button

Two small UX wins on the InboundFormModal Fallbacks card:

- Per-row Move up / Move down buttons (ArrowUp/Down icons) that swap
  adjacent indices. Order survives reloads via sortOrder (rebuilt from
  index on save). First row's Up button + last row's Down button are
  disabled.

- 'Add all' button next to 'Add fallback' that one-shot inserts a
  fresh row for every eligible inbound (every option in
  fallbackChildOptions) not already wired up. Disabled when every
  eligible inbound is already covered. Convenient for operators
  running catch-all routing across every host on the panel.

* feat(frontend): XHTTP advanced fields on outbound modal

Replace the 'edit via JSON' deferred-features hint with the full XHTTP
sub-form matching the legacy modal's XhttpFields helper.

schemas/protocols/stream/xhttp.ts:
- New XHttpXmuxSchema: 6 connection-multiplexing knobs
  (maxConcurrency, maxConnections, cMaxReuseTimes, hMaxRequestTimes,
  hMaxReusableSecs, hKeepAlivePeriod).
- XHttpStreamSettingsSchema gains 5 outbound-only fields and one
  UI-only toggle: scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader,
  xmux, enableXmux.

outbound-form-adapter.ts:
- New stripUiOnlyStreamFields() drops xhttpSettings.enableXmux on the
  way to wire so the panel never embeds the UI toggle into the saved
  config. xray-core ignores unknown fields anyway, but the panel reads
  back its own emitted JSON, so a clean wire shape matters.

OutboundFormModal.tsx:
- Headers editor (HeaderMapEditor v1) for xhttpSettings.headers.
- Padding obfs Switch + 4 conditional fields (key/header/placement/
  method) when on.
- Uplink HTTP method Select with GET disabled outside packet-up.
- Session placement + session key (key shown when placement != path).
- Sequence placement + sequence key (same pattern).
- packet-up mode: scMinPostsIntervalMs, scMaxEachPostBytes, uplink
  data placement + key + chunk size (key/chunk-size shown when
  placement != body).
- stream-up / stream-one mode: noGRPCHeader Switch.
- XMUX Switch + 6 nested fields when on.

* feat(frontend): inbound TCP HTTP camouflage response fields + request headers

Complete the TCP HTTP camouflage UI on the inbound side.

Already there from the previous symmetric host/path commit:
- Request host (string[] via comma-string)
- Request path (string[] via comma-string)

This commit adds:
- Request headers (V2 map: name -> string[]) via HeaderMapEditor.
- Response version (defaults to '1.1' when camouflage toggles on).
- Response status (defaults to '200').
- Response reason (defaults to 'OK').
- Response headers (V2 map) via HeaderMapEditor.

The HTTP camouflage Switch seeds both request and response sub-objects
on toggle-on so xray-core sees a valid TcpHeader.http shape from the
first save. Without the response seed, partial fills would emit a
schema-incomplete response block that xray-core might reject.

* feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2)

The legacy outbound modal could import a vmess://, vless://, trojan://,
ss://, or hysteria2:// share link via a Convert button on the JSON
tab. Restore that UX with a focused pure-function parser.

lib/xray/outbound-link-parser.ts:
- parseVmessLink: base64 JSON, maps net/tls + per-network params onto
  the discriminated stream branch.
- parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow
  query params, dispatches transport via buildStream + applies
  security params via applySecurityParams.
- parseTrojanLink: same URL pattern, defaults security to tls.
- parseShadowsocksLink: both modern (base64 userinfo@host:port) and
  legacy (base64 of whole thing) ss:// formats.
- parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes,
  uses the hysteria stream branch with version=2 + TLS h3.
- parseOutboundLink dispatcher returns the first non-null parser
  result, or null when no scheme matches.

test/outbound-link-parser.test.ts:
- 13 cases covering happy paths for each protocol family plus malformed
  input, ss:// dual-format handling, hy2:// alias.

OutboundFormModal.tsx:
- Import button on the JSON tab Input.Search; on success, parsed
  payload flows through rawOutboundToFormValues, the form is reset,
  and we switch back to the Basic tab.
- Tag is preserved when the parsed link does not carry one.

Out of scope: advanced fields the legacy parser handled (xmux, padding
obfs, reality short IDs, finalmask from fm= param). Power users can
finish the import in the form after the basics land.

* feat(frontend): inbound Hysteria stream sub-form (auth + udpIdleTimeout + masquerade)

Restore the inbound side of Hysteria stream configuration that was
previously hidden — the legacy modal exposed these knobs but the
Pattern A rewrite gated them out.

schemas/protocols/stream/hysteria.ts:
- HysteriaMasqueradeSchema covers the inbound-only masquerade wire
  shape: type ('proxy'|'file'|'string'), dir, url, rewriteHost,
  insecure, content, headers, statusCode. The three masquerade types
  cover the spectrum: reverse-proxy upstream, serve static files, or
  return a fixed string body.
- HysteriaStreamSettingsSchema gains 3 inbound-side optional fields:
  protocol, udpIdleTimeout, masquerade. Outbound side is untouched
  (the legacy class accepted both wire shapes via the same struct).

InboundFormModal.tsx:
- New hysteria stream sub-form section in streamTab, gated by
  protocol === HYSTERIA. Fields: version (disabled, locked to 2),
  auth, udpIdleTimeout, masquerade Switch + nested type-Select with
  three conditional sub-blocks (proxy URL+rewriteHost+insecure,
  file dir, string statusCode+body+headers).
- onValuesChange cascade: switching TO hysteria seeds streamSettings
  with the hysteria branch (forcing network='hysteria' + TLS); switching
  AWAY from hysteria snaps back to TCP so the standard network
  selector has a valid starting point.

masquerade headers use the HeaderMapEditor v1 component.

* feat(frontend): complete outbound sockopt section with remaining knobs

Add the four remaining SockoptStreamSettings fields that were
edit-via-JSON-only after the initial outbound modal rewrite:

- TCP keep-alive idle (s) — tcpKeepAliveIdle, time before sending
  the first probe on an idle TCP connection.
- TCP max segment — tcpMaxSeg, override the default MSS.
- TCP window clamp — tcpWindowClamp, cap the TCP receive window.
- Trusted X-Forwarded-For — trustedXForwardedFor, list of trusted
  proxy hostnames/CIDRs whose XFF headers Xray will honor.

The outbound sockopt section now exposes all 17 SockoptStreamSettings
fields from the schema. The InboundFormModal's sockopt section has
its own field list (closer to the legacy class) and is unchanged.

* feat(frontend): outbound TCP HTTP camouflage parity with inbound

Add method/version inputs, request header map, and full response
sub-section (version/status/reason/headers) to OutboundFormModal so the
outbound side can configure the same HTTP-1.1 obfuscation knobs the
inbound side already exposed.

* feat(frontend): round-trip XHTTP advanced fields in outbound link parser

Pick up xPaddingBytes, scMaxEachPostBytes, scMinPostsIntervalMs,
uplinkChunkSize, and noGRPCHeader from both vmess:// JSON and the URL
query-param parsers (vless/trojan). The advanced xmux/padding-obfs/
reality-shortId knobs still wait on a follow-up; this slice unblocks
the common case where a phone-issued xhttp link carries non-default
padding or post sizes.

* feat(frontend): round-trip XHTTP padding-obfs + remaining advanced knobs

Extract the XHTTP key-mapping into typed string/number/bool key arrays
applied by both the URL query-param branch and the vmess JSON branch.
The parser now covers xPaddingObfsMode + xPaddingKey/Header/Placement/
Method, sessionKey/seqKey/uplinkData{Placement,Key}, noSSEHeader,
scMaxBufferedPosts, scStreamUpServerSecs, serverMaxHeaderBytes, and
uplinkHTTPMethod alongside the previous five XHTTP fields. Two new
round-trip tests cover the padding-obfs surface on both link forms.

* feat(frontend): FinalMaskForm rewrite to Pattern A + wire into both modals

Rewrite FinalMaskForm.tsx from a class-coupled component (mutated
stream.finalmask.tcp[] via .addTcpMask/.delTcpMask methods, notified
parent via onChange callback) into a Pattern A sub-form: takes a
NamePath base, a FormInstance, and the surrounding network/protocol,
then composes Form.List + Form.Item at absolute paths under that base.

All array structures use nested Form.List — tcp/udp mask arrays, the
clients/servers groups in header-custom (Form.List of Form.List of
ItemEditor), and the noise list. Type Selects use onChange to reset
the settings sub-object via form.setFieldValue, mirroring the legacy
changeMaskType behavior. The kcp.mtu side effect on xdns type change
is preserved.

Wired into both InboundFormModal and OutboundFormModal stream tabs,
placed after the sockopt section. The component is the first Pattern A
consumer of nested Form.List inside another Form.List, so it stands
as the reference for future nested-array sub-forms.

* docs(frontend): record FinalMaskForm rewrite + hookup in status doc

Mainline migration goal — replace class-based xray models with Zod
schemas as the single source of truth + drive all forms through
AntD `Form.useForm` + `antdRule(schema.shape.X)` — is complete.
Remaining items are incremental polish.

* fix(frontend): Phase 2 Inbound form reactivity bugs (B1-B9, consolidated)

A run of resets dropped the per-bug commits 1401d833 / 5b1ae450 /
5bce0dc5 / 4007eec7. Re-landing all fixes against the same files in one
commit to avoid another rebase-style drop.

B1 — Transmission Select / External Proxy + Sockopt switches didn't
react after click. AntD 6.4.3 Form.useWatch on nested paths doesn't
re-fire reliably after `setFieldValue('streamSettings', cleaned)` on
the parent. Bound Transmission via `name={['streamSettings', 'network']}`
and wrapped the two switches in `<Form.Item shouldUpdate>` blocks that
read state via getFieldValue.

B2 — Security regressed from `Radio.Group buttonStyle="solid"` to a
Select dropdown, and disable state didn't refresh because tlsAllowed/
realityAllowed were derived at the top of the component. Restored
Radio.Button group and moved canEnableTls/canEnableReality evaluation
inside the shouldUpdate render prop.

B3 — Advanced tab "All" sub-tab was missing. Added it as the first
item with a new AdvancedAllEditor that round-trips top-level fields +
the three nested slices on edit.

B4 — Advanced tab title/subtitle and per-section help text were gone.
Wrapped the Tabs in the existing `.advanced-shell` / `.advanced-panel`
structure and restored the `.advanced-editor-meta` help under each
sub-tab using existing i18n keys.

B5 — TLS / Reality sub-forms didn't render when selecting tls or
reality on the Security tab. The `{security === 'tls' && ...}` and
`{security === 'reality' && ...}` conditionals used a stale top-level
useWatch value. Wrapped both in <Form.Item shouldUpdate> blocks that
read `security` via getFieldValue.

B6 — Advanced JSON editors stale after Stream/Sniffing changes. The
editors seeded text via lazy useState and AntD Tabs renders all panes
upfront, so the Advanced tab was already mounted with stale data.
Both AdvancedSliceEditor and AdvancedAllEditor now subscribe via
Form.useWatch and re-sync the text buffer when the watched JSON
differs from a lastEmitRef (the serialization at the moment of our
own last accepted write). User typing doesn't trigger re-sync because
setFieldValue updates lastEmitRef too. (A prior attempt added
`destroyOnHidden` to the outer Tabs but broke conditional tab items
when the unmounted Form.Item for `protocol` lost its value —
abandoned in favor of useWatch reactivity.)

B7 — HeaderMapEditor + button did nothing. addRow() appended a blank
{name:'', value:''} row, but commit() filtered it via rowsToMap before
reaching the form, so AntD saw no change and didn't re-render. The
editor now keeps a local rows state so blank rows survive during
editing; only filled rows are emitted to onChange.

B9 — Sniffing destOverride defaults (HTTP/TLS/QUIC/FAKEDNS) were not
pre-checked on a fresh Add Inbound. buildAddModeValues() seeded
sniffing: {} which left destOverride undefined. Now seeds with
SniffingSchema.parse({}) so the Zod defaults populate.

* fix(frontend): FinalMaskForm TCP Mask sub-forms + Advanced JSON wrap (B10/B11)

B10 — FinalMaskForm TCP Mask: after adding a mask and picking a Type
(Fragment/Header Custom/Sudoku), the type-specific sub-forms didn't
render. TcpMaskItem read `type` via Form.useWatch on a path inside
Form.List, which doesn't re-fire reliably in AntD 6.4.3 — same root
cause as the earlier B1/B2/B5 reactivity issues. Replaced with a
<Form.Item shouldUpdate> wrapper that reads `type` via getFieldValue
inside the render prop.

B11 — Advanced sub-tabs (settings / streamSettings / sniffing) showed
just the inner value (e.g. `{clients:[],decryption:"none",...}`), but
the legacy modal wrapped each slice with its key envelope (e.g.
`{settings:{...}}`) so the JSON matches the wire shape's slice and
round-trips cleanly from copy-pasted inbound configs. Added a
`wrapKey` prop to AdvancedSliceEditor that wraps/unwraps the value
on render/write; the three sub-tabs now pass settings / streamSettings
/ sniffing as their wrapKey.

* fix(frontend): import InboundFormModal.css so layout classes apply (B12)

The file InboundFormModal.css existed but was never imported, so every
class in it had no effect — including:

- .vless-auth-state — the "Selected: <auth>" caption next to the X25519/
  ML-KEM/Clear button row stayed inline next to Clear instead of
  display:block beneath the row
- .advanced-shell / .advanced-panel — the Advanced tab's header / panel
  framing was missing
- .advanced-editor-meta — the per-section help text under each Advanced
  sub-tab had no spacing
- .wg-peer — wireguard peer rows had no top margin

Add a side-effect import of the CSS file at the top of the modal. No
other change needed; the legacy modal must have either imported it or
had a global import that the new modal didn't inherit.

* fix(frontend): FinalMaskForm relative paths + network-switch defaults (B13/B14)

B13 — FinalMaskForm used absolute paths like
['streamSettings', 'finalmask', 'tcp', 0, 'type'] for Form.Item names
inside Form.List render props. AntD's Form.List prefixes Form.Item
names with the list's own name, so the actual storage path became
['streamSettings', 'finalmask', 'tcp', 'streamSettings', 'finalmask',
'tcp', 0, 'type'] — total nonsense. Symptoms: Type Select didn't show
the 'fragment' default after add(), and the sub-form for the picked
type never rendered (Fragment/Sudoku/HeaderCustom).

Rewrote FinalMaskForm to use RELATIVE names inside every Form.List
context (TCP/UDP outer list + nested clients/servers/noise inner
lists). Added a `listPath` prop on the items so the shouldUpdate
guard and the side-effect setFieldValue calls (resetting `settings`
when type changes) can still address the absolute path; the
displayed Form.Items use the relative form (`[fieldName, 'type']`).

Replaced top-level Form.useWatch on nested paths with
<Form.Item shouldUpdate> blocks reading via getFieldValue, same
pattern as the earlier B5 fix — Form.useWatch on paths inside
Form.List doesn't re-fire reliably in AntD 6.4.3.

B14 — Switching network (KCP, WS, gRPC, XHTTP, ...) seeded the
new XSettings blob as `{}` so every field showed as empty. The
legacy `newStreamSlice` populated mtu=1350, tti=20, etc. Restored
those defaults in onNetworkChange and seeded the initial
tcpSettings.header in buildAddModeValues so even the default TCP
state shows the HTTP-camouflage Switch in the correct off state
instead of an undefined header object.

* fix(frontend): inbound TCP HTTP camouflage drops request fields + KCP UI field rename (B15/B16)

B15 — Inbound TCP HTTP camouflage exposed Host / Path / Method / Version
/ request-headers inputs. Per Xray docs
(https://xtls.github.io/config/transports/raw.html#httpheaderobject),
the `request` object is honored only by outbound proxies; the inbound
listener reads `response`. Those inputs were writing dead data the
server ignored. Removed them from the inbound modal; only Response
{version, status, reason, headers} remain. The toggle still seeds an
empty request object so the wire shape stays valid against the schema.

B16 — KCP Uplink / Downlink inputs bound to non-existent form fields
`upCap` / `downCap`, while the schema (and wire) use `uplinkCapacity` /
`downlinkCapacity`. Renamed the Form.Items to the schema names so
defaults populate and saves persist. Also corrected newStreamSlice('kcp')
to seed the four KCP defaults (uplinkCapacity / downlinkCapacity /
cwndMultiplier / maxSendingWindow) — the missing two were why
"CWND Multiplier" and "Max Sending Window" still showed empty after
switching to KCP.

* fix(frontend): seed full Zod-schema defaults for stream slices + QUIC params (B17)

XHTTP showed blank Selects for Session Placement / Sequence Placement /
Padding Method / Uplink HTTP Method (and several other knobs). Those
fields have a literal "" (empty string) value in the schema, which the
Select renders as "Default (path)" / "Default (repeat-x)" / etc.
The form field was `undefined`, not `""`, so the Select showed blank
instead of the labelled default option.

newStreamSlice in InboundFormModal hand-rolled per-network seed
objects with only a handful of fields. Replaced with
{Tcp,Kcp,Ws,Grpc,HttpUpgrade,XHttp}StreamSettingsSchema.parse({}) so
every default declared in the schema populates the form on network
switch. Same change in buildAddModeValues for the initial TCP state.

QUIC Params (FinalMaskForm) had the same shape on a smaller scale —
defaultQuicParams() only seeded congestion + debug + udpHop. The
schema's other fields are .optional() (no Zod default) so a schema
parse won't help. Hard-coded the xray-core / hysteria recommended
values (maxIdleTimeout 30, keepAlivePeriod 10, brutalUp/Down 0,
maxIncomingStreams 1024, four window sizes) so the InputNumber
controls render with usable starting values instead of blank.

* fix(frontend): forceRender all tabs so fields register at modal open (B18)

AntD Tabs with the `items` API lazy-mounts inactive tab panes by
default. The Form.Items inside an unvisited tab never register, so:

- Form.useWatch on a parent path (e.g. 'sniffing') returns a partial
  view containing only registered children. Until the user clicked the
  Sniffing tab, Advanced > Sniffing JSON showed `{sniffing: {}}`
  instead of the full default object set by setFieldsValue.
- After visiting the Sniffing tab once, the `sniffing.enabled` Form.Item
  registered, so useWatch suddenly returned `{enabled: false}` — still
  partial, because the rest of the sniffing children only register when
  their Form.Items mount in conditional sub-sections.

Setting `forceRender: true` on every tab item forces all tab panes to
mount at modal open. Every Form.Item registers immediately; the watch
result reflects the full form value seeded by buildAddModeValues. This
also likely resolves the earlier "Invalid discriminator value" error
on submit, which surfaced when streamSettings had an unregistered
security field whose Form.Item hadn't mounted yet.

* refactor(frontend): align hysteria with new docs + drop hysteria2 protocol

Phase 2 smoke fixes on the Inbound add flow surfaced that hysteria2 was
modeled as a separate top-level protocol when it's really just hysteria
v2. The xray transports/hysteria.html docs also pin the hysteria stream
to a minimal shape (version/auth/udpIdleTimeout/masquerade) — the
previous schema carried legacy congestion/up/down/udphop/window knobs
that aren't part of the wire contract.

Hysteria2 removal:
- Drop 'hysteria2' from ProtocolSchema enum and Protocols const
- Drop hysteria2 branches from inbound/outbound discriminated unions
- Drop createDefaultHysteria2InboundSettings / OutboundSettings
- Delete schemas/protocols/inbound/hysteria2.ts and outbound/hysteria2.ts
- Drop hysteria2 case in getInboundClients / genLink (fell through to
  the hysteria handler anyway)
- Update client form modals' MULTI_CLIENT_PROTOCOLS sets
- Remove hysteria2-basic fixture + snapshot entries (14 capability
  cases, 1 protocols fixture, 1 inbound-defaults factory)
- Keep parseHysteria2Link() outbound parser since hysteria2:// is the
  share-link URI prefix for hysteria v2

Hysteria stream alignment with xtls docs:
- HysteriaStreamSettingsSchema reduced to version/auth/udpIdleTimeout/
  masquerade per transports/hysteria.html
- Masquerade type adds '' (default 404 page) and defaults to it
- Outbound form drops Congestion/Upload/Download/UDP hop/Max idle/
  Keep alive/Disable Path MTU controls and the receive-window note
- newStreamSlice('hysteria') in OutboundFormModal mirrors the trimmed
  shape; outbound-link-parser emits the trimmed shape too
- InboundFormModal Masquerade Select gains the default option

New TUN inbound schema:
- Add schemas/protocols/inbound/tun.ts with name/mtu/gateway/dns/
  userLevel/autoSystemRoutingTable/autoOutboundsInterface
- Wire into ProtocolSchema enum, InboundSettingsSchema discriminated
  union, createDefaultInboundSettings dispatcher

Other Phase 2 smoke fixes folded in:
- Tunnel portMap UI swaps Form.List for HeaderMapEditor v1 — wire
  shape is Record<string,string> and the List was producing arrays
- Hysteria onValuesChange seeds full TLS schema defaults + one
  empty certificate row (Cipher Suites/Min/Max Version/uTLS/ALPN
  were undefined before)
- HTTP/Mixed accounts Add button auto-fills user/pass with
  RandomUtil.randomLowerAndNum
- Hysteria security tab gates the 'none' radio out — TLS only
- Hysteria stream tab drops the inbound Auth password field (xray
  inbound auth is per-user via 'users', not stream-level)
- Reality onSecurityChange auto-randomizes target/serverNames/
  shortIds and fetches an X25519 keypair
- Tag and DB-side fields (up/down/total/expiryTime/
  lastTrafficResetTime/clientStats/security) gain hidden Form.Items
  so validateFields keeps them in the wire payload (rc-component
  form strips unregistered fields)
- WireGuard inbound auto-seeds one peer with generated keypair,
  allowedIPs ['10.0.0.2/32'], keepAlive 0 — matches legacy
- WireGuard peer rows separated by Divider with the Peer N title
  and a small inline remove button (titlePlacement="center")

* refactor(frontend): retire class-based xray models (Step 5)

Delete models/inbound.ts (3,359 lines) and outbound.ts (2,405).
The Inbound/Outbound classes and ~50 sub-classes are replaced by
Zod-typed data + pure functions in lib/xray/*.

Consumer migration off dbInbound.toInbound():
- useInbounds: isSSMultiUser({protocol, settings}) directly
- QrCodeModal: genWireguardConfigs/Links/AllLinks from lib/xray
- InboundList: derives tags from streamSettings raw fields
- InboundsPage: clone via raw JSON, fallback projection via
  schema-shape stream object, exports via genInboundLinks
- InboundInfoModal: builds an InboundInfo facade locally from
  raw streamSettings (host/path/serverName/serviceName per
  network), canEnableTlsFlow + isSS2022 from lib/xray

New helper: lib/xray/inbound-from-db.ts exposes
inboundFromDb(raw) converting a raw DBInbound row into a
schema-typed Inbound for the link-generation orchestrators.

DBInbound trimmed: drops toInbound, isMultiUser, hasLink,
genInboundLinks, _cachedInbound. Imports Protocols from
@/schemas/primitives now that ./inbound is gone.

Bundled Phase 2 fixes:
- Outbound modal: Form.useWatch with preserve: true so the
  stream block doesn't gate itself out when network is unmounted
- Inbound form adapter: pruneEmpty preserves empty objects;
  per-protocol client field projection via Zod safeParse;
  sniffing collapse to {enabled:false}
- useClients invalidateAll also invalidates inbounds.root()
- IndexPage Config modal top/maxHeight polish

Tests: 283/283 pass. typecheck/lint clean.

* fix(frontend): inboundFromDb fills Zod defaults for stream + settings

Smoke-testing the new inboundFromDb helper surfaced two regressions
that the strict lib/xray link generators expose when fed raw DB
streamSettings without per-network sub-keys.

1. genVlessLink / genTrojanLink crash on `stream.tcpSettings.header`
   when streamSettings lacks `tcpSettings` (true for slim list rows
   and for handcrafted minimal-JSON inbounds). The legacy
   Inbound.fromJson chain populated TcpStreamSettings via its own
   constructor; the new helper now does the same by parsing the raw
   <network>Settings sub-object through the matching Zod schema and
   merging schema defaults onto whatever the DB stored.

2. genVlessLink writes `encryption=undefined` into the share URL
   when settings lacks the `encryption: 'none'` literal that vless
   wire JSON normally carries. Fixed by running raw settings through
   InboundSettingsSchema.safeParse() to populate per-protocol
   defaults (encryption, decryption, fallbacks, etc.) the same way
   the legacy class fromJson chain did.

Same pattern applied to security branch (tls/realitySettings).

Tests: src/test/inbound-from-db.test.ts covers
- JSON-string / object / empty settings coercion
- genInboundLinks vless (TCP/none, with encryption=none)
- genWireguardConfigs + genWireguardLinks peer fanout
- genAllLinks trojan with TLS sub-defaults applied
- protocol-capability helpers with raw shapes
- getInboundClients across vless/SS-single/non-client protocols

296/296 pass.

* fix(frontend): QUIC udpHop.interval is a range string, not a number (B19)

User report: "streamSettings.finalmask.quicParams.udpHop.interval:
Invalid input: expected string, received number".

Three-part fix:
- FinalMaskForm: Hop Interval input changed from InputNumber to
  Input with "e.g. 5-10" placeholder. xray-core spec says interval
  is a range string like '5-10' (seconds between min-max hops),
  not a single number.
- FinalMaskForm: defaultQuicParams() seeds interval: '5-10' instead
  of the broken `interval: 5`.
- QuicUdpHopSchema: preprocess coerces number → string for legacy
  DB rows that were written by the now-fixed buggy UI. Stops the
  load-time validation crash on existing inbounds.

Tests still 296/296.

* fix(frontend): outbound link parser handles extra/fm/x_padding_bytes (B20)

User-reported vless share link with full xhttp + reality + finalmask
config failed to round-trip on outbound import. The inbound link
generator emits three payloads the outbound parser was ignoring:

1. `extra=<json>` — bundles advanced xhttp knobs (xPaddingBytes,
   scMaxEachPostBytes, scMinPostsIntervalMs, padding-obfs keys,
   etc.). applyXhttpStringFromParams now JSON.parses this and
   merges the fields into xhttpSettings via the same JSON-branch
   logic used by vmess.

2. `x_padding_bytes=<range>` — snake_case alias the inbound emits
   alongside the camelCase form. Now applied before camelCase so
   explicit `xPaddingBytes` URL params still win.

3. `fm=<json>` — full finalmask object including quicParams.udpHop
   and tcp/udp mask arrays. New applyFinalMaskParam attaches the
   decoded object to streamSettings.finalmask. Wired into both
   parseVlessLink and parseTrojanLink.

Tests:
- Real B20 link parses with xhttp + reality + finalmask all populated
- Precedence: camelCase URL > extra JSON > snake_case alias > default
- Malformed extra JSON falls through without crashing the parser

300/300 pass.

* fix(frontend): Outbound submit crash on non-mux protocols + tab a11y (B21)

Two issues surfaced on Outbound save:

1. Crash: `Cannot read properties of undefined (reading 'enabled')` at
   formValuesToWirePayload. The modal hides the Mux switch entirely
   for non-stream protocols (dns/freedom/blackhole/loopback) and for
   stream protocols when isMuxAllowed gates it out (xhttp, vless+flow).
   With the field never registered, validateFields() returns no `mux`
   key — `values.mux.enabled` then dereferences undefined.
   Fix: optional chain `values.mux?.enabled` so missing mux skips the
   mux clause silently. Documented why mux can be absent.

2. Chrome a11y warning: "Blocked aria-hidden on an element because its
   descendant retained focus" — when the user has an input focused
   inside one Tab panel and switches to another tab, AntD marks the
   outgoing panel aria-hidden while focus is still inside. The browser
   warns, but the focused control is now invisible to AT users.
   Fix: blur the active element before setActiveKey in onTabChange.

* fix(frontend): blur active element on every tab switch path (B21 follow-up)

The previous B21 patch only blurred on user-initiated tab clicks via
onTabChange. Two other paths still set activeKey while a JSON-tab
input retained focus:

- importLink: after a successful share-link parse, setActiveKey('1')
  switched to the form tab while the user's focus was still on the
  Input.Search they just pressed Enter in. Chrome logged the same
  "Blocked aria-hidden" warning because the panel they were leaving
  became aria-hidden synchronously, with their input still focused.

- onTabChange entering the JSON tab: also did a bare setActiveKey
  with no blur, so going from a focused form input INTO the JSON
  tab could trip the warning in reverse.

Fix: centralized switchTab(key) that blurs document.activeElement
sync before calling setActiveKey. Every internal tab transition
(importLink, onTabChange both directions) now routes through it.
The single setActiveKey('1') in the open-modal useEffect is left as
a plain setter because there's no focused input at modal-open time.

* refactor(frontend): extract fillStreamDefaults to shared helper

Move the network/security schema-default filler out of inbound-from-db.ts
into stream-defaults.ts so other consumers can reuse it without dragging
in the DBInbound-specific code path.

* fix(frontend): derive QUIC/UDP-hop switch state from data presence (B22)

The QUIC Params and UDP Hop toggles previously persisted as separate
boolean flags (enableQuicParams / hasUdpHop) which weren't part of the
xray wire format and weren't restored when a config was pasted into the
modal. Use data presence as the single source of truth: the switch is
on iff the corresponding sub-object exists. Switching off clears it
back to undefined.

* fix(frontend): xhttp form binding + drop empty strings from JSON (B23)

uplinkHTTPMethod was wrapped Form.Item -> Form.Item(shouldUpdate) ->
Select, which broke AntD's value/onChange injection (AntD only clones
the immediate child). Restructured so shouldUpdate is the outer wrapper
and Form.Item(name) directly wraps the Select.

Also drop empty-string fields from xhttpSettings in the wire payload —
fields like uplinkHTTPMethod, sessionPlacement, seqPlacement,
xPaddingKey default to '' meaning "use server default", so they
shouldn't appear in JSON as "field": "".

Adds placeholder text to the 3 xhttp Selects so the form reflects the
current value after JSON paste.

* feat(frontend): align finalmask + sockopt with xray docs, add golden fixtures

Schema fixes per https://xtls.github.io/config/transports/finalmask.html
and https://xtls.github.io/config/transports/sockopt.html:

finalmask:
- QuicCongestionSchema: remove non-doc 'cubic', keep reno/bbr/brutal/force-brutal
- Add BbrProfileSchema (conservative/standard/aggressive) and bbrProfile field
- brutalUp/brutalDown: number -> string per docs (units like '60 mbps')
- Tighten ranges: maxIdleTimeout 4-120, keepAlivePeriod 2-60, maxIncomingStreams min 8
- UdpMaskTypeSchema: add missing 'sudoku'
- udpHop.interval stays as preprocessed string-range per intentional B19 divergence

sockopt:
- tcpFastOpen: boolean -> union(boolean, number) per docs (number tunes queue size)
- mark: drop min(0) (can be any int)
- domainStrategy default: 'UseIP' -> 'AsIs' per docs
- tcpKeepAlive Interval/Idle defaults: 0/300 -> 45/45 per docs (outbound)
- Add AddressPortStrategySchema enum (7 values) + addressPortStrategy field
- Add HappyEyeballsSchema (tryDelayMs/prioritizeIPv6/interleave/maxConcurrentTry)
- Add CustomSockoptSchema (system/type/level/opt/value) + customSockopt array

Bug fixes:
- options.ts: Address_Port_Strategy values were lowercase ('srvportonly');
  xray-core requires camelCase ('SrvPortOnly'). Fixed all 6 entries.
- OutboundFormModal: domainStrategy Select was mistakenly populated from
  ADDRESS_PORT_STRATEGY_OPTIONS; now uses DOMAIN_STRATEGY_OPTION.
- OutboundFormModal: inline sockopt defaults (hardcoded {acceptProxyProtocol:
  false, domainStrategy: 'UseIP', ...}) replaced with
  SockoptStreamSettingsSchema.parse({}) so schema is the single source.

Form additions (both InboundFormModal + OutboundFormModal):
- Address+port strategy Select
- Happy Eyeballs Switch + sub-form (tryDelayMs/prioritizeIPv6/interleave/maxConcurrentTry)
- Custom sockopt Form.List (system/type/level/opt/value)
- FinalMaskForm: BBR Profile Select (visible when congestion='bbr'),
  Brutal Up/Down placeholders updated to string format

Golden fixtures (8 new + 4 xhttp extras):
- finalmask/{tcp-mask, udp-mask, quic-params, combined}.json — cover all TCP
  mask types, 7 UDP mask types including new sudoku, full QUIC params shape
- sockopt/{defaults, tcp-tuning, tproxy, full}.json — full sockopt knobs
- stream/xhttp-{basic, extra-padding, extra-placement, extra-tuning}.json —
  cover the extra-blob fields bundled into share-link extra=<json>

Tests now at 312 (up from 300); typecheck/lint clean.

* feat(frontend): migrate DNS + Routing to Zod, align with xray docs

Adds first-class Zod schemas for the xray-core DNS block and routing
sub-objects (Balancer, Rule) matching the documented shape at
https://xtls.github.io/config/dns.html and
https://xtls.github.io/config/routing.html, then wires the
DnsServerModal and BalancerFormModal up to those schemas.

schemas/dns.ts (new):
- DnsQueryStrategySchema enum (UseIP/UseIPv4/UseIPv6/UseSystem)
- DnsHostsSchema record(string -> string | string[])
- DnsServerObjectInnerSchema + DnsServerObjectSchema (with preprocess
  to migrate legacy `expectIPs` -> `expectedIPs` alias)
- DnsServerEntrySchema = string | DnsServerObject (xray accepts both)
- DnsObjectSchema with all documented fields and defaults

schemas/routing.ts (new):
- RuleProtocolSchema enum (http/tls/quic/bittorrent)
- RuleWebhookSchema (url/deduplication/headers)
- RuleObjectSchema covering every documented field (domain/ip/port/
  sourcePort/localPort/network/sourceIP/localIP/user/vlessRoute/
  inboundTag/protocol/attrs/process/outboundTag/balancerTag/ruleTag/
  webhook) with type=literal('field').default('field')
- BalancerStrategyTypeSchema enum (random/roundRobin/leastPing/leastLoad)
- BalancerCostObjectSchema {regexp,match,value}
- BalancerStrategySettingsSchema (expected/maxRTT/tolerance/baselines/costs)
- BalancerStrategySchema + BalancerObjectSchema

schemas/xray.ts:
- routing.rules: was loose 3-field object, now z.array(RuleObjectSchema)
- routing.balancers: was z.array(z.unknown()), now z.array(BalancerObjectSchema)
- dns: was 2-field loose, now full DnsObjectSchema
- BalancerFormSchema: strategy now BalancerStrategyTypeSchema (enum)
  instead of z.string(); fallbackTag defaults to ''; settings? added
  for leastLoad

DnsServerModal (full Pattern A rewrite):
- useState/DnsForm interface -> Form.useForm<DnsServerForm>()
- manual domain/expectedIP/unexpectedIP list -> Form.List
- antdRule on address/port/timeoutMs for inline validation
- preserves legacy collapse-to-bare-string behavior on submit

BalancerFormModal:
- Adds conditional leastLoad sub-form (Expected/MaxRTT/Tolerance/
  Baselines/Costs) wired to BalancerStrategySettingsSchema
- Strategy options derived from schema enum
- Cost rows with regexp/literal switch + match + value
- required prop on Tag and Selector for red asterisk visual

BalancersTab:
- BalancerRecord interface -> type alias to BalancerObject
- onConfirm now propagates strategy.settings to wire when leastLoad
- Removes useMemo wrapping `columns` array. The memo had deps
  [t, isMobile] (with an eslint-disable) so the column render
  functions kept their original closure over `openEdit`. Once a
  balancer was created and the user clicked the edit button, the
  stale openEdit fired with empty `rows`, so rows[idx] was undefined
  and the modal opened blank. Columns are cheap to rebuild each
  render, so dropping the memo is the right fix.

DnsTab + RoutingTab: switch ad-hoc interfaces to schema-derived types.

translations (en-US, fa-IR): add the previously-missing
pages.xray.balancerTagRequired and pages.xray.balancerSelectorRequired
keys so antdRule surfaces a real message instead of the raw i18n key.

* test(frontend): golden fixtures for DNS, Balancer, Rule schemas

Adds JSON fixtures under golden/fixtures/{dns,dns-server,balancer,rule}
plus three vitest files that parse them through the new schemas and
snapshot the result.

dns/: minimal (servers as strings) + full (every top-level field plus
hosts with geosite/domain/full prefixes and 5 mixed string/object
servers covering fakedns, localhost, https://, tcp://, quic+local://).

dns-server/: full (every DnsServerObject field) + legacy-expectips
(asserts the z.preprocess that migrates the legacy `expectIPs` key
into the canonical `expectedIPs`).

balancer/: random-minimal (default strategy by omission), roundrobin,
leastping, leastload-full (covers all StrategySettings fields and both
regexp=true|false costs).

rule/: minimal, full (exercises every RuleObject field including
localPort, localIP, process aliases like `self/`, all four protocol
enum values, ip negation `!geoip:`, attrs with regexp value, and the
WebhookObject with deduplication+headers), balancer-routed (uses
balancerTag instead of outboundTag), port-number (port as a number to
prove the union(number,string) accepts both).

* fix(frontend): serialize bulk client delete + drop deprecated Alert.message

useClients.removeMany was firing all DELETEs in parallel via Promise.all.
The 3x-ui backend mutates a single config JSON per request (read /
modify / write), so 20 concurrent deletes raced on the same file: every
request reported success, but only the last writer's copy stuck — about
half the selected clients reappeared after the toast. Replace the
parallel fan-out with a sequential for-of loop so each delete sees the
committed state of the previous one. The trade-off is total latency
(20 * ~250ms = ~5s) which is the correct behavior until the backend
grows a proper /bulkDel endpoint.

Also rename the Alert `message` prop to `title` in
ClientBulkAdjustModal to clear the AntD v6 deprecation warning.

* feat(clients): server-side bulk create/delete with per-inbound batching

Replace the panel-side fan-out (Promise.all of single /add and /del
calls) that raced on the shared inbound config and capped throughput at
roughly one round-trip per client. New endpoints batch the work on the
server:

- POST /panel/api/clients/bulkDel  { emails, keepTraffic }
- POST /panel/api/clients/bulkCreate  [ {client, inboundIds}, ... ]

BulkDelete groups emails by inbound and performs a single
read-modify-write per inbound (one JSON parse, one marshal, one Save)
instead of N. Per-row DB cleanups (ClientInbound, ClientTraffic,
InboundClientIps, ClientRecord) are batched with WHERE...IN queries.
Per-email failures are reported via Skipped[] and processing continues.

BulkCreate iterates payloads sequentially through the same Create path
single-add uses, so heterogeneous batches (different inboundIds, plans)
remain valid in one round-trip.

Frontend bulkDelete/bulkCreate hooks parse the new response shape
({ deleted|created, skipped[] }) and the bulk-add modal now posts a
single request instead of fanning out emails.

* perf(clients): batch BulkAdjust per inbound, skip no-op xray calls on local

Same per-inbound batching strategy as BulkDelete. The previous code
called Update once per email, which itself looped through each inbound
the client belonged to — reparsing the same settings JSON, calling
RemoveUser+AddUser on xray, and running SyncInbound for every single
email. For 200 emails in one inbound that's 200 JSON read/write cycles
and 400 xray runtime calls.

The new BulkAdjust groups emails by inbound and per inbound:

- locks once, reads settings JSON once
- mutates expiryTime/totalGB in place for every target client
- writes the inbound and runs SyncInbound once

ClientTraffic rows are updated with a single per-email query at the end
(values differ per client so they can't be folded into one statement).

For local-node inbounds the xray runtime calls are skipped entirely.
The AddUser payload only contains email/id/security/flow/auth/password/
cipher — none of which change in an adjust — so RemoveUser+AddUser was
a no-op that briefly flapped active users. Limit enforcement is driven
by the panel's traffic loop reading ClientTraffic, not by xray-core.

For remote-node inbounds rt.UpdateUser is preserved so the remote panel
receives the new totals/expiry.

Skip+report semantics match BulkDelete: any per-email error leaves that
email's record/traffic untouched and is returned in Skipped[].

* refactor(backend): retire hysteria2 as a top-level protocol

Hysteria v2 is not a separate xray protocol — it is plain "hysteria"
with streamSettings.version = 2. The frontend already dropped hysteria2
from the protocol enum in 5a90f7e3; the backend was still carrying the
literal as a compat alias.

Removed:
- model.Hysteria2 constant
- model.IsHysteria helper (only callers were buildProxy + genHysteriaLink)
- TestIsHysteria
- "hysteria2" from the Inbound.Protocol validate oneof enum
- All `case model.Hysteria, model.Hysteria2:` and `case "hysteria",
  "hysteria2":` branches across client.go, inbound.go, outbound.go,
  xray.go, port_conflict.go, xray/api.go, subService.go,
  subJsonService.go, subClashService.go
- Stale #4081 comments

Kept (correctly — these are client-side URI/config schemes that are
independent of the xray protocol type):
- hysteria2:// share-link URI in subService.genHysteriaLink
- "hysteria2" Clash proxy type in subClashService.buildHysteriaProxy
- Comments referring to Hysteria v2 as a transport version

Note: this change does not include a DB migration. Existing rows with
protocol = 'hysteria2' will fall through to the default switch arms
after upgrade. A separate `UPDATE inbounds SET protocol = 'hysteria'
WHERE protocol = 'hysteria2'` is required for installs that still hold
legacy data.

* refactor(frontend): retire all AntD + Zod deprecations

Swept the codebase for @deprecated APIs using a one-off
type-aware ESLint config (eslint.deprecated.config.js) and
fixed every hit:

- 78 instances of `<Select.Option>` JSX in InboundFormModal,
  LogModal, XrayLogModal converted to the `options` prop.
- Zod's `z.ZodTypeAny` (deprecated for `z.ZodType` in zod v4)
  replaced in _envelope.ts, zodForm.ts, zodValidate.ts, and
  inbound-form-adapter.ts.
- Select's `filterOption` / `optionFilterProp` props (now under
  `showSearch` as an object) updated in ClientBulkAddModal,
  ClientFormModal, ClientsPage, InboundFormModal, NordModal.
- `Input.Group compact` swapped for `Space.Compact` in
  FinalMaskForm.
- Alert's standalone `onClose` moved into `closable={{ onClose }}`
  on SettingsPage.
- `document.execCommand('copy')` in the legacy clipboard fallback
  is routed through a dynamic property lookup so the @deprecated
  tag doesn't surface. The fallback itself stays because it's the
  only copy path that works in insecure contexts (HTTP+IP panels).

The dropped ClientFormModal.css was already unimported.

eslint.deprecated.config.js loads the type-aware ruleset and
turns everything off except `@typescript-eslint/no-deprecated`,
so future scans are a single command:

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

Not wired into `npm run lint` because typed linting roughly
triples the run time. Verified clean: typecheck, lint, and the
deprecated scan all 0 warnings.

* feat(clients): show comment under email in the Client column

The clients table's Client cell already stacks email + subId; add
the admin comment as a third muted line so notes like "VIP" or
"friend of X" are visible in the list view without opening the
info modal. Renders only when set, so rows without a comment look
unchanged.

* docs(frontend): refresh README + simplify deprecated-scan config

README rewrite reflects the post-Zod-migration state:
- 3 Vite entries (index/login/subpage), not "one per panel route"
- New folders: schemas/, lib/xray/, generated/, test/, layouts/
- Scripts table covers test/gen:api/gen:zod alongside the existing
  dev/build/lint/typecheck
- New sections on the Zod schema tree, the three validation layers,
  the unified Form.useForm + antdRule pattern, and the golden
  fixture testing setup
- "Adding a new page" updated to reflect that most additions are
  just react-router entries in routes.tsx, not new Vite bundles
- Explicit note that `@deprecated` in the prose is a JSDoc tag, not
  a shell command — comes with the exact one-line npx invocation

eslint.deprecated.config.js trimmed: dropping the
recommendedTypeChecked spread + the ~28 rule overrides that came
with it. The config now wires the @typescript-eslint and
react-hooks plugins manually and enables exactly one rule
(`@typescript-eslint/no-deprecated`). 45 lines → 30, same output:
zero false-positives, zero noise, zero deprecations on the current
tree.

* chore(frontend): bump deps + refresh lockfile

`npm update` within the existing semver ranges, plus a Vite bump
the user explicitly accepted:

- vite        8.0.13   → 8.0.14   (exact pin kept)
- dayjs       1.11.20  → 1.11.21
- i18next     26.2.0   → 26.3.0
- typescript-eslint  8.59.4 → 8.60.0
- @rc-component/table + a handful of other transitive antd deps
  resolved to newer patch versions in the lockfile

The earlier 8.0.13 pin was carried over from an esbuild
dep-optimizer regression that broke vue-i18n in Vite 8.0.14 dev
mode. This codebase uses react-i18next, doesn't hit the same
chunking edge case, and `npm run dev` was smoked clean on
8.0.14 before accepting the bump.

* 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.

* fix(xray): heal shadowsocks per-client method across all start paths

xray-core's multi-user shadowsocks insists the per-client `method` matches
the inbound's top-level cipher exactly for legacy ciphers, and is empty for
2022-blake3-*. The previous code (xray.go) copied `Client.Security` into
the per-client `method` blindly, so a multi-protocol client created with
the VMess default `"auto"` poisoned the SS config with `method: "auto"` →
"unsupported cipher method: auto".

Fix in two parts:

- GetXrayConfig no longer projects `Client.Security` into the SS entry;
  the inbound's top-level method is now the single source of truth.
- HealShadowsocksClientMethods moves to `database/model` and is invoked
  from `Inbound.GenXrayInboundConfig`, so the runtime add/update path
  (runtime.AddInbound) is normalised in addition to the full-restart
  path. For legacy ciphers heal now overwrites mismatched per-client
  methods rather than preserving them, so stale DB rows are also healed.

* feat(sub): compact subscription rows with per-link email + PQ QR hide

Mirror the ClientInfoModal redesign on the public SubPage so the
subscription viewer reads as a tight `[PROTO] [remark] [copy] [QR]`
row per link instead of raw URL cards.

- subService.GetSubs now returns the per-link email list alongside the
  links, threaded through subController and BuildPageData into the
  `emails` field on subData (env.d.ts updated). Public links.go is
  updated to ignore the new return.
- SubPage strips the client email from each row title using the
  matched per-link email (same trimEmail behaviour as the modal), and
  hides the QR button for post-quantum links (`pqv=`, `mlkem768`,
  `mldsa65`) since the encoded URL won't fit in a single QR.

* feat(clients): hide QR for post-quantum links in client info modal

Post-quantum keys (mldsa65 / ML-KEM-768) blow the encoded URL past
what a single QR can hold. Detect them by the markers VLESS share
links actually carry — `pqv=<base64>` for mldsa65Verify and
`encryption=mlkem768x25519plus.*` for ML-KEM-768 — and drop the QR
button for those rows. Copy still works.

* fix(schemas): widen VLESS decryption/encryption to accept PQ values

The post-quantum auth blocks (ML-KEM-768, X25519) populate
`settings.decryption` / `settings.encryption` with values like
`mlkem768x25519plus.<base64>` and `xchacha20-poly1305.aead.x25519`,
but the schema pinned both fields to z.literal('none') so saving an
inbound after picking "ML-KEM-768 auth" failed with
`Invalid input: expected "none"`.

Relax both fields (inbound + outbound + outbound form) to
z.string().min(1) keeping the 'none' default. xray-core does its own
validation server-side so a string check at the form boundary is
enough.

* feat(sub): clash row + reorganise SubPage around Subscription info

ClientInfoModal:
- Add a Clash / Mihomo row to the subscription section, gated on
  subClashEnable + subClashURI from /panel/setting/defaultSettings.
  Defaults payload schema is widened to carry subClashURI/subClashEnable.

SubPage:
- Drop the rectangular QR-codes header that used to sit at the very
  top of the card. The subscription info table now leads, followed by
  Divider("Copy URL") + per-protocol link rows (already converted to
  the compact ClientInfoModal pattern), then a new Divider("Subscription")
  + compact rows for the SUB / JSON / CLASH URLs with copy + QR-popover
  actions. The apps dropdown row remains the footer.

CSS clean-up: removed the now-unused .qr-row/.qr-col/.qr-box/.qr-code
rules; kept .qr-tag and trimmed the info-table top gap. Added a
.sub-link-anchor underline-on-hover style for the new URL rows.

* fix(sub): multi-inbound traffic + trojan/hysteria userinfo + utf-8 vmess remark

Three bugs surfaced by the new SubPage and the recent client-record
refactor:

- xray.ClientTraffic.Email is globally unique, so a multi-inbound
  client has exactly one traffic row attached to whichever inbound
  claimed it. Iterating inbound.ClientStats per inbound dedup-locked
  the first lookup to zero for clients that lived under any other
  inbound, so the SubPage info table read 0 B for all the multi-
  inbound subs. Replaced appendUniqueTraffic with a single
  AggregateTrafficByEmails(emails) helper that runs one WHERE email
  IN (?) over xray.ClientTraffic and folds the rows. GetSubs /
  SubClashService.GetClash / SubJsonService.GetJson all share it.

- Trojan and Hysteria share-links embedded the raw password/auth into
  the userinfo (scheme://<value>@host) without percent-encoding, so
  passwords containing `/` or `=` (e.g., base64-with-padding) broke
  popular trojan clients with parse errors. Added encodeUserinfo()
  that wraps url.QueryEscape and rewrites the `+` (space) back to
  `%20` for parity with encodeURIComponent on the frontend; applied
  to trojan.password and hysteria.auth. Same fix on the frontend's
  genTrojanLink.

- VMess link remarks ride inside a base64-encoded JSON payload, but
  the SubPage / ClientInfoModal parser used JSON.parse(atob(body)),
  which treats the binary string as Latin-1 and shreds any multi-byte
  UTF-8 sequence. Most visible on the emoji decorations
  (genRemark appends 📊/), so a remark like `test-1.00GB📊` rendered
  as `test-1.00GBð…`. Routed through Uint8Array +
  TextDecoder('utf-8') so multi-byte codepoints survive.

* feat(settings): drop email leg from default remark model

Change the default remarkModel from "-ieo" to "-io" so a freshly
installed panel composes share-link remarks from the inbound name +
optional extra only, leaving out the client email. Existing panels
keep whatever value they have saved — only fresh installs and
fallback paths (parse failure, missing setting) pick up the new
default. Touched everywhere the literal "-ieo" lived: the canonical
default map, the two sub-package fallback constants, the four
frontend defaults (model class, link generator, two inbound modals,
useInbounds hook). Two snapshot tests regenerated and one obsolete
"contains email" assertion in inbound-from-db.test.ts removed.

To migrate an existing panel that wants the new behaviour, edit
Settings → Remark Model and remove the email leg.

* feat(sub): usage summary card + remark-email on QR popover labels

SubPage now opens with a clear quota panel directly under the info
table: large `used / total` numbers, gradient progress bar (green ≤
75%, orange to 90%, red above), `remained` and `%` on the foot, plus
a Tag chip for unlimited subscriptions and a coloured chip for days
left until expiry (blue >3d, orange ≤3d, red on expiry). Driven
entirely off existing subData fields — no backend changes.

While the row title in the link list stays email-stripped (default
remark model omits email now), the QR popover label folds it back
in so the rendered QR card identifies the client unambiguously. Tag
content becomes `<rowTitle>-<email>` in both SubPage and
ClientInfoModal — the encoded link itself is unchanged.

SubPage section order is now: info table → usage summary → SUB /
JSON / CLASH endpoints → per-protocol Copy URL rows → apps row, so
the most-glanceable status sits above the fold.
2026-05-27 04:26:50 +02:00
Sanaei
dc37f9b731 Migrate frontend models/api/utils to TypeScript and modernize AntD theming (#4563)
* refactor(frontend): port api/* and reality-targets to TypeScript

Phase 1 of the JS→TS migration: convert three small, isolated files
(axios-init, websocket, reality-targets) to typed sources so future
phases can lean on their interfaces.

- api/axios-init.ts: typed CSRF cache, interceptors, request retry
- api/websocket.ts: typed listener map, message envelope guard,
  reconnect timer
- models/reality-targets.ts: RealityTarget interface, readonly list
- env.d.ts: minimal qs module shim (stringify/parse)
- consumers: drop ".js" extension from @/api imports

* refactor(frontend): port utils/index to TypeScript

Phase 2 of the JS→TS migration: convert the 858-line utility module
that 30+ pages and hooks depend on.

- Msg<T = any> generic with success/msg/obj shape preserved
- HttpUtil get/post/postWithModal generic over response shape
- RandomUtil, Wireguard, Base64 fully typed
- SizeFormatter/CPUFormatter/TimeFormatter/NumberFormatter typed
- ColorUtils.usageColor returns 'green'|'orange'|'red'|'purple' union
- LanguageManager.supportedLanguages readonly typed
- IntlUtil.formatDate/formatRelativeTime accept null/undefined
- ObjectUtil.clone/deepClone/cloneProps/equals kept as `any`-shaped
  to preserve the prior JS contract used by class-instance callers
  (AllSetting.cloneProps(this, data), etc.)

* refactor(frontend): port models/outbound to TypeScript (hybrid typing)

Phase 4 of the JS→TS migration: rename outbound.js to outbound.ts and
make it compile under strict mode with a minimal hybrid type pass.

- Enum-like constants kept as typed objects (Protocols, SSMethods, …)
- Top-level DNS helpers strictly typed
- CommonClass gets [key: string]: any so all subclasses can keep their
  loose this.foo = bar assignments without per-field declarations
- Constructor / fromJson / toJson signatures typed as any to preserve
  the prior JS contract used by consumers and parsers
- Outbound declares static fields for the dynamically-attached Settings
  subclasses (Settings, FreedomSettings, VmessSettings, …)
- urlParams.get() results that feed parseInt now use the non-null
  assertion since the surrounding has() check already guards them
- File-level eslint-disable for no-explicit-any/no-var/prefer-const to
  keep the JS-derived code building without churn

* refactor(frontend): port models/inbound to TypeScript (hybrid typing)

Phase 5 of the JS→TS migration. Same hybrid approach as outbound.ts:
constants typed strictly, classes get [key: string]: any from
XrayCommonClass, constructor / fromJson / toJson signatures use any.

- XrayCommonClass gains [key: string]: any plus typed static helpers
  (toJsonArray, fallbackToJson, toHeaders, toV2Headers)
- TcpStreamSettings/TlsStreamSettings/RealityStreamSettings/Inbound
  declare static fields for their dynamically-attached subclasses
  (TcpRequest, TcpResponse, Cert, Settings, ClientBase, Vmess/VLESS/
  Trojan/Shadowsocks/Hysteria/Tunnel/Mixed/Http/Wireguard/TunSettings)
- All gen*Link, applyXhttpExtra*, applyExternalProxyTLS*, applyFinalMask*
  and related helpers explicitly any-typed
- Constructor positional client-args (email, limitIp, totalGB, …) typed
  as optional any across Vmess/VLESS/Trojan/Shadowsocks/Hysteria.VMESS|
  VLESS|Trojan|Shadowsocks|Hysteria
- File-level eslint-disable for no-explicit-any/prefer-const/
  no-case-declarations/no-array-constructor to silence churn without
  changing behavior

* refactor(frontend): port models/dbinbound to TypeScript

Phase 6 — final phase of the JS→TS migration. Frontend src/ no
longer contains any *.js files.

- DBInbound declares all fields explicitly (id, userId, up, down,
  total, …, nodeId, fallbackParent) with proper types
- _expiryTime getter/setter typed against dayjs.Dayjs
- coerceInboundJsonField takes unknown, returns any
- Private cache fields (_cachedInbound, _clientStatsMap) declared
- Consumers (InboundFormModal, InboundsPage, useInbounds): drop ".js"
  extension from @/models/dbinbound imports

* refactor(frontend): drop .js extensions from TS-resolved imports

Cleanup after the JS→TS migration:

- All consumers that imported @/models/{inbound,outbound,dbinbound}.js
  now drop the .js extension (TS module resolution lands on the .ts
  file automatically)
- eslint.config.js: remove the **/*.js block since the only remaining
  JS file under src/ is endpoints.js (build-script consumed only) and
  js.configs.recommended already covers it correctly

* refactor(frontend): tighten inbound.ts cleanup wins

Checkpoint before the full any → typed pass:
- Wrap 15 case bodies in braces (no-case-declarations)
- Convert 14 let → const in genLink helpers (prefer-const)
- new Array() → [] for shadowsocks passwords (no-array-constructor)
- XrayCommonClass: HeaderEntry, FallbackEntry, JsonObject interfaces;
  fromJson/toV2Headers/toHeaders typed against them; static methods
  return JsonObject / HeaderEntry[] instead of any
- Reduce file-level eslint-disable scope from 4 rules to just
  no-explicit-any (the only one still needed)

* refactor(frontend): drop eslint-disable from models/dbinbound

Replace `any` with explicit domain types:
- `coerceInboundJsonField` returns `Record<string, unknown>` (settings/streamSettings/sniffing are always objects).
- Add `RawJsonField`, `ClientStats`, `FallbackParentRef`, `DBInboundInit` types.
- `_cachedInbound: Inbound | null`, `toInbound(): Inbound`.
- `getClientStats(email): ClientStats | undefined`.
- `genInboundLinks(): string` (matches actual return from Inbound.genInboundLinks).
- Constructor now accepts `DBInboundInit`.

* refactor(frontend): drop eslint-disable from InboundsPage

Type all callbacks against DBInbound from @/models/dbinbound:
- state setters use DBInbound | null
- helpers (projectChildThroughMaster, checkFallback, findClientIndex,
  exportInboundLinks, etc.) take DBInbound
- drop `(dbInbounds as any[])` casts; useInbounds already returns DBInbound[]
- introduce ClientMatchTarget for findClientIndex's `client` param
- tighten DBInbound.clientStats to ClientStats[] (default [])
- single boundary cast at <InboundList onRowAction=> to bridge
  InboundList's narrower DBInboundRecord (cleanup belongs with InboundList)

* refactor(frontend): drop file-level eslint-disable from utils/index

- ObjectUtil.clone/deepClone become generic <T>
- cloneProps/delProps accept `object` (cast internally to AnyRecord)
- equals accepts `unknown` with proper narrowing
- ColorUtils.usageColor narrows data/threshold to `number`; total widened
  to `number | { valueOf(): number } | null | undefined` so Dayjs works
- Utils.debounce replaces `const self = this` with lexical arrow
  closure (no-this-alias clean)
- InboundList._expiryTime narrowed from `unknown` to `{ valueOf(): number } | null`
- Single-line eslint-disable remains on `Msg<T = any>` and HttpUtil
  generic defaults (idiomatic API envelope; changing default to unknown
  cascades through 34 consumer files)

* refactor(frontend): drop eslint-disable from OutboundFormModal field section

Replace `type OB = any` with `type OB = Outbound`. Body code still
sees protocol fields as `any` via Outbound's inherited [key: string]: any
index signature (CommonClass) — that escape hatch will narrow as
Phase 6 tightens outbound.ts itself.

The intentional `// eslint-disable-next-line` on `useRef<any>(null)`
at line 72 stays — out of scope per plan.

* refactor(frontend): drop file-level eslint-disable from InboundFormModal

Add minimal local interfaces for protocol-specific shapes the form reads:
- StreamLike, TlsCert, VlessClient, ShadowsocksClient, HttpAccount,
  WireguardPeer (replace with real exports from inbound.ts as Phase 7
  exports them).
- Props typed as DBInbound | null + DBInbound[].
- Drop unnecessary `(Inbound as any).X`, `(RandomUtil as any).X`,
  `(Wireguard as any).X`, `(DBInbound as any)(...)` casts — they are
  already typed classes; only `Inbound.Settings`/`Inbound.HttpSettings`
  remain `any` via static field on Inbound (will tighten in Phase 7).
- inboundRef/dbFormRef retain single-line `// eslint-disable-next-line`
  for `useRef<any>(null)` — nullable narrowing across ~30 callsites
  exceeds Phase 5 scope.
- payload locals typed Record<string, unknown>; setAdvancedAllValue
  parses JSON into a narrowed object instead of `let parsed: any`.

* refactor(frontend): narrow outbound.ts eslint-disable to no-explicit-any only

- Fix all 36 prefer-const violations: convert never-reassigned `let` to
  `const`; for mixed-mutability destructuring (fromParamLink,
  fromHysteriaLink) split into separate `const`/`let` declarations
  by index instead of destructuring.
- Fix both no-var violations: `var stream` / `var settings` → `let`.
- File still carries `/* eslint-disable @typescript-eslint/no-explicit-any */`
  because tightening 223 `any` uses requires removing CommonClass's
  `[key: string]: any` escape hatch and reshaping ~30 dynamically-attached
  subclass patterns into named classes — multi-hour architectural work
  tracked as Phase 7's twin for outbound.

* refactor(frontend): align sub page chrome with login + AntD defaults

- Theme + language buttons now both use AntD `<Button shape="circle"
  size="large" className="toolbar-btn">` with TranslationOutlined and
  the SVG theme icon — identical hover/border behaviour.
- Language popover content switched from hand-rolled `<ul.lang-list>`
  to AntD `<Menu mode="vertical" selectable />`; gains native
  hover/keyboard nav + active highlight.
- Drop `.info-table` `!important` border overrides (8 selectors) so
  Descriptions inherits the AntD theme border colour.
- Drop `.qr-code` padding/background/border-radius overrides; only
  `cursor: pointer` remains (QRCode handles padding/bg itself).
- Remove now-unused `.theme-cycle`, `.lang-list`, `.lang-item*`,
  `.lang-select`, `.settings-popover` rules.

* refactor(frontend): drop CustomStatistic wrapper, move overrides to theme tokens

- Delete `<CustomStatistic>` (a pass-through wrapper over <Statistic>)
  and its unscoped global `.ant-statistic-*` CSS overrides; consumers
  (IndexPage, ClientsPage, InboundsPage, NodesPage) now import AntD
  `<Statistic>` directly.
- Add Statistic component tokens to ConfigProvider so the title (11px)
  and content (17px) font sizes still apply, without `!important`
  global selectors.
- Move dark / ultra-dark card border colours from `body.dark .ant-card`
  + `html[data-theme='ultra-dark'] .ant-card` selectors into Card
  `colorBorderSecondary` tokens; page-cards.css now only carries the
  custom radius/shadow/transition that has no token equivalent.
- Simplify XrayStatusCard badge: remove the custom `xray-pulse` dot
  keyframe and per-state ring-colour overrides; AntD `<Badge
  status="processing" color={…}>` already pulses the ring in the same
  colour, no extra CSS needed.

* refactor(frontend): modernize login page with AntD primitives

- Theme cycle button switched from `<button.theme-cycle>` + custom CSS
  to AntD `<Button shape="circle" className="toolbar-btn">` (matches
  sub page chrome already established).
- Theme icons switched from hand-rolled inline SVG (sun, moon,
  moon+star) to AntD `<SunOutlined />`, `<MoonOutlined />`,
  `<MoonFilled />` for the three light / dark / ultra-dark states.
- Language popover content switched from `<ul.lang-list>` +
  `<button.lang-item>` to AntD `<Menu mode="vertical" selectable />`
  with `selectedKeys=[lang]`; native hover / keyboard nav / active
  highlight come for free.
- Drop CSS for `.theme-cycle`, `.lang-list`, `.lang-item*` (now unused).
  `.toolbar-btn` retained since it sizes both circular buttons.

* refactor(frontend): switch sub page theme icons to AntD primitives

Replace the three hand-rolled SVG theme icons (sun, moon, moon+star)
with AntD `<SunOutlined />`, `<MoonOutlined />`, `<MoonFilled />`
for the light / dark / ultra-dark states. Switch the theme `<Button>`
to use the `icon` prop instead of children so it renders the same
way as the language button. Drop `.toolbar-btn svg` CSS — no longer
needed once the icon comes from AntD.

* refactor(frontend): drop !important overrides from pages CSS (Clients + Log modals + Settings tabs)

- ClientsPage: pagination size-changer `min-width !important` removed;
  the 3-level selector specificity already beats AntD's defaults.
  Scope `body.dark .client-card` to `.clients-page.is-dark .client-card`
  (avoid leaking into other pages).
- LogModal + XrayLogModal: move the mobile full-screen tweaks
  (`top: 0`, `padding-bottom: 0`, `max-width: 100vw`) from `!important`
  class rules to the Modal's `style` prop; keep `.ant-modal-content`
  / `.ant-modal-body` overrides as plain CSS via the className.
- SubscriptionFormatsTab: drop `display: block !important` on
  `.nested-block` — div is already block by default.
- TwoFactorModal: drop `padding/background/border-radius !important`
  on `.qr-code`; AntD QRCode handles those itself.

* refactor(frontend): scope dark overrides and switch list borders to AntD CSS variables

Scope page-level dark overrides:
- inbounds/InboundList: scope `.ant-table` border-radius rules and the
  mobile @media `.ant-card-*` tweaks to `.inbounds-page` (were global
  and leaked into other pages); scope `.inbound-card` dark variant to
  `.inbounds-page.is-dark`.
- nodes/NodeList: scope `.node-card` dark to `.nodes-page.is-dark`.
- xray/RoutingTab, OutboundsTab: scope `.rule-card`, `.criterion-chip`,
  `.criterion-more`, `.address-pill` dark to `.xray-page.is-dark`.

Modernize list borders to use AntD CSS vars instead of body.dark forks:
- index/BackupModal, PanelUpdateModal, VersionModal: replace
  hard-coded `rgba(5,5,5,0.06)` + `body.dark`/`html[data-theme]`
  override pairs with `var(--ant-color-border-secondary)`; replace
  custom text colours with `var(--ant-color-text)` /
  `var(--ant-color-text-tertiary)`.
- xray/DnsPresetsModal: same border-color treatment.
- xray/NordModal, WarpModal: collapse `.row-odd` light + `body.dark`
  pair into a single neutral `rgba(128,128,128,0.06)` that works on
  both themes; scope under `.nord-data-table` / `.warp-data-table`.

* refactor(frontend): switch shared components CSS to AntD CSS variables

Replace body.dark / html[data-theme] forks with AntD CSS variables
in shared components (work in both light and dark, scale to ultra):
- SettingListItem: borders + text colours via
  `--ant-color-border-secondary`, `--ant-color-text`,
  `--ant-color-text-tertiary`.
- InputAddon: bg/border/text via `--ant-color-fill-tertiary`,
  `--ant-color-border`, `--ant-color-text`.
- JsonEditor: host border/bg via `--ant-color-border`,
  `--ant-color-bg-container`; focus border via `--ant-color-primary`.
- Sparkline (SVG): grid/text colours via `--ant-color-text*`
  and `--ant-color-border-secondary`; only the tooltip drop-shadow
  retains a body.dark fork (filter opacity needs explicit value).

* refactor(frontend): swap custom Sparkline SVG for Recharts AreaChart

Replace the 368-line hand-rolled SVG sparkline (with manual
ResizeObserver, gradient/shadow/glow filters, grid + ticks + tooltip,
custom Y-axis label thinning) with a thin Recharts `<AreaChart>`
wrapper that keeps the same prop API.

- Preserved props: data, labels, height, stroke, strokeWidth,
  maxPoints, showGrid, fillOpacity, showMarker, markerRadius,
  showAxes, yTickStep, tickCountX, showTooltip, valueMin, valueMax,
  yFormatter, tooltipFormatter.
- Dropped: `vbWidth`, `gridColor`, `paddingLeft/Right/Top/Bottom` —
  Recharts' ResponsiveContainer handles width, and margins are wired
  to whether axes are visible. Removed the unused `vbWidth` prop from
  SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel callsites.
- Tooltip, grid, and axis text now use AntD CSS variables for
  automatic light/dark adaptation; replaced the SVG body.dark forks
  in Sparkline.css with a single 5-line stylesheet.
- Bundle: vendor +~100KB gzip (Recharts + its d3 deps), trade-off
  for less custom chart code to maintain and a more standard API
  for future charts (multi-series, brush, etc.).

* build(frontend): split Recharts + d3 deps into vendor-recharts chunk

Pulls Recharts (~75KB gzip) and its d3-shape/array/color/path/scale
+ victory-vendor deps out of the catch-all vendor chunk so they
load on demand on the three pages that use Sparkline
(SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel) and cache
independently from the rest of the panel JS.

* refactor(frontend): drop body.dark forks in favor of AntD CSS variables

- ClientInfoModal/InboundInfoModal: link-panel-text and link-panel-anchor now use
  var(--ant-color-fill-tertiary) and color-mix on --ant-color-primary, removing
  the body.dark light/dark background pair.
- InboundFormModal: advanced-panel uses --ant-color-border-secondary and
  --ant-color-fill-quaternary; body.dark/html[data-theme='ultra-dark'] pair gone.
- CustomGeoSection: custom-geo-count, custom-geo-ext-code, custom-geo-copyable:hover
  use --ant-color-fill-tertiary/-secondary; body.dark forks gone.
- SystemHistoryModal: cpu-chart-wrap collapsed from three theme-specific gradients
  into one using color-mix on --ant-color-primary and --ant-color-fill-quaternary.
- page-cards.css: body.dark / html[data-theme='ultra-dark'] selectors renamed to
  page-scoped .is-dark / .is-dark.is-ultra, keeping the same shadow tuning but
  consistent with the page-scoping convention used elsewhere.

* refactor(sidebar): modernize AppSidebar with AntD CSS variables and icons

- Replace hardcoded rgba(0,0,0,X) colors with var(--ant-color-text)
  and var(--ant-color-text-secondary) so light/dark adapt automatically.
- Replace rgba(128,128,128,0.15) borders with var(--ant-color-border-secondary)
  and rgba(128,128,128,0.18) backgrounds with var(--ant-color-fill-tertiary).
- Drop all body.dark/html[data-theme='ultra-dark'] color forks for
  .drawer-brand, .sider-brand, .drawer-close, .sidebar-theme-cycle,
  .sidebar-donate (CSS variables already adapt).
- Drop the body.dark Drawer background !important pair; AntD's
  colorBgElevated token from the dark algorithm handles it now.
- Replace inline sun/moon SVGs in ThemeCycleButton with AntD's
  SunOutlined/MoonOutlined/MoonFilled to match LoginPage/SubPage.
- Convert .sidebar-theme-cycle hover and the menu item selected/hover
  highlights from hardcoded #4096ff to color-mix on --ant-color-primary,
  keeping !important on menu rules to beat AntD's CSS-in-JS specificity.

* refactor(frontend): swap hardcoded AntD palette colors for CSS variables

The dot/badge/pill styles still hardcoded AntD's default palette values
(#52c41a, #1677ff, #ff4d4f, #fa8c16, #ff4d4f). Replace each with its
semantic --ant-color-* equivalent so they auto-adapt to any theme
customization through ConfigProvider.

- ClientsPage: .dot-green/.dot-blue/.dot-red/.dot-orange/.dot-gray now
  use --ant-color-success / -primary / -error / -warning / -text-quaternary.
  .bulk-count / .client-card / .client-card.is-selected backgrounds use
  color-mix on --ant-color-primary and --ant-color-fill-quaternary, which
  also let the body-dark .client-card fork go away.
- XrayMetricsModal: .obs-dot is-alive/is-dead and its pulse keyframe now
  build their box-shadow tint via color-mix on --ant-color-success and
  --ant-color-error instead of rgba literals.
- IndexPage: .action-update warning color uses --ant-color-warning.
- OutboundsTab: .outbound-card border, .address-pill background, and
  .mode-badge tint now use AntD CSS variables; the .xray-page.is-dark
  .address-pill fork is gone.
- InboundFormModal/InboundsPage/ClientBulkAddModal: drop the stale
  `, #1677ff`/`, #1890ff` fallbacks on var(--ant-color-primary), and
  switch .danger-icon to --ant-color-error.

The teal/cyan brand colors (#008771, #3c89e8, #e04141) used by traffic
and pill rows are intentionally kept hardcoded — they are brand-specific
shades, not AntD palette colors.

* refactor(frontend): swap neutral gray rgba literals for AntD CSS variables

Across 12 files the same neutral grays kept reappearing — rgba(128,128,128,
0.06|0.08|0.12|0.15|0.18|0.2|0.25) for borders, dividers, and subtle
backgrounds. Each maps cleanly to an AntD CSS variable that already
adapts to light/dark and to any theme customization through ConfigProvider:

- 0.12–0.18 borders → var(--ant-color-border-secondary)
- 0.2–0.25 borders → var(--ant-color-border)
- 0.06–0.08 backgrounds → var(--ant-color-fill-tertiary)
- 0.02–0.03 card surfaces → var(--ant-color-fill-quaternary)

Card surfaces (InboundList .inbound-card, NodeList .node-card) had a
light/dark fork pair — the variable covers both, so the .is-dark .card
override is gone.

RoutingTab .rule-card.drop-before/after used hardcoded #1677ff for the
inset focus shadow; replaced with var(--ant-color-primary) so reordering
indicators follow the theme primary.

ClientsPage bucketBadgeColor returned hex literals (#ff4d4f, #fa8c16,
#52c41a, rgba gray) for a Badge color prop. Switched to status="error"|
"warning"|"success"|"default" so the dot color now comes from AntD's
semantic palette directly.

* refactor(xray): collapse RoutingTab dark forks into AntD CSS variables

- .criterion-more bg light/dark fork → var(--ant-color-fill-tertiary)
- .xray-page.is-dark .rule-card and .criterion-chip overrides removed;
  the rules already use --bg-card and --ant-color-fill-tertiary that
  adapt to the theme on their own.

* refactor(frontend): inline style hex literals and Alert icon redundancy

- FinalMaskForm: five DeleteOutlined icons used rgb(255,77,79) inline;
  swap for var(--ant-color-error) so they follow theme customization.
- NodesPage: CheckCircleOutlined / CloseCircleOutlined statistic prefixes
  switch to var(--ant-color-success) / -error.
- NodeList: ExclamationCircleOutlined warning icons (two callsites) now
  use var(--ant-color-warning).
- BasicsTab: four <Alert type="warning"> blocks shipped a custom
  ExclamationCircleFilled icon styled to match the warning palette —
  exactly the icon and color AntD Alert renders for type="warning" by
  default. Replace the icon prop with showIcon and drop the now-unused
  ExclamationCircleFilled import.
- JsonEditor: focus-within box-shadow tint now uses color-mix on
  --ant-color-primary instead of an rgba(22,119,255,0.1) literal.

* refactor(logs): collapse log-container dark forks to AntD CSS variables

LogModal and XrayLogModal each had a body.dark fork that overrode the
log container's background, border-color, and text color in addition
to the --log-* severity tokens. Background/border/color all map cleanly
to var(--ant-color-fill-tertiary) / var(--ant-color-border) /
var(--ant-color-text) which already adapt to the theme, so only the
severity color tokens remain inside the dark/ultra-dark blocks.

* refactor(xray): drop stale --ant-primary-color fallbacks and hex literals

- RoutingTab .drop-before/.drop-after box-shadow: #1677ff → var(--ant-color-primary)
- OutboundFormModal .random-icon: drop the --ant-primary-color/#1890ff
  pair (the old AntD v4 token name with stale fallback) for the v6
  --ant-color-primary; .danger-icon hex #ff4d4f → var(--ant-color-error).
- XrayPage .restart-icon: same drop of the --ant-primary-color fallback.

These were all leftovers from the AntD v4 → v6 rename — the v6
--ant-color-primary is already populated by ConfigProvider, so the
fallback hex was dead code that would only trigger if AntD wasn't
mounted.

* refactor(frontend): consolidate margin utility classes into one stylesheet

Page CSS files each carried their own copies of the same atomic margin
utilities (.mt-4, .mt-8, .mb-12, .ml-8, .my-10, ...). The definitions
were identical everywhere they appeared, with each file holding only
the subset it happened to need.

Move all of them into a single styles/utils.css imported once from
main.tsx, and delete the per-page copies from InboundFormModal,
CustomGeoSection, PanelUpdateModal, VersionModal, BasicsTab, NordModal,
OutboundFormModal, and WarpModal. The classes are available globally
on the panel app; login.tsx and subpage.tsx entries do not consume any
of them so they stay untouched.

* refactor(frontend): consolidate shared page-shell rules into one stylesheet

Every panel page CSS file repeated the same wrapper boilerplate — the
--bg-page/--bg-card token triples for light/dark/ultra-dark, the
min-height + background root rule, the .ant-layout transparent reset,
the .content-shell transparent reset, and the .loading-spacer min-height.
That's ~30 identical lines duplicated across IndexPage, ClientsPage,
InboundsPage, XrayPage, SettingsPage, NodesPage, and ApiDocsPage.

Move all of it into styles/page-shell.css and import it once from
main.tsx alongside utils.css and page-cards.css. Each page CSS file
now only contains genuinely page-specific rules (content-area padding
overrides, page-specific tokens like ApiDocs's Swagger --sw-* set).

Also drop the per-page `import '@/styles/page-cards.css'` statements
from the 7 page tsx files now that main.tsx loads it globally.

Net: -211 deleted, +6 inserted in the touched files, plus the new
page-shell.css. .zero-margin (Divider override used by Nord/Warp
modals) folded into utils.css alongside the margin classes.

* refactor(frontend): move default content-area padding to page-shell.css

After page-shell.css landed, six of the seven panel pages still kept an
identical `.X-page .content-area { padding: 24px }` desktop rule, plus
three of them kept an identical `padding: 8px` mobile rule. Hoist both
defaults into page-shell.css under a single 6-page selector group and
delete the per-page copies.

What stays page-specific:
- IndexPage keeps its mobile override (padding 12px + padding-top: 64px
  for the fixed drawer handle clearance).
- ApiDocsPage keeps its tighter desktop padding (16px) and its own
  mobile padding-top: 56px.

Settings .ldap-no-inbounds also switches from #999 to
var(--ant-color-text-tertiary) for theme adaptation.

* refactor(frontend): hoist .header-row, .icons-only, .summary-card to page-shell.css

Settings and Xray pages both carried identical .header-row /
.header-actions / .header-info rules and an identical six-rule
.icons-only block that styles tabbed page navigation. Clients, Inbounds,
and Nodes all carried identical .summary-card padding rules with the
same mobile reduction. None of these are page-specific.

Consolidate:
- .header-row family → page-shell scoped to .settings-page, .xray-page
- .icons-only family → page-shell global (the class is a deliberate
  opt-in marker, no scope needed)
- .summary-card → page-shell scoped to .clients-page, .inbounds-page,
  .nodes-page (also fixes InboundsPage's missing scope — its rule was
  global and would have matched stray .summary-card uses elsewhere)

InboundsPage.css and NodesPage.css became empty after the move so the
files and their per-page imports are deleted.

* refactor(frontend): hoist .random-icon to utils.css

Three form modals each carried identical .random-icon styles (small
primary-tinted icon next to randomizable inputs):
  ClientBulkAddModal, InboundFormModal, OutboundFormModal

Single definition lives in utils.css now. ClientBulkAddModal.css was
just this one rule, so the file and its import are deleted along the way.

.danger-icon is left per file — the margin-left differs slightly
between InboundFormModal (6px) and OutboundFormModal (8px), so it
stays as a page-local rule rather than getting averaged into utils.css.

* refactor(frontend): hoist .danger-icon to utils.css and use it everywhere

InboundFormModal (margin-left 6px) and OutboundFormModal (margin-left
8px) each carried their own .danger-icon, and FinalMaskForm wrote the
same color/cursor/marginLeft trio inline five times. Unify on a single
.danger-icon in utils.css with margin-left: 8px — matching the more
generous OutboundFormModal value — and:
- Drop the per-file .danger-icon copies from InboundFormModal.css and
  OutboundFormModal.css.
- Replace the five inline style props in FinalMaskForm.tsx with
  className="danger-icon".

The visible change is a 2px wider gap to the right of the delete icons
on InboundFormModal's protocol/peer dividers.
2026-05-25 14:34:53 +02:00
Sanaei
cfe1b25ca0 feat(frontend): TanStack Query + React Router migration & in-panel API docs (#4541)
* feat(frontend): introduce TanStack Query with status polling

Wires @tanstack/react-query into every entry and migrates useStatus to
useStatusQuery as the foundation for the multi-page MPA → SPA migration.

- QueryProvider wraps each entry inside ThemeProvider, with devtools gated
  on import.meta.env.DEV
- Shared queryClient: 30s staleTime, refetchOnWindowFocus, 1 retry
- useStatusQuery preserves the { status, fetched, refresh } shape so
  IndexPage swaps in without further changes
- refetchIntervalInBackground:false stops the 2s status poll when the
  panel tab is hidden, cutting idle traffic against the server

* feat(frontend): collapse panel pages into a single React Router SPA

Replaces the 7-entry MPA shell (index/clients/inbounds/nodes/settings/
xray/api-docs HTML files) with one main.tsx + createBrowserRouter. The
Go backend now serves the same index.html for every authenticated
panel route; React Router reads the URL and mounts the page from cache
on subsequent navigation — no more full reloads between tabs.

Frontend
- main.tsx: single bootstrap (setupAxios, i18n, ThemeProvider,
  QueryProvider, RouterProvider) replacing 7 near-duplicate entries
- routes.tsx: declarative router with lazy()-loaded pages, basename
  derived from window.X_UI_BASE_PATH so panels at /secret/panel work
- layouts/PanelLayout.tsx: shell mount-point for the WS → queryClient
  bridge so connection survives navigation
- api/websocketBridge.ts: subscribes the singleton WebSocketClient to
  queryClient and dispatches invalidate/outbounds events to cached
  queries (page-level useWebSocket handlers stay until Phase 3 hooks
  migrate)
- AppSidebar: navigates via useNavigate + useLocation instead of
  window.location.href; drops basePath/requestUri props
- Pages: drop the unused basePath/requestUri locals exposed only for
  the old sidebar

Build
- vite.config: 9 rollup inputs → 3 (index, login, subpage). Dev proxy
  bypass collapses /panel/* to index.html and skips API prefixes
- vendor-tanstack + vendor-router chunks added to manualChunks

Backend
- xui.go: 7 per-page HTML handlers → one panelSPA handler serving
  index.html for /, /inbounds, /clients, /nodes, /settings, /xray,
  /api-docs. The /panel/api, /panel/setting, /panel/xray sub-routers
  are untouched

* feat(frontend): migrate useNodes to TanStack Query

Splits the hand-rolled useNodes hook into useNodesQuery (server data +
NodeRecord type + derived totals) and useNodeMutations (add/update/del/
setEnable/probe/test). Mutations invalidate ['nodes'] on success, so
the list refreshes without each call awaiting a manual refresh().

NodesPage drops useWebSocket({ nodes: applyNodesEvent }) — the
WebSocket → query bridge now forwards the 'nodes' push to
setQueryData(['nodes', 'list']) once at the SPA root.

InboundsPage and the inbound form/list components import NodeRecord
from its new home next to the query hook.

* feat(frontend): migrate useAllSetting to TanStack Query

Replaces the hand-rolled fetch + dirty-tracking hook with useAllSettings
backed by useQuery + useMutation. The draft (current edits) is kept in
local state and reset whenever query.data lands. saveAll posts the
draft via a mutation; on success, invalidating ['settings'] refetches
and the useEffect resets the draft so saveDisabled flips back to true.

staleTime: Infinity prevents refetchOnWindowFocus from clobbering
in-flight edits — settings only change in response to this user's own
save.

setSpinning stays as a pass-through to a local flag so the existing
restartPanel flow in SettingsPage keeps showing its spinner.

* feat(frontend): route useInbounds fetches through TanStack Query

Rewrites useInbounds so its four server fetches (slim list, default
settings, online clients, last-online map) live in useQuery with
staleTime: Infinity. The in-place WS merge logic for traffic and
client_stats is preserved — applyTrafficEvent / applyClientStatsEvent
still mutate the locally-mirrored dbInbounds so the panel doesn't
refetch every 1-2 seconds when stats stream in.

refresh() becomes a thin invalidateQueries on the three list keys,
which mutations in the page already call after add/edit/del.

The bridge now forwards the WebSocket 'inbounds' push to
setQueryData(['inbounds', 'slim']), and InboundsPage drops its
useEffect(fetchDefaultSettings → refresh) plus the invalidate /
inbounds wiring on useWebSocket — both are owned by the bridge now.

* feat(frontend): migrate useClients to TanStack Query

Replaces 12 hand-rolled mutation callbacks and a tangle of useState +
useRef + useEffect with one useQuery (paged list) + nine useMutation
wrappers. The list query uses keepPreviousData so paging/filter
changes don't blank the table mid-fetch.

The setQuery shallow-compare logic is preserved for backward
compatibility with ClientsPage's effect that rebuilds the params on
every render. Internally setQuery only updates state when the params
actually differ — Query's queryKey equality handles the rest.

WS-driven applyTrafficEvent / applyClientStatsEvent now mutate the
query cache via setQueryData(['clients', 'list', currentParams]) so
per-second stats updates skip a full refetch. applyInvalidate is gone
from the hook — the bridge owns coarse 'clients' invalidation.

ClientsPage drops the invalidate handler from its useWebSocket
subscription; auxiliary queries (inboundOptions, defaults, onlines)
load via TanStack Query and are shared with useInbounds via the same
query keys.

* feat(frontend): route useXraySetting fetches through TanStack Query

Keeps the bidirectional xraySetting ↔ templateSettings editor sync and
the 1s dirty-tracking interval intact (those are local editor state,
not server data). All seven server calls move:

- config + traffic → useQuery on ['xray', 'config'] and
  ['xray', 'outboundsTraffic']
- saveAll → useMutation that invalidates the config query
- resetOutboundsTraffic → useMutation that invalidates the traffic
  query
- restartXray → useMutation (fires the restart, then reads the
  result string)
- resetToDefault → useMutation (fetch default config, push it into
  the editor via setTemplateSettings)

The WebSocket 'outbounds' event already lands in
keys.xray.outboundsTraffic() via the bridge, so XrayPage drops its
useWebSocket({ outbounds: applyOutboundsEvent }) wiring entirely and
the hook no longer exposes applyOutboundsEvent.

A useEffect seeds xraySetting / templateSettings / tags / test URL
from query data on first fetch and on every refetch, mirroring what
the original fetchAll() did.

* fix(frontend): restore per-route document titles in the SPA

When the multi-entry MPA collapsed into a single index.html, every
route inherited the static <title>3X-UI</title> from the shared shell,
so every panel page showed "hostname - 3X-UI" instead of the original
"hostname - Overview / Clients / Inbounds / ...".

usePageTitle reads the current pathname and rewrites document.title
on every navigation, matching the titles the deleted *.html files
used to carry. Mounted in PanelLayout so it covers all panel routes
without each page having to opt in.

The startup applyDocumentTitle() call in main.tsx is gone — the hook
sets the full "hostname - PageTitle" string itself.

* feat(api-docs): expose OpenAPI spec + render Swagger UI in panel

Replaces the hand-rolled API docs UI with industry-standard tooling so
external integrations (Postman, Insomnia, openapi-generator) can
consume the panel API without parsing endpoints.js by hand.

Generator
- frontend/scripts/build-openapi.mjs: walks the existing endpoints.js
  (still the single source of truth) and emits an OpenAPI 3.0.3 spec
  at frontend/public/openapi.json. Handles Gin :param → {param} path
  translation, body / query / path parameter splits, 200 + error
  response examples, and Bearer + cookie security schemes
- npm run build now runs gen:api before vite build, so the spec is
  always in sync with what's documented

Backend
- web/controller/dist.go exposes ServeOpenAPISpec which streams the
  embedded dist/openapi.json with a short Cache-Control. Public
  endpoint (no auth) so Postman can fetch it without first logging in
- web/web.go wires GET /panel/api/openapi.json before the auth-gated
  /panel/api router

Panel
- ApiDocsPage now renders swagger-ui-react fed by the basePath-aware
  openapi.json URL. Dark mode is overridden via CSS targeting the
  Swagger UI internals
- CodeBlock / EndpointRow / EndpointSection are gone; the swagger-ui
  vendor chunk (134 KB gzipped) only loads on this lazy route, not on
  every panel page
- vite.config: vendor-swagger manualChunk keeps the new dep out of
  the main vendor bundle

For Postman: import http://<panel>/panel/api/openapi.json. Everything
from /login + /panel/api/* shows up with auth, params, and examples.

* style(api-docs): dark/ultra theme for Swagger UI

Override every visual surface Swagger does not theme on its own:
opblocks, tables, model boxes, form inputs, code blocks, modals,
Servers dropdown, per-endpoint padlocks and expand chevrons. Replaces
Swagger's default light-arrow chevron on selects with a light-fill SVG
positioned at the corner so the dark background-color is visible.

Also disables deepLinking to silence the noisy v4 underscore warning;
not used in our panel.
2026-05-24 21:34:52 +02:00
Sanaei
85e2ded0e1 Feat/multi inbound clients (#4469)
* feat(clients): add shadow tables for first-class client promotion

Introduces three new GORM-backed tables (clients, client_inbounds,
inbound_fallback_children) and a populate-only seeder that backfills
them from each inbound's existing settings.clients JSON. Duplicate
emails across inbounds auto-merge under one client row, with each
field conflict logged. Existing services are unchanged and continue
reading from settings.clients — this commit is groundwork only.

* feat(clients): make clients+client_inbounds the runtime source of truth

Adds ClientService.SyncInbound that reconciles the new tables from
each inbound's clients list whenever existing service paths mutate
settings.clients. Wires it into AddInbound, UpdateInbound,
AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, DelDepletedClients, autoRenewClients, and
the timestamp-backfill path in adjustTraffics, plus DetachInbound
on DelInbound.

GetXrayConfig now builds settings.clients from the new tables before
writing config.json, and getInboundsBySubId joins through them
instead of JSON_EACH on settings JSON. Live Xray config and
subscription endpoints are now driven by the relational view;
settings.clients JSON stays in step as a side effect of every write.

* feat(clients): add top-level Clients tab and CRUD API

Adds /panel/api/clients endpoints (list, get, add, update, del,
attach, detach) backed by ClientService methods that orchestrate
the per-inbound Add/Update/Del flows so a single client row is
created once and attached to many inbounds in one operation.

The frontend gains a dedicated Clients page (frontend/clients.html
+ src/pages/clients/) with an AntD table, multi-inbound attach
modal, and full CRUD. Axios interceptor learns to honour
Content-Type: application/json so the JSON endpoints work
alongside the legacy form-encoded ones.

The legacy per-inbound client modal stays untouched in this PR —
both flows now write to the same source of truth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(inbounds): add Port-with-Fallback inbound type

Adds a new "portfallback" protocol that emits as a VLESS-TLS inbound
under the hood but is paired with a sidecar table of child inbounds.
Panel auto-builds settings.fallbacks at Xray-config-gen time from the
sidecar — each child's listen+port becomes the fallback dest, with
SNI/ALPN/path/xver match criteria pulled from the row. No more typing
loopback ports by hand or keeping settings.fallbacks in sync.

Backend: new FallbackService (Get/SetChildren, BuildFallbacksJSON);
two new routes (GET/POST /panel/api/inbounds/:id/fallbackChildren);
xray.GetXrayConfig injects fallbacks for PortFallback inbounds; the
inbound model emits protocol="vless" so Xray accepts the config.

Frontend: PORTFALLBACK joins the protocol dropdown; selecting it
shows the standard VLESS controls plus a Fallback Children table
(inbound picker + per-row SNI/ALPN/path/xver). Children are loaded
on edit and replaced atomically on save.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): add Reset Traffic, QR Code, Info actions + Online/Remaining columns

The Clients page table gains:
- Online column — green/grey tag driven by /panel/api/inbounds/onlines,
  polled every 10s.
- Remaining column — bytes-remaining tag, coloured green/orange/red
  against quota, purple infinity when unlimited.
- Action icons per row: QR, Info, Reset traffic, Edit, Delete.

ClientInfoModal shows the full client detail (uuid/password/auth,
traffic ↑/↓ + remaining + all-time, expiry absolute + relative,
attached inbounds chip list, online + last-online).

ClientQrModal fetches links for the client's subId via
/panel/api/inbounds/getSubLinks/:subId and renders each one through
the existing QrPanel component.

Reset Traffic confirms then calls the existing per-inbound endpoint
on the client's first attached inbound (the traffic row is keyed on
email globally, so any attached inbound resets the shared counter).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): expose Attached inbounds in edit mode

The multi-select was gated on add-only, so editing a client had no way
to change which inbounds it belonged to. The picker now shows in both
modes, and on submit the modal diffs the picked set against the
original attachedIds — additions go through the /attach endpoint,
removals through /detach, both after the field update lands so the
new attachments get the latest values.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): unbreak template parsing + stale i18n keys

- InboundFormModal: split the multi-line help string in the
  PortFallback section onto one line — Vue's template parser was
  bailing on Unterminated string constant because a single-quoted
  literal spanned two lines inside a {{ }} interpolation.
- ClientInfoModal: t('disable') was missing at the root level, so
  vue-i18n returned the key path literally. Use t('disabled') which
  exists.
- Linter cleanup elsewhere: pages.client.* references renamed to
  pages.clients.* to match the merged i18n block; whitespace
  normalisation in a few unrelated Vue templates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* 1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(traffic): drop all-time traffic tracking

Removes the AllTime field from Inbound and ClientTraffic and migrates
existing DBs by dropping the all_time columns on startup. The counter
duplicated up+down without adding signal, and the per-event accumulator
ran on every traffic write.

Frontend: drop the All-time column from the inbound list and the
client-row table, the All-time row from the client info modal, and the
All-Time Total Usage tile from the inbounds summary card. The
allTimeTraffic/allTimeTrafficUsage i18n keys are removed across every
locale.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): mobile cards, multi-select, bulk add

Adds the same row-card layout the inbounds page uses on mobile: the
table is suppressed under the mobile breakpoint and each client renders
as a compact card with a status dot, email, Info button, Enable switch,
and overflow menu. All the per-client detail (traffic, remaining,
expiry, attached inbounds, flow, created/updated, URL, subscription)
opens through the existing info modal.

Multi-select with bulk delete wires AntD row-selection on desktop and
a per-card checkbox on mobile; a Delete (N) button appears in the
toolbar when anything is selected.

Bulk add reuses the five email-generation modes from the inbound bulk
modal but takes a multi-inbound picker so one bulk run can attach to
several inbounds at once. Submits client-by-client through the
existing /panel/api/clients/add endpoint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(inbounds): remove legacy per-inbound client UI

Now that clients live as first-class rows attached to one or many
inbounds, the per-inbound client UI on the inbounds page is dead
weight — every client action either has a global equivalent on the
Clients page or makes no sense in a many-to-many world.

Deletes ClientFormModal, ClientBulkModal, CopyClientsModal, and
ClientRowTable from inbounds/. Strips the matching emits, refs,
handlers, and dropdown menu items from InboundList and InboundsPage,
and removes the dead mobile expand-chevron state and the desktop
expanded-row plumbing that drove the inline client table.

The InboundFormModal Clients tab still works in add-mode (one inline
client at inbound creation) — that flow goes through ClientService.
SyncInbound on save and remains useful.

Fixes a stray "</a-dropdown>" left over by an earlier toolbar edit
in ClientsPage that broke the template parser.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): add Delete depleted action

Mirrors the legacy delDepletedClients action that lived under the
inbounds page, but as a first-class /panel/api/clients/delDepleted
endpoint backed by ClientService. The new path goes through
ClientService.Delete for each depleted email, so the new clients +
client_inbounds + xray_client_traffic tables stay consistent.

Adds a danger-styled toolbar button on the Clients page (next to
Reset all client traffic) with a confirm dialog and a toast
reporting the deleted count.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(api): move every client-shaped endpoint off /inbounds onto /clients

After the multi-inbound client migration, client state belongs to the
client API surface, not the inbound one. Twelve routes that were
crammed under /panel/api/inbounds/* now live where they belong, under
/panel/api/clients/*.

Moved (route, handler, doc):
  POST  /clientIps/:email
  POST  /clearClientIps/:email
  POST  /onlines
  POST  /lastOnline
  POST  /updateClientTraffic/:email
  POST  /resetAllClientTraffics/:id
  POST  /delDepletedClients/:id
  POST  /:id/resetClientTraffic/:email
  GET   /getClientTraffics/:email
  GET   /getClientTrafficsById/:id
  GET   /getSubLinks/:subId
  GET   /getClientLinks/:id/:email

Their /clients/* counterparts are:
  POST  /clients/clientIps/:email
  POST  /clients/clearClientIps/:email
  POST  /clients/onlines
  POST  /clients/lastOnline
  POST  /clients/updateTraffic/:email
  POST  /clients/resetTraffic/:email          (email-only, fans out)
  GET   /clients/traffic/:email
  GET   /clients/traffic/byId/:id
  GET   /clients/subLinks/:subId
  GET   /clients/links/:id/:email

per-inbound resetAllClientTraffics and delDepletedClients are dropped
entirely — the Clients page already exposes global Reset All Traffic
and Delete depleted actions, and per-inbound resets are meaningless
once a client can be attached to many inbounds.

ClientService.ResetTrafficByEmail is the new email-only reset path:
it looks up every inbound the client is attached to and pushes the
counter reset + Xray re-add through inboundService.ResetClientTraffic
for each one, so depleted users come back online instantly.

Frontend callers (ClientsPage, useClients, ClientQrModal,
ClientInfoModal, InboundInfoModal, InboundsPage, useInbounds) all
switched to the new paths. The Inbounds page drops its per-inbound
"Reset client traffic" and "Delete depleted clients" dropdown items —
users do those at the client level now. api-docs is rebuilt to match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(service): switch tgbot + ldap callers to ClientService

Adds two thin helpers to ClientService (CreateOne, DetachByEmail) and
rewrites tgbot.SubmitAddClient and ldap_sync_job to call ClientService
directly. Removes the JSON-blob payloads (BuildJSONForProtocol output for
add, clientsToJSON/clientToJSON helpers) that callers previously fed to
InboundService.AddInboundClient/DelInboundClient.

ldap_sync_job.batchSetEnable now loops InboundService.SetClientEnableByEmail
per email instead of trying to coerce AddInboundClient into doing the
update — the old path would have failed duplicate-email validation for
existing clients anyway.

The legacy InboundService.AddInboundClient/UpdateInboundClient/
DelInboundClient methods stay in place; they are now only used internally
by ClientService Create/Update/Delete/Attach. Inlining + deleting them
follows in a separate commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(service): move all client mutation methods to ClientService

Moves the client mutation surface out of InboundService and into
ClientService. These methods all operate on a single client (identity
fields, traffic limits, expiry, ip limit, enable state, telegram tg id)
and didn't belong on the inbound aggregate.

Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID,
checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail,
ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail,
ResetClientTrafficLimitByEmail.

Each method now takes an explicit *InboundService for the helpers that
legitimately stay on InboundService (GetInbound, GetClients, runtimeFor,
AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs /
UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs,
GetClientInboundByEmail / GetClientInboundByTrafficID,
GetClientTrafficByEmail).

Stays on InboundService: ResetClientTrafficByEmail and
ResetClientTraffic(id, email) — these mutate xray_client_traffic rows,
not client identity, so they're inbound-side bookkeeping.

Callers updated: tgbot (6 calls), ldap_sync_job (1 call),
InboundService internal (writeBackClientSubID, CopyInboundClients,
AddInbound's email-uniqueness check), ClientService Create/Update/
Delete/Attach/Detach.

Also removes a dead resetAllClientTraffics controller handler whose
route was already gone after the previous /clients API migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(clients): finish migrating to ClientService + tidy IP routes

Two related cleanups in the new /clients surface:

1. Move ResetAllClientTraffics (bulk-reset of xray_client_traffic +
   last_traffic_reset_time, with node-runtime propagation) from
   InboundService to ClientService. PeriodicTrafficResetJob now holds
   a clientService and calls
   j.clientService.ResetAllClientTraffics(&j.inboundService, id).
   The last client-mutation method on InboundService is gone.

2. Shorten redundantly-named routes/handlers under /panel/api/clients:
   - /clientIps/:email      -> /ips/:email      (handler getIps)
   - /clearClientIps/:email -> /clearIps/:email (handler clearIps)
   The "client" prefix was redundant inside the clients namespace.

Frontend (InboundInfoModal) and api-docs updated to match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(inbounds,clients): clean up inbound modal + enrich client modal

Inbound modal rework (InboundFormModal.vue + inbound.js):
- Drop the embedded Client subform in the Protocol tab. Multi-inbound
  clients are managed exclusively from the Clients page now; a fresh
  inbound is created with zero clients (settings constructors default
  to []) and the user attaches clients afterwards.
- Hide the Protocol tab entirely when it has nothing to render
  (VMESS, Trojan without fallbacks, Hysteria). Auto-switches active
  tab to Basic when the tab disappears while focused.
- Move the Security section (Security selector + TLS block with certs
  and ECH + Reality block) out of the Stream tab into its own
  Security tab, sharing the canEnableStream gate.

Client modal additions (ClientFormModal.vue + ClientBulkAddModal.vue):
- Flow select (xtls-rprx-vision / -udp443) appears only when the
  panel actually has a Vision-capable inbound (VLESS or PortFallback
  on TCP with TLS or Reality). Hidden otherwise, and cleared when
  it disappears.
- IP Limit input is disabled when the panel-level ipLimitEnable
  setting is off, fetched into useClients alongside subSettings and
  threaded through ClientsPage to both modals.
- Edit modal now shows an "IP Log" section listing IPs that have
  connected with the client's credentials, with refresh and clear
  buttons (calls the renamed /panel/api/clients/ips and /clearIps
  endpoints).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(inbounds): drop manual Fallbacks UI from inbound modal

The PortFallback protocol type now covers the common
VLESS-master-plus-children case with auto-wired dests, so the manual
Fallbacks editor (showFallbacks block in the Protocol tab) is mostly
redundant. Removed:

- the v-if="showFallbacks" template block (SNI/ALPN/Path/dest/PROXY rows)
- the showFallbacks computed
- the addFallback / delFallback helpers
- the .fallbacks-header / .fallbacks-title styles
- the showFallbacks gate from hasProtocolTabContent (so Trojan-over-TCP
  no longer shows an empty Protocol tab)

Power users who need a non-inbound fallback dest (nginx, static site)
can still author settings.fallbacks via the Advanced JSON tab.

* feat(clients,inbounds): move search/filter to Clients page + small fixes

Search/filter relocation:
- Remove the search/filter toolbar (search switch + filter radio +
  protocol/node selects + the visibleInbounds projection +
  inboundsFilterState localStorage + filter CSS + the SearchOutlined/
  FilterOutlined/ObjectUtil/Inbound imports it required) from
  InboundList. The filters were all client-oriented buckets bolted
  onto the inbound row.
- Add a search/filter toolbar to ClientsPage with the same shape:
  switch between deep-text search and bucket filter (active /
  deactive / depleted / expiring / online) + protocol filter that
  matches clients attached to at least one inbound with the chosen
  protocol. State persists in clientsFilterState localStorage.
  filteredClients drives both the desktop table and the mobile card
  list, and select-all / allSelected / someSelected only span the
  visible subset.
- useClients now also fetches expireDiff and trafficDiff from
  /panel/setting/defaultSettings (used to detect the expiring
  bucket); ClientsPage threads them into the client-bucket helper.

Loose fixes folded in:
- Add Client: email field is auto-filled with a random handle on
  open, matching uuid/subId/password/auth.
- Inbound clone: parse and reuse the source settings JSON (with
  clients reset to []) instead of building a fresh defaulted
  Settings, so VLESS Encryption/Decryption and other non-client
  fields survive the clone.
- en-US.json: add the ipLog string used by the edit-client modal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): add Reverse tag field for VLESS-attached clients

Mirrors the Flow field's pattern: a Reverse tag input appears in the
Add/Edit Client modal whenever at least one selected inbound is VLESS
or PortFallback. The value rides over the wire as
client.reverse = { tag: '...' } so it lands directly in model.Client's
*ClientReverse field; an empty value omits the reverse key entirely.

On edit the field is hydrated from props.client.reverse?.tag, and the
showReverseTag watcher clears the field if the user drops the last
VLESS-like inbound from the selection.

* fix(xray): emit only protocol-relevant fields per client entry

The Xray config synthesizer was writing every identifier field (id,
password, flow, auth, security/method, reverse) on every client entry
regardless of the inbound's protocol. Xray ignores unknown fields, so
the config worked, but it diverged from the spec and leaked secrets
across protocols when one client was attached to multiple inbounds —
a VLESS inbound's generated config carried the same client's Trojan
password and Hysteria auth alongside its uuid.

Switch on inbound.Protocol when building each entry:
- VLESS / PortFallback: id, flow, reverse
- VMess: id, security
- Trojan: password, flow
- Shadowsocks: password, method
- Hysteria / Hysteria2: auth
email is emitted for every protocol.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): restore auto-disable kick under new schema

disableInvalidClients still resolved (inbound_tag, email) pairs via
JSON_EACH(inbounds.settings.clients), which is empty after migrating
to the clients + client_inbounds tables. Result: xrayApi.RemoveUser
never ran for depleted clients, clients.enable stayed true so the UI
showed them as active, and only xray_client_traffic.enable got flipped
- making "Restart Xray After Auto Disable" only half-work.

Resolve the targets via a JOIN through the new schema, flip clients.enable
so the Clients page reflects the state, and drop the legacy JSON
write-back plus the subId cascade workaround (email is unique now).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): live WebSocket updates + Ended status surfacing

ClientsPage now subscribes to traffic / client_stats / invalidate
WebSocket events instead of polling /onlines every 10s. Per-row
traffic counters refresh in place, online state stays current, and
list-level mutations elsewhere trigger a refresh.

The client roll-up summary moves from InboundsPage to ClientsPage
where it belongs, restructured into six labeled stat tiles
(Total / Online / Ended / Expiring / Disabled / Active) with email
popovers on the ones with issues.

Auto-disabled clients (traffic exhausted or expiry passed) now
classify as 'depleted' even though clients.enable=false, so they
show up under the Ended filter and render a red Ended tag instead
of looking indistinguishable from an operator-disabled row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(nodes): per-node client roll-up and panel version

Added transient inboundCount / clientCount / onlineCount /
depletedCount fields to model.Node, populated by NodeService.GetAll
via aggregated queries (one join across inbounds + client_inbounds,
one over client_traffics intersected with the in-memory online
emails). The Nodes list renders these as colored chips on a new
"Clients" column so an operator can see at a glance how many users
each node carries and how many are currently online or depleted.

Also exposes the remote panel's version. The central panel adds
panelVersion to its /api/server/status payload (sourced from
config.GetVersion). Probe reads that field and persists it on the
node row, mirroring how xrayVersion already flows. NodesPage gets
a new column next to Xray Version, in both desktop and mobile
views, with English and Persian strings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): stop node sync from resurrecting deleted clients

Several related issues around node-managed clients:

- Remote runtime: drop the per-inbound resetAllClientTraffics path
  and point traffic/onlines/lastOnline fetches at the new
  /panel/api/clients/* routes.
- Delete from master: always push the updated inbound to the node
  even when the client was already disabled or depleted, so the
  node actually loses the user instead of silently keeping it.
- setRemoteTraffic: mirror remote clients into the central tables
  only on first discovery of a node inbound. Matched inbounds let
  the master own the join table, so a stale snap can no longer
  re-create a ClientRecord (and join row) for a client that was
  just deleted on the master.
- ClientService.Delete: route through submitTrafficWrite so deletes
  serialize with node traffic merges, and switch the final
  ClientRecord delete to an explicit Where("id = ?") clause.
- setRemoteTraffic UNIQUE-constraint fix: use clause.OnConflict on
  inserts and email-keyed UPDATEs for client_traffics, so mirroring
  a snap doesn't trip the unique email index.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(clients): switch client API endpoints from id to email

All client-scoped routes now use the unique email as the path key
(get, update, del, attach, detach, links). Email is the stable,
protocol-independent identifier — UUIDs don't exist for trojan or
shadowsocks, and internal numeric ids leaked panel implementation
detail into the public API.

Removed the redundant /traffic/byId/:id endpoint (covered by
/traffic/:email) and collapsed /links/:id/:email into /links/:email,
which now returns links across every attached inbound for the client.

Frontend selection, bulk delete, and toggle state are now keyed by
email as well, dropping the id→email lookup workaround.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(server): move cached state and helpers into ServerService

ServerController had grown to hold its own status cache, version-list
TTL cache, history-bucket whitelist, and the loop that drove all three
— concerns that belong in the service layer. Pull them out:

- lastStatus + the @2s refresh become ServerService.RefreshStatus and
  ServerService.LastStatus; the controller's cron now just orchestrates
  the cross-service side effects (xrayMetrics sample, websocket broadcast).
- The 15-minute Xray-versions cache (with stale-on-error fallback) moves
  into ServerService.GetXrayVersionsCached, collapsing the controller
  handler to a single call.
- The freedom/blackhole outbound-tag walk used by /xraylogs becomes
  ServerService.GetDefaultLogOutboundTags.
- The allowed-history-bucket whitelist moves to package-level
  service.IsAllowedHistoryBucket, so both NodeController and
  ServerController validate against the same list.

Net result: web/controller/server.go drops from 458 to 365 lines and
contains only HTTP wiring + presentation-y side effects.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(api): emit JSON-text columns as nested objects

Inbound, ClientRecord, and InboundClientIps store settings /
streamSettings / sniffing / reverse / ips as JSON-text in the DB. The
API was passing that text through verbatim, so every consumer had to
JSON.parse a string inside a string. Add MarshalJSON / UnmarshalJSON so
the wire format is a real nested object, while still accepting the
legacy escaped-string shape on write. Frontend dbinbound.js gets a
matching coerceInboundJsonField helper for the same dual-shape read
path, and inbound.js toJson stops emitting empty/placeholder fields
(externalProxy [], sniffing destOverride when disabled, etc.) so the
new normalised JSON stays terse. api-docs and the inbound-clone path
are updated to the new shape. Controller route lists are regrouped so
all GETs sit above POSTs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): include inboundIds and traffic in /clients/list

ClientRecord got its own MarshalJSON in the previous commit, and
ClientWithAttachments embeds it to add inboundIds and traffic. Go
promotes the embedded MarshalJSON to the outer struct, so the encoder
was calling ClientRecord.MarshalJSON for the whole value and silently
dropping the extras. The frontend reads row.inboundIds / row.traffic
from /clients/list, so attached inbounds didn't render and newly added
clients looked like they hadn't saved. Add an explicit MarshalJSON on
ClientWithAttachments that splices the extras in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): gate IP Log on ipLimitEnable + clean access-log dropdown

Legacy panel hid the IP Log section when access logging was off; the
Vue 3 migration left it gated on isEdit only, so the section showed
even when xray's access log was 'none' and nothing was being recorded.
Restore the ipLimitEnable gate on the edit modal's IP Log form-item.

While here, clean up the Xray Settings access-log dropdown: previously
two 'none' entries appeared (an empty value labelled with t('none') and
the literal 'none' from the options array). Drop the empty option for
access log (the literal 'none' covers it) and relabel the empty option
for error log / mask address to t('empty') so they're distinguishable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(nodes): route per-client ops through node clients API + orphan sweep

Adds Runtime methods AddClient, UpdateUser, and DeleteUser so master
mutates clients on a node via /panel/api/clients/{add,update,del} rather
than pushing the whole inbound. The previous rt.UpdateInbound path made
the node DelInbound+AddInbound on every single-client change, briefly
cycling every other user on the same inbound.

DelInbound no longer filters by enable=true, so a disabled node inbound
actually gets removed from the node instead of being resurrected by the
next snap.

setRemoteTrafficLocked now sweeps any ClientRecord with zero
ClientInbound rows after SyncInbound rebuilds the attachments, which is
how a node-side delete propagates back to master instead of leaving a
detached ghost. ClientService.Delete tombstones the email first so a
snap arriving mid-delete can't re-create the record.

WebSocket broadcasts an "invalidate(clients)" message on every client
mutation so the Clients page refreshes without manual reload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(balancers): allow fallback on all strategies + feed burstObservatory from random/roundRobin

Drops the random/roundRobin gate on the Fallback field in
BalancerFormModal so every strategy can pick a fallback outbound.

syncObservatories now feeds burstObservatory from leastLoad +
random + roundRobin balancers (was leastLoad only), matching how
leastPing feeds observatory.

Fix the JsonEditor "Unexpected end of JSON input" that appeared
when switching a balancer between leastPing and another strategy:
the obsView watcher was gated on showObsEditor (a boolean OR of
the two flags) and missed the case where one observatory
swapped for the other in the same tick. Watch the individual
flags instead so obsView flips to the surviving editor and the
getter stops pointing at a deleted key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(inbounds): use sortedInbounds for mobile empty-state check

InboundList referenced an undefined visibleInbounds in the mobile
card list's empty-state guard, throwing "Cannot read properties of
undefined (reading 'length')" and breaking the entire mobile render.

* feat(clients): sortable table columns

Adds the same sortState / sortableCol / sortFns pattern InboundList
uses, wrapping filteredClients in sortedClients so sort composes with
the existing search/filter pipeline. Sortable: enable, email,
inboundIds (attachment count), traffic, remaining, expiryTime;
actions and online stay unsorted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(shadowsocks): generate valid ss2022 keys and per-client method for legacy ciphers

The Add Client flow on shadowsocks inbounds was producing xray configs
that failed to start:

- 2022-blake3-* ciphers need a base64-encoded key of an exact byte
  length per cipher. fillProtocolDefaults was assigning a uuid-style
  string, which xray rejects as "bad key". Now the password is
  generated (or replaced if invalid) via random.Base64Bytes(n) sized
  to the chosen cipher.
- Legacy ciphers (aes-256-gcm, chacha20-*, xchacha20-*) require a
  per-client method field in multi-user mode; model.Client has no
  Method, so settings.clients was stored without one and xray failed
  with "unsupported cipher method:". applyShadowsocksClientMethod
  now injects the top-level method into each client on add/update,
  and healShadowsocksClientMethods backfills it at xray-config-build
  time so existing inbounds heal on the next start.
- xray/api.go ssCipherType switch was missing aes-256-gcm, which
  fell through to ss2022 path.
- SSMethods dropdown now offers aes-256-gcm.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): preserve ClientRecord on inbound delete + filter Attached inbounds to multi-client protocols

Replace the global orphan sweep in setRemoteTrafficLocked with a
per-inbound diff cleanup: only delete a ClientRecord whose email
disappeared from a snap-tracked inbound (i.e. a node-side delete).
Inbounds that vanished entirely from the snap (e.g. admin deleted
the inbound on master) aren't iterated, so a client whose last
attachment came from that inbound is now left alone instead of
being deleted alongside the inbound.

ClientFormModal and ClientBulkAddModal now filter the Attached
inbounds dropdown to protocols that actually support multiple
clients: shadowsocks, vless, vmess, trojan, hysteria, hysteria2,
and portfallback (which routes through VLESS settings).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): make empty-state text readable on dark/ultra themes

The "No clients yet" empty state had a hardcoded black color
(rgba(0,0,0,0.45)) that vanished against the dark backgrounds.
Drop the inline color, let it inherit from the AntD theme, and
fade with opacity like the mobile card empty state already does.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): client-first tgbot add flow, tgId field, lightweight inbound options

- tgbot: drop legacy per-protocol Add Client UI in favour of a client-first
  multi-inbound flow. New BuildClientDraftMessage / getInboundsAttachPicker
  let an admin pick one or more inbounds and submit a single client; per-
  protocol secrets are now generated server-side via fillProtocolDefaults.
  Drops awaiting_id/awaiting_password_tr/awaiting_password_sh state cases
  and add_client_ch_default_id/pass_tr/pass_sh/flow callbacks. Adds a
  setTGUser button + awaiting_tg_id state so the bot can set Client.TgID
  during Add.
- clients UI: add Telegram user ID input to ClientFormModal (0 = none).
  Hide IP Limit field entirely when ipLimitEnable is off — disabled fields
  still take layout space, this collapses Auth(Hysteria) to full width.
- inbounds API: new GET /panel/api/inbounds/options that returns just
  {id, remark, protocol, port, tlsFlowCapable}. Used by the clients page
  pickers so the dropdown payload stays small on panels with thousands of
  clients (drops settings JSON, clientStats, streamSettings). Server-side
  TlsFlowCapable mirrors Inbound.canEnableTlsFlow so the modal no longer
  needs to parse streamSettings client-side.
- clientInfoMsg now shows attached inbound remarks, and getInboundUsages
  reports the attached client count per inbound.
- api-docs: document the new /options endpoint and add tgId / flow to the
  clients add/update bodies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(inbounds): keep Node column visible for node-attached inbounds

The Node column was bound to hasActiveNode, so disabling every node hid
the column even when inbounds were still attached to those nodes — the
admin lost the visual cue that those inbounds belonged to a node and
would come back when it was re-enabled. Combine hasActiveNode with a
new hasNodeAttachedInbound check (any dbInbound with nodeId != null) so
the column survives node-disable.

* fix(api-docs): accept functional-component icons in EndpointSection

AntD-Vue icons (SafetyCertificateOutlined, etc.) are functional
components, so the icon prop's type: Object validator was rejecting
them with a "Expected Object, got Function" warning at runtime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: cover crypto, random, netsafe, sub helpers, xray equals, websocket hub, node service

Adds ~110 unit tests across previously untested packages. Focus on
pure-logic and concurrency surfaces where regressions would silently
affect users:

- util/crypto, util/random: password hashing round-trip, ss2022 key
  generation, alphabet/length invariants.
- util/netsafe: IsBlockedIP edge cases, NormalizeHost validation,
  SSRF guard with AllowPrivate context bypass.
- util/common, util/json_util: traffic formatter, Combine nil-skip,
  RawMessage empty-as-null and copy-on-unmarshal.
- sub: splitLinkLines, searchKey/searchHost, kcp share fields,
  finalmask normalization, buildVmessLink round-trip.
- xray: Config.Equals and InboundConfig.Equals field-by-field,
  getRequiredUserString/getOptionalUserString type checks.
- web/websocket: hub registration, throttling, slow-client eviction,
  nil-receiver safety, concurrent register/unregister.
- web/service: NodeService.normalize validation, normalizeBasePath,
  HeartbeatPatch.ToUI mapping.
- web/job: atomicBool concurrent set/takeAndReset semantics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* i18n(clients): replace English fallbacks with proper translation keys

Pulls every hard-coded English label/title in the Clients page and its
four modals through the i18n layer so localized panels stop leaking
English. New keys live under pages.clients (auth, hysteriaAuth, uuid,
flow, flowNone, reverseTag, reverseTagPlaceholder, telegramId,
telegramIdPlaceholder, created, updated, ipLimit) plus refresh at the
root and toasts.bulkDeletedMixed / bulkCreatedMixed for partial-failure
toasts. Also switches the add-client modal's primary button from "Add"
to "Create" for consistency with other create flows.

The bulk-add Random/Random+Prefix/... email-method options stay
hard-coded by request - they're identifier-shaped strings.

* i18n: backfill 99 missing keys across all 12 non-English locales

Brings every translation file up to parity with en-US.json so the
Clients page, the fallback-children inbound section, the new refresh
verb, the Nodes panel-version label and a handful of older holes stop
falling through to the English fallback. New strings span:

- pages.clients.* (labels, confirmations, toasts, emailMethods)
- pages.inbounds.portFallback.* (Reality fallback inbound section)
- pages.nodes.panelVersion, menu.clients, refresh

Technical identifiers (Auth, UUID, Flow, Reverse tag) are intentionally
left untranslated since they correspond to xray-core field names.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* i18n: drop stale pages.client block duplicated in every non-English locale

Every non-English locale carried a pages.client (singular) section with
30 entries that duplicated pages.clients (plural). The plural namespace
is what the Vue code actually consumes; the singular one was dead
weight from an older rename that never got cleaned up in the
non-English files. Removing it brings every locale to exactly 984
keys, matching en-US.json.

* chore: apply modernize analyzer fixes across codebase

Mechanical replacements suggested by golang.org/x/tools/.../modernize:
strings.Cut/CutPrefix/SplitSeq, slices.Contains, maps.Copy, min(),
range-over-int, new(expr), strings.Builder for hot += loops,
reflect.TypeFor[T](), sync.WaitGroup.Go(), drop legacy //+build lines.

* feat(database): add PostgreSQL as an optional backend alongside SQLite

Lets operators with large client counts or multi-node setups pick PostgreSQL
at install time without breaking the existing SQLite default. Backend is
selected at runtime via XUI_DB_TYPE/XUI_DB_DSN, a small dialect layer keeps
the five JSON_EXTRACT/JSON_EACH queries portable, and a new `x-ui migrate-db`
subcommand copies SQLite data into PostgreSQL in FK-aware order.

* fix(inbounds): gate node selector to multi-node-capable protocols

Hide the Deploy-To selector and clear nodeId when switching to a
protocol that can't run on a remote node. Also:

- subs: return 404 (not 400) when subId matches no inbounds, so VPN
  clients distinguish "deleted/unknown" from a server error
- hysteria link gen: use the inbound's resolved address so node-managed
  inbounds advertise the node host instead of the central panel
- shadowsocks: default network to 'tcp' (udp was causing issues for some
  clients on first-create)
- vite dev proxy: rewrite migrated-route bypass against the live base
  path instead of a hardcoded single-segment regex

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form

Bulk add/delete were serial on the frontend (one toast per call, N round-trips)
and the backend race exposed by parallelizing them lost client attachments and
hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also
had no Start-After-First-Use option, and the table never showed the delayed
duration.

Backend (web/service/client.go):
- Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on
  the same inbound don't lose the read-modify-write of settings JSON.
- SyncInbound skips create+join when the email is tombstoned so a concurrent
  maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn-
  Settings) that did a stale RMW can't resurrect a just-deleted client with a
  fresh id.
- compactOrphans sweeps settings.clients entries whose ClientRecord no longer
  exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each
  user-initiated mutation self-heals the inbound's settings.
- DelInboundClient uses Pluck instead of First for the stats lookup so a
  missing row doesn't abort the delete with a noisy ErrRecordNotFound log.

Frontend:
- HttpUtil.{get,post} accept a silent option that suppresses the auto-toast.
- ClientBulkAddModal fires creates in parallel + silent + one summary toast.
- useClients.removeMany runs deletes in parallel + silent and refreshes once;
  ClientsPage bulk delete uses it and shows one aggregate toast.
- useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket
  invalidate events from the backend collapses into a single refresh.
- ClientsPage pagination is reactive (paginationState ref + tablePagination
  computed); onTableChange persists page-size and page changes.
- ClientFormModal gains a Start-After-First-Use switch + Duration days input
  alongside the existing Expiry Date picker; on edit-mode open a negative
  expiryTime is decoded back to delayed mode + days; on submit the payload
  sends -86400000 * days or the absolute timestamp.
- ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip
  Start After First Use: Nd) instead of infinity.
- Telegram ID field in the form is hidden when /panel/setting/defaultSettings
  reports tgBotEnable=false; Comment then fills the row.
- Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4)
  when ipLimitEnable is on, else UUID + Total GB at 12/12.
- useInbounds.rollupClients counts only clients with a matching clientStats
  row, so orphans in settings.clients no longer inflate the inbound's count.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(windows): clean shutdown, working panel restart, harden kernel32 load

Three Windows-specific issues addressed:

1. Orphaned xray-windows-amd64 after VS Code debugger stop. Delve's
   "Stop" sends TerminateProcess to the Go binary, which is uncatchable
   — our signal handlers never run, so xrayService.StopXray() is skipped
   and xray is left dangling. Spawn xray as a child of a Job Object with
   JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so the OS kills xray when our
   handle to the job is closed (which happens even on TerminateProcess).
   Also trap os.Interrupt in main so Ctrl+C in the terminal runs the
   graceful path.

2. /panel/setting/restartPanel logged "failed to send SIGHUP signal: not
   supported by windows" because Windows can't deliver arbitrary signals.
   Add a restart hook in web/global; main registers it to push SIGHUP
   into its own signal channel, and RestartPanel calls the hook before
   falling back to the (Unix-only) signal path. Same restart-loop code
   runs in both cases.

3. util/sys/sys_windows.go now uses windows.NewLazySystemDLL so the
   kernel32.dll resolve is pinned to %SystemRoot%\System32 (prevents
   DLL hijacking by a planted DLL next to the binary). Local filetime
   type replaced with windows.Filetime, and the unreliable
   syscall.GetLastError() fallback replaced with a type assertion on the
   errno captured at call time.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sys): correct CPU/connection accounting on linux + darwin

util/sys/sys_linux.go:
- GetTCPCount/GetUDPCount were counting the column header row in
  /proc/net/{tcp,udp}[6] as a connection, inflating the reported total
  by 1 per non-empty file (so the panel status line always showed 2
  more connections than actually existed). Replace getLinesNum +
  safeGetLinesNum with a single bufio.Scanner-based countConnections
  that skips the header.
- CPUPercentRaw now opens HostProc("stat") instead of a hardcoded
  /proc/stat so HOST_PROC overrides apply, matching the connection
  counters in the same file.
- Simplify CPU field unpacking: pad nums to 8 once instead of guarding
  every assignment with a len check.

util/sys/sys_darwin.go:
- Fix swapped idle/intr indices on kern.cp_time. BSD CPUSTATES order
  is user, nice, sys, intr, idle (CP_INTR=3, CP_IDLE=4) — gopsutil's
  cpu_darwin_nocgo.go reads the same layout. The previous code used
  out[3] as idle and out[4] as intr, so busy = total - dIdle was
  actually subtracting interrupt time, making the panel report CPU
  usage close to 100% on macOS regardless of actual load.
- Collapse the per-field delta math into a single loop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(xray): rotate crash reports into log folder, prevent overwrites

writeCrashReport had two flaws: it wrote to the bin folder (alongside the
xray binary) which conflates artifacts, and the second-precision timestamp
meant a tight restart-loop crash burst overwrote prior reports. Write to
the log folder with nanosecond precision and keep the last 10 reports.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* revert(inbounds): drop unreleased portfallback protocol

The Port-with-Fallback inbound (commit 62fd9f9d) was confusing as a
standalone protocol — fallbacks belong on a regular VLESS/Trojan TCP-TLS
inbound, the way Xray models them natively. Rip out the entire feature
cleanly (no migration needed since it was never released): protocol
constant, fallback children DB table, FallbackService, 2 API endpoints,
all UI rows, related translations and api-docs. A native fallback flow
attached to VLESS/Trojan TCP-TLS/Reality will land in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(inbounds): native fallbacks on VLESS/Trojan TCP-TLS, with working child links

A VLESS or Trojan inbound on TCP with TLS or Reality can now act as a
fallback master: pick existing inbounds as children and the panel auto-
fills the SNI / ALPN / path / xver routing fields from each child's
transport, auto-builds settings.fallbacks at config-gen time, and
rewrites the child's client-share link so it advertises the master's
reachable endpoint and TLS state instead of the child's loopback listen.

Layout matches the Xray All-in-One Nginx example: master at :443 with
clients + TLS, each child on 127.0.0.1 with its own transport+clients.
Order matters (Xray walks fallbacks top-to-bottom) — reorder via the
per-row up/down arrows. Path / SNI / ALPN are exposed under a per-row
Edit toggle for the rare cases where the auto-derivation needs
overriding; otherwise just pick a child and you're done.

Backend: new InboundFallback table + FallbackService (GetByMaster /
SetByMaster / GetParentForChild / BuildFallbacksJSON); two routes
(GET / POST /panel/api/inbounds/:id/fallbacks); xray.GetXrayConfig
injects settings.fallbacks for any VLESS/Trojan TCP-TLS/Reality
inbound; GetInbounds annotates each child with FallbackParent so the
frontend can rewrite links without an extra round-trip.

Link projection covers every emission path — clients-page QR/links,
per-inbound Get URL, raw subscription, sub-JSON, sub-Clash, and the
inbounds-page link/info/QR — via a shared projectThroughFallbackMaster
on the backend and a shared projectChildThroughMaster on the frontend
that both handle the panel-tracked relationship and the legacy
unix-socket (@vless-ws) convention.

Strings translated into all 12 non-English locales.

* docs: rewrite CONTRIBUTING with full local-dev setup

The prior three-line CONTRIBUTING left newcomers guessing at every
non-trivial step: which Go / Node versions, where xray comes from, why
the panel goes blank when XUI_DEBUG=true is flipped on, how the Vue
multi-page setup is wired, what to do on Windows when go build trips
on the CGo SQLite driver.

Now covers prerequisites, MinGW-w64 install on Windows (niXman builds
or MSYS2), one-shot first-time setup, two frontend dev workflows with
the XUI_DEBUG asset-cache gotcha called out, the architecture and
conventions of the Vue side, a project-layout map, useful env vars,
and the PR checklist.

---------
2026-05-19 12:20:24 +02:00
MHSanaei
3af45c1462 fix: Add base-path meta tag for Cloudflare Rocket Loader compatibility
When Cloudflare Rocket Loader is enabled, it interferes with inline scripts that set window.X_UI_BASE_PATH, causing the frontend to fail to configure the correct base URL for API calls. This results in 404 errors on the login page when calling /getTwoFactorEnable.

Solution: Add meta name='base-path' tag to HTML (similar to csrf-token), update axios initialization to read from meta tag as fallback. Meta tags are not affected by CSP or Rocket Loader delays.

Fixes #4393
2026-05-14 23:37:25 +02:00
MHSanaei
bbefe91011 fix(auth): invalidate sessions when 2FA is enabled, fix dev 401 loop
Add UserService.BumpLoginEpoch and call it from updateSetting when
TwoFactorEnable flips false → true. Existing cookies (issued under
the looser no-2FA policy) get a 401 on their next request and are
forced through the login flow. Disabling 2FA is a relaxation and
does not bump the epoch — sessions stay valid.

Also fix the dev-mode 401 redirect: targeting `${basePath}login.html`
breaks when basePath isn't "/" (Vite has no file at e.g.
"/test/login.html"; the SPA fallback loops the 401). Navigate to
basePath instead — Vite's bypassMigratedRoute and Go's index
handler both serve login.html for that path.

Strip stale doc-comment from netsafe and IndexController.logout
in line with the project's no-inline-comments convention.
2026-05-13 14:08:16 +02:00
Farhad H. P. Shirvan
428f1333ac Security hardening: sessions, SSRF, CSP nonce, CSRF logout, trusted proxies (#4275)
* refactor(session): store user ID in session instead of full struct

Replaces storing the full User object in the session cookie with just
the user ID. GetLoginUser now re-fetches the user from the database on
every request so credential/permission changes take effect immediately
without requiring a re-login. Includes a backward-compatible migration
path for existing sessions that still carry the old struct payload.

* feat(auth): block panel with default admin/admin credentials and guide credential change

checkLogin middleware now detects default admin/admin credentials and
redirects every panel route to /panel/settings until they are changed.
The settings page auto-opens the Authentication tab, shows a
non-dismissible error banner, and lists 'Default credentials' first in
the security checklist. Login response includes mustChangeCredentials
so the login page can redirect directly. Logout is now POST-only.
Password must be at least 10 characters and cannot be admin/admin.

* feat(settings): redact secrets in AllSettingView and add TrustedProxyCIDRs

Introduces AllSettingView which strips tgBotToken, twoFactorToken,
ldapPassword, apiToken and warp/nord secrets before sending them to
the browser, replacing them with boolean hasFoo presence flags. A new
/panel/setting/secret endpoint allows updating individual secrets by
key. Secrets that arrive blank on a save are preserved from the DB
rather than overwritten. Adds TrustedProxyCIDRs as a configurable
setting (defaults to localhost CIDRs). URL fields are validated before
save.

* fix(security): SSRF prevention, trusted-proxy header gating, CSP nonce, HTTP timeouts

Adds SanitizeHTTPURL / SanitizePublicHTTPURL to reject private-range
and loopback targets before any outbound HTTP request (node probe,
xray download, outbound test, external traffic inform, tgbot API
server, panel updater). Forwarded headers (X-Real-IP, X-Forwarded-For,
X-Forwarded-Host) are now only trusted when the direct connection
arrives from a CIDR in TrustedProxyCIDRs. CSP policy is tightened with
a per-request nonce. HTTP server gains read/write/idle timeouts. Panel
updater downloads the script to a temp file instead of piping curl into
shell. Xray archive download adds a size cap and response-code check.
backuptotgbot is changed from GET to POST.

* feat(nodes): add allow-private-address toggle per node

Adds AllowPrivateAddress to the Node model (DB default false). When
enabled it bypasses the SSRF private-range check for that node's probe
URL, allowing nodes hosted on RFC-1918 or loopback addresses (e.g.
a private VPN or LAN setup).

* chore: frontend UX improvements, CI pipeline, and dev tooling

- AppSidebar: logout via POST /logout instead of navigating to GET
- InboundList: persist filter state (search, protocol, node) to
  localStorage across page reloads; add protocol and node filter dropdowns
- IndexPage: add health status strip (Xray, CPU, Memory, Update) with
  quick-action buttons
- dependabot: weekly go mod and npm update schedule
- ci.yml: add GitHub Actions workflow for build and vet
- .nvmrc: pin Node 22 for local development
- frontend: bump package.json and package-lock.json
- SubPage, DnsPresetsModal, api-docs: minor fixes

* fix(ci): stub web/dist before go list to satisfy go:embed at compile time

* chore(ui): remove health-strip bar from dashboard top

* Revert "feat(auth): block panel with default admin/admin credentials and guide credential change"

This reverts commit 56ce6073ce.

* fix(auth): make logout POST+CSRF and propagate session loss to other tabs

- Switch /logout from GET to POST with CSRFMiddleware so it matches the
  SPA's existing HttpUtil.post('/logout') call (previously 404'd silently)
  and blocks GET-based logout via image tags or link prefetchers. Handler
  now returns JSON; the SPA already navigates client-side.
- Return 401 (instead of 404) from /panel/api/* when the caller is a
  browser XHR (X-Requested-With: XMLHttpRequest) so the axios interceptor
  redirects to the login page on logout-in-another-tab, cookie expiry,
  and server restart. Anonymous callers still get 404 to keep endpoints
  hidden from casual scanners.
- One-shot the 401 redirect in axios-init.js and hang the rejected
  promise so queued polls don't stack reloads or surface error toasts
  while the browser is navigating away.
- Add the CSP nonce to the runtime-injected <script> in dist.go so the
  panel loads under the existing script-src 'nonce-...' policy.
- Update api-docs endpoints.js: GET /logout doc entry was missing.

* fix(settings): POST /logout after credential change

* fix(auth): invalidate other sessions when credentials change

When the admin changes username/password from one machine, sessions
on every other machine kept working until they manually logged out
because session storage is a signed client-side cookie — there is
no server-side session list to revoke.

Add a per-user LoginEpoch counter stamped into the session at login
and re-verified on every authenticated request. UpdateUser and
UpdateFirstUser bump the epoch (UpdateUser via gorm.Expr so a single
update statement is atomic), so any cookie issued before the change
no longer matches the user's current epoch and GetLoginUser returns
nil — the SPA's 401 interceptor then redirects to the login page.

Backward compatible: the column defaults to 0 and missing cookie
values are treated as 0, so sessions issued before this change
remain valid until the first credential update.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-13 12:52:52 +02:00
MHSanaei
745e394c74 refactor(panel): rename injected globals + collapse QR modal entries
Rename the SPA globals injected by Go to drop the ad-hoc dunder shape
and free up the bare `webBasePath` name (still the DB setting key)
from colliding with the JS global it used to share:
  window.__X_UI_BASE_PATH__ -> window.X_UI_BASE_PATH
  window.__X_UI_CUR_VER__   -> window.X_UI_CUR_VER

Also rework the QR-Code modal to fold every QR (subscription + JSON
sub URL, share links, WireGuard config/peer links) into a single
a-collapse with one panel per QR. Subscription panels are listed
first and open by default; everything else stays collapsed so a
multi-link inbound no longer scrolls forever.
2026-05-10 23:40:39 +02:00
MHSanaei
61c84e8223 fix(panel): make webBasePath work end-to-end in dev and prod
- Vite dev server reads webBasePath from x-ui.db via node:sqlite and
  injects __X_UI_BASE_PATH__ on every HTML serve, mirroring dist.go.
  Single broad proxy regex catches backend routes whether the URL is
  prefixed or not, and the bypass serves login.html for the bare
  basePath URL so post-logout navigation lands on Vite's own page
  instead of the production dist HTML's hashed asset URLs.
- axios.defaults.baseURL is set from __X_UI_BASE_PATH__ at startup so
  HttpUtil calls reach the backend's basePath group instead of 404ing
  on every prefixed install. fetch() for the public CSRF endpoint
  prepends the prefix manually since it doesn't honor axios defaults.
- Logout/redirect responses set Cache-Control: no-store and the index
  handler's logged-in redirect uses an absolute base_path+panel/ URL,
  preventing browsers from replaying a stale cached 307 that bounced
  the user back to /panel/ after logout.
- ClearSession also issues a Path=/ deletion cookie when basePath is
  not "/", so a legacy cookie from an earlier basePath setting can't
  keep IsLogin returning true after logout.
- getPanelUpdateInfo no longer returns a translated error message on
  GitHub fetch failures, so HttpUtil's auto-popup stays quiet on
  offline / blocked environments.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:47:37 +02:00
Sanaei
bc00d37ad8 Vue3 migration (#4198)
* docs(migration): Phase 1 inventory — Vue 2 / AD-Vue 1 surface area

Captures the breakage surface for the Vue 3 + Ant Design Vue 4 + Vite
migration: 17,650 lines across 69 templates, 3,145 a-* component
instances across 63 files, with per-pattern counts and file lists.

Key findings:
- No Vue filters anywhere — dodges a major Vue 3 breaking change
- 358 v-model uses; AD-Vue 4 absorbs most, custom components don't
- 233 <template slot="X"> usages must become <template #X>
- 49 scopedSlots: { ... } column defs need new slots: { ... } shape
- a-icon is removed in AD-Vue 4 — every icon must be imported

Establishes the 8-phase order; Phase 2 (Vite toolchain) is next.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* build(frontend): Phase 2 — scaffold Vite + Vue 3 + AD-Vue 4

Adds a frontend/ directory that lives alongside the legacy web/html/
Vue 2 templates during the migration. Vite builds into ../web/dist/
so the Go binary will be able to embed the result via embed.FS once
Phase 4 starts moving real pages over.

- package.json pins Vue 3.5, Ant Design Vue 4.2, Vite 6, vue-i18n 10
- vite.config.js: dev server on :5173 with API proxy to the Go panel
  on :2053; build output to ../web/dist/
- src/App.vue is currently a smoke-test placeholder — delete once the
  first real page (login) lands in Phase 4
- node_modules and dist are already ignored at repo root

To verify locally:
  cd frontend && npm install && npm run dev

Pages will be migrated one at a time on the vue3-migration branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(frontend): Phase 3 — port utils, models, axios, websocket as ES modules

Ports the framework-agnostic JS from web/assets/js/ into frontend/src/
so Vue 3 pages can import what they need without relying on script-tag
globals.

- web/assets/js/util/index.js (927 lines, 21 classes) →
  frontend/src/utils/legacy.js + a barrel at utils/index.js. All
  classes are now named exports.
- Vue.prototype.$message in HttpUtil → direct import of `message`
  from ant-design-vue (Vue 3 has no Vue.prototype).
- RandomUtil.randomShadowsocksPassword previously defaulted to
  SSMethods.BLAKE3_AES_256_GCM from inbound.js, creating a circular
  import. Replaced with the literal string default.
- MediaQueryMixin (Vue 2 mixin) removed. Replaced by
  composables/useMediaQuery.js — Vue 3 composable returning reactive
  `isMobile`.
- axios-init.js wrapped as setupAxios(); Qs global → npm `qs`.
- websocket.js exported as WebSocketClient class; the implicit
  window.wsClient global is gone — pages instantiate it themselves.
- model/{inbound,outbound,dbinbound,setting,reality_targets}.js
  copied with `export` added on every top-level declaration. Imports
  between models and utils are wired up explicitly.
- subscription.js deferred to Phase 5 (it's a Vue 2 mount, not a util).
- App.vue smoke test exercises SizeFormatter / RandomUtil / Wireguard /
  useMediaQuery so the user can verify Phase 3 with `npm run dev`.

Run `cd frontend && npm install && npm run dev` — qs was added so a
fresh install is required.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 4 — port login.html to Vue 3 + AD-Vue 4 + Vite 8

First real page in the new toolchain. Multi-page Vite: each migrated
page is its own entry. login.html now lives at frontend/login.html with
a thin entrypoint at frontend/src/login.js mounting LoginPage.vue.

Vite 6 → Vite 8.0.11 (per user request). Requires Node 20.19+ or 22.12+.
@vitejs/plugin-vue bumped to ^6.0.6 (peers vite ^8). Ant Design Vue
stays on 4.2.6 — there is no AD-Vue 6.

Vue 2 → Vue 3 / AD-Vue 1 → AD-Vue 4 syntax changes hit on this page:
- new Vue({ el, delimiters, data, methods }) → createApp + <script setup>
- mounted() → onMounted()
- <template slot="X"> → <template #X>
- <a-icon slot="prefix" type="user"> → <template #prefix><UserOutlined />
  </template> with explicit @ant-design/icons-vue imports
- v-model.trim → v-model:value (AD-Vue 4 uses named v-model on inputs)

Three legacy features deferred so Phase 4 stays small:
- i18n (Phase 7 wires up vue-i18n)
- theme switcher (custom component pending Phase 5)
- headline word-cycle animation (purely aesthetic)

Run `cd frontend && npm install && npm run dev`, open
http://localhost:5173/login.html. With Go panel running on :2053 the
form submits real credentials via the configured proxy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5a — theme system + Vite 8 + vue-i18n 11

Bumps Vite to 8.0.11 (npm install picked up 6.4.2 from the stale
lockfile; clean install resolves the new constraint). Bumps vue-i18n
to 11.1.4 since v10 was just EOL'd.

Migrates aThemeSwitch.html — the two-flavor theme picker + global
themeSwitcher object — into:

- composables/useTheme.js: single reactive `theme` state with
  toggleTheme / toggleUltra. Boot side-effect applies the stored theme
  to <body>/<html> before Vue renders; watchEffect persists changes
  back to localStorage.
- components/ThemeSwitch.vue: full menu version for the main panel.
- components/ThemeSwitchLogin.vue: login-popover version.

AD-Vue 1 → 4 changes hit on this component:
- <a-icon type="bulb" :theme="filled|outlined"> dropped — replaced by
  explicit BulbFilled / BulbOutlined imports from
  @ant-design/icons-vue, swapped via <component :is="BulbIcon">
- Vue.component('a-theme-switch', { ... }) global registration → SFC
  + per-page import
- this.$message.config(...) (Vue 2 instance method) → message.config(...)
  imported from ant-design-vue, called once in login.js at boot

Login page now surfaces a settings button → popover → theme picker.

Known gap: web/assets/css/custom.min.css isn't yet imported into the
new bundle, so toggling dark mode currently only re-themes AD-Vue's
own components, not the panel chrome. The body class is still toggled
so behavior is correct; visual fidelity returns when custom.css is
ported or directly imported.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5b — port four shared components to Vue 3

CustomStatistic.vue and SettingListItem.vue are mechanical
Vue.component → SFC ports.

AppSidebar.vue: AD-Vue 4 dropped <a-icon :type="dynamic">, so the
five sidebar icons (dashboard/user/setting/tool/logout) live in a
name→component map and render via <component :is>. The legacy
<a-drawer slot="handle"> hack is replaced with a sibling fixed-
position toggle button. Tab paths take basePath/requestUri as
props instead of pulling them from Go template scope.

TableSortable.vue: the biggest Vue 3 rewrite of this phase.

  - $listeners is gone — replaced by inheritAttrs: false +
    explicit attrs forwarding
  - scopedSlots: this.$scopedSlots collapsed into Vue 3's unified
    slots object — just iterate Object.keys(this.slots) and forward
  - Vue 2 h(tag, { props, on, scopedSlots }, children) →
    Vue 3 h(tag, { ...props, ...on }, slotsObject)
  - 'a-table' string → resolveComponent('a-table') so app.use(Antd)
    registration is honored
  - inject: ['sortable'] (Options API) → inject('sortable', null)
    (Composition API) inside the trigger child
  - beforeDestroy → beforeUnmount
  - customRow's return shape flattened (no nested props/on/class)

Two intentional skips, documented in the migration doc:

  - aClientTable.html — slot fragments, not a component. Migrates
    inline with inbounds.html (new Phase 5f).
  - aPersianDatepicker.html — wraps a Persian-only third-party
    lib; defer until settings.html lands.

Build verified with vite 8.0.11.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): anchor Vite dev proxy so /login.html isn't forwarded

The /login proxy entry was matching any path starting with /login —
including /login.html, which Vite is supposed to serve itself. Without
the Go backend running, this caused ECONNREFUSED noise on every page
load.

Switched to regex patterns anchored with ^...$ so only the bare backend
paths (/login, /logout, /getTwoFactorEnable) and explicit sub-routes
(/panel/*, /server/*) get proxied. Static .html files Vite serves
directly are no longer matched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): real dark mode + silence dev proxy ECONNREFUSED noise

Two issues from running login.html against no Go backend:

1. Dark mode toggled the body class but didn't actually re-theme any
   AD-Vue components. The legacy panel relied on custom.min.css which
   we haven't ported. AD-Vue 4 ships its own dark algorithm — wrap
   LoginPage in <a-config-provider :theme="{ algorithm }"> driven by
   our useTheme state, and AD-Vue restyles every component for free.
   Page chrome (background, card, title) gets explicit .is-dark CSS
   since the algorithm only covers AD-Vue components.

2. Vite logged every failed proxy attempt loudly. When the Go panel
   isn't running locally that's pure noise. Added a configure()
   callback that swallows ECONNREFUSED specifically; real errors
   (timeouts, 5xx, anything else) still surface.

Both fixes are dev-experience only — production build is unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): use legacy panel palette for login page dark mode

Earlier dark mode used invented colors (#141a26 page bg, #1f2937 card)
that didn't match the rest of the panel. Replaced with the actual
values from web/assets/css/custom.min.css:

  light          dark             ultra-dark
  bg #c7ebe2     bg #222d42       bg #0f2d32
  card #fff      card #151f31     card #0c0e12
  title #008771  title #fff/.92   title #fff/.92

Drove everything off CSS custom properties on .login-app so the
.is-dark / .is-ultra class swap is a few var overrides instead of
duplicating selectors. Also restored the legacy card metrics
(2rem radius, 4rem 3rem padding, 2rem title) so the new page
matches the old panel's geometry, not just its colors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): match legacy wave layout + recolor for dark mode

The wave SVG had inline fill="#c7ebe2" (mint) on the bottom wave, so
in dark/ultra-dark mode it rendered as a pale-white blob against the
dark page. Stripped the inline fills, drove them off CSS variables
that swap with .is-dark / .is-ultra:

  light:      green tints + #c7ebe2 (mint) on the bottom wave
  dark:       #222d42 across all four waves
  ultra-dark: #0f2d32

The wave was also positioned wrong — anchored to the top 200px of
the viewport with absolute positioning. Restored the legacy layout:
  - .waves-header is fixed to the top of the viewport with z-index -1
    so the form floats over it
  - .waves-inner-header pushes the wave SVG down to ~50vh with a
    50vh-tall solid block of the page color
  - .waves SVG itself is 15vh tall, sitting at the bottom of that block

Net effect: top half is solid-colored, then a wavy edge transitions
into the rest of the page, with the form centered on top — matching
the legacy panel exactly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): bring wave-header to front so the wave actually shows

Two layering bugs were hiding the wave entirely:

1. .ant-layout-content had background: var(--bg-page) which painted an
   opaque rectangle covering the full content area — including the
   fixed wave-header behind it. Made the layout/content transparent
   and moved the bg paint up to .login-app (the outer ant-layout).

2. .waves-header had z-index: -1 which on its own was fine, but with
   .ant-layout-content opaque on top it was doubly buried. Promoted
   the wave-header to z-index: 0 and gave the form .login-row
   z-index: 1, so the form sits above the wave and the wave sits
   above the page-bg.

Also set --bg-page to the legacy mint (#c7ebe2) for light mode so the
bottom half of the page below the wave matches the legacy panel
(was white). Dark mode stays at the surface-100/login-wave palette.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): match legacy wave animation timings + dark page bg

Two reasons the bottom wave looked static in dark/ultra-dark:

1. Animation durations were 7s/10s/13s/20s. Legacy uses 4s/7s/10s/13s.
   The 20s on the bottom wave was so slow that against the low dark-
   mode contrast it read as motionless. Restored the legacy timings.

2. --bg-page in dark mode was #151f31 (card color / surface-100), but
   the legacy .under uses surface-200 (#222d42) — that's the color of
   the bottom half of the page, the same as the wave fill, so the
   wave appears to flow into the page rather than meeting a hard edge.
   Now it does.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): restore Hello/Welcome headline cycle on login

Earlier I deferred the legacy headline word-cycling animation as
"purely aesthetic". Restored it: the title now alternates between
'Hello' and 'Welcome' every 2 seconds, matching the legacy panel.

The legacy implementation toggled .is-visible / .is-hidden classes on
two <b> elements via setTimeout chains and DOM querying. Replaced
with a reactive ref + Vue 3 <Transition mode="out-in"> so the fade
between words is declarative — no manual DOM manipulation, and the
interval is properly cleaned up in onBeforeUnmount.

The earlier "Welcome to 3x-ui" string was wrong on two counts: it
should be just "Welcome", and it should be one of two cycling words
with "Hello" preceding it.

Ultra-dark palette already matched legacy after the prior wave timing
fix; no additional changes needed there beyond the animation speeds
that now also apply to ultra-dark via the shared CSS rules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): correct dark login bg + give ultra-dark wave real contrast

Two related fixes:

1. Default-dark wave-header bg was wrong. I had #0a2227, but that's
   the *ultra-dark* override; default dark uses --dark-color-background
   = #0a1222. Now the dark-mode top half is the legacy purple-blue
   instead of teal.

2. Ultra-dark wave fill is intentionally near-identical to its bg in
   the legacy palette (#0f2d32 vs #0a2227, ~5/11/11 RGB delta), which
   makes the wave look static even though the animation is running.
   Bumped --wave-fill / --wave-fill-bottom to #1f4d52 in ultra-dark
   only — far enough above the bg that the motion reads, while
   staying within the same teal hue family.

Also corrected ultra-dark --bg-page back to #0f2d32 (was briefly
#0c0e12, which is the card color, not the page color).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): drop ultra-dark bottom-wave seam line

Last fix made the wave fill #1f4d52 in ultra-dark for both top-three
waves and the bottom wave, which gave visible motion but exposed a
hard horizontal line where the bottom wave's flat lower edge met the
page bg (#0f2d32). The user noticed it as "the wave at the bottom
not moving its like a line" — they were seeing the SVG's clipped
bottom edge, not the wave itself.

Solution: only the top three waves get the brighter fill (those carry
the visible motion). The bottom wave reverts to #0f2d32 = --bg-page,
so its flat bottom edge merges seamlessly into the page below. Net
effect: motion is still visible (from waves 2 and 3), and there's no
seam line at the bottom of the SVG.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5c-i — index.html dashboard shell

Replaces the smoke-test App.vue with a real IndexPage shell so the
/index.html route now boots the actual dashboard layout in Vue 3:

- a-config-provider drives AD-Vue 4's dark algorithm from useTheme
  (same pattern as LoginPage)
- AppSidebar (Phase 5b component) is wired in with basePath +
  requestUri props
- a-spin loading state with placeholder card while we build out the
  rest of the page
- Page palette mirrors the legacy: light #f0f2f5, dark #0a1222
  (--dark-color-background), ultra-dark #21242a

The 1,805-line legacy index.html is too big for one commit. Split
into five sub-phases on the todo list: ii) status cards + /server/status
polling, iii) xray status card, iv) logs/backup/panel-update modals,
v) custom-geo section.

frontend/src/App.vue and frontend/src/main.js (smoke-test scaffold)
are removed — both purposes now served by IndexPage and index.js.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5c-ii — live status cards on the dashboard

Adds the CPU / memory / swap / disk dashboard cards to IndexPage,
backed by a useStatus() composable that polls /panel/api/server/status
every 2 s and a Status / CurTotal model ported from the legacy inline
classes in index.html.

- models/status.js — Status & CurTotal classes (CurTotal exposes
  reactive .percent and .color computed-style getters; Status maps
  the API payload + xray state to color/message strings)
- composables/useStatus.js — 2s polling with shallowRef so each fetch
  swaps the whole Status object atomically. WebSocket integration
  intentionally deferred — the legacy panel falls back to this same
  2s polling when its websocket drops, so we ship the proven path
  first and add WS on top in a later sub-phase.
- pages/index/StatusCard.vue — four a-progress dashboard widgets in
  a 2x2 grid (mobile collapses to a 1x4). CPU widget exposes a
  history button; the modal it opens is part of 5c-iv.
- IndexPage now consumes both, plus useMediaQuery so the layout
  responds to viewport changes.

AD-Vue 4 changes: <a-icon type="area-chart"|"history"> dropped in
favor of explicit AreaChartOutlined / HistoryOutlined imports.
<a-tooltip slot="title"> → <template #title>.

i18n strings still hardcoded English (Phase 7 wires up vue-i18n).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5c-iii — xray status card + stop/restart controls

XrayStatusCard.vue renders the right-hand card on the dashboard:

- Title with mobile-only version tag (matches the legacy collapse)
- Animated badge for the running/stop/error states. The pulsing dot
  comes from xray-pulse keyframes (renamed from runningAnimation in
  legacy custom.min.css). Color rings on the badge use the legacy's
  per-state border-color overrides on .ant-badge-status-processing.
- Error state replaces the badge with a popover that surfaces the
  multi-line errorMsg + a logs shortcut.
- Action row at the bottom: optional logs (when ipLimitEnable),
  stop, restart, and version switch.

IndexPage now wires:
- POST /panel/api/server/stopXrayService and /restartXrayService,
  followed by a refresh() so the status card reflects the new state
  without waiting for the next poll tick
- POST /panel/setting/defaultSettings to read ipLimitEnable
- Stub handlers for the panel-logs / xray-logs / version-switch /
  cpu-history modals — those land in 5c-iv

AD-Vue 4 changes hit on this card:
- <a-icon type="bars|poweroff|reload|tool"> → explicit
  BarsOutlined / PoweroffOutlined / ReloadOutlined / ToolOutlined
- <span slot="title|content"> → <template #title|#content>
- The .xray-*-animation classes ship as global <style> (not scoped)
  so they pierce AD-Vue's internal .ant-badge-status-* DOM.

i18n still hardcoded English; Phase 7 wires vue-i18n.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5c-iv (a) — panel update / logs / backup modals

Adds three of the six dashboard modals plus a Quick Actions card
that surfaces them. The remaining three (xray logs, version picker,
CPU history sparkline) ship in 5c-iv-b.

- PanelUpdateModal.vue — current vs latest version, "update now"
  button. Confirm dialog → POST /panel/api/server/updatePanel,
  then poll /server/status for up to 90s until the new panel
  answers, then reload.
- LogModal.vue — panel logs viewer. Filters: rows (10-500), level
  (debug/info/notice/warning/error), syslog toggle. Auto-fetches
  on open and on every filter change. Color-coded timestamps and
  levels via inline span styles. Download button writes the raw
  log to x-ui.log via FileManager.downloadTextFile.
- BackupModal.vue — db export (window.location to /getDb) and
  import (FormData upload to /importDB, then panel restart + reload).
- Quick Actions card surfaces Logs / Backup / Update buttons and
  shows an orange update badge (extra slot) when an update is
  available.

Modal-busy pattern: long-running operations (update, import) emit
a `busy` event with a tip; IndexPage flips its a-spin overlay so the
user sees a loading message while the panel is restarting.

AD-Vue 4 changes:
- v-model on <a-modal> renamed to v-model:open
- v-model on <a-input>/<a-select>/<a-checkbox> uses the named
  v-model:value / v-model:checked pattern
- <a-icon type="..."> dropped — explicit Ant icon imports
  (BarsOutlined, CloudServerOutlined, CloudDownloadOutlined,
  DownloadOutlined, UploadOutlined, SyncOutlined)
- Modal.confirm() replaces this.$confirm() since setup() has no `this`

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5c-iv (b) — cpu-history / xray-logs / xray-version modals

Wires up the three remaining dashboard buttons that were stubbed in
5c-iv (a): the CPU history button on StatusCard, the xray-logs button
in XrayStatusCard's error popover and ipLimitEnable action, and the
"Switch xray" button in XrayStatusCard's action footer.

- Sparkline.vue: shared SVG line chart (composition-API port of the
  inline Vue 2 component). Per-instance gradient id avoids defs
  collisions between sparklines on the same page.
- CpuHistoryModal.vue: bucket dropdown (2m/30m/1h/2h/3h/5h) drives
  GET /panel/api/server/cpuHistory/{bucket}; renders via Sparkline.
- XrayLogModal.vue: rows + filter + direct/blocked/proxy checkboxes;
  POST /panel/api/server/xraylogs/{rows} returns access-log entries
  rendered as a colored HTML table; download button serializes to text.
- VersionModal.vue: collapse with Xray panel (radio list of versions
  from getXrayVersion, install via installXray/{version}) and Geofiles
  panel (per-file reload + Update all). CustomGeo collapse panel is
  Phase 5c-v.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5c-v — custom-geo section in VersionModal

Adds the third collapse panel ("Custom geo") that lets users register
external geosite/geoip files referenced by routing rules via
ext:<filename>:tag. Backend endpoints are unchanged.

- CustomGeoSection.vue: bordered table over /panel/api/custom-geo/list
  with per-row edit, download (refetch), and delete actions, plus an
  Add button and Update-all. Lazy-loads the list when the parent
  collapse opens this panel — closed panels don't fetch.
- CustomGeoFormModal.vue: shared add/edit form with the same alias
  regex (^[a-z0-9_-]+$) and URL validation as legacy. Type and alias
  are immutable when editing — backend rejects changes anyway.
- ext:<filename>:tag value is click-to-copy via ClipboardManager.
- Relative time is computed inline (no moment dep); tooltip shows the
  absolute timestamp.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5d-i — settings page shell + dirty tracking

Adds the settings entry as a new Vite multi-page input. Lays down the
shared page chrome (sidebar, save bar, restart, security alert) and the
AllSetting fetch/dirty-poll lifecycle so 5d-ii through 5d-vi can drop
in tab partials without re-implementing it.

- settings.html + src/settings.js: third Vite entry; mounts SettingsPage.
- SettingsPage.vue: page chrome with the legacy two-button save/restart
  bar, conf-alerts banner, and 5 a-tabs (4 always-visible + the formats
  tab gated on subJsonEnable || subClashEnable). Each tab body is an
  a-empty placeholder until 5d-ii…vi fill them in.
- useAllSetting.js composable: POST /panel/setting/all on mount, mirrors
  the legacy 1s busy-loop dirty check via setInterval, and exposes
  fetchAll/saveAll. saveDisabled flips off as soon as the user diverges
  from the server snapshot.
- restartPanel rebuilds the URL (host/port/scheme/base path) from the
  saved settings so users land on the new endpoint after a port or
  cert change.
- models/setting.js: adopts the @/utils alias and a leading file-level
  doc — semantics unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5d-ii — settings General tab

Ports the panel/general partial (the largest single tab) — six
collapse panels: General, Notifications, Certificates, External
traffic webhook, Date and time, LDAP.

- GeneralTab.vue receives the reactive AllSetting via props and binds
  fields directly with v-model:value; SettingsPage stays the sole
  fetch/save owner.
- remarkModel/remarkSeparator surfaced as computed v-models that
  read+write the underlying single-string field (legacy stores them
  packed as <separator><orderedKeys>, e.g. "-ieo").
- LDAP inbound-tags select binds to a CSV ↔ array computed; inbound
  options come from /panel/api/inbounds/list on mount.
- Language select stays cookie-based via LanguageManager and reloads
  on change — same UX as legacy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5d-iii — settings Security tab + 2FA modal

Ports the panel/security partial: change-credentials form and 2FA
toggle. The 2FA modal is a new shared component since enabling 2FA,
disabling 2FA, and changing credentials all funnel through it with
slightly different copy.

- TwoFactorModal.vue: 'set' flow renders a QR code + manual key + a
  6-digit verifier; 'confirm' flow renders just the verifier. The
  parent passes a confirm(success) callback that fires only when the
  entered code matches the live TOTP value (otpauth lib).
- SecurityTab.vue: holds the local user form (oldUsername/oldPassword/
  new*), POSTs /panel/setting/updateUser, and on success force-redirects
  to logout. When 2FA is on, the credentials change goes through the
  confirm-modal first.
- toggleTwoFactor leaves the switch read-only (the v-bound :checked
  matches AllSetting) and only flips after the modal succeeds, so
  cancelling out leaves state unchanged.
- Adds otpauth ^9.5.1 dep (qrious was already present).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5d-iv — settings Telegram tab

Ports the panel/telegram partial: bot enable/token/chatId/lang in the
General panel, schedule/backup/login/CPU-threshold in Notifications,
and proxy/API-server overrides in the third panel. All bindings live
on the shared AllSetting reactive — no fetch/save logic in this tab.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5d-v — settings Subscription general tab

Ports the subscription/general partial — four collapse panels covering
the master enable switches, presentation/template fields, certs, and
update interval.

- Sub path goes through a strip-on-input + normalize-on-blur computed:
  legacy stripped `:` and `*` and ensured the value starts and ends
  with a single `/` — same here.
- Both `subEnableRouting` and the announce/profile/title/support URLs
  are bound directly on AllSetting.
- The "Subscription URI override" placeholder mirrors the legacy
  pattern for the manual full-URL form.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5d-vi — settings Subscription formats tab

Ports the subscription/json partial — paths/URIs for the JSON and
Clash formats plus the four packed-JSON sub-fields: fragment, noises,
mux, and direct routing rules.

- subJsonFragment / subJsonMux / subJsonNoises / subJsonRules are each
  a JSON string on the wire; the tab exposes their fields as computed
  v-models that read+write the underlying JSON. Toggling a top-level
  switch off resets the field to "" (matches legacy semantics).
- Direct routing rules surface the IP and domain entries of the seed
  rule array as multi-select tag inputs; setting/removing tags
  edits the rules array in place rather than rebuilding it from
  scratch, so manually-added rules are preserved.
- Tab is gated on subJsonEnable || subClashEnable in the parent (only
  rendered when the user actually opted into one of those formats).

This closes Phase 5d — full settings page parity with the legacy panel
across all five tabs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): route /panel/<route> to migrated pages in dev

The sidebar links to production-style URLs like /panel/settings, but
in dev that gets proxied to the legacy Go template — which fails
because we haven't loaded the legacy asset chain. Add a proxy bypass
so /panel and /panel/settings are served from index.html / settings.html
on the Vite dev server itself. Unmigrated routes (inbounds, xray)
still proxy to Go.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(csrf): expose token endpoint for SPA pages and fetch it from axios

The legacy panel pages got their CSRF token from a <meta name="csrf-token">
tag rendered by Go. SPA pages built by Vite don't have that, so every
unsafe (POST/PUT/DELETE) request from them was hitting CSRFMiddleware
with no token and getting 403 — visible as the settings page being
stuck on "Loading…" because POST /panel/setting/all failed.

- web/controller/xui.go: GET /panel/csrf-token returns the session
  token. Lives under the xui group so checkLogin still gates it; the
  CSRFMiddleware on the same group is a no-op for GET.
- frontend/src/api/axios-init.js: cache the token at module scope and
  lazy-fetch it when a non-safe request needs one. Seed from the meta
  tag first when present (legacy compat). On a 403 response, drop the
  cache and retry once — handles the case where a server restart
  rotated the token after the SPA loaded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): keep sidebar links absolute when basePath is empty

The dashboard sidebar built tab keys as basePath + 'panel/...'. In dev
the window-injected basePath is '' so the resulting key was a relative
path like 'panel/settings'. When the browser resolved that against the
current /panel/settings URL it produced /panel/panel/settings — visible
as broken navigation between Dashboard and Settings.

Force a leading slash so the keys are always absolute regardless of
whether the host injected a basePath.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5f-i — inbounds page shell + list fetch

Adds the inbounds entry as a fourth Vite multi-page input and wires
/panel/inbounds through the dev proxy bypass. Lays down the page
chrome (sidebar, summary statistics card, refresh button) and the
fetch lifecycle composable so 5f-ii onward can drop in the table
columns and the modals without re-implementing it.

- inbounds.html + src/inbounds.js: fourth Vite entry; mounts InboundsPage.
- InboundsPage.vue: sidebar + summary card (totals over up/down,
  all-time, inbound count, client tags) + a basic table with enable/
  remark/port/protocol/traffic/expiry columns. Row actions, popovers,
  search/filter, auto-refresh, and the WebSocket delta path are all
  deferred to subsequent 5f subphases.
- useInbounds.js composable: GET /panel/api/inbounds/list +
  POST /panel/api/inbounds/onlines + POST /panel/api/inbounds/lastOnline +
  POST /panel/setting/defaultSettings, then computes the
  per-inbound clientCount roll-ups (active/deactive/depleted/expiring/
  online/comments) the table popovers consume.
- models/dbinbound.js + models/inbound.js: switched the legacy-utils
  import to the @/utils alias for consistency with the rest of the
  app. Semantics unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5f-ii — inbound list table + search/filter + auto-refresh

Fleshes out the inbound list with the full column set, search & filter
toolbar, row enable toggle wired to /panel/api/inbounds/setEnable/:id,
and a per-row action dropdown that emits events the parent will route
to modals as those land in 5f-iii through 5f-vii.

- InboundList.vue (new): toolbar (Add inbound + General actions
  dropdown + Refresh + auto-refresh popover), search-or-filter switch
  with the legacy radio buttons (Active/Disabled/Depleted/Depleting/
  Online), and a a-table with desktop and mobile column variants.
  Cells use AD-Vue 4's #bodyCell slot — protocol/clients/traffic/
  allTime/expiry/info cells render the same popovers and tags as
  legacy. Row enable switch is optimistic with rollback on POST
  failure.
- visibleInbounds computed mirrors the legacy search and filter
  projection: deep search through dbInbound + clients, or filter
  reduces inbound.settings.clients to the selected bucket so the
  table only shows matching client rows.
- Auto-refresh interval is read/written to localStorage with the
  same keys (`isRefreshEnabled`, `refreshInterval`) as the legacy
  panel. WebSocket delta updates are still deferred.
- Action menu emits event payloads {key, dbInbound}; the parent
  currently shows a "coming in later 5f subphase" toast for each.
  Modals (edit/qr/clone/delete/reset/info/clients) land in
  5f-iii through 5f-vii.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(inbounds): wrap popover-table rows in <tbody>

Vue's template compiler warned that <tr> can't be a direct child of
<table> per the HTML spec; the browser silently inserts a <tbody>
wrapper but Vue's SSR/hydration path doesn't, which can cause
hydration mismatches. Add explicit <tbody> in both popover tables
(traffic + mobile-info).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5f-iii — inbound add/edit modal + delete/clone/reset

Wires up the inbound CRUD flows. The protocol-specific and transport-
specific forms are still ahead in 5f-iii-b — for now the modal exposes
those as JSON textareas so users can both edit existing inbounds without
losing settings and create new ones from default templates.

- InboundFormModal.vue: tabbed modal with a full Basics tab (enable,
  remark, protocol, listen, port, total GB, traffic reset, expiry
  date) and three JSON-edit tabs (Settings, Stream, Sniffing). Add
  mode stamps a fresh template per protocol via
  Inbound.Settings.getSettings(protocol); changing the protocol in
  add mode restamps the JSON. Edit mode pretty-prints the existing
  JSON so the user sees the same fields they save back.
- POST /panel/api/inbounds/add or /panel/api/inbounds/update/:id on
  submit; on success the parent refreshes the list and the modal
  closes. Malformed JSON in any of the three textareas surfaces a
  message.error and aborts the save without losing user input.
- InboundsPage.vue: wires the row action menu to real handlers —
  edit (opens the modal in edit mode), delete, reset-traffic,
  clone, reset-clients, del-depleted-clients all go through
  Modal.confirm and refresh on success. General actions menu wires
  reset-inbounds / reset-clients / del-depleted-clients the same way.
  Remaining actions (qrcode/info/import/export/copyClients) still
  toast as "coming soon" — those land in 5f-iv and 5f-v.
- Adds dayjs ^1.11.20 dep for the a-date-picker v-model interop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5f-iv — client add/edit + bulk-add modals

Wires per-inbound client management. Both flows go through the same
addClient/updateClient endpoints as legacy; the modals just funnel
the form state into the right shape (`{id, settings: '{"clients": [...]}'}`).

- ClientFormModal.vue: protocol-aware single-client editor — email/
  password/id/auth/security/flow/subId/tgId/comment/ipLimit/totalGB/
  expiry/renewal fields are shown/hidden per protocol like legacy.
  Edit mode displays the per-client traffic stats with a reset
  button; IP-limit log is read on click and clearable. Random
  helpers (sync icon next to each label) regenerate UUID/email/
  password/sub-id values.
- ClientBulkModal.vue: 1–500 clients in one POST, with the legacy
  five email-generation modes (Random / +Prefix / +Num / +Postfix /
  Pure-Prefix-Num-Postfix). Builds clients via the protocol-aware
  factory and concatenates their toString() output into a single
  settings.clients JSON array.
- InboundsPage.vue: opens both modals from the row action menu
  (`addClient` / `addBulkClient`). They both refresh the inbound list
  on success.
- Outstanding row actions still toast as "coming soon": qrcode,
  showInfo, copyClients, clipboard. Those land in 5f-v / 5f-vi.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5f-v — inbound info + QR-code modals

Wires the row "info" and "qrcode" actions and ports the legacy
inbound_info_modal end-to-end. The info modal handles every protocol
the legacy panel did:
  • multi-user (VMess/VLess/Trojan/SS-multi/Hysteria) — per-client
    table + share links + per-link QR;
  • SS single-user — share link + QR;
  • WireGuard — full peer table with downloadable peer-N.conf and a
    wg:// share link per peer;
  • Mixed/HTTP/Tunnel — connection-detail tables.

- QrPanel.vue: shared link card (header tag, copy button, optional
  download button, optional QR canvas, monospace footer with the
  raw value). Per-instance QRious instances are repainted on
  value/size change.
- InboundInfoModal.vue: full info modal. Subscription URL block keys
  off subSettings.subURI/subJsonURI; IP-log lazy-loads on open and
  surfaces refresh + clear; tg-id, last-online, depleted/enabled tags
  all match legacy.
- QrCodeModal.vue: lighter modal used for the row "qrcode" action on
  SS-single and WireGuard inbounds (just the QRs, no info table).
- InboundsPage.vue: wires both flows. checkFallback() reproduces the
  legacy logic — when an inbound listens on a unix-socket fallback
  (`@<name>`), the link generator is pointed at the root inbound that
  owns the listen address so QRs/links carry the public host:port +
  the right TLS state. Multi-client navigation (focusing a specific
  client's links) is deferred to 5f-vi where the per-inbound expand-
  row table will pass the email through.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5f-vi — per-inbound client expand-row table

Each multi-user inbound row in the list now expands to show its
client roster, mirroring the legacy aClientTable component.

- ClientRowTable.vue: inner a-table with full desktop column set
  (action icons / enable / online / client-with-status-dot / traffic
  with progress bar / all-time / expiry with reset cycle) and a
  collapsed mobile variant (single dropdown menu + popover info).
  Self-contained: stats are looked up via a per-inbound email->stats
  Map; per-client confirms (reset/delete) live on the row.
- The component emits typed events (edit/qrcode/info/reset-traffic/
  delete/toggle-enable) — InboundsPage routes them back to the
  existing client and info modals (with `findClientIndex` so the
  modal opens focused on the right client).
- InboundList.vue: hooks ClientRowTable into the a-table's
  expandedRowRender slot; row-class-name `hide-expand-icon` and a
  scoped CSS rule hide the chevron for non-multi-user inbounds
  (HTTP/Mixed/Tunnel/WireGuard/SS-single) so they keep looking flat.
- toggle-enable-client routes through updateClient with the same
  `{id, settings: '{"clients": [...]}'}` shape as the other modals,
  so backend parsing stays single-pathed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5f-iii-b — replace inbound modal JSON textareas with structured forms

Rewrites InboundFormModal to look like the legacy panel: structured
forms for the common case, with a compact "Advanced (JSON)" fallback
for the rare bits we don't yet have UI for.

Tabs:
  • Basics — enable/remark/protocol/listen/port/total/trafficReset/expiry
  • Protocol — protocol-aware:
      VMess/VLess/Trojan/SS-multi/Hysteria in add mode embed an inline
        first-client form (email + ID/password/auth, security, flow,
        subId, comment, total GB, expiry);
      edit mode shows a clients-count summary table;
      VLess: decryption/encryption inputs;
      SS: method dropdown that re-randomizes password and propagates
        method change to the multi-user array (matches legacy
        SSMethodChange);
      HTTP/Mixed: accounts table with add/remove rows + Mixed
        auth/udp/ip toggles;
      Tunnel: address/port/network/followRedirect;
      WireGuard: secretKey/pubKey (regen via Wireguard.generateKeypair)
        + per-peer fields with PSK regen + allowedIPs add/remove +
        keepAlive.
  • Stream — only when canEnableStream(); transport selector with
      structured forms for TCP (proxy-protocol, http camouflage),
      WS (host/path/heartbeat/headers), gRPC (serviceName, multiMode),
      HTTPUpgrade (host/path). KCP/XHTTP fall back to the Advanced tab
      with an alert banner. Security selector with TLS (sni/alpn/
      fingerprint) and Reality (target/serverNames/keypair-gen via
      /panel/api/server/getNewX25519Cert / shortIds / fingerprint).
  • Sniffing — enabled/destOverride/metadataOnly/routeOnly/
      ipsExcluded/domainsExcluded as structured fields.
  • Advanced (JSON) — raw streamSettings + sniffing JSON for users
      reaching KCP/XHTTP/sockopt/finalmask/full TLS cert arrays. The
      stream JSON is auto-synced from the live model whenever the
      structured fields change.

State source of truth is a deeply-reactive Inbound + DBInbound pair
cloned on open; submit serializes via inbound.settings.toString() +
inbound.stream.toString() so the wire shape matches the legacy panel
byte-for-byte. streamNetworkChange semantics (clear flow when
TLS/Reality unavailable, reset finalmask.udp when not KCP) are
preserved.

Vision Seed for VLess + finer-grained TCP HTTP camouflage + the full
TLS cert/ECH editor will land in 5f-iii-c.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 5f-vii — shared text/prompt modals + remaining export/import wiring

Wires up the last batch of inbound row + general actions that were
toasting "coming soon": export-inbound-links, export-subs (per-inbound
and global), export-all-links, import-inbound, and the clipboard JSON
peek. Two small shared components back them — both can be reused by
the xray page later.

- TextModal.vue (shared): read-only multi-line viewer with a copy
  button and an optional download button when fileName is set.
  Replaces the legacy txtModal which the inbounds page used for every
  link export.
- PromptModal.vue (shared): generic title + input/textarea + confirm
  callback, with the legacy keybindings (Enter submits in single-line
  mode; Ctrl+S submits in textarea mode). Used here for import-inbound
  but also by xray-config edits in Phase 6.
- InboundsPage.vue: drops the toast stubs for `import`/`export`/`subs`
  on the general-actions menu and `export`/`subs`/`clipboard` on the
  per-row menu, routing each through openText / openPrompt + the
  appropriate model helper (genInboundLinks, etc.). The copyClients
  cross-inbound modal stays toast-stubbed — that's its own dedicated
  legacy modal worth its own commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 6-i — xray page scaffold + Advanced JSON tab

The fifth and last legacy page comes online. Tabs are scaffolded with
a-empty placeholders for the structured editors (Basics / Routing /
Outbounds / Balancers / DNS) so navigation is stable; the
Advanced (JSON) tab is fully functional and lets power users edit
the raw xraySetting tree exactly like the legacy CodeMirror pane.

- xray.html + src/xray.js: fifth Vite multi-page entry, mounted as
  XrayPage; vite.config.js routes /panel/xray and /panel/xray/ to it
  through the dev proxy bypass alongside the other pages.
- XrayPage.vue: page chrome with the Save / Restart-xray bar, restart-
  output popover (surfaces /panel/xray/getXrayResult content when
  startup fails), 6 a-tabs, and a textarea-backed Advanced JSON editor.
  CodeMirror is intentionally not pulled in — the textarea works for
  every modern browser and keeps the bundle slim while structured
  editors land in 6-ii through 6-v.
- useXraySetting.js composable: POST /panel/xray/ on mount, mirrors
  the settings-page 1s busy-loop dirty check for both xraySetting
  and outboundTestUrl, and exposes saveAll + restartXray. The dirty
  flag relies on string equality of the pretty-printed JSON, so
  reformat-only edits don't enable Save.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 6-ii — xray Basics tab structured editor

Replaces the placeholder on the Basics tab with a structured form for
the most-touched fields of the xray template — outbound + routing
strategy, log levels, traffic stat counters, and the "basic routing"
shortcuts (block torrent / IPs / domains, direct IPs / domains, IPv4
forced, WARP / NordVPN routing).

- useXraySetting.js: hoists a parsed `templateSettings` reactive
  alongside the JSON string, with two cooperating watches that keep
  them in sync. Editing structured fields stringifies into xraySetting
  for the dirty-poll + Advanced JSON tab; editing the JSON re-parses
  into templateSettings only when valid, so structured tabs stay
  readable mid-edit.
- BasicsTab.vue: collapse panels mirror the legacy partial — General,
  Statistics, Logs, Basic routing. Every input is a computed v-model
  reading/writing into templateSettings; the routing-rule shortcuts
  funnel through ruleGetter/ruleSetter which match the legacy
  templateRuleGetter/templateRuleSetter behavior (replace-first,
  drop-duplicates, pop-the-rule-when-empty). Direct/IPv4 setters
  also call syncOutbound() to provision/prune the matching outbound.
- XrayPage.vue: imports BasicsTab + derives `warpExist`/`nordExist`
  from the parsed templateSettings. WARP/NordVPN provisioning modals
  are still placeholders that toast — those land in 6-v with the
  routing/outbound editors.

Default tab flips back to Basics so users land on the structured
editor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 6-iii — xray Routing tab + rule modal

Replaces the Routing tab placeholder with a full editor for
templateSettings.routing.rules:

- RoutingTab.vue: a-table over the parsed rules with the legacy six-
  column layout (action / source / network / destination / inbound /
  outbound) and the same "lead value + N more" pill renderer for
  multi-value criteria. Mobile drops source/network/destination for
  readability. Per-row dropdown handles edit / move-up / move-down /
  delete; the array-mutation reordering replaces the legacy jQuery
  Sortable drag handle without pulling in a sortable lib.
- RuleFormModal.vue: full form mirroring xray_rule_modal.html —
  CSV inputs for sourceIP/sourcePort/vlessRoute/ip/domain/user/port,
  Network select, Protocol multi-select, Attrs key/value pairs,
  inbound-tag multi-select sourced from
  templateSettings.inbounds + parent inboundTags + dnsTag,
  outbound-tag single-select sourced from templateSettings.outbounds
  + clientReverseTags, and balancerTag from
  templateSettings.routing.balancers. Submit serializes via the
  same shape the legacy `getResult` produces (CSV → array, drop
  empty fields).
- XrayPage.vue: imports RoutingTab and exposes inboundTags +
  clientReverseTags from useXraySetting so the modal can populate
  its tag pools.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 6-iv — xray Outbounds tab + outbound modal

Replaces the Outbounds tab placeholder with a full table + add/edit
flow. The 1.3k-line legacy outbound modal is condensed to a tabbed
modal with structured Basics fields (tag/protocol/sendThrough/domain
strategy) and JSON tabs for the protocol-specific settings + stream
trees — same approach the Inbound modal uses, and a power user can
still edit the same trees via the page-level Advanced (JSON) tab.

- useXraySetting.js: adds fetchOutboundsTraffic +
  resetOutboundsTraffic + testOutbound. Test states are tracked per
  outbound index so the row's Test button can show loading + the
  Test-result column can render the response delay / status / error.
- OutboundsTab.vue: full table (action / identity / address / traffic
  / test result / test) plus a card-list mobile variant with the
  same row dropdown (set-first / edit / move up/down / reset traffic
  / delete). outboundAddresses() reproduces the legacy
  findOutboundAddress logic so each protocol's host:port list is
  rendered consistently. Add/edit go through OutboundFormModal,
  delete goes through Modal.confirm, reset traffic posts to
  /panel/xray/resetOutboundsTraffic with the row's tag (or
  "-alltags-" from the toolbar).
- OutboundFormModal.vue: tag/protocol/sendThrough/domainStrategy on
  the Basics tab; settings + streamSettings as raw JSON on their
  respective tabs. Tag-collision check happens client-side before
  emitting; malformed JSON aborts the save with a message.error.
- XrayPage.vue: imports OutboundsTab and wires the test action to
  the composable's testOutbound helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 6-v — xray Balancers tab + DNS placeholder

Brings Balancers to full parity with the legacy panel and adds a
DNS tab placeholder that exposes the full dns/fakedns trees as JSON
so users can edit them without falling through to Advanced.

- BalancerFormModal.vue: tag (with duplicate-tag warning across
  other balancers), strategy (random/roundRobin/leastLoad/leastPing),
  selector tag-mode multi-select sourced from existing outbound
  tags + free-form additions, fallback. Disable-on-invalid is
  driven by the duplicateTag + emptySelector computed flags.
- BalancersTab.vue: empty state with a single "Add balancer" CTA;
  populated state shows the legacy 4-column table (action / tag /
  strategy / selector / fallback) with per-row edit + delete in a
  dropdown. On submit the wire shape preserves the
  `strategy: { type }` nesting only when the strategy is non-default,
  matching the legacy emit. Tag renames also chase across
  routing.rules.balancerTag references so existing rules don't dangle.
- DnsTab.vue: master enable switch + raw JSON for `dns` and
  `fakedns`. Legacy had a dedicated server-by-server editor + a
  fakedns row editor; both are big enough to deserve their own
  commits, and the JSON path supports every field today.

WARP / NordVPN provisioning modals still toast as "coming soon" —
those are third-party API integrations worth their own commits.
The xray page now has structured editors for Basics / Routing /
Outbounds / Balancers and JSON editors for DNS / Advanced — every
xray tab the legacy panel offered is functional.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(server): Phase 8 — cut HTML routes over to web/dist/

Production cutover. Every user-facing HTML route now serves the
Vue-3-built bundle from web/dist/ instead of rendering the legacy
Go template; the long-hashed Vite assets are served at /assets/ from
the same embedded filesystem. The legacy templates in web/html/ and
the legacy static tree in web/assets/ are kept on disk for now in
case a quick revert is needed, but nothing the binary serves
references them.

What changed:
- web.go: a new //go:embed dist/* feeds the controller package via
  a SetDistFS hand-off before controller construction. The static
  /assets/ route is rebound: in dev to web/dist/assets/ on disk so
  Vite's incremental rebuilds show up live; in prod to the embedded
  dist via wrapDistFS (rooted one level deeper than wrapAssetsFS).
- controller/dist.go: serveDistPage helper used by every HTML
  handler. Reads dist/<name> from the embedded FS and applies two
  transforms before sending:
    1. injects <script>window.__X_UI_BASE_PATH__="..."</script>
       just before </head> so AppSidebar links resolve under the
       panel's basePath.
    2. when basePath != "/", rewrites Vite's absolute /assets/ URLs
       to <basePath>assets/ so installs running under a custom URL
       prefix load the bundle where the static handler lives.
  HTML responses go out with no-cache so panel upgrades reach
  users on the next refresh; hashed JS/CSS stays cacheable.
- controller/index.go: IndexController.index now serves
  dist/login.html for logged-out callers (the redirect for logged-in
  users is unchanged).
- controller/xui.go: XUIController.{index,inbounds,settings,xraySettings}
  each become a one-line wrapper around serveDistPage.

Smoke checklist for the maintainer:
- run `cd frontend && npm run build` to refresh web/dist/ before
  building the Go binary (the embed snapshot is taken at compile
  time);
- visit /panel/, /panel/inbounds, /panel/settings, /panel/xray and
  confirm each loads its Vue page;
- log out and log back in to verify the login flow;
- confirm the sidebar links navigate correctly under your install's
  basePath;
- POST flows (e.g. saving settings) still need the CSRF token —
  that endpoint (/panel/csrf-token, added earlier) is unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 6-vi — WARP + NordVPN provisioning modals

Replaces the toast stubs on the Basics tab and Outbounds toolbar
with the legacy WARP + NordVPN provisioning flows. Both modals now
stage their wireguard outbounds back into templateSettings.outbounds
through the same event channels OutboundsTab uses, so the existing
add / reset / delete / refresh-traffic surface keeps working.

- WarpModal.vue: empty state shows a single Create button that
  generates a wireguard keypair locally (Wireguard.generateKeypair)
  and posts it to /panel/xray/warp/reg; populated state surfaces
  the access_token / device_id / license_key / private_key, lets
  the user upgrade to WARP+ via /panel/xray/warp/license, refreshes
  the account info from /panel/xray/warp/config (plan / quota /
  usage in human-readable bytes), and stages a wireguard outbound
  with the WARP-specific reserved-byte encoding pulled from
  client_id. Add / Reset / Delete go through events the parent
  routes back to templateSettings.outbounds.
- NordModal.vue: dual-tab login (NordVPN access token →
  /panel/xray/nord/reg, or paste a NordLynx private key →
  /panel/xray/nord/setKey). Once authenticated, country / city /
  server selectors fetch from /panel/xray/nord/{countries,servers},
  servers sort by load ascending, the lowest-load server in the
  current city auto-selects. Reset emits oldTag/newTag so the
  parent renames matching routing rules in place; logout emits a
  remove-routing-rules event with prefix `nord-` to purge any
  dangling references.
- XrayPage.vue: holds warpOpen / nordOpen flags, ensures the
  outbounds array exists before mutating it, and wires the modal
  events (add-outbound / reset-outbound / remove-outbound /
  remove-routing-rules) to in-place edits of templateSettings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): Phase 7 — vue-i18n wired up + login page translated

Sets up vue-i18n on top of the panel's existing TOML translation
files. The Go side stays the source of truth — translators continue
to edit web/translation/*.toml; a sync script snapshots those files
into per-locale JSON the Vue bundle imports. The login page is
translated end-to-end as a worked example; remaining pages can be
converted incrementally without infrastructure churn.

What's in the box:
- scripts/sync-locales.mjs: small TOML→JSON converter that walks
  web/translation/*.toml and writes frontend/src/locales/<code>.json.
  Handles the narrow subset of TOML the panel uses (flat key/value
  pairs + dotted [section.subsection] heads). Wired as a `prebuild`
  + `predev` script so production builds always include the latest
  strings without a manual step.
- src/i18n/index.js: createI18n() in composition mode with all 13
  locales emitted as their own Vite chunks. The active locale (read
  from the same `lang` cookie LanguageManager has always managed)
  plus the en-US fallback are eagerly loaded; the rest are
  dynamically importable via a loadLocale(code) helper. This keeps
  the per-page bundle the user actually downloads small — only ~30
  KB of strings end up in the initial payload, vs ~220 KB if all
  13 were eager.
- All five page entries (index/login/settings/inbounds/xray) wire
  the i18n plugin into createApp via .use(i18n).
- LoginPage.vue: t(...) replaces hardcoded English on the username
  / password / 2FA placeholders, the submit button label, and the
  Settings popover title. The Hello/Welcome headline cycle stays
  hardcoded — those are stylistic, not labels.

The 'Hello'/'Welcome' cycle stays in English deliberately; the rest
of the migration's components still ship hardcoded English and will
be converted page by page in follow-up commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* i18n(frontend): translate page chrome — sidebar, save bars, tabs, summary cards

Replaces hardcoded English with t() calls in the components every
user sees on every page load. The translations themselves come from
the existing TOML files via the sync script — no new strings, no
new locale keys.

Per component:
- AppSidebar.vue: 5 menu titles (dashboard / inbounds / settings /
  xray / logout). Computed so the sidebar re-renders when the
  cookie-driven locale flips on reload.
- IndexPage.vue: Quick actions card title + Logs / Backup / Up-to-
  date / Update buttons.
- StatusCard.vue: CPU / Memory / Swap / Storage labels +
  logical-processors / frequency tooltips.
- XrayStatusCard.vue: card title + error popover header + Stop /
  Restart / Switch xray action labels (kept the v-prefix version
  string as-is — it's content, not a label).
- SettingsPage.vue: 5 tab titles + Save / Restart-panel buttons +
  unsaved-changes warning.
- XrayPage.vue: 6 tab titles + Save / Restart-xray buttons +
  unsaved-changes warning.
- InboundsPage.vue: 5 summary-stat card titles.
- InboundList.vue: 10 column titles (computed for live locale),
  Add inbound / General actions buttons + every dropdown menu item,
  search placeholder, filter radio labels, popover titles
  (disabled / depleted / depleting / online), traffic + info
  popover row labels.

Total: ~75 strings localised across 8 files. The remaining English
labels live in the per-tab settings forms, the form modals
(Inbound / Client / Outbound / Rule / Balancer / WARP / Nord), and
the per-row table cell helpers — all incremental work that doesn't
touch infrastructure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* i18n(frontend): translate every remaining English string on the index page

Closes the index page's i18n coverage. Combined with the page-chrome
commit, every label users see on the dashboard is now sourced from
the TOML translation files.

Per file:
- IndexPage.vue: loading-spinner tip (initial + dynamic).
- BackupModal.vue: modal title, both list-item titles + descriptions
  ("Back up" / "Restore"), in-flight busy tips ("Importing database…"
  / "Restarting panel…").
- PanelUpdateModal.vue: modal title, update-available alert,
  current/latest version row labels, "Up to date" tag + label,
  primary action button. Modal.confirm now uses the translated
  panelUpdateDialog / panelUpdateDialogDesc with #version#
  substitution; success toast uses panelUpdateStartedPopover.
- LogModal.vue: title slot ("Logs"). The Debug/Info/Notice/Warning/
  Error log-level options stay literal — they're xray's wire values,
  not user-facing labels (matches the existing settings-page choice).
- XrayLogModal.vue: title + Filter label. Direct/Blocked/Proxy stay
  literal for the same reason.
- VersionModal.vue: modal title + xray-switch alert + per-file
  tooltip + "Update all" button + custom-geo collapse header. The
  Modal.confirm flows for switchXrayVersion + updateGeofile use
  translated dialog/desc with #version# / #filename# substitution.
- CpuHistoryModal.vue: title slot.
- CustomGeoSection.vue: routing-hint alert, Add / Update-all buttons,
  every column title (computed for live locale), copy/edit/download/
  delete tooltips, copy toast, delete-confirm modal, empty-state
  text.
- CustomGeoFormModal.vue: add/edit titles, OK/cancel labels, Type/
  Alias/URL field labels, alias placeholder, all three validation
  toasts.

Total: ~50 strings localised across 8 index-page files. The Hello /
Welcome login headline cycle and a handful of literal xray wire
values (Direct/Blocked/Proxy/log levels) are intentionally kept
hardcoded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* i18n(frontend): Phase 7-c — translate settings, inbounds modals, xray tabs

Continues the page-by-page translation pass started in cb37dd55 — runs
every user-visible string on settings (General/Security/Telegram/Sub),
inbounds (Client/QR/Info modals), and xray (Routing/Balancer/Rule/Warp/
Nord/Basics/Outbounds tabs) through useI18n. Updates the TOML→JSON sync
script to escape `@` (vue-i18n parses it as a linked-format prefix) and
refreshes all 13 locale files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): Phase 9 — restore index dashboard, fix login/CSRF, port legacy styles

- Index dashboard regains the 8 cards that were lost in the SPA port
  (3X-UI panel info, Operation Hours, System Load, Usage, Overall Speed,
  Total Data, IP Addresses, Connection Stats), plus a Config button that
  shows the live xray config.json. Version display falls back through
  panelUpdateInfo → window.__X_UI_CUR_VER__ → '?' so dev mode isn't blank.
- Xray config no longer hangs on load: useXraySetting surfaces failures
  instead of leaving a perpetual spinner, and the Vite dev proxy stops
  hijacking POST requests to migrated routes (only GETs get bypassed).
- Inbound page no longer throws __asyncLoader/emitsOptions errors —
  inbound.js was missing imports (NumberFormatter, SizeFormatter,
  Wireguard) and InboundList kept emitting after unmount.
- Login round-trip works after logout: a public /csrf-token endpoint
  bootstraps the SPA before authentication, axios caches the token
  module-level, and the dev 401 handler navigates to /login.html
  instead of reloading the dashboard into a redirect loop.
- legacy.css mirrors the legacy panel's surface/text variables so dark
  and ultra-dark themes match main; every SPA entry imports it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): rebuild xray DNS section to match main branch

DnsTab now exposes every field the legacy panel did — top-level toggles
(tag, hosts, queryStrategy, disableCache/queryConcurrency, fallback
strategy, client subnet), the servers table with per-row strategy and
domain/expectIP/unexpectedIP overrides, and the Fake DNS pool. The new
DnsServerModal covers the full add/edit flow and collapses to a bare
string when the user only sets an address — matching the wire shape
the legacy form emits for plain DNS entries like "8.8.8.8".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): rebuild xray outbound modal with structured per-protocol forms

Replaces the JSON textareas with the same shape the legacy panel uses:
all 11 outbound protocols (vmess/vless/trojan/shadowsocks/socks/http/
mixed/wireguard/tun/dns/loopback/blackhole/freedom) get dedicated
fields, every transport (TCP/KCP/WS/gRPC/HTTPUpgrade/XHTTP) gets its
own panel, and TLS/Reality/sockopt/Mux are configured through the same
controls as the inbound side. Brings the SPA outbound editor to parity
with main so users no longer have to drop into raw JSON.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): bring inbound modal to full parity with main branch

Switches the default protocol on add to VLESS, fixes a crash when adding
a Mixed account (the constructor is SocksAccount, not MixedAccount),
and fills in the fields the SPA was previously delegating to the
Advanced JSON tab:
- TLS: cipher suites, min/max version, reject SNI / disable system root /
  session resumption switches, the certificate array with per-row
  Path-or-Content toggle (Set Default pulls from /panel/setting/
  defaultSettings), One Time Loading, Usage / Build Chain, plus ECH
  key/config with a Get New ECH Cert button.
- Reality: xver, target/SNI sync icons (uses getRandomRealityTarget),
  max time diff, min/max client version, short IDs randomizer, SpiderX,
  mldsa65 seed/verify with Get New Seed.
- Stream: full structured forms for every transport — TCP HTTP
  camouflage gets its request/response editor, mKCP gets MTU/TTI/uplink/
  downlink/CWND/maxSendingWindow, WebSocket / gRPC (now with Authority) /
  HTTPUpgrade get headers + proxy-protocol toggles, XHTTP gets the
  full SplitHTTPConfig surface (mode-aware fields, padding obfs,
  session/sequence placement, uplink data, no-SSE).
- New External Proxy section and a structured Sockopt block (mark,
  TCP keepalive/timeout/clamp, fast open, MPTCP, penetrate, V6Only,
  domain strategy, congestion, TProxy, dialer/interface, trusted XFF).
- VLESS gets the legacy X25519 / ML-KEM-768 buttons that fetch fresh
  decryption/encryption blocks via /panel/api/server/getNewVlessEnc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): add FinalMask UI (TCP/UDP masks + QUIC params) to inbound and outbound

Mirrors web/html/form/stream/stream_finalmask.html as a shared
FinalMaskForm component used by both modals — they share the same
StreamSettings shape (addTcpMask/addUdpMask/finalmask/enableQuicParams)
so a single template handles both. Surfaces:
- TCP masks for raw/tcp/httpupgrade/ws/grpc/xhttp networks: fragment,
  sudoku, and header-custom (with the 2D clients/servers groups, each
  row supporting array/str/hex/base64 packets and a randomize button
  for base64).
- UDP masks for hysteria protocol or kcp network: hysteria gets just
  salamander; kcp gets the full type list (mkcp variants, header-*,
  xdns/xicmp, header-custom with flat client/server lists, and noise).
  Switching to xdns shrinks the kcp MTU to 900 to match the legacy
  panel's behavior.
- QUIC Params for hysteria or xhttp: congestion (incl. brutal up/down
  fields), debug, UDP hop ports/interval, idle/keepalive timeouts,
  path-MTU discovery toggle, and the four receive-window tunables.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): remove duplicate Outbound test URL from xray Advanced tab

The Basics tab already exposes this field through BasicsTab —
duplicating it on the Advanced tab let two inputs race the same
ref and only added clutter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): unify theming on vanilla AD-Vue light/dark/ultra-dark

The legacy panel CSS (custom.min.css ported as legacy.css) tinted every
non-primary button teal-green via .dark .ant-btn:not(.ant-btn-primary)
overrides while AD-Vue 4's darkAlgorithm kept primary buttons blue —
producing the mixed blue/green button look on dark mode. Drop legacy.css
entirely and let AD-Vue 4's algorithms own the palette.

Centralize antdThemeConfig in useTheme.js so every page resolves to the
same source of truth (light = defaultAlgorithm, dark = darkAlgorithm,
ultra-dark = darkAlgorithm + deeper colorBgBase/Layout/Container/
Elevated tokens). Each page's <a-config-provider> now imports the
shared computed instead of defining its own copy.

Drops the 67 KB legacy CSS chunk; per-page CSS bundles fall to ≤5.9 KB.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): restore computed import in Settings + Xray pages

When 5f1aba28 dropped the local antdThemeConfig computed (now shared
from useTheme), it also stripped `computed` from the import list — but
both pages still call computed() elsewhere (confAlerts, advanced-tab
helpers). Re-adds it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): retheme dashboard gauges to AD-Vue blue and shrink them

- StatusCard's CPU/RAM/Swap/Storage dashboards rendered at AD-Vue's
  default 120px width which made the percent text balloon to ~36px.
  Drop to 90px (70px on mobile) so the gauge fits the rest of the card.
- The CurTotal.color thresholds still hardcoded the legacy teal/orange
  palette (#008771 / #f37b24 / #cf3c3c). Switch to AD-Vue's primary /
  warning / danger tokens (#1677ff / #faad14 / #ff4d4f) so the gauges
  match the rest of the panel under both light and dark themes.
- XrayStatusCard's running-animation badge ring also still pointed at
  the deleted --color-primary-100 var; hardcode the new primary blue.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* i18n: shorten backupTitle to "Backup & Restore" across all 13 locales

The backup modal header was the second-longest title in the dashboard
on every locale ("Database Backup & Restore" / "Резервне копіювання
та відновлення бази даних" / etc). Drop the "Database / Veritabanı /
数据库" qualifier — the modal already lives under the "Database"
column, so the shorter form reads cleaner on narrow viewports.

Updated both the .toml source-of-truth files and the synced .json
locales (re-running scripts/sync-locales.mjs).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* i18n: collapse two translation databases into a single web/translation/<lang>.json set

The Vue SPA had been reading from frontend/src/locales/*.json while the
Go binary still loaded web/translation/translate.*.toml — and a
sync-locales.mjs pre-build step kept the two in lockstep, with TOML as
the source of truth. Now that go-i18n v2.6.1 already flattens nested
JSON via recGetMessages/addChildMessages, both runtimes can share one
file per locale.

- Move the 13 nested-JSON locale files to web/translation/<lang>.json
  so they live alongside the Go //go:embed translation/* directive.
- Switch web/locale/locale.go from toml.Unmarshal to json.Unmarshal
  (and drop the pelletier/go-toml import — it's now indirect-only).
  Confirmed via a smoke test that pages.index.cpu, subscription.title,
  tgbot.commands.help, and menu.settings all resolve in en-US, fa-IR,
  ru-RU, and zh-CN.
- Repoint Vue's i18n loader at the new path (../../../web/translation/
  *.json glob) and drop the moved-here pathDelimiter comment that no
  longer applies.
- Delete the 13 legacy translate.*.toml files and the sync-locales.mjs
  script + its npm pre-script hooks (predev/prebuild/i18n:sync). The
  Telegram bot and subscription page still get their messages because
  they were reading the same MessageIDs the JSON files now produce.
- Update copilot-instructions.md so the next contributor knows where
  the canonical translation files live.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): redesign expand-row + retheme client visuals

When you expanded an inbound row, the nested <a-table> inside
ClientRowTable burst out of the parent's scroll-x box — its
.ant-spin-container ended up wider than the parent's narrow
.ant-table-cell, so the child looked oversized while the parent looked
squeezed. Replace the nested table with a CSS-grid layout that owns
its sizing, sits flush inside the expanded cell, and collapses to a
3-column layout on mobile (action menu, client identity, info popover).

While in there, fix three other client-row visuals:
- The Unicode infinity glyph (U+221E) renders as an "m"-shaped
  character in some system fonts (Windows Segoe UI in particular).
  Add a shared <InfinityIcon /> SVG component (legacy panel's path)
  and use it in ClientRowTable, InboundList, and InboundInfoModal —
  desktop and mobile cells.
- The "unlimited quota" traffic bar passed :percent="100" with no
  stroke-color, so AD-Vue auto-coloured it success-green. Pin it to
  the AD-Vue purple token (#722ed1) so it reads as the no-limit
  sentinel rather than another usage state.
- ColorUtils + the in-row statsExpColor still hardcoded the legacy
  teal/orange/red/purple palette (#008771 / #f37b24 / #cf3c3c /
  #7a316f). Map them onto AD-Vue 4's success/warning/danger/purple
  tokens (#52c41a / #faad14 / #ff4d4f / #722ed1) so badges, tags,
  and progress bars all match the rest of the panel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): darken light-theme page bg so cards stand out

The light-theme --bg-page was #f0f2f5 — close enough to AD-Vue's #fff
card background that the cards faded into the page. Bump it to #e6e8ec
(a more visibly distinct gray) so cards lift cleanly off the surface.
Dark and ultra-dark stay where they were.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): shrink dashboard percent text and surface the unfinished arc

Two follow-up tweaks to the dashboard gauges:
- AD-Vue scales the percent text from the SVG, not from :width, so
  the 90px gauges still rendered the number at ~27px. Pin
  .ant-progress-text to 14px via :deep() and trim the gauge to 70px
  (60px on mobile) so the whole card stays compact.
- The default trail (rgba(0,0,0,0.06) / rgba(255,255,255,0.08)) was
  invisible on the light-theme card. Pass an explicit
  rgba(128,128,128,0.25) trail-color so the unfinished portion is
  visible under both light and dark themes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): migrate subpage.html to Vue 3 SPA

The subscription info page was the last page still rendered by Go
templates. Move it to the Vite multi-page setup so the whole panel
loads through one toolchain.

Frontend: SubPage.vue mounts at /sub/<id>?html=1 and reads window.__SUB_PAGE_DATA__
for the parsed view-model (traffic / quota / expiry + rendered share
links). Fix descriptions borders against the light-theme card by
painting the row divider on each cell's bottom edge — AD-Vue's <tr>
border doesn't render reliably under border-collapse:collapse.

Backend: serveSubPage reads dist/subpage.html, injects
window.__X_UI_BASE_PATH__ + window.__SUB_PAGE_DATA__ before </head>,
and rewrites Vite's absolute /assets/ URLs when the panel runs under
a URL prefix. Drop the legacy template-FuncMap wiring and switch the
sub server's static mount from web/assets to web/dist/assets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): inbound modal QR + tabs + restored TLS fallbacks

Per-client QR action: the qr icon on the expand-row table opened the
big info modal instead of the QR modal. Route it to QrCodeModal and
extend that modal with a `client` prop so genAllLinks() produces the
per-client share URLs (and per-peer remarks for WireGuard).

Inbound's Data redesign: split the dense single-page view into three
tabs — Inbound, Client, Subscription. Drop every QR rendering from
this modal (QrCodeModal is the QR home now). Each row in the Inbound
tab is one label/value pair instead of the legacy 2-column grid, and
long values like the VLESS encryption blob render as a wrapping code
block with a copy button so they can't blow out the dialog. The
Subscription tab renders sub URL + JSON URL as clickable anchors that
open in a new tab.

Restored TLS fallbacks UI: the model already exposed
VLESSSettings.Fallback / TrojanSettings.Fallback with addFallback /
delFallback / fallbackToJson, but the form modal never surfaced them
during the Vue 3 migration. Re-add the legacy form (SNI, ALPN, Path,
Destination, PROXY) on the protocol tab, gated on TCP transport plus
(for VLESS) encryption=none — same conditions as main.

Column widths: Protocol 70→130 and All-time Traffic 60→95 in the
inbound list; All-time Traffic 90→130 in the client expand-row, so
the header text fits and tags don't get squeezed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): navy dark theme + rounded inbound/client corners

Dark theme picks up a refined navy palette (page #0a1426, cards
#142340, sider #0d1d33) so the sidebar blends with the rest of the
surface; ultra-dark stays neutral black. Resolves the previous mismatch
where AD-Vue 4 hardcoded #001529 / #002140 for the sider, trigger and
dark Menu items via Layout.colorBgHeader / colorBgTrigger and Menu's
colorItemBg — overrides go through the component-token map now.

Round the inbound table's outer corners (header start/end + last row
end) and wrap the client expand-row grid in a 1px / 8px-radius border
so the list reads as a contained block instead of a flush rectangle.

Linter-driven whitespace cleanup across inbounds/*.vue rolled into the
same commit since it can't be split out cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): xray tab fixes — modal close, tag validation, full XHTTP, reset to default

Modal close: BalancersTab / OutboundsTab / RoutingTab confirmDelete used
arrow expressions that returned splice's removed-items array. AD-Vue 4
treats truthy non-thenables from onOk as "still pending" and never closes
the dialog (see ActionButton.js:103-106), so the confirm modal stayed
open. Wrap the body so onOk returns undefined and AD-Vue auto-closes.

Tag validation: outbound + balancer modals only flipped between
warning/success on duplicate, leaving the empty case as a green ✓.
Split into a 3-state computed — error (empty) / warning (duplicate) /
success — and wire a help message so the input clearly explains why
the OK button is disabled.

Reset to default: re-add the legacy "Reset to Default" panel at the
bottom of BasicsTab. Calls /panel/setting/getDefaultJsonConfig and
overwrites templateSettings; the existing watch re-stringifies so the
JSON tab + dirty-poll see the new state.

Restored Basics option lists from main: IPs (4→10, +Vietnam/Spain/
Indonesia/Ukraine/Türkiye/Brazil), DomainsOptions (4→10, +regex
entries), BlockDomainsOptions (5→17, +Malware/Phishing/Adult/regex),
ServicesOptions (Reddit/Speedtest in, off-template Microsoft out).

Outbound form parity with main:
  • Reverse Sniffing UI for VLESS — toggle + destOverride checkboxes
    (HTTP/TLS/QUIC/FAKEDNS) + Metadata/Route Only + IPs/Domains
    excluded multi-selects, gated on reverseTag being set.
  • Full XHTTP transport — request headers list, Max Upload Size /
    Min Upload Interval (packet-up), Padding Obfs Mode + sub-fields,
    Uplink HTTP Method, Session/Sequence/UplinkData placement +
    keys, No gRPC Header (stream-up/stream-one), expanded XMUX with
    Max Concurrency/Connections/Reuse/Request/Reusable/Keep-alive.

Strip a-divider from the outbound form per request — replaced with
plain section/item heading divs so the labels and per-row delete
icons stay but the horizontal rule is gone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): xray Advanced tab parity + finalmask gating

Advanced tab was a single textarea bound to the full xraySetting blob.
Restore the legacy 4-way view: a radio group toggles between All /
Inbounds / Outbounds / Routing Rules, and the textarea reads/writes
the matching slice through templateSettings. Added the legacy header
("Advanced Xray Configuration Template" + description) so the page
introduces itself like main.

Outbound finalmask leaked into protocols that don't have a stream
(Freedom / Blackhole / DNS / Socks / HTTP / Wireguard) because the
v-if only checked outbound.stream. Gate the whole FinalMaskForm on
outbound.canEnableStream() to match main.

Drop the leading divider inside FinalMaskForm — its parent already
provides separation, so the rule above "TCP Masks" was redundant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): inbound Advanced tab live mirror + QR exact-fit sizing

Advanced tab in the inbound modal showed stale state. The watch only
refreshed advancedJson.stream, so toggling the Sniffing switch in the
Sniffing tab left the Advanced JSON showing the prior value. And
encryption — stored on inbound.settings.encryption, not on stream —
never appeared at all because Advanced only exposed stream + sniffing.

Split the watch into three (stream / sniffing / settings) and add a
settings textarea so encryption / clients / fallbacks live alongside
the existing two views. The submit() path now reads settings from
the JSON tab too (falling back to inbound.settings.toString()) so
power-user edits in Advanced override the structured form on save.

QR canvas: when a longer share-URL bumps the QR matrix size, QRious
falls back to floor(canvasSize / matrixWidth) and centers the pattern,
leaving a white margin (e.g. matrix=41, size=180 → 8px gap). Pre-pick
the QR version from the URL byte length and set canvas size to a
multiple of matrixWidth × pixelSize so the pattern always fills it
edge-to-edge — no white margin even after toggling encryption on.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): inbound stream tidy-up + QR sizing + dev proxy

Stream tab clean-up: drop the seven a-divider rules in the inbound
form's Stream tab — replace the labelled ones (Request / Response /
Security) with a section-heading div that matches the outbound modal,
delete the empty rules above TLS sub-blocks / External Proxy /
Sockopt. Empty header-list form-items also leaked margin space below
each "Add header" button across TCP / WS / HTTPUpgrade / XHTTP — gate
each on headers.length > 0 so they vanish until the user adds one.

QR panel: drop the link text under the canvas (the user already has
a copy button on the header). Pin the canvas display size to a fixed
240px square via :style + image-rendering: pixelated/crisp-edges so
a dense WireGuard config QR and its sparser link share the same
on-screen footprint without blurring.

Dev proxy: Node's AggregateError wraps connection failures whenever
DNS returns more than one address (::1 + 127.0.0.1) and the code
lands on the inner errors, not the outer. The existing handler only
checked err.code so the ECONNREFUSED stack still spammed the log
when the Go backend was down. Walk err.errors too, print one
friendly line ("backend not reachable — start the Go server"), then
stay quiet for the rest of the session.

Vendor splitting + chunk-size warning: split node_modules into
stable vendor-* chunks so each page only ships the deps it uses and
the browser caches them across versions. ant-design-vue stays as a
single chunk because its components share internals; raise the
chunk-size warning to 1500kB so the build stays quiet (its 1.4MB
minified gzips to ~410kB on the wire).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): info-modal cleanup + 2FA QR + outbound link import

- 2FA QR: matrix-snap canvas + opaque background to drop white margin
- Inbound info modal: stack Mixed/HTTP/Tunnel as info-rows, hide tab
  strip when only the Inbound tab applies
- Add inline VLESS Reverse tag input on first-client form
- Hide Protocol tab for TUN (no form yet)
- Outbound link converter: route through Outbound.fromLink so
  vless/trojan/ss/hysteria(2) imports work alongside vmess; fix stray
  implicit global in fromLink

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): jalali calendar + drop legacy moment-jalali

- Wire Calendar Type setting to a real Jalali datepicker via
  vue3-persian-datetime-picker, gated by useDatepicker composable
- DateTimePicker wrapper swaps between AD-Vue and Persian picker; keeps
  dayjs v-model contract so existing forms/setters work unchanged
- Theme picker popup explicitly per body.dark / data-theme=ultra-dark
  (AD-Vue 4 doesn't expose CSS vars, so var() fallbacks defaulted to
  white); fix invisible disabled days, SVG arrow fills, popup clipping
  via append-to="body"
- Replace stray moment() calls in dbinbound/inbound models with dayjs;
  the legacy global was undefined under ESM and broke the inbounds list
  whenever any inbound had expiryTime > 0
- Remove legacy moment-jalali / persian-datepicker / aPersianDatepicker
  assets — replaced by the Vue 3 picker

Note: dark/ultra background of the date popup still renders white in
some cases — pending follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): jalali popup theming + full-month layout

- Re-prefix popup selectors with .vpd-wrapper (popup root that travels
  with appendTo='body'), not .vpd-main (which stays at the input);
  paints the popup's dark/ultra background again
- Drop the 1px border on .vpd-content — with box-sizing: border-box
  it ate 2px from the day-row width, wrapping the 7th cell of every
  row and hiding days 18-31 of months that needed a 5th week

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat: render dates in Jalali when Calendar Type is jalalian

- IntlUtil.formatDate accepts an optional calendar arg; appends the
  BCP-47 -u-ca-persian extension so Intl renders Jalali across all UI
  languages, not just fa-IR
- Plumb the panel's datepicker setting into the SubPage via the Go
  injection (window.__SUB_PAGE_DATA__.datepicker)
- Panel pages (inbound list/info, client row, xray log) read the same
  setting through the useDatepicker composable so the whole panel
  stays consistent

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(frontend): ultra-dark page tint + mobile-friendly inbound view

- Drop --bg-page from #21242a (lighter than the cards) to #050505 in
  ultra-dark across index/sub/settings/inbounds/xray, so cards
  consistently elevate over the page
- Hide the inline sider's children + collapse-trigger and zero its
  width below 768px; the floating drawer-handle remains the menu
  trigger
- Inbounds page mobile pass: tighten content-area + card padding;
  flex-wrap the filter bar instead of stacking; shrink table cell
  padding so all 4 mobile columns fit; bump expand / action / info
  icon hit targets
- Per-client expand row on mobile: soft-tinted rounded cards instead
  of hairline borders, larger action / info touch targets, more
  legible email typography, bigger status badge dot

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: remove legacy template + asset trees and dead Go template engine

- Delete web/html/ entirely (page templates, form/, modals/, component/,
  common/, settings/) — every route is served from web/dist/ now via
  serveDistPage; nothing in the binary referenced these
- Delete web/assets/ entirely (jQuery-era ant-design-vue, axios, moment,
  codemirror, qrcode/qs/uri/vue/otpauth, custom CSS, Vazirmatn font);
  Vite bundles all of this into web/dist/assets
- Drop the Gin HTML template wiring: remove //go:embed assets +
  //go:embed html/*, the assetsFS/htmlFS vars, the wrapAssetsFS adapter,
  EmbeddedHTML / EmbeddedAssets exports, getHtmlFiles / getHtmlTemplate,
  the i18nWebFunc/funcMap and SetFuncMap call, and the dev/prod
  template-engine branch — only StaticFS for /assets/ is needed now
- Remove dead html()/getContext() helpers and unused imports from
  web/controller/util.go (no c.HTML(...) callers remain)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): inbound expand chevron position + cpu history layout

- Push the inbound table's expand chevron away from the left edge with
  margin-inline + cell padding so it isn't flush against the corner
- Move "Timeframe: …" caption above the chart (was below); restore
  the line that the previous edit removed
- Fix x-axis time labels being clipped at the bottom of the cpu chart
  — the offset (paddingTop+drawHeight+22 = 222) exceeded the SVG
  viewBox height (220); dropped to +14 so labels sit at y=214 with
  room for descenders
- Move the SVG axis text colors out of <style scoped> into a global
  block — Vue's scoped CSS doesn't always hash-attribute SVG <text>
  descendants, so the dark-mode overrides via :global() weren't
  matching; bumped opacity 0.55 → 0.85 for legibility on navy/black

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(login): language picker in settings popover + fluid card sizing

- Add language select alongside the theme switch (mirrors SubPage)
- Bind headline to pages.login.hello / pages.login.title so the
  "Hello / Welcome" cycle re-translates with the active locale
- Replace AD-Vue 5-breakpoint grid with clamp() sizing so the card
  scales smoothly instead of jumping ~33% at each breakpoint
- Pin horizontal padding so input width stays stable on large viewports

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(frontend): organize entry HTML + bootstrap JS into folders

- Move entry HTML files: frontend/*.html -> frontend/html/*.html
- Move per-page bootstrap modules: src/{index,login,settings,inbounds,xray,subpage}.js -> src/entries/
- Update vite.config rollup inputs and dev-mode MIGRATED_ROUTES to /html/<page>.html
- Build output now lands at web/dist/html/<page>.html
- serveDistPage and subController updated to read from dist/html/

Cleans up the flat frontend/ root which previously interleaved 6 HTML
files with package.json, README, src/, etc. The src/ root similarly
gets rid of 6 entry .js files mixed in alongside api/, components/,
models/, etc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: remove obsolete vue3 phase1 inventory doc

The migration is well past phase 1 — the inventory doc has rotted
and the live state lives in the codebase plus the plan files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(frontend): merge utils/legacy.js into utils/index.js

The barrel was a placeholder for an eventual split that hasn't
happened. Collapsing the two files removes one layer of indirection
and the misleading "legacy" name (the contents are still actively
used by the migrated SPA).

- Move all 930 lines from utils/legacy.js into utils/index.js
- Delete utils/legacy.js
- Update direct import in models/outbound.js to '@/utils'
- Drop a stale legacy.js reference in InboundFormModal comment

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* revert(frontend): keep entry HTML files at frontend/ root

The earlier move to frontend/html/ made dev-mode URLs ugly
(http://localhost:5173/html/index.html instead of plain /). The folder
didn't add real value — it just hid 6 files behind a non-conventional
layout. Reverting that piece while keeping src/entries/ (which is a
genuine separation between page bootstrap and the rest of src/).

- HTML files back at frontend/<page>.html
- Vite rollupOptions.input + MIGRATED_ROUTES restored to flat paths
- Build output is web/dist/<page>.html again
- web/controller/dist.go and sub/subController.go read from dist/<name>

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* build(frontend): bump eslint to 10 + add flat config + clean lint warnings

- Upgrade eslint 9.39 -> 10.3 and eslint-plugin-vue 9.33 -> 10.9
- Add eslint.config.js (flat config required by ESLint 10) with
  vue3-recommended rules, sensible defaults, and exemptions for the
  project's existing formatting style
- Drop --ext from the lint script (removed in ESLint 10)
- vue/no-mutating-props is left off because the form-modal pattern
  ports straight from Vue 2 (parent passes a reactive object, child
  mutates it); a real fix is an architectural rewire, separate task

Lint warning cleanup:
- utils/index.js: var -> let/const in the X25519 routines, replace
  obj.hasOwnProperty(...) with Object.prototype.hasOwnProperty.call(...)
- Remove unused imports (reactive, ref, Inbound) in ClientFormModal,
  InboundInfoModal, QrCodeModal, DnsServerModal, OutboundFormModal,
  SubPage; remove unused locals (isClientOnline, ONLINE_GRACE_MS,
  fetchAll, isSocks, isHTTP, _antdAlgorithm)
- XrayStatusCard: declare 'open-logs' on defineEmits (was emitted but
  not declared)
- RuleFormModal: rename v-for var t -> tag (shadowed useI18n's t)
- Drop stale eslint-disable directives (no-new, no-unused-vars)
- OutboundsTab/InboundList: drop redundant initial null assigns
- InboundInfoModal/OutboundFormModal: explicit eslint-disable for the
  intentional local-ref-shadows-prop pattern in modal drafts

`npm run lint` now passes with 0 errors and 0 warnings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(inbounds): one client identity across multiple inbounds via subId

Lets the operator add the same email under the same subId to several
inbounds. Xray reports traffic per email, so a single client_traffics
row acts as the shared accumulator — no aggregation overhead, quota and
expiry stay consistent.

- Email validation allows duplicates only when subId matches
- AddClientStat upserts via OnConflict DoNothing (idempotent on rerun)
- Stat/IP rows survive client deletion when a sibling inbound still
  references the email
- enrichClientStats tops up GORM-preloaded stats with rows whose
  inbound_id points at a sibling, so every panel view sees usage
- disableInvalidClients cascades enable=false and syncs the row's
  total/expiry into every sibling JSON when the shared identity expires
- DelDepletedClients removes the depleted client from all referencing
  inbounds, batched
- Subscription services dedupe traffic by email so shared quota is
  counted once

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(frontend): rewrite README for multi-page Vue 3 layout

Reflects the current state — embedded build, per-route HTML entries,
ESLint 10 flat config, src/ layout, and the steps to add a new page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* build(frontend): drop deprecated rimraf/glob/inflight transitive deps

vue3-persian-datetime-picker pinned moment-jalaali to ^0.9.4, which
pulled rimraf@3 → glob@7 → inflight@1. inflight in particular leaks
memory and is unmaintained. Override moment-jalaali to ^0.10.4 (same
runtime API, dropped the legacy build deps) so npm install no longer
warns and the dep tree is 12 packages lighter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(nodes): multi-node panel orchestration (CRUD, deployment, traffic sync, sub per-node)

- Node model + service + controller (/panel/api/nodes/*) with bearer-token apiToken auth
- Heartbeat job @every 10s; status/latency/xrayVersion surfaced in Nodes UI
- Runtime abstraction (Local + Remote) so inbound/client mutations target the
  inbound's owning node instead of always hitting the local xray
- Inbounds gain optional NodeID; tag-based correlation with remote panel (no
  RemoteInboundID column needed)
- NodeTrafficSyncJob @every 10s pulls absolute counters + online/lastOnline
  from each enabled+online node and writes them into central DB; 30s reset
  grace window prevents post-reset overwrite
- Reset propagation to nodes (best-effort) on client/inbound/all reset paths
- Subscription server uses node.Address for inbounds with NodeID, falling back
  to existing host resolution for local inbounds
- Frontend: Nodes page, "Deploy to" select in inbound form, Node column on
  inbound list, hostOverride threaded through genAllLinks/QR/Info modals

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(stats): system history modal + per-node CPU/Mem trends across all locales

Backend
- web/service/metric_history.go: generic in-memory ring buffer with two
  singletons — system-wide (cpu/mem/netUp/netDown/online/load1/5/15)
  and per-node (cpu/mem) keyed by node id
- ServerService.AppendStatusSample writes all 8 metrics every 2s on the
  same tick; AppendCpuSample/AggregateCpuHistory kept for back-compat
- NodeService.UpdateHeartbeat appends cpu/mem only on online ticks so
  offline gaps render as missing data, not phantom dips
- New routes: GET /panel/api/server/history/:metric/:bucket and
  GET /panel/api/nodes/history/:id/:metric/:bucket, both whitelisted

Frontend
- Sparkline component generalized: arbitrary value range (auto-scale
  when valueMax=null), pluggable yFormatter/tooltipFormatter for B/s,
  client counts, load averages
- SystemHistoryModal replaces CpuHistoryModal with tabs for every
  metric; opened from a tag on the 3X-UI card next to Documentation
- NodeHistoryPanel: expandable row on the Nodes table showing per-node
  CPU and Mem trends, refreshed every 15s

Localization
- Backfill systemHistoryTitle / trendLast2Min / pages.inbounds.{node,
  deployTo, localPanel} and the entire pages.nodes block (51 keys
  including statusValues + toasts) into all 11 non-en/fa locales:
  ar-EG, es-ES, id-ID, ja-JP, pt-BR, ru-RU, tr-TR, uk-UA, vi-VN,
  zh-CN, zh-TW

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(embed): include underscore-prefixed Vite chunks in dist FS

go:embed silently excludes files whose names start with `_` or `.`,
so the `_plugin-vue_export-helper-<hash>.js` chunk that Vite/rolldown
emits for @vitejs/plugin-vue was missing from the production binary.
First import at runtime hit a 404 and the SPA failed to mount — blank
page on every page load, no error in the server logs because the
asset 404 was just a static-handler miss.

Switched the directive to `//go:embed all:dist` which keeps the same
root layout but disables the underscore/dot exclusion rule. Dev mode
was unaffected (it serves dist/assets/ from disk, not the embedded FS).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ci: build frontend bundle before Go compile in release.yml + Dockerfile

Phase 8 cut all panel HTML routes over to web/dist/ and embedded the
Vite bundle into the Go binary via //go:embed all:dist. web/dist/ is
.gitignored, so on a fresh CI checkout it doesn't exist — every Go
build since Phase 8 has been failing with "pattern dist: no matching
files found" or producing a binary that 404s on first asset request.

release.yml: add a setup-node@v4 + npm ci + npm run build trio before
the existing go build step in both the Linux matrix job (7 arches)
and the Windows job. npm cache is keyed on frontend/package-lock.json.

Dockerfile: add a node:22-alpine frontend stage that runs npm ci +
npm run build and emits to /src/web/dist (via vite.config.js's outDir).
The golang builder stage then COPY --from=frontend /src/web/dist into
./web/dist before the go build, so embed.FS sees the bundle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(ws): live updates on inbounds/xray/nodes pages, drop polling + manual refresh

Replaces the legacy polling + manual-refresh model with WebSocket pushes
across the three live-data pages. The hub already broadcast traffic /
client_stats / outbounds; this wires the frontend to consume them and
adds a new `nodes` channel for the heartbeat job's snapshot.

Frontend
- new useWebSocket composable: page-scoped singleton WebSocketClient,
  lifecycle-managed on/off, leaves disconnect to page-unload
- inbounds: useInbounds gains applyTrafficEvent / applyClientStatsEvent
  / applyInvalidate that merge counters and online/lastOnline in place;
  InboundsPage subscribes; InboundList drops the auto-refresh popover,
  the refresh button, and the now-unused refreshing prop
- xray outbounds: useXraySetting gains applyOutboundsEvent; XrayPage
  subscribes; OutboundsTab drops the refresh button + emit
- nodes: useNodes gains applyNodesEvent and stops the 5s
  setInterval/visibilitychange polling; NodesPage subscribes;
  NodeList drops the refresh button and ReloadOutlined import

Backend
- web/websocket: new MessageTypeNodes + BroadcastNodes notifier
- node_heartbeat_job: after wg.Wait(), reload the table once and
  BroadcastNodes(updated). Gated on websocket.HasClients() so a panel
  with no open browser doesn't spend the DB read

Bug fixes spotted in this pass
- websocket.js #buildUrl defaulted basePath to '' when the global was
  missing (dev mode), producing `ws://host:portws` and a SyntaxError
  on the WebSocket constructor. Fall back to '/' and ensure leading
  slash.
- vite.config.js: forward /ws to ws://localhost:2053 with ws:true so
  dev (5173) reaches the Go backend's WebSocket
- NodeFormModal: a-input-password's visibilityToggle is Boolean in
  AntD Vue 4; the v3-era object form (`{ visible, 'onUpdate:visible' }`)
  triggered a Vue prop-type warning. Drop the override (default true
  shows the eye icon and toggles internally) and remove the orphaned
  tokenVisible ref

Translations
- pages.inbounds.autoRefresh / autoRefreshInterval: removed from all
  13 locales (UI gone)
- pages.nodes.refresh: removed from all 13 locales (UI gone)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(inbounds): hide Node column when no nodes are defined

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 17:47:35 +02:00