Mirror the clients page: checkbox selection on the desktop table and on
mobile cards, with a danger Delete button in the toolbar that removes all
selected inbounds in one call.
Backend adds POST /panel/api/inbounds/bulkDel, which loops the existing
DelInbound per id (xray restarts at most once) and returns {deleted,
skipped}. Frontend shows a confirm modal plus a result toast, clears the
selection on success, adds bulk-delete i18n keys across all 13 languages,
and documents the endpoint in the in-panel API docs.
Deleting an inbound now only detaches its clients (removes the
client_inbounds rows). It no longer deletes client_traffics or client IP
logs: those are keyed centrally by email (one row per client) and must
survive, since a client may stay attached to other inbounds and is
managed from the Clients page.
Separately, /get/:id now uses a new GetInboundDetail that preloads and
enriches ClientStats, so hydrated records (info / QR / export) carry
per-client traffic instead of null. DBInbound.toJSON drops the internal
_clientStatsMap cache so it no longer leaks into the exported JSON.
webBasePath, subPath, subJsonPath and subClashPath are URL paths, so '/'
stays allowed, but spaces, backslashes and control characters break
routing. Strip them as you type (shared sanitizePath helper, now also
applied to the panel base path) and reject them on save in
AllSetting.CheckValid so direct API callers are covered too.
Like the client email, the subId is embedded directly in subscription
URLs, so the same characters break it. Validate it on the backend
(Create + Update) and the frontend (Zod), with a localized message
across all 13 locales. An empty subId stays allowed (it is then
auto-generated).
Client emails containing a slash broke the path-param routes
(edit/delete/view returned 404 / "client not found"), leaving stale
records that could only be cleared with manual SQLite edits. Validate
the email on both the backend (Create + Update, which also covers the
bulk paths) and the frontend (Zod) so these characters are rejected at
save time with a clear, localized message across all 13 locales.
Closes#4695
* refactor(frontend): reorganize components & pages into feature folders
No behavior change; pure file relocation + import path updates.
* refactor(frontend): move shared protocol enums to schemas/protocols/shared
Decouple Outbound from Inbound schemas: SSMethodSchema and VmessSecuritySchema (shared between inbound & outbound) now live in a neutral schemas/protocols/shared/ module. Outbound no longer reaches into schemas/protocols/inbound/*. Pure relocation + import rewiring; schema values identical, snapshots & golden tests unchanged.
* refactor(frontend): break InboundList into helpers/types/RowActions/columns hook/stats modal
InboundList.tsx 781 -> 203 lines. Extracted pure helpers (network labels, sort fns, isInboundMultiUser), shared types, the row-actions menu/cell, the table columns hook, and the mobile stats modal into the list/ folder. Code moved verbatim; no behavior change. typecheck/lint/test/build green, 337 tests pass.
* refactor(frontend): extract InboundInfoModal helpers, types & buildInboundInfo
InboundInfoModal.tsx 1081 -> 836 lines. Moved the pure data helpers (network host/path readers, link-protocol check, copy/download/statsColor/IP formatting) plus all shared types and the buildInboundInfo data builder into info/helpers.ts and info/types.ts. The state-coupled render body is left intact (no React render tests to guard a deeper split). Code moved verbatim; no behavior change. All gates green, 337 tests pass.
* test(frontend): add React Testing Library + jsdom render-test harness
- vitest projects: node unit tests stay lean; new jsdom 'components' project runs *.test.tsx
- component setup: matchMedia/ResizeObserver/localStorage polyfills, react-i18next init, persian-calendar-suite stub (only used under jalali locale)
- smoke + field-label structure snapshots for Inbound & Outbound form modals
- establishes the regression net required before decomposing the oversized form modals
- 341 tests pass (337 unit + 4 component); typecheck/lint/build green
* test(frontend): per-protocol field-structure coverage for both form modals
- drive the protocol Select in jsdom and snapshot rendered Form.Item labels for every protocol
- 10 outbound + 10 inbound protocol states captured as the regression net for protocol-core extraction
- add robust select-driving helpers (test-utils) + post-test body cleanup (setup.components)
- 341 tests pass; typecheck/lint green
* refactor(frontend): extract OutboundFormModal constants & stream helpers
OutboundFormModal.tsx 2238 -> 2080. Moved the pure option arrays/sets and the stream-slice helpers (newStreamSlice, hysteriaStreamSlice, isMuxAllowed, buildAddModeValues) into outbound-form-constants.ts and outbound-form-helpers.ts. Per-protocol render snapshots unchanged -> verified no behavior change. typecheck/lint/build green.
* refactor(frontend): extract InboundFormModal advanced JSON editors
InboundFormModal.tsx 3129 -> 2863. Moved AdvancedSliceEditor and AdvancedAllEditor (the in-modal JSON slice/all editors) into advanced-editors.tsx along with their adapter-helper imports. Per-protocol render snapshots unchanged -> verified no behavior change. typecheck/lint/build green.
* refactor(frontend): extract OutboundFormModal loopback/blackhole/dns field blocks
Moved the outbound-only protocol field blocks (loopback, blackhole, dns) out of the modal render body into outbound-only-fields.tsx. First render-body extraction behind the per-protocol snapshot net: loopback/blackhole/dns snapshots unchanged -> verified no behavior change. typecheck/lint/build green.
* refactor(frontend): extract OutboundFormModal freedom field block
OutboundFormModal.tsx 2063 -> 1753. Moved the freedom protocol field block (domainStrategy, fragment, noises, finalRules) into outbound-freedom-fields.tsx. Verbatim relocation; freedom per-protocol snapshot unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): extract OutboundFormModal wireguard field block
OutboundFormModal.tsx 1753 -> 1622. Moved the wireguard protocol field block (address, keypair gen, domainStrategy, peers + allowedIPs) into outbound-wireguard-fields.tsx; dropped now-unused icon/InputAddon/WireguardDomainStrategy imports. Verbatim relocation; wireguard snapshot unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): extract OutboundFormModal core protocol fields
OutboundFormModal.tsx 1622 -> 1538. Moved the shared protocol core field blocks (vmess/vless ID, vmess security, vless encryption/reverseTag, trojan/ss password, ss method/uot, socks/http user/pass) into outbound-core-fields.tsx; dropped now-unused schema/option imports. Per-protocol snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): fold OutboundFormModal server address/port block into core fields
OutboundFormModal.tsx 1538 -> 1516. Moved the shared connect-target (address/port) block into OutboundCoreProtocolFields at the same render position; dropped the now-unused SERVER_PROTOCOLS import. Snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): split outbound-only protocol forms into per-protocol files
Replace the grouped outbound-only-fields.tsx + outbound-freedom-fields.tsx with one file per protocol under outbounds/protocols/: freedom.tsx, blackhole.tsx, dns.tsx, loopback.tsx (+ barrel). Matches the prompt's 1-file-per-protocol structure. Outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): split outbound protocol forms into per-protocol files
Replace the grouped outbound-core-fields / outbound-wireguard-fields with one file per protocol under outbounds/protocols/: vmess, vless, trojan, shadowsocks, http, socks, wireguard, freedom, blackhole, dns, loopback (+ shared server-target). Matches the prompt's 1-file-per-protocol structure (per-modal). Outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): split outbound transport forms into per-transport files
Extract the tcp(raw)/kcp/ws/grpc/httpupgrade transport blocks into outbounds/transport/ per-file components (RawForm, KcpForm, WsForm, GrpcForm, HttpUpgradeForm). xhttp + hysteria transport remain inline for a follow-up. Verbatim relocation; outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): extract OutboundFormModal xhttp transport form
Move the xhttp transport block into transport/xhttp.tsx (takes form + onXmuxToggle prop); drop now-unused HeaderMapEditor and MODE_OPTIONS imports from the modal. OutboundFormModal.tsx down to ~1001 lines (from 2238 originally). Verbatim relocation; outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): extract OutboundFormModal tls/reality security forms
Move the TLS and Reality field blocks into outbounds/security/{tls,reality}.tsx; the none/TLS/Reality Radio.Group selector stays in the modal. Drop now-unused ALPN_OPTIONS/UTLS_OPTIONS imports. OutboundFormModal.tsx down to ~918 lines (from 2238 originally). Verbatim relocation; outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): split inbound-only protocol forms (tun, tunnel) into per-file
Extract the tun and tunnel protocol blocks from InboundFormModal into inbounds/form/protocols/{tun,tunnel}.tsx (presentational, declarative). First inbound-side per-protocol split. Verbatim relocation; inbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): split inbound wireguard & shadowsocks protocol forms
Extract the wireguard and shadowsocks protocol blocks from InboundFormModal into inbounds/form/protocols/{wireguard,shadowsocks}.tsx (presentational; form + regen handlers / isSSWith2022 passed as props). Drop now-unused Divider + SSMethodSchema imports. Verbatim relocation; inbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): split inbound vless/http/mixed/hysteria protocol forms
Extract the remaining inbound protocol blocks into inbounds/form/protocols/: vless (auth handlers/state as props), http + mixed (shared accounts-list), hysteria. Drop now-unused HysteriaMasqueradeForm/Typography/Text imports from the modal. InboundFormModal.tsx 2841 -> 2478. Inbound snapshots unchanged -> no behavior change. typecheck/lint/build green.
* refactor(frontend): move HysteriaMasqueradeForm to lib/xray/forms/transport
The hysteria masquerade form edits streamSettings.hysteriaSettings.masquerade (a transport/stream concept) and is rendered identically by both modals, so it belongs next to FinalMaskForm in lib/xray/forms/transport/ rather than protocols/shared/. Moved the file, updated the transport barrel + both consumers (inbound hysteria protocol form, outbound modal), and removed the now-empty protocols/shared/ folder. Pure relocation; snapshots unchanged, typecheck/lint/build green.
* refactor(frontend): extract inbound transport forms into transport/ folder
Move the six inbound stream-transport blocks (tcp/raw, ws, grpc, xhttp,
httpupgrade, kcp) out of InboundFormModal into presentational components
under inbounds/form/transport/. XhttpForm takes the form instance and
re-derives its mode/obfs/placement watches internally; the rest are
declarative. InboundFormModal drops from 2566 to 2105 lines. No behavior
change — per-protocol field-label snapshots unchanged.
* refactor(frontend): extract inbound security forms into security/ folder
Move the inbound TLS and Reality stream-security blocks out of
InboundFormModal into presentational components under
inbounds/form/security/. The Radio.Group security selector stays in the
modal; TlsForm and RealityForm receive their cert/key/ECH generation
handlers and the saving flag as props. InboundFormModal drops from 2105
to 1708 lines.
Add inbound-form-blocks.test.tsx: render-snapshot coverage for each
extracted transport (raw/ws/grpc/kcp/httpupgrade/xhttp) and security
(tls/reality) component in isolation inside a minimal Form. The full
modal cannot exercise the stream/security tabs in jsdom because they are
gated behind Form.useWatch values that do not propagate in the test
harness, so component-level snapshots are the regression net for these
blocks. No behavior change.
* refactor(frontend): extract outbound sockopt/mux/hysteria transport blocks
Move the last three oversized inline stream blocks out of
OutboundFormModal into presentational components under
xray/outbounds/transport/: SockoptForm (~260 lines, the worst offender),
MuxForm, and HysteriaForm. Each takes the form instance; MuxForm also
takes protocol/network and keeps its isMuxAllowed gate. OutboundFormModal
drops from 962 to 621 lines and no inline section now exceeds the
250-line guideline. Existing outbound-form-modal snapshots already cover
sockopt/mux and stay byte-identical, confirming no behavior change.
* refactor(frontend): extract inbound sockopt + external-proxy blocks
Move the inbound Sockopt (~250 lines) and External Proxy stream blocks
out of InboundFormModal into presentational components under
inbounds/form/transport/, mirroring the outbound extraction. Each takes
its toggle handler (toggleSockopt / toggleExternalProxy) as a prop and
keeps its render-prop getFieldValue gate. InboundFormModal drops from
1708 to 1332 lines.
Extend inbound-form-blocks.test.tsx with isolated render-snapshot
coverage for both (SockoptForm seeded enabled + happyEyeballs;
ExternalProxyForm seeded with one TLS entry). No behavior change.
* refactor(frontend): break down RoutingTab into sections
Extract RoutingTab's presentational pieces into the routing/ folder:
helpers.ts (arrJoin/csv/chipPreview/ruleCriteriaChips), types.ts
(RuleRow), CriterionRow.tsx, RuleCardList.tsx (mobile card view), and
useRoutingColumns.tsx (desktop table columns hook). RoutingTab stays the
orchestrator holding rule state, mutate, tag-option memos and the
pointer-drag reorder logic, and drops from 550 to 291 lines. No behavior
change.
* refactor(frontend): extract BasicsTab constants and rule helpers
Move BasicsTab's geo option arrays + freedom/ipv4 outbound presets into
basics/constants.ts and the routing-rule get/set/sync helpers into
basics/helpers.ts. BasicsTab drops from 550 to 447 lines and keeps its
Collapse-of-settings panels (which stay coupled to mutate + derived
state, so splitting them into components would only add prop-drilling).
No behavior change.
* refactor(frontend): break down DnsTab columns/helpers/types
Extract DnsTab's pure pieces into the dns/ folder: helpers.ts
(STRATEGIES/DEFAULT_FAKEDNS + addr/domains/expectedIPs accessors),
types.ts (DnsConfig/HostRow/FakednsRow), and useDnsColumns.tsx
(useDnsServerColumns + useFakednsColumns table-column hooks taking their
row handlers as params). DnsTab stays the orchestrator for dns state,
mutate, hosts sync and the Collapse panels, and drops from 539 to 424
lines. No behavior change.
* refactor(frontend): break down OutboundsTab into sections
Extract OutboundsTab's pieces: outbounds-tab-types.ts (OutboundRow),
outbounds-tab-helpers.ts (address/untestable/security/breakdown +
traffic/testing/result accessors), useOutboundColumns.tsx (desktop table
columns hook) and OutboundCardList.tsx (mobile card view). OutboundsTab
stays the orchestrator for outbound state, mutate, reorder and the
toolbar, and drops from 516 to 238 lines. No behavior change.
This completes plan section 2.4.5 — all four oversized Xray tabs
(Basics/Routing/Dns/Outbounds) are now broken into sections + hooks.
* refactor(frontend): fold HysteriaMasqueradeForm into the hysteria forms
Inline the masquerade fields directly into both hysteria transport forms
(inbounds/form/protocols/hysteria + xray/outbounds/transport/hysteria)
and delete the shared lib/xray/forms/transport/HysteriaMasqueradeForm so
each hysteria form is self-contained. The masquerade JSX is unchanged;
form is typed as the untyped FormInstance (as the shared component was)
so the masquerade name paths still resolve. No behavior change.
* refactor(frontend): slim InboundFormModal by extracting hooks + sections
Pull the modal's non-layout logic into focused files at the form root:
- useSecurityActions.ts: TLS/Reality key + cert generation handlers and
onSecurityChange (consumed by the security tab)
- useInboundFallbacks.ts: fallback row state + load/save/derive/add/
update/remove/move handlers + eligible-child options
- FallbacksCard.tsx: the fallbacks card UI (presentational)
- SniffingTab.tsx: the sniffing tab UI (presentational)
Also drop the stale "Pattern A rewrite / sibling file" header comment and
the imports the extractions made unused. InboundFormModal goes from 1332
to 868 lines with no behavior change (351 tests green, snapshots
unchanged).
The hysteria protocol now offers only the Hysteria transport (other transports removed) and security is always TLS. This prevents the broken hysteria-over-tcp / security:none outbounds that made xray-core fail to start with 'Failed to build Hysteria config. > version != 2'.
Show the fixed version field directly under Transmission, and expose the full masquerade sub-form on the outbound too. The masquerade UI was extracted into a shared HysteriaMasqueradeForm component used by both the inbound and outbound forms.
Closes#4665
Add an IP Log popup (view list + refresh + clear) to the client edit form and the Client Information modal, with IPs stacked vertically.
Identify inbounds by their xray tag (not remark/protocol:port) across every picker and chip: attach/detach modals, the attached-inbounds column and field, the filter drawer, and bulk-add. Add the tag field to the InboundOption schema (the backend already returned it).
Clarify modal titles/labels: Client Information (was More Information) and Inbound Information (was Inbound's Data); Client Information / QR Code titles now include the client email.
i18n: rename keys moreInformation->clientInfo and inboundData->inboundInfo with proper translations in all languages; addTitle->addClient, editTitle->editClient, addToGroupPlaceholder->groupName.
Add parseWireguardLink to the outbound import dispatcher: maps the secretKey userinfo, peer publicKey/endpoint, address, mtu, reserved, preSharedKey and keepAlive (probing common client aliases). Previously any wireguard:// link fell through to null and showed "Wrong Link!".
Also fix parseShadowsocksLink so a trailing query string (e.g. ?type=tcp) no longer leaks into the host:port slice, which made Number(port) NaN and silently fell back to 443. Strip the query before parsing in both the modern and legacy ss forms.
Outbound connection tester (#4657): UDP-based outbounds (wireguard,
hysteria, kcp/quic transports) were probed with a raw UDP dial that
treated the inevitable read timeout as success, so every one reported a
fake ~5s 'alive'. Route them through the authoritative xray
burstObservatory probe and drop the broken raw-UDP path. Test All now
runs a parallel TCP lane and a serial HTTP lane so xray-probe outbounds
don't collide on the test semaphore.
Vision testseed: the [900, 500, 900, 256] default repeats 900, and a
tags Select keys each tag by value -> 'two children with the same key,
900'. Render it as four InputNumbers (inbound + outbound forms); the
field is a fixed 4-tuple where repeats are valid.
Inbound form: drop the null-valued 'Local Panel' Select option (AntD
rejects null option values; placeholder + allowClear already cover it).
Outbound form: add an explicit 'None' option to the Flow selector.
- derive XMUX toggle from saved xmux on load, seed defaults on enable,
and drop xmux when disabled (#4654)
- save the JSON tab straight from parsed text so sockopt, finalmask (TCP
masks), mux, and reverse excludes round-trip instead of being dropped
by the form-store bounce
- remove the redundant Host/Path fields from HTTP obfuscation that fought
the request.headers editor over the same form path
- rebuild the outbounds table columns on row content change (rows, not
rows.length) so a re-opened edited outbound shows fresh values
- add adapter round-trip regression tests
Closes#4654
Xray's freedom outbound accepts a numeric proxyProtocol (0 disabled,
1 or 2 for the PROXY protocol version), but the panel had no field for
it and the typed form adapter dropped the key on save — so a value set
via the JSON editor disappeared the moment the outbound was saved.
Model proxyProtocol through the freedom wire schema, the form schema,
and both adapter directions (clamped to 0/1/2, omitted from the wire
when 0), and add a Select (none / v1 / v2) to the freedom section of
the outbound form. Add round-trip test coverage and the proxyProtocol
label across all locales.
Closes#4486
Opening the client sublinks/QR modal crashed when a link used
post-quantum keys (ML-DSA-65 / ML-KEM-768): the encoded URL exceeds
the antd QRCode capacity and the component throws. The client QR modal
rendered the QRCode unconditionally, so it took down the page.
The names don't appear verbatim in a share link — mldsa65Verify rides
inside pqv=<base64> and ML-KEM-768 inside encryption=mlkem768x25519plus.
The QR modal and inbound QR modal used a literal-substring guard that
missed those encoded forms, leaving the QR (and the crash) in place.
Consolidate detection into a single isPostQuantumLink() helper in
inbound-link.ts and reuse it across the client QR, inbound QR, client
info, and sub surfaces. The copy/download link still works; only the
QR image is suppressed for oversized post-quantum links.
Closes#4656
The clients list returns slim rows without secrets (uuid/password/auth)
or flow/security/tgId/reset/group. setEnable built its update payload
straight from the slim row, sending an empty id, so the backend treated
it as a new client and regenerated the UUID (and dropped the omitted
fields). Hydrate the full record first and send a complete payload that
changes only the enable flag.
OutboundFormModal.onOk built the save payload from form.validateFields(),
which only returns REGISTERED Form.Item values. The security selector is a
Radio.Group that writes streamSettings.security via setFieldValue with no
bound Form.Item, so validateFields() dropped it — network, tlsSettings and
realitySettings (all registered) survived, but the security discriminator
vanished and xray-core fell back to security="none". This hit both new
outbounds and re-saved ones.
Read the full form store with getFieldsValue(true) for the payload (still
validating first), matching how the inbound modal already does it.
Closes#4634
The xHTTP transport schema and share-link emitter already supported a
headers map, but the inbound form lost its editor row, so operators had
no way to set custom headers on xHTTP inbounds. Add the HeaderMapEditor
row in the same position the outbound form uses.
Operators can now type an explicit dest (e.g. "8443", "127.0.0.1:8443",
"/dev/shm/x.sock") on each fallback row to override the auto-resolved
child listen+port. Empty keeps the existing auto behavior.
Adds the column to inbound_fallbacks (GORM AutoMigrate), threads it
through the panel form, API docs, and translations.
GroupsPage was sourcing modal candidates from useClients(), which is server-paginated at 25 rows — so "Add clients to group" only ever offered the first page, "Remove" missed members past page 1, and SubLinks silently skipped emails whose record wasn't in the cached page. Pull the unpaginated list via /panel/api/clients/list when any of the three modals open.
Adds a panel-only `pinnedPeerCertSha256` field on TLS settings with a tags input and a random-hash generator. The hashes ride share links as `pcs` (v2rayN-compatible), Clash sub as `pin-sha256`, and JSON sub as `pinnedPeerCertSha256`, while remaining stripped from the run-config sent to xray-core.
- TextModal: route the Copy button label and the post-copy toast
through t('copy')/t('copied') instead of hardcoded English.
- PromptModal: route cancelText through t('cancel') and default okText
through t('confirm') so the import-inbound prompt stops showing
"Cancel" in non-English UI.
- InboundsPage: pass the All-Inbounds and All-Inbounds-Subs download
filenames through t(...) so each locale can localize them.
- en-US.json: add pages.inbounds.exportAllLinksFileName and
pages.inbounds.exportAllSubsFileName.
- All 12 non-English locales: translate streamTab and sniffingTab
(previously left as literal English) and add the two new filename
keys with appropriate translations.
All 13 locale files now have 1541 lines.
The "create" form opened with a 9-char random email default, the bulk
modal's random portion was only 6 chars, and the inbound-defaults seed
used 8 — all below the 10-char minimum we want for new clients. Bring
each generator to 10 so an unedited auto-generated email meets the
threshold without the user having to extend it.
Surface ~400 hardcoded English labels, tooltips, placeholders, dt/divider
text, modal okText/cancelText, and Spin loading from the panel pages
(clients/groups/inbounds/nodes/settings/xray/sub/index) into
web/translation/en-US.json under existing pages.<page>.* namespaces, with
JSX swapped to t(...). Brand and protocol identifiers (TLS, MTU, SNI,
NordVPN, Cloudflare WARP, etc.) stay literal.
Sync all 12 non-English locales (ar-EG, es-ES, fa-IR, id-ID, ja-JP,
pt-BR, ru-RU, tr-TR, uk-UA, vi-VN, zh-CN, zh-TW) to match en-US's
structure and translate the 521 new key paths per locale. Every locale
file now has 1539 lines, mirroring en-US ordering.
Also remove a dead duplicate "info": "Info" key under pages.inbounds
that collided with the new pages.inbounds.info.* object.
Backend: bulk attach/detach errors in web/service/client.go now route
through logger.Warningf (so they appear under /panel/api/server/logs/)
instead of only living on the response payload.
The backend returns descriptive error strings (email/inbound + reason)
but the UI only surfaced a count. Forward result.errors to console.error
so the actual failure cause is recoverable from DevTools.
- Detach preserves client traffic stats. DelInboundClient,
DelInboundClientByEmail, and bulkDelInboundClients now take a
keepTraffic flag; Detach passes true, delete-paths keep prior
behavior. Runtime user removal still runs so xray drops the session.
- Two startup seeders normalize legacy inbound settings JSON:
clients:null -> [] and any non-numeric tgId -> 0 (string, bool,
NaN, Inf, non-integer floats). Each records itself once in
history_of_seeders.
- MigrationRequirements no longer rewrites empty clients arrays back
to null: newClients is initialized as a non-nil slice and incoming
clients:null is coerced before the type assertion.
- TLS cert form: rawInboundToFormValues synthesizes a useFile
discriminator per cert from whichever side carries data, so the
edit modal can show file-mode paths again. formValuesToWirePayload
strips useFile so saved JSON stays in wire shape.
The inbound form intentionally only exposes the response side of the
TCP HTTP header object (xray-core's inbound listener reads the
response object, not request — see the existing comment in
InboundFormModal). But the share-link generators were still reading
the Host header from request.headers, so the configured value ended
up in tcpSettings.header.response.headers while the link query
emitted host= (empty).
Fix the host lookup in both code paths:
- sub/subService.go: applyShareNetworkParams (VLESS / Trojan /
Shadowsocks share URLs) and applyVmessNetworkParams (the VMess
base64 JSON link) now try header.response.headers first and fall
back to request.headers for legacy / hand-edited configs.
- frontend/src/lib/xray/inbound-link.ts mirrors the same fallback in
the three TCP HTTP branches (VMess obj, VLESS params, the shared
Trojan+Shadowsocks writer) so the JS-side generator used by the
API docs preview stays in sync with the Go output.
Also restore the request-side inputs (version / method / path /
headers) under the TCP HTTP toggle in InboundFormModal. They were
previously removed because xray-core ignores them on the inbound
side, but they're still useful when copying the same config out to
an outbound or hand-tuning the share link, and they no longer
mislead users about Host — the link now derives Host from
response.headers.host where the response-only form writes it.
A bundle of small UI fixes that surfaced together while reviewing the
panel.
Routing rules — stale Edit after drag:
- Dragging a rule and then clicking its Edit button used to open the
modal with the *previous* rule's content. Root cause: desktopColumns
was memoized with [t, isMobile, rows.length] (rows.length doesn't
change on reorder), so the cached render function kept handing AntD
the openEdit closure that captured the pre-drag rules array. Fix is
a rulesRef updated each render and read inside openEdit, so even the
cached closure sees the live array.
- Mobile rule cards on the same page were hard to tell apart: bumped
the inter-card gap, slightly stronger border, soft shadow, and a
small centered divider line between adjacent cards.
Mobile drawer (dark / ultra):
- The AntD Menu inside the mobile drawer was rendering with its own
darkItemBg (#15161a / #050507) while the drawer body used the
lighter colorBgElevated, producing visible two-tone seams. Force
the drawer-content / drawer-body to the same dark color that the
desktop sider uses, and make the menus transparent so they inherit.
Row menus — visual grouping:
- Groups page row menu: moved Rename above the divider so the
ordering reads safe → divider → destructive (Remove from group,
Delete clients, Delete group only) instead of mixing the two
groups.
- Inbounds page row menu: inserted a divider before delAllClients /
delete so the destructive items sit visually separated from the
earlier safe actions.
Dropdown affordances:
- Non-danger dropdown items had no perceivable hover state (default
colorBgTextHover is too subtle, especially under the light theme).
Apply the same primary-tint pattern the sider/drawer menu uses: 14%
primary background and primary color on label + icon.
- ant-dropdown-menu-item-divider now uses var(--ant-color-border)
(and an explicit rgba in dark) so the separator is actually visible
in the light theme.
Clients toolbar — narrow-desktop wrap:
- Between 769px and 920px, the bulk-action bar (Attach / Detach /
Add to group / Ungroup / more + Delete) wrapped to two rows with
Delete stranded alone on the right. In that range, switch the
toolbar buttons to icon-only, tighten gap to 6px and inline padding
to 8px so everything stays on one line.
This bundles a set of group-related improvements that built up across
one session and only make sense together.
Terminology / API surface:
- Rename "assign group" → "add to group" everywhere: i18n keys,
callback names (bulkAddToGroup), component + file names
(BulkAddToGroupModal, AddClientsToGroupModal), Go controller/struct
names (bulkAddToGroup, AddToGroup), OpenAPI summaries. Nothing keeps
the word "assign" anymore.
- Move group routes under /panel/api/clients/groups/* (was
/bulkAssignGroup at the clients root).
- Split add and remove into two endpoints: /groups/bulkAdd now rejects
empty group; new /groups/bulkRemove clears the label for the given
emails. The old "submit empty to clear" UX is gone — Ungroup is its
own action.
UI affordances on Clients page:
- Promote Group + Ungroup to visible bar buttons next to Attach +
Detach. Group reuses BulkAddToGroupModal; Ungroup pops a danger
confirm and calls bulkRemoveFromGroup.
- Custom UngroupIcon (TagsOutlined with a diagonal strike) for the
Ungroup button so the pairing reads at a glance.
- Hide the Group column when no clients have a group label yet —
removes a column of em-dashes on fresh installs.
UI on Groups page:
- New per-row Add clients… / Remove clients… actions backed by
GroupAddClientsModal and GroupRemoveClientsModal: rich client picker
(email / comment / current group / enable) with search and
preserveSelectedRowKeys, mirroring the inbounds Attach modal UX.
Controller split:
- Move all /groups/* routes, handlers, and request bodies out of
web/controller/client.go into a dedicated web/controller/group.go
(GroupController with leaner clientService + xrayService
dependencies). URLs are byte-identical because the new controller
registers on the same parent gin.RouterGroup; api_docs_test.go gets
a group.go → /panel/api/clients basePath entry so its route
extraction keeps working.
Invalidation dedup:
- Removing a client from a group on the Groups page used to refetch
/clients/groups and /clients/onlines three times: once from the
mutation's onSuccess, once from a redundant invalidate() in the
page's onSubmit, once from the WebSocket invalidate broadcast that
the backend fires after every mutation. The manual invalidate() is
gone, and a small invalidationTracker module lets websocketBridge
skip WS-driven invalidates that arrive within 1.5s of a local
invalidate — bringing the refetch count down to one. The WS path
still works for changes made by another tab or user.
When at least one client is selected, the toolbar now collapses to a
small selection indicator plus the three most-used actions instead of
spreading six count-suffixed buttons across the row:
- Replaces every per-button "(N)" with a single closable "{N} selected"
tag on the left — one click on its × clears the selection.
- Hides "+ Add Clients" while a selection is active (focus mode).
- Keeps Attach, Detach, and Delete as visible buttons; Delete is pushed
to the right with auto margin so it doesn't sit flush against the
non-destructive actions.
- Folds Adjust, Group, and Sub links into the existing "more"
dropdown, which is now context-aware: selection-scoped overflow when
rows are picked, global actions (Add Bulk / Reset all / Del depleted)
otherwise.
On mobile the new buttons collapse to icon-only the same way as the
rest of the toolbar.
Inbounds page:
- AttachClientsModal now shows a per-client selection table (email,
comment, enabled tag) with search and a live "selected of total"
counter; all clients are pre-selected so the old "attach all"
workflow stays a single OK click.
- New DetachClientsModal on the inbound row menu lets you pick which
clients to remove from that inbound (records are kept so they can be
re-attached later; for full removal use Delete).
Clients page:
- New "Attach (N)" bulk-action button + BulkAttachInboundsModal that
attaches selected clients to one or more multi-user inbounds.
- New "Detach (N)" bulk-action button + BulkDetachInboundsModal that
removes selected clients from chosen inbounds; (email, inbound) pairs
where the client isn't attached are silently skipped.
Backend adds POST /panel/api/clients/bulkDetach, wrapping the existing
Detach service for each email and reporting per-email
detached/skipped/errors. ClientRecord rows are kept on detach to match
the single-client endpoint; bulkDel remains the path for full removal.
Bring back the per-certificate buttons in the inbound TLS section (File Path
mode): "Set Cert from Panel" fetches the panel's own webCertFile/webKeyFile via
/panel/setting/all and fills the cert's certificateFile/keyFile, warning when no
panel cert is configured; "Clear" empties both paths.
Reuses the existing pages.inbounds.setDefaultCert label and adds a
setDefaultCertEmpty warning string.
Add a "Vision testseed" form item to the inbound modal for TCP + TLS/reality
inbounds, normalized to positive integers and defaulting to [900,500,900,256].
Apply the same default in the outbound form adapter when no valid saved seed
is present.
Replace the http/mixed snapshot assertions in inbound-defaults with explicit
field checks so generated credentials don't break the snapshots.
- Bulk-attach an inbound's clients onto other inbounds (same identity, shared traffic): new ClientService.BulkAttach + POST /clients/bulkAttach, an inbound row action, and AttachClientsModal.
- Assign all of an inbound's clients to a group from the inbound page, reusing /clients/bulkAssignGroup and the existing BulkAssignGroupModal.
- Default a random user/pass account for new Mixed and HTTP inbounds instead of an empty accounts list.
- Capitalize the inbound Security toggle labels (None/TLS/Reality).
Add a panelProxy setting that routes the panel's self-initiated HTTP requests (geo updates, Xray version/core download, panel update check) through an admin-configured socks5/http(s) proxy, to bypass server-side filtering of GitHub/Telegram. The Telegram bot falls back to it when tgBotProxy is empty (socks5 only). New util/netproxy.NewHTTPClient builds the proxied client.
Also fix the Mixed-inbound SOCKS/HTTP share URLs that had host:port and user:pass in the wrong order, and consolidate the Telegram settings tab (move API server into the general tab, drop the empty Proxy & Server tab).
* fix(clients): fall back to inbound scan when ClientRecord is missing
DeleteByEmail looked up the email in client_records and returned the
raw "record not found" gorm error when nothing matched, even though
the client could still live inside an inbound's settings.clients JSON
(legacy entries that SyncInbound never picked up, or rows deleted out
from under a stale inbound). The user-visible delete then fails
mysteriously while xray happily keeps serving the client.
When GetRecordByEmail returns ErrRecordNotFound, walk inbounds whose
settings JSON references the email and run DelInboundClientByEmail on
each. The traffic / IP rows are cleaned up at the end unless keepTraffic
is set. If no inbound carries the email either, surface a clear
"client %q not found in any inbound or client record" error instead.
* chore(logging): include request + caller context in jsonMsgObj warnings
The generic "X-UI: Something went wrong. Error: record not found" log
gave no clue about which endpoint, client, or controller line emitted
it. Prepend a context block:
[POST /panel/api/clients/del/ADMIN ip=109.124.234.127
handler=controller.(*ClientController).delete client.go:146]
Handler frame is located by scanning the stack for the first caller
outside util.go, so it points at the right controller method whether
the path went through jsonMsg, jsonObj, or jsonMsgObj directly.
* fix(clients): tolerate orphan client_inbounds rows in Delete
DeleteByEmail's previous fix only covered the case where GetRecordByEmail
returned ErrRecordNotFound. When the ClientRecord exists but a client_inbounds
row points to an inbound that has been removed out-of-band (failed mid-delete,
manual SQL, pre-SyncInbound migration), Delete bubbled the raw gorm
"record not found" from inboundSvc.GetInbound and aborted before any cleanup
ran — leaving the client un-deletable through the UI/API.
Match the tolerance bulkDelInboundClients already has: when GetInbound
returns gorm.ErrRecordNotFound for a join row, log a warning and continue.
The unconditional Delete(&model.ClientInbound{}) later in the function then
removes the stale row, and the ClientRecord delete succeeds.
* fix(schemas): accept empty-string fingerprint on externalProxy
The External Proxy form offers a "Default" option with value '' for the
uTLS fingerprint dropdown, but UtlsFingerprintSchema.optional() rejects
empty strings (only undefined or a valid enum member). Saving an inbound
with externalProxy rows failed with `expected one of "360"|"chrome"|...`.
Preprocess '' to undefined before the optional enum, matching the existing
pattern used for VmessSecuritySchema.
* chore(logging): drop noisy orphan client_inbounds warning
Per-row WARNINGs spammed logs whenever a client referenced multiple
already-deleted inbounds. The continue keeps the orphan-tolerant
behavior; just no longer announces each skipped row.
* feat(clients): per-client VMess security in client form
Restores the VMess `security` selector on the client form (auto, aes-128-gcm,
chacha20-poly1305, none, zero) and surfaces it only when at least one attached
inbound is VMess. The value rides into the share link via the existing
`scy=` field in genVmessLink; the panel persists it on ClientRecord and in
the inbound's settings.clients so the link generator can read it back.
Adds the pages.clients.vmessSecurity i18n key in en-US and fa-IR.
* fix(xray-config): strip panel-only fields from inbound config
Two fields the panel stores but Xray doesn't accept on the inbound side:
- VMess clients[].security — panel persists it so the share-link generator
can write `scy=...`, but xray's vmess inbound spec has no per-client
security. The field was leaking into the inbound JSON pushed to xray-core.
- VLESS settings.encryption — per the xray spec the inbound only takes
`decryption`; `encryption` is for the matching client outbound. The panel
keeps it for operator reference, but it must not appear in the inbound
payload.
Add two strip helpers next to HealShadowsocksClientMethods and wire them
into GenXrayInboundConfig via a per-protocol switch, so both local and
remote runtime paths get the cleaned config.
* chore(db): backend-aware pool sizes with env overrides
Per-backend defaults:
- Postgres: 25 max open / 25 max idle. Matching idle to open removes
pool churn under bursts (Postgres handles concurrency at the server,
idle connections are cheap).
- SQLite: 1 max open / 1 max idle. Single-writer model means a wider
cap just queues behind busy_timeout; tight cap is honest.
Both back ends share ConnMaxLifetime=1h and ConnMaxIdleTime=30m so
stale connections (vault rotation, pgbouncer drops, load-balancer
idle eviction) rotate out without operator intervention.
Operators can override either default at boot via:
XUI_DB_MAX_OPEN_CONNS=...
XUI_DB_MAX_IDLE_CONNS=...
envInt parses these; missing/empty/non-positive values fall back to
the per-backend default.
* fix(schemas): accept boolean acceptProxyProtocol on TCP stream
TcpStreamSettingsSchema declared `acceptProxyProtocol: z.literal(true).optional()`,
so saving an inbound where the AntD Switch sat in the off state failed
validation with `Invalid input` because the Switch always emits a plain
boolean.
Switch to `z.boolean().default(false)` — same shape ws/sockopt/httpupgrade
already use, and matches the actual wire payload (golden fixtures and
other settings blocks all store `acceptProxyProtocol: false`).
Snapshots for stream.test and inbound-full.test pick up the new defaulted
field on TCP fixtures.
On-chart extrema labels were colliding with the Y-axis ticks at the
top, the X-axis timestamps at the bottom, and the chart line itself
when min/max sat near a chart edge. Replace the floating labels with
a single rounded pill in the chart's top-right corner that lists
"▲ max ▼ min", outside the drawing area. Dots still mark the points
on the line. Also nudge Y tick text 4px left, push X timestamps down
with tickMargin=14, and widen YAxis to 56px so values like "234 KB/s"
don't crowd the chart.
Adds POST /panel/api/inbounds/:id/delAllClients that collects every
client email from settings.clients[] and runs ClientService.BulkDelete
in one pass. Row action lives in the More menu as a danger item, only
shown for multi-user inbounds that currently have at least one client;
confirmation modal displays the live client count.
Persistent client groups
- New ClientGroup model + client_groups table that holds empty
(placeholder) groups so a user can define a label before any client
references it. ListGroups merges these with the distinct group_name
values already stored on clients and reports {name, clientCount}.
- ClientRecord gains group_name column; the model.Client wire shape
gains a matching `group` JSON field that survives the
inbound.settings → SyncInbound round-trip.
- Rename/Delete on a group mutates client_groups (rename row / delete
row) AND propagates to all matching clients in ClientRecord and in
every owning inbound's settings JSON, all in one transaction.
Bulk operations
- AssignGroup(emails, group) updates clients.group_name + patches each
affected inbound's settings JSON in one read-modify-write per inbound.
Empty group clears the label. Auto-creates the client_groups row when
the user assigns to a brand-new name.
- BulkResetTraffic(emails) loops the existing single-reset path so the
caller can zero traffic across a whole selection or a whole group.
- EmailsByGroup(name) returns just the email list (used by the groups
page to fan a single bulk action over every member).
Endpoints (all under /panel/api/clients)
- GET /groups — summaries with counts
- GET /groups/:name/emails — emails in a group
- POST /groups/create — empty placeholder group
- POST /groups/rename — rename (table + clients + JSON)
- POST /groups/delete — drop label everywhere (clients survive)
- POST /bulkAssignGroup — assign N selected clients
- POST /bulkResetTraffic — reset traffic on a list
Clients page UX
- New Group column (Actions → Client → Group → Inbounds → …) with a
click-to-filter chip.
- FilterDrawer gains a multi-select Group filter whose options come
from the new ClientPageResponse.groups field (sourced from ListGroups
so empty/placeholder groups are pickable too).
- Single-client and bulk-add forms gain a Group AutoComplete pre-loaded
with all known group names.
- New toolbar buttons when selection > 0: "Group ({n})" opens
BulkAssignGroupModal, "Sub links ({n})" opens SubLinksModal.
Sub-links export modal (new SubLinksModal.tsx)
- Table of selected clients with their subscription URL (and JSON URL
when subJsonEnable is on), per-row copy, Copy all, and Download as
sub-links-<timestamp>.txt. Warns when subscription is disabled or
none of the selected clients have a subId.
Dedicated Groups page (new pages/groups/GroupsPage.tsx)
- /groups route + sidebar entry (TagsOutlined icon) + page title key.
- Card-based layout matching Clients/Inbounds/Nodes — summary card with
Total/Grouped/Empty stats, main card with Add Group button + table.
- Per-row More dropdown (icon-first column on the left): Sub links,
Adjust (days+traffic), Reset traffic, Rename, Delete clients in
group, Delete group (keep clients). Empty groups disable the
client-targeted actions.
- Reuses SubLinksModal and ClientBulkAdjustModal — emails for the
group are fetched on demand from GET /groups/:name/emails.
Other polish
- /groups + groups-page selectors added to page-shell.css and
page-cards.css so the new page inherits the same background, padding,
card borders, hover shadow, and summary-card padding.
- .card-toolbar gains a small vertical padding so the larger toolbar
buttons (now default size, matching Inbounds) don't crowd the top of
the card-head on Clients and Groups pages.
Frontend
- New Sort dropdown in the clients toolbar covering oldest/newest,
recently updated, recently online, email A↔Z, most traffic, highest
remaining, expiring soonest. Default is Oldest first.
- Strip per-column sorter arrows from the Table — all sorting now flows
through the single dropdown, so the column headers stop competing
with it.
- Empty state: TeamOutlined icon, t('noData'), text-secondary color
(matching the inbound/node polish).
Backend
- sortClients: add createdAt, updatedAt and lastOnline cases (with id
tie-break for stable ordering when timestamps collide).
- Fix Recently updated: SyncInbound was calling tx.Save on every client
in the inbound, and GORM's autoUpdateTime tag stamped updated_at to
time.Now() each time — so editing one client bumped ALL of them.
After the Save, restore each row's preserved updated_at via
UpdateColumn (skips hooks). The actually-edited client gets its
fresh stamp from the explicit UpdateColumn at the end of Update().
- Fix periodic updated_at churn: adjustTraffics unconditionally set
c["updated_at"] = now() for every client in any inbound that had a
delayed-start expiry, every traffic-stats pass. Turn that into a
backfill (only when the key is missing), matching the created_at
treatment one line above.
- AppSidebar: switch the inbounds icon from UserOutlined (a single
person — wrong semantic) to ImportOutlined, matching the empty-state
icon and reflecting the actual concept of an incoming entry point.
- usePageTitle: stop hardcoding English titles; resolve them through
i18n (menu.* keys are already translated), so the browser tab now
follows the active language.
- InboundList / NodeList: replace the bare "—" empty cell with a
centered icon + t('noData') message (ImportOutlined for inbounds,
ClusterOutlined for nodes), and swap opacity:0.4 for
var(--ant-color-text-secondary) so the text stays readable on the
light theme's tinted card background.
Drop the Current/Min/Avg/Max stats row and Live auto-refresh toggle —
clutter that didn't earn its space. Min/max are now rendered as colored
dots on the chart itself (green ▼ for min, orange ▲ for max), which
exposes both the value AND the time-axis position of each extremum at a
glance. Tooltip now formats the timestamp fully (with date prefix when
the sample crosses a day boundary).
Switch CartesianGrid stroke from var(--ant-color-border-secondary) to
rgba(128,128,140,0.35) so the gridlines stay readable in light theme
against the chart-wrap's faint primary tint — the AntD variable
resolved to near-zero alpha and the gridlines disappeared.
XrayMetricsModal keeps its implicit 2s observatory polling.
Bulk Add now exposes the same Auto Renew (`reset`, days) input as the
single-client form, applied to every client the batch produces. The
field was already on ClientBulkAddFormSchema's siblings; just wire it
into the schema, the empty-form defaults, the UI, and the bulkCreate
payload. Also relabel "Subscription info" to "Subscription ID" by
switching to the canonical pages.clients.subId key and modernise the
SyncOutlined-in-label random affordance on the same row.
On the inbound submit path, two payload-shape cleanups in
dropLegacyOptionalEmpties:
- streamSettings.hysteriaSettings.auth is a holdover slot whose
real per-client value lives in settings.clients[*].auth; drop the
field entirely when empty instead of shipping `"auth": ""`.
- finalmask's `tcp` / `udp` arrays were already dropped together when
both were empty, but a UDP-only setup still emitted a stray
`"tcp": []`. Drop each sub-array on its own when empty so a
Hysteria-style "salamander on udp only" config no longer carries
the empty tcp sibling.
Replace the last holdouts of the old random-affordance patterns:
- ClientFormModal's five "↻" text buttons (email / subId / auth /
password / uuid) now use <Button icon={<ReloadOutlined />} /> so
they match the icon-based actions elsewhere in the form.
- OutboundFormModal's WireGuard private-key SyncOutlined-in-label
becomes a real button inside a Space.Compact next to the key
field — same pattern the inbound side already uses.
The shared .random-icon CSS class has no remaining consumers after
this and the previous inbound-form pass, so drop it from utils.css.
Picking Hysteria from the protocol select used to leave finalmask.udp
empty, so the listener went out without obfs unless the admin added
the salamander wrapper by hand. Hook into onValuesChange so switching
to Hysteria seeds finalmask.udp with
{type: 'salamander', settings: {password: <random>}} alongside the
hysteriaSettings / tlsSettings reset already happening there.
Also modernise the SyncOutlined-in-label "random" affordances on
Shadowsocks password, WireGuard secret key (server + per-peer), and
Reality target / SNI / shortIds into proper icon buttons inside a
Space.Compact next to the field. The old pattern dropped a tiny
clickable icon into the form-item label, which was easy to miss and
inconsistent with the other action buttons in the modal.
Picking mKCP from the Transmission select used to leave finalmask.udp
empty, so the inbound went out as unobfuscated mKCP unless the admin
remembered to add the wrapper by hand in the FinalMask section. Hook
into onNetworkChange so switching the network to kcp appends
{type: 'mkcp-original', settings: {}} to finalmask.udp (only when no
mkcp-original entry exists yet, so re-selecting kcp or editing other
udp masks doesn't pile up duplicates).
checkPortConflict used to return a bare bool, which the API layer
translated into "Port already exists: 443" with no hint about which
existing inbound owned the port, what listen address it used, or
which L4 transport actually clashed. On a panel with dozens of
inbounds the admin had to scan the list by hand to figure out the
collision.
Return a portConflictDetail{InboundID, Remark, Tag, Listen, Port,
Transports} instead; a String() method formats it as
"port 443 (tcp) already used by inbound 'my-vless' (#7) on *" so the
existing common.NewError wrapping carries the full context up to the
UI without a second round-trip.
Two predicate gaps fixed at the same time:
- streamSettings.network="quic" rides on UDP the same way "kcp" does,
so it now joins KCP in the UDP branch instead of falling through to
the TCP default (a QUIC inbound used to silently allow a UDP
neighbour on the same port).
- Tunnel reads settings.allowedNetwork ("tcp" / "udp" / "tcp,udp"),
not settings.network — 3x-ui's dokodemo-door wrapper renames the
field, and treating it as Shadowsocks-shaped left every Tunnel
inbound looking like plain TCP regardless of what the admin
configured.
Tests: TCP/UDP coexist + same-transport collision matrix already
covered the happy path; added QUICTreatedAsUDP, TunnelAllowedNetwork,
and DetailMessage to lock in the new behaviour. Dropped the unused
transportBits.conflicts() helper now that the call site composes the
mask itself to populate the detail.
The InboundList protocol column had a few rough edges: raw transports
rendered with mixed casing (TCP vs ws vs grpc), WireGuard never got a
network tag at all, and Mixed/Tunnel rows had no L4 indication even
though they listen on tcp/udp combinations through their own settings
keys (settings.udp for Mixed, settings.allowedNetwork for Tunnel).
Normalise the column: a small networkLabel helper upper-cases every
known transport (so TCP / UDP / KCP / QUIC / WS / GRPC / HTTP all
share the same visual weight, with HTTPUpgrade / SplitHTTP / XHTTP
keeping a touch of casing for readability). Add an extra UDP tag
beside KCP / QUIC so the user sees the underlying L4 without having
to know each transport's wire shape. Add isTunnel to the dbinbound
model and per-protocol branches for Mixed (TCP / TCP,UDP) and Tunnel
(reads settings.allowedNetwork the same shape Shadowsocks uses for
settings.network).
Also polish the attached-inbounds Select in the client form: open
upwards (placement="topLeft") with a 220px listHeight and
maxTagCount="responsive" so a long selection doesn't push the modal's
Save button below the viewport.
The old toolbar exposed a single-value Search box, a single bucket
radio, and one Protocol + Inbound dropdown. Real panels with hundreds
of clients across mixed protocols need to slice by combinations
(active + expiring, two specific inbounds, expiring within a window,
high-usage subset, etc.), which the old shape couldn't express.
Backend ClientPageParams now accepts comma-separated multi values for
Filter / Protocol / Inbound and three new structured fields each:
expiry/usage ranges (ms / bytes), and three trinary toggles
(AutoRenew / HasTgID / HasComment with on/off, yes/no). The free-text
search predicate also picks up UUID / Password / Auth, which were
previously invisible to search.
Frontend introduces a dedicated FilterDrawer (multi-select for
state/protocol/inbound, DatePicker.RangePicker for expiry, paired
InputNumbers for usage, radio buttons for the trinary toggles) opened
from a single Filter button with a badge for the active count. Active
filters render as closable chips above the table so the user can drop
them one at a time, with a Clear-all next to the Filter button. The
search box stays inline and always visible.