Browse Source

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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 1

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

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

---------
Sanaei 21 hours ago
parent
commit
85e2ded0e1
100 changed files with 10181 additions and 5230 deletions
  1. 5 19
      .vscode/launch.json
  2. 14 0
      .vscode/tasks.json
  3. 221 4
      CONTRIBUTING.md
  4. 4 0
      Dockerfile
  5. 32 0
      README.md
  6. 16 0
      config/config.go
  7. 167 42
      database/db.go
  8. 26 0
      database/dialect.go
  9. 143 0
      database/migrate_data.go
  10. 399 14
      database/model/model.go
  11. 188 1
      database/model/model_test.go
  12. 17 0
      docker-compose.yml
  13. 13 0
      frontend/clients.html
  14. 46 53
      frontend/package-lock.json
  15. 16 4
      frontend/src/api/axios-init.js
  16. 3 0
      frontend/src/components/AppSidebar.vue
  17. 21 0
      frontend/src/entries/clients.js
  18. 19 15
      frontend/src/models/dbinbound.js
  19. 30 18
      frontend/src/models/inbound.js
  20. 1 1
      frontend/src/pages/api-docs/EndpointSection.vue
  21. 195 149
      frontend/src/pages/api-docs/endpoints.js
  22. 267 0
      frontend/src/pages/clients/ClientBulkAddModal.vue
  23. 402 0
      frontend/src/pages/clients/ClientFormModal.vue
  24. 411 0
      frontend/src/pages/clients/ClientInfoModal.vue
  25. 97 0
      frontend/src/pages/clients/ClientQrModal.vue
  26. 1067 0
      frontend/src/pages/clients/ClientsPage.vue
  27. 217 0
      frontend/src/pages/clients/useClients.js
  28. 0 280
      frontend/src/pages/inbounds/ClientBulkModal.vue
  29. 0 394
      frontend/src/pages/inbounds/ClientFormModal.vue
  30. 0 841
      frontend/src/pages/inbounds/ClientRowTable.vue
  31. 0 185
      frontend/src/pages/inbounds/CopyClientsModal.vue
  32. 577 353
      frontend/src/pages/inbounds/InboundFormModal.vue
  33. 2 2
      frontend/src/pages/inbounds/InboundInfoModal.vue
  34. 23 325
      frontend/src/pages/inbounds/InboundList.vue
  35. 55 250
      frontend/src/pages/inbounds/InboundsPage.vue
  36. 11 23
      frontend/src/pages/inbounds/useInbounds.js
  37. 34 0
      frontend/src/pages/nodes/NodeList.vue
  38. 12 2
      frontend/src/pages/nodes/useNodes.js
  39. 3 14
      frontend/src/pages/xray/BalancerFormModal.vue
  40. 12 10
      frontend/src/pages/xray/BalancersTab.vue
  41. 2 3
      frontend/src/pages/xray/BasicsTab.vue
  42. 2 3
      frontend/src/pages/xray/OutboundFormModal.vue
  43. 3 3
      frontend/src/pages/xray/RuleFormModal.vue
  44. 8 6
      frontend/src/utils/index.js
  45. 15 10
      frontend/vite.config.js
  46. 7 2
      go.mod
  47. 15 4
      go.sum
  48. 252 307
      install.sh
  49. 31 1
      main.go
  50. 1 0
      sub/links.go
  51. 40 0
      sub/links_test.go
  52. 3 3
      sub/sub.go
  53. 3 11
      sub/subClashService.go
  54. 15 3
      sub/subController.go
  55. 1 8
      sub/subJsonService.go
  56. 92 26
      sub/subService.go
  57. 480 0
      sub/subService_test.go
  58. 28 0
      util/common/format_test.go
  59. 44 0
      util/common/multi_error_test.go
  60. 69 0
      util/crypto/crypto_test.go
  61. 76 0
      util/json_util/json_test.go
  62. 2 7
      util/ldap/ldap.go
  63. 127 0
      util/netsafe/netsafe_test.go
  64. 12 0
      util/random/random.go
  65. 63 0
      util/random/random_test.go
  66. 11 18
      util/sys/sys_darwin.go
  67. 38 75
      util/sys/sys_linux.go
  68. 14 22
      util/sys/sys_windows.go
  69. 5 2
      web/controller/api.go
  70. 4 1
      web/controller/api_docs_test.go
  71. 311 0
      web/controller/client.go
  72. 46 322
      web/controller/inbound.go
  73. 1 1
      web/controller/node.go
  74. 44 136
      web/controller/server.go
  75. 2 2
      web/controller/util.go
  76. 5 0
      web/controller/xui.go
  77. 1 1
      web/entity/entity.go
  78. 25 0
      web/global/global.go
  79. 46 102
      web/job/ldap_sync_job.go
  80. 19 0
      web/job/node_traffic_sync_job.go
  81. 69 0
      web/job/node_traffic_sync_job_test.go
  82. 2 1
      web/job/periodic_traffic_reset_job.go
  83. 49 4
      web/runtime/local.go
  84. 46 19
      web/runtime/remote.go
  85. 3 3
      web/runtime/remote_test.go
  86. 7 1
      web/runtime/runtime.go
  87. 1959 0
      web/service/client.go
  88. 59 0
      web/service/client_test.go
  89. 147 0
      web/service/fallback.go
  90. 260 646
      web/service/inbound.go
  91. 1 1
      web/service/metric_history.go
  92. 119 15
      web/service/node.go
  93. 162 0
      web/service/node_test.go
  94. 14 6
      web/service/panel.go
  95. 1 1
      web/service/port_conflict.go
  96. 5 4
      web/service/port_conflict_test.go
  97. 132 7
      web/service/server.go
  98. 284 407
      web/service/tgbot.go
  99. 1 1
      web/service/tgbot_test.go
  100. 132 36
      web/service/xray.go

+ 5 - 19
.vscode/launch.json

@@ -10,26 +10,12 @@
       "program": "${workspaceFolder}",
       "cwd": "${workspaceFolder}",
       "env": {
-        "XUI_DEBUG": "true"
-      },
-      "console": "integratedTerminal"
-    },
-    {
-      "name": "Run 3x-ui (Debug, custom env)",
-      "type": "go",
-      "request": "launch",
-      "mode": "auto",
-      "program": "${workspaceFolder}",
-      "cwd": "${workspaceFolder}",
-      "env": {
-        // Set to true to serve assets/templates directly from disk for development
         "XUI_DEBUG": "true",
-        // Uncomment to override DB folder location (by default uses working dir on Windows when debug)
-        // "XUI_DB_FOLDER": "${workspaceFolder}",
-        // Example: override log level (debug|info|notice|warn|error)
-        // "XUI_LOG_LEVEL": "debug"
+        "XUI_DB_FOLDER": "x-ui",
+        "XUI_LOG_FOLDER": "x-ui",
+        "XUI_BIN_FOLDER": "x-ui"
       },
       "console": "integratedTerminal"
-    }
+    },
   ]
-}
+}

+ 14 - 0
.vscode/tasks.json

@@ -70,6 +70,20 @@
       "problemMatcher": [
         "$go"
       ]
+    },
+    {
+      "label": "go: fmt",
+      "type": "shell",
+      "command": "gofmt",
+      "args": [
+        "-l",
+        "-w",
+        "."
+      ],
+      "options": {
+        "cwd": "${workspaceFolder}"
+      },
+      "problemMatcher": []
     }
   ]
 }

+ 221 - 4
CONTRIBUTING.md

@@ -1,5 +1,222 @@
-## Local Development Setup
+# Contributing
 
-- Create a directory named `x-ui` in the project root 
-- Rename `.env.example` to `.env `
-- Run `main.go`
+Thanks for taking the time to contribute to 3x-ui. This guide gets a development panel running on your machine in a few minutes.
+
+## Prerequisites
+
+- **Go 1.26+** (the version in `go.mod`)
+- **Node.js 22+** and npm (for the Vue frontend)
+- **Git**
+- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux/macOS already ship one; on Windows see below.
+
+### Windows: MinGW-w64
+
+`go build` on Windows will fail with `cgo: C compiler "gcc" not found` until you install a GCC toolchain. Two options — pick whichever fits.
+
+**Option A — standalone zip (fastest, no package manager)**
+
+1. Grab the latest build from **<https://github.com/niXman/mingw-builds-binaries/releases>**. For most setups you want a release named like:
+   ```
+   x86_64-<version>-release-posix-seh-ucrt-rt_<n>-rev<m>.7z
+   ```
+   (64-bit, POSIX threads, SEH exceptions, UCRT runtime — matches the modern Windows defaults.)
+2. Extract it somewhere stable, e.g. `C:\mingw64\`.
+3. Add `C:\mingw64\bin` to your **Windows** `PATH` (System Properties → Environment Variables → Path → New).
+4. Open a fresh terminal and confirm:
+   ```powershell
+   gcc --version
+   ```
+
+**Option B — MSYS2 (if you also want a Unix-y shell)**
+
+1. Install MSYS2 from <https://www.msys2.org/>.
+2. Open the **MSYS2 UCRT64** shell from the Start menu and update once:
+   ```bash
+   pacman -Syu
+   ```
+3. Install the UCRT64 toolchain:
+   ```bash
+   pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkg-config
+   ```
+4. Add `C:\msys64\ucrt64\bin` to your Windows `PATH`.
+5. Verify with `gcc --version` in a fresh terminal.
+
+After either, `go build ./...` and `go run .` work normally.
+
+> Why MinGW-w64 over MSVC: `mattn/go-sqlite3` officially supports GCC, builds are faster on Windows, and the toolchain doesn't lock you into a Visual Studio install. If you already have Visual Studio Build Tools installed it works too — just make sure `CC=cl` is **not** set in your environment.
+
+The Linux SQLite cross-build from Windows (or vice versa) needs an extra cross-compiler — out of scope here; build natively on the target OS.
+
+## First-time setup
+
+```bash
+git clone https://github.com/MHSanaei/3x-ui.git
+cd 3x-ui
+
+cp .env.example .env
+
+mkdir x-ui
+
+go mod download
+
+cd frontend
+npm install
+npm run build
+cd ..
+```
+
+`.env.example` ships with sane defaults that point the database, logs, and xray binary at the local `x-ui/` folder so nothing escapes the project directory:
+
+```
+XUI_DEBUG=true
+XUI_DB_FOLDER=x-ui
+XUI_LOG_FOLDER=x-ui
+XUI_BIN_FOLDER=x-ui
+```
+
+You need to drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` / `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows you also want `wintun.dll` if you plan to test TUN inbounds.
+
+## Running
+
+```bash
+go run .
+```
+
+Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `admin`. You will be prompted to change the credentials on first login.
+
+### Inside VS Code
+
+The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy from the snippet below if it is missing):
+
+```jsonc
+{
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "name": "Run 3x-ui (Debug)",
+      "type": "go",
+      "request": "launch",
+      "mode": "auto",
+      "program": "${workspaceFolder}",
+      "cwd": "${workspaceFolder}",
+      "env": {
+        "XUI_DEBUG": "true",
+        "XUI_DB_FOLDER": "x-ui",
+        "XUI_LOG_FOLDER": "x-ui",
+        "XUI_BIN_FOLDER": "x-ui"
+      },
+      "console": "integratedTerminal"
+    }
+  ]
+}
+```
+
+## Working on the frontend
+
+The panel UI is a Vue 3 + Ant Design Vue 4 app under `frontend/`. A few things worth knowing before you dive in.
+
+### Architecture in one paragraph
+
+It's a **multi-page app**, not a SPA. Every panel route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/xray`, `/panel/settings`, `/panel/sub`, `/panel/api-docs`, plus `login`) has its own HTML entry under `frontend/*.html` and its own bootstrap in `src/entries/<page>.js`. Vite builds them into `web/dist/`, and the Go binary embeds that directory at compile time with `embed.FS`. Each navigation triggers a real document load — but each page's bundle is small, so it stays snappy. There's no Vue Router and no central store; Vuex/Pinia were rejected as overkill for the panel's surface area.
+
+### State and data flow
+
+- **No global store.** State lives where it's used. Cross-page data (settings, current user, theme) is re-fetched on each page load — the backend is on the same box and responses are cheap.
+- **Composables** in `src/composables/` carry reactive logic worth sharing inside a page (theme switching, status polling, node lists). Reach for one before adding a new global.
+- **Domain classes** in `src/models/` (`Inbound`, `DBInbound`, `Outbound`, `Status`, …) own the protocol-specific logic — link generation, settings JSON shape, TLS/Reality stream handling. The Vue components stay dumb; they ask the model "what's my link?" and render the answer.
+- **HTTP** goes through `src/utils/index.js`'s `HttpUtil`, which is a thin Axios wrapper with CSRF, response toast handling, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts.
+
+### i18n
+
+Locale strings live in `web/translation/<locale>.json`, not under `frontend/`. The Go side embeds the same JSON and serves it to both backend templates and `vue-i18n`. When you add a new English key, add it to **every** non-English locale too — missing keys don't fail the build, they just render the raw key in the UI.
+
+### Two dev workflows
+
+| When you want… | Use |
+|----------------|-----|
+| To iterate on UI tweaks fast | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and `/api/*` to the Go panel on `:2053`). Start the Go panel first. |
+| To test what users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle either embedded (release mode) or from disk (debug mode). |
+
+The Vite dev proxy auto-rewrites the sidebar's production-style links (`/panel`, `/panel/inbounds`, `/panel/clients`, etc.) to the matching Vite-served HTML, so the navigation feels identical to prod without round-tripping through Go. The route allowlist lives in `MIGRATED_ROUTES` in `vite.config.js` — if you add a new page, register it there too.
+
+> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML out of the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names → blank page with 404s in the browser console. Always restart `go run .` after a frontend rebuild.
+
+### Adding a new page
+
+1. Create `frontend/<page>.html` (copy an existing one and adjust the title + the imported entry).
+2. Create `src/entries/<page>.js` — `createApp(Page).use(antd).use(i18n).mount('#app')`.
+3. Create the page component under `src/pages/<page>/<Page>.vue` (kebab-case folder, PascalCase component).
+4. Register the entry in `rollupOptions.input` inside `vite.config.js`.
+5. If the page is reachable from the sidebar at `/panel/<route>`, add `<route>` to `MIGRATED_ROUTES` so dev-mode navigation works.
+6. Wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in prod.
+
+### Conventions
+
+- **Ant Design Vue** is the only UI kit — no Tailwind, no shadcn. A previous attempt to migrate was rolled back as ugly + bloated. Small targeted UX tweaks beat sweeping rewrites; if a section *really* needs new visual language, raise it first.
+- **Composition API** (`<script setup>`) everywhere. Options API survives only in components nobody has touched yet.
+- **No `//` line comments** in committed JS/Vue. HTML `<!-- ... -->` is fine for template structure. Identifiers should carry the meaning; if you need a comment to explain *what* code does, rename the variable. Comments are for the *why* and only when surprising.
+- **Persian / Arabic users matter.** RTL is supported via `ConfigProvider` + `dir="rtl"`. When you write Persian text in toasts or labels, keep prose clean — isolate code/identifiers on their own lines so the RTL reading flows.
+- **Don't break links.** Share-link generation has two paths: the **inbounds page** (`InboundsPage.vue` → `checkFallback()`) and the **clients page** (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`). Exercise both whenever you touch URL generation, fallback projection, or TLS handling.
+
+### Project layout
+
+```
+frontend/
+├── *.html                — Vite entry HTML, one per panel route
+├── eslint.config.js      — ESLint 10 flat config (vue3-recommended)
+├── vite.config.js
+└── src/
+    ├── entries/          — per-page bootstrap (createApp + mount)
+    ├── pages/            — one folder per route (index, login, inbounds, clients, xray, settings, sub, api-docs)
+    ├── components/       — cross-page Vue components (DateTimePicker, FinalMaskForm, …)
+    ├── composables/      — reusable reactive logic (useTheme, useStatus, useNodeList, …)
+    ├── api/              — Axios setup + CSRF interceptor + WebSocket client
+    ├── i18n/             — vue-i18n bootstrap (the JSON lives in web/translation/)
+    ├── models/           — Inbound, DBInbound, Outbound, Status, reality-targets, …
+    └── utils/            — HttpUtil, ObjectUtil, LanguageManager, RandomUtil, SizeFormatter, …
+```
+
+Lint with `cd frontend && npm run lint`. The deeper reference is [`frontend/README.md`](frontend/README.md).
+
+## Project layout
+
+| Path | What lives there |
+|------|------------------|
+| `main.go` | Process entry point, CLI subcommands, signal handling |
+| `web/` | Gin HTTP server, controllers, services, embedded frontend |
+| `frontend/` | Vue 3 + Ant Design source for the panel UI |
+| `database/` | GORM models, migrations, seeders (SQLite / PostgreSQL) |
+| `xray/` | Xray-core process lifecycle + gRPC API client |
+| `sub/` | Subscription endpoints (raw, JSON, Clash) |
+| `config/` | Environment-var helpers, paths, defaults |
+| `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
+
+## Sending a pull request
+
+1. Branch off `main` (e.g. `feat/short-description`).
+2. Keep the diff focused — separate refactors from feature work.
+3. Run the relevant builds before pushing:
+   - `go build ./...`
+   - `go test ./...` (if you touched Go code)
+   - `cd frontend && npm run build` (if you touched the Vue side)
+4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*.
+5. Open the PR against `main` with a brief description of what changed and how to test it.
+
+## Useful environment variables
+
+| Variable | Default | Purpose |
+|----------|---------|---------|
+| `XUI_DEBUG` | `false` | Verbose logs + Gin debug mode + serve `/assets` from disk |
+| `XUI_LOG_LEVEL` | `info` | `debug` / `info` / `notice` / `warning` / `error` |
+| `XUI_DB_FOLDER` | platform default | Where `x-ui.db` lives |
+| `XUI_LOG_FOLDER` | platform default | Where `3xui.log` lives |
+| `XUI_BIN_FOLDER` | `bin` | Where the xray binary + geo files + xray `config.json` live |
+| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
+| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
+
+## Issues and discussion
+
+- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
+- General questions and ideas: [GitHub Discussions](https://github.com/MHSanaei/3x-ui/discussions)
+
+Before filing a bug, please include your OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.

+ 4 - 0
Dockerfile

@@ -64,6 +64,10 @@ RUN chmod +x \
   /usr/bin/x-ui
 
 ENV XUI_ENABLE_FAIL2BAN="true"
+# Database backend: set XUI_DB_TYPE=postgres and XUI_DB_DSN=postgres://... to use PostgreSQL.
+# Default (unset) is SQLite stored under /etc/x-ui.
+ENV XUI_DB_TYPE=""
+ENV XUI_DB_DSN=""
 EXPOSE 2053
 VOLUME [ "/etc/x-ui" ]
 CMD [ "./x-ui" ]

+ 32 - 0
README.md

@@ -30,6 +30,38 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
 
 For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
 
+## Database Options
+
+3X-UI supports two backends, chosen during the install:
+
+- **SQLite** (default) — a single file at `/etc/x-ui/x-ui.db`. Zero setup, ideal for small/medium deployments.
+- **PostgreSQL** — recommended for high client counts or multi-node setups. The installer can install PostgreSQL locally for you, or accept a DSN to an existing server.
+
+At runtime the backend is selected via env vars (the installer writes these to `/etc/default/x-ui` for you):
+
+```
+XUI_DB_TYPE=postgres
+XUI_DB_DSN=postgres://xui:[email protected]:5432/xui?sslmode=disable
+```
+
+### Migrating an existing SQLite install to PostgreSQL
+
+```bash
+x-ui migrate-db --dsn "postgres://xui:[email protected]:5432/xui?sslmode=disable"
+# then set XUI_DB_TYPE and XUI_DB_DSN in /etc/default/x-ui and restart:
+systemctl restart x-ui
+```
+
+The source SQLite file is left untouched; remove it manually once you have verified the new backend.
+
+### Docker
+
+The default `docker compose up -d` keeps using SQLite. To run with the bundled PostgreSQL service, uncomment the two `XUI_DB_*` env lines in `docker-compose.yml` and start with the profile:
+
+```bash
+docker compose --profile postgres up -d
+```
+
 ## A Special Thanks to
 
 - [alireza0](https://github.com/alireza0/)

+ 16 - 0
config/config.go

@@ -100,6 +100,22 @@ func GetDBPath() string {
 	return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
 }
 
+// GetDBKind returns the configured database backend: "sqlite" (default) or "postgres".
+func GetDBKind() string {
+	v := strings.ToLower(strings.TrimSpace(os.Getenv("XUI_DB_TYPE")))
+	switch v {
+	case "postgres", "postgresql", "pg":
+		return "postgres"
+	default:
+		return "sqlite"
+	}
+}
+
+// GetDBDSN returns the PostgreSQL DSN from XUI_DB_DSN. Empty for sqlite.
+func GetDBDSN() string {
+	return strings.TrimSpace(os.Getenv("XUI_DB_DSN"))
+}
+
 // GetLogFolder returns the path to the log folder based on environment variables or platform defaults.
 func GetLogFolder() string {
 	logFolderPath := os.Getenv("XUI_LOG_FOLDER")

+ 167 - 42
database/db.go

@@ -1,9 +1,10 @@
 // Package database provides database initialization, migration, and management utilities
-// for the 3x-ui panel using GORM with SQLite.
+// for the 3x-ui panel using GORM with SQLite or PostgreSQL.
 package database
 
 import (
 	"bytes"
+	"encoding/json"
 	"errors"
 	"io"
 	"log"
@@ -18,6 +19,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/util/crypto"
 	"github.com/mhsanaei/3x-ui/v3/xray"
 
+	"gorm.io/driver/postgres"
 	"gorm.io/driver/sqlite"
 	"gorm.io/gorm"
 	"gorm.io/gorm/logger"
@@ -25,6 +27,27 @@ import (
 
 var db *gorm.DB
 
+const (
+	DialectSQLite   = "sqlite"
+	DialectPostgres = "postgres"
+)
+
+// IsPostgres reports whether the active connection is a PostgreSQL backend.
+func IsPostgres() bool {
+	if db == nil {
+		return config.GetDBKind() == "postgres"
+	}
+	return db.Dialector.Name() == "postgres"
+}
+
+// Dialect returns the active GORM dialect name, or "" if the DB is not open.
+func Dialect() string {
+	if db == nil {
+		return ""
+	}
+	return db.Dialector.Name()
+}
+
 const (
 	defaultUsername = "admin"
 	defaultPassword = "admin"
@@ -42,6 +65,9 @@ func initModels() error {
 		&model.CustomGeoResource{},
 		&model.Node{},
 		&model.ApiToken{},
+		&model.ClientRecord{},
+		&model.ClientInbound{},
+		&model.InboundFallback{},
 	}
 	for _, mdl := range models {
 		if err := db.AutoMigrate(mdl); err != nil {
@@ -61,20 +87,25 @@ func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
 		return false
 	}
 	errMsg := strings.ToLower(err.Error())
-	const dupPrefix = "duplicate column name:"
-	if !strings.Contains(errMsg, dupPrefix) {
-		return false
-	}
-	idx := strings.Index(errMsg, dupPrefix)
-	if idx < 0 {
-		return false
-	}
-	col := strings.TrimSpace(errMsg[idx+len(dupPrefix):])
-	col = strings.Trim(col, "`\"[]")
-	if col == "" {
-		return false
+	// SQLite: "duplicate column name: foo"
+	// Postgres: `pq: column "foo" of relation "bar" already exists` / `sqlstate 42701`
+	const sqlitePrefix = "duplicate column name:"
+	if _, after, ok := strings.Cut(errMsg, sqlitePrefix); ok {
+		col := strings.TrimSpace(after)
+		col = strings.Trim(col, "`\"[]")
+		return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
+	}
+	if strings.Contains(errMsg, "already exists") && strings.Contains(errMsg, "column ") {
+		// Best effort: extract the column name between the first pair of double quotes.
+		if _, after, ok := strings.Cut(errMsg, "column \""); ok {
+			rest := after
+			if e := strings.Index(rest, "\""); e > 0 {
+				col := rest[:e]
+				return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
+			}
+		}
 	}
-	return db != nil && db.Migrator().HasColumn(mdl, col)
+	return false
 }
 
 // initUser creates a default admin user if the users table is empty.
@@ -157,9 +188,91 @@ func runSeeders(isUsersEmpty bool) error {
 			return err
 		}
 	}
+
+	if !slices.Contains(seedersHistory, "ClientsTable") {
+		if err := seedClientsFromInboundJSON(); err != nil {
+			return err
+		}
+	}
 	return nil
 }
 
+func seedClientsFromInboundJSON() error {
+	var inbounds []model.Inbound
+	if err := db.Find(&inbounds).Error; err != nil {
+		return err
+	}
+
+	return db.Transaction(func(tx *gorm.DB) error {
+		byEmail := map[string]*model.ClientRecord{}
+
+		for _, inbound := range inbounds {
+			if strings.TrimSpace(inbound.Settings) == "" {
+				continue
+			}
+			var settings map[string]any
+			if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
+				log.Printf("ClientsTable seed: skip inbound %d (invalid settings json): %v", inbound.Id, err)
+				continue
+			}
+			rawList, ok := settings["clients"].([]any)
+			if !ok {
+				continue
+			}
+
+			for _, raw := range rawList {
+				obj, ok := raw.(map[string]any)
+				if !ok {
+					continue
+				}
+				blob, err := json.Marshal(obj)
+				if err != nil {
+					continue
+				}
+				var c model.Client
+				if err := json.Unmarshal(blob, &c); err != nil {
+					continue
+				}
+				email := strings.TrimSpace(c.Email)
+				if email == "" {
+					continue
+				}
+				incoming := c.ToRecord()
+
+				row, dup := byEmail[email]
+				if !dup {
+					if err := tx.Create(incoming).Error; err != nil {
+						return err
+					}
+					byEmail[email] = incoming
+					row = incoming
+				} else {
+					conflicts := model.MergeClientRecord(row, incoming)
+					for _, x := range conflicts {
+						log.Printf("client merge: email=%s conflict on %s old=%v new=%v kept=%v",
+							email, x.Field, x.Old, x.New, x.Kept)
+					}
+					if err := tx.Save(row).Error; err != nil {
+						return err
+					}
+				}
+
+				link := model.ClientInbound{
+					ClientId:     row.Id,
+					InboundId:    inbound.Id,
+					FlowOverride: c.Flow,
+				}
+				if err := tx.Where("client_id = ? AND inbound_id = ?", row.Id, inbound.Id).
+					FirstOrCreate(&link).Error; err != nil {
+					return err
+				}
+			}
+		}
+
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "ClientsTable"}).Error
+	})
+}
+
 // seedApiTokens copies the legacy `apiToken` setting into the new
 // api_tokens table as a row named "default" so existing central panels
 // keep working after the upgrade. Idempotent — records itself in
@@ -195,43 +308,56 @@ func isTableEmpty(tableName string) (bool, error) {
 }
 
 // InitDB sets up the database connection, migrates models, and runs seeders.
+// When XUI_DB_TYPE=postgres, dbPath is ignored and XUI_DB_DSN is used instead.
 func InitDB(dbPath string) error {
-	dir := path.Dir(dbPath)
-	err := os.MkdirAll(dir, 0755)
-	if err != nil {
-		return err
-	}
-
 	var gormLogger logger.Interface
-
 	if config.IsDebug() {
 		gormLogger = logger.Default
 	} else {
 		gormLogger = logger.Discard
 	}
+	c := &gorm.Config{Logger: gormLogger}
 
-	c := &gorm.Config{
-		Logger: gormLogger,
-	}
-	dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
-	db, err = gorm.Open(sqlite.Open(dsn), c)
-	if err != nil {
-		return err
+	var err error
+	switch config.GetDBKind() {
+	case "postgres":
+		dsn := config.GetDBDSN()
+		if dsn == "" {
+			return errors.New("XUI_DB_TYPE=postgres but XUI_DB_DSN is empty")
+		}
+		db, err = gorm.Open(postgres.Open(dsn), c)
+		if err != nil {
+			return err
+		}
+	default:
+		dir := path.Dir(dbPath)
+		if err = os.MkdirAll(dir, 0755); err != nil {
+			return err
+		}
+		dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
+		db, err = gorm.Open(sqlite.Open(dsn), c)
+		if err != nil {
+			return err
+		}
+		sqlDB, err := db.DB()
+		if err != nil {
+			return err
+		}
+		if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
+			return err
+		}
+		if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
+			return err
+		}
+		if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
+			return err
+		}
 	}
 
 	sqlDB, err := db.DB()
 	if err != nil {
 		return err
 	}
-	if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
-		return err
-	}
-	if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
-		return err
-	}
-	if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
-		return err
-	}
 	sqlDB.SetMaxOpenConns(8)
 	sqlDB.SetMaxIdleConns(4)
 	sqlDB.SetConnMaxLifetime(time.Hour)
@@ -284,13 +410,12 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
 }
 
 // Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
+// No-op on PostgreSQL (WAL there is managed by the server).
 func Checkpoint() error {
-	// Update WAL
-	err := db.Exec("PRAGMA wal_checkpoint;").Error
-	if err != nil {
-		return err
+	if IsPostgres() {
+		return nil
 	}
-	return nil
+	return db.Exec("PRAGMA wal_checkpoint;").Error
 }
 
 // ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection

+ 26 - 0
database/dialect.go

@@ -0,0 +1,26 @@
+package database
+
+import "fmt"
+
+// JSONClientsFromInbound returns the FROM clause that yields one row per element
+// of inbounds.settings -> clients, with a column named `client.value` whose text
+// fields can be read with JSONFieldText("client.value", "<key>").
+func JSONClientsFromInbound() string {
+	if IsPostgres() {
+		return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"
+	}
+	return "FROM inbounds, JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client"
+}
+
+// JSONFieldText returns a SQL expression that extracts the textual value of <key>
+// from a JSON expression. On both backends the result is the raw (unquoted) string,
+// so callers do NOT need to trim surrounding quotes.
+func JSONFieldText(expr, key string) string {
+	if IsPostgres() {
+		return fmt.Sprintf("(%s ->> '%s')", expr, key)
+	}
+	// SQLite's JSON_EXTRACT on a text value returns the JSON-encoded form
+	// (with surrounding quotes). Wrap it in json_extract(json_quote(...)) trick
+	// is fragile; simpler: unwrap quotes with TRIM(BOTH '"').
+	return fmt.Sprintf("TRIM(JSON_EXTRACT(%s, '$.%s'), '\"')", expr, key)
+}

+ 143 - 0
database/migrate_data.go

@@ -0,0 +1,143 @@
+package database
+
+import (
+	"errors"
+	"fmt"
+	"log"
+	"os"
+	"path"
+	"reflect"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/xray"
+
+	"gorm.io/driver/postgres"
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+// migrationModels is the FK-aware order in which tables are created and copied.
+// Parents come before their children so foreign-key constraints stay satisfied
+// even when checks are not explicitly disabled.
+func migrationModels() []any {
+	return []any{
+		&model.User{},
+		&model.Setting{},
+		&model.HistoryOfSeeders{},
+		&model.CustomGeoResource{},
+		&model.Node{},
+		&model.ApiToken{},
+		&model.Inbound{},
+		&xray.ClientTraffic{},
+		&model.OutboundTraffics{},
+		&model.InboundClientIps{},
+		&model.ClientRecord{},
+		&model.ClientInbound{},
+		&model.InboundFallback{},
+	}
+}
+
+// MigrateData copies every row from the configured SQLite file at srcPath into
+// a fresh PostgreSQL database described by dstDSN. The destination tables are
+// (re)created with AutoMigrate before the copy. Source data is left untouched.
+func MigrateData(srcPath, dstDSN string) error {
+	if _, err := os.Stat(srcPath); err != nil {
+		return fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
+	}
+	if dstDSN == "" {
+		return errors.New("destination DSN is required")
+	}
+
+	if err := os.MkdirAll(path.Dir(srcPath), 0755); err != nil {
+		return err
+	}
+
+	srcDSN := srcPath + "?_journal_mode=WAL&_busy_timeout=10000"
+	src, err := gorm.Open(sqlite.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		return fmt.Errorf("open sqlite source: %w", err)
+	}
+	srcSQL, err := src.DB()
+	if err != nil {
+		return err
+	}
+	defer srcSQL.Close()
+
+	dst, err := gorm.Open(postgres.Open(dstDSN), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		return fmt.Errorf("open postgres destination: %w", err)
+	}
+	dstSQL, err := dst.DB()
+	if err != nil {
+		return err
+	}
+	defer dstSQL.Close()
+	dstSQL.SetConnMaxLifetime(time.Hour)
+
+	log.Println("Creating destination schema...")
+	for _, m := range migrationModels() {
+		if err := dst.AutoMigrate(m); err != nil {
+			return fmt.Errorf("AutoMigrate %T: %w", m, err)
+		}
+	}
+
+	totalRows := 0
+	for _, m := range migrationModels() {
+		n, err := copyTable(src, dst, m)
+		if err != nil {
+			return fmt.Errorf("copy %T: %w", m, err)
+		}
+		totalRows += n
+		log.Printf("  %-32s %d rows", reflect.TypeOf(m).Elem().Name(), n)
+	}
+
+	if err := resetPostgresSequences(dst); err != nil {
+		log.Printf("warning: failed to reset some postgres sequences: %v", err)
+	}
+
+	log.Printf("Migration complete: %d rows across %d tables.", totalRows, len(migrationModels()))
+	log.Println("Set XUI_DB_TYPE=postgres and XUI_DB_DSN=... in /etc/default/x-ui, then restart x-ui.")
+	return nil
+}
+
+// copyTable streams every row of `mdl` from src to dst in batches.
+func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
+	sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem()))
+	batchPtr := reflect.New(sliceType)
+	batchPtr.Elem().Set(reflect.MakeSlice(sliceType, 0, 0))
+
+	total := 0
+	err := src.Model(mdl).FindInBatches(batchPtr.Interface(), 500, func(tx *gorm.DB, _ int) error {
+		batch := batchPtr.Elem()
+		if batch.Len() == 0 {
+			return nil
+		}
+		if err := dst.CreateInBatches(batchPtr.Interface(), 200).Error; err != nil {
+			return err
+		}
+		total += batch.Len()
+		return nil
+	}).Error
+	return total, err
+}
+
+// resetPostgresSequences advances each table's id sequence past MAX(id),
+// otherwise the next INSERT-without-id would clash with copied rows.
+func resetPostgresSequences(dst *gorm.DB) error {
+	tables := []string{
+		"users", "inbounds", "outbound_traffics", "settings", "inbound_client_ips",
+		"client_traffics", "history_of_seeders", "custom_geo_resources", "nodes",
+		"api_tokens", "client_records", "client_inbounds", "inbound_fallback_children",
+	}
+	for _, t := range tables {
+		// setval is a no-op if the table or its id sequence doesn't exist; we ignore errors per-table.
+		_ = dst.Exec(fmt.Sprintf(
+			`SELECT setval(pg_get_serial_sequence('%s','id'), COALESCE((SELECT MAX(id) FROM "%s"), 1), true)
+			 WHERE pg_get_serial_sequence('%s','id') IS NOT NULL`,
+			t, t, t,
+		)).Error
+	}
+	return nil
+}

+ 399 - 14
database/model/model.go

@@ -2,7 +2,10 @@
 package model
 
 import (
+	"bytes"
+	"encoding/json"
 	"fmt"
+	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/util/json_util"
 	"github.com/mhsanaei/3x-ui/v3/xray"
@@ -13,16 +16,16 @@ type Protocol string
 
 // Protocol constants for different Xray inbound protocols
 const (
-	VMESS       Protocol = "vmess"
-	VLESS       Protocol = "vless"
-	Tunnel      Protocol = "tunnel"
-	HTTP        Protocol = "http"
-	Trojan      Protocol = "trojan"
-	Shadowsocks Protocol = "shadowsocks"
-	Mixed       Protocol = "mixed"
-	WireGuard   Protocol = "wireguard"
-	Hysteria    Protocol = "hysteria"
-	Hysteria2   Protocol = "hysteria2"
+	VMESS        Protocol = "vmess"
+	VLESS        Protocol = "vless"
+	Tunnel       Protocol = "tunnel"
+	HTTP         Protocol = "http"
+	Trojan       Protocol = "trojan"
+	Shadowsocks  Protocol = "shadowsocks"
+	Mixed        Protocol = "mixed"
+	WireGuard    Protocol = "wireguard"
+	Hysteria     Protocol = "hysteria"
+	Hysteria2    Protocol = "hysteria2"
 )
 
 // IsHysteria returns true for both "hysteria" and "hysteria2".
@@ -47,7 +50,6 @@ type Inbound struct {
 	Up                   int64                `json:"up" form:"up"`                                                                                    // Upload traffic in bytes
 	Down                 int64                `json:"down" form:"down"`                                                                                // Download traffic in bytes
 	Total                int64                `json:"total" form:"total"`                                                                              // Total traffic limit in bytes
-	AllTime              int64                `json:"allTime" form:"allTime" gorm:"default:0"`                                                         // All-time traffic usage
 	Remark               string               `json:"remark" form:"remark"`                                                                            // Human-readable remark
 	Enable               bool                 `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"`                           // Whether the inbound is enabled
 	ExpiryTime           int64                `json:"expiryTime" form:"expiryTime"`                                                                    // Expiration timestamp
@@ -64,6 +66,23 @@ type Inbound struct {
 	Tag            string   `json:"tag" form:"tag" gorm:"unique"`
 	Sniffing       string   `json:"sniffing" form:"sniffing"`
 	NodeID         *int     `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
+
+	// FallbackParent is populated by the API layer when this inbound is
+	// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
+	// The frontend uses it to rewrite client-share links so they advertise
+	// the master's externally reachable endpoint instead of the child's
+	// loopback listen. Not persisted.
+	FallbackParent *FallbackParentInfo `json:"fallbackParent,omitempty" gorm:"-"`
+}
+
+// FallbackParentInfo carries everything the frontend needs to rewrite a
+// child inbound's client link: where to connect (the master's address
+// and port) and which path matched on the master's fallbacks array.
+// The frontend already has the master inbound in its dbInbounds list,
+// so we only ship identifiers + the match path here.
+type FallbackParentInfo struct {
+	MasterId int    `json:"masterId"`
+	Path     string `json:"path,omitempty"`
 }
 
 // OutboundTraffics tracks traffic statistics for Xray outbound connections.
@@ -82,6 +101,38 @@ type InboundClientIps struct {
 	Ips         string `json:"ips" form:"ips"`
 }
 
+// MarshalJSON emits the Ips column as a real JSON array instead of an escaped
+// JSON-text string. Empty or unparseable storage renders as null so API
+// consumers don't have to special-case the legacy double-encoded shape.
+func (ic InboundClientIps) MarshalJSON() ([]byte, error) {
+	type alias InboundClientIps
+	return json.Marshal(struct {
+		alias
+		Ips json.RawMessage `json:"ips"`
+	}{
+		alias: alias(ic),
+		Ips:   jsonStringFieldToRaw(ic.Ips),
+	})
+}
+
+// UnmarshalJSON accepts ips as either a JSON array (modern shape) or a
+// JSON-encoded string (legacy shape), normalising back to the JSON-text the
+// column stores.
+func (ic *InboundClientIps) UnmarshalJSON(data []byte) error {
+	type alias InboundClientIps
+	aux := struct {
+		*alias
+		Ips json.RawMessage `json:"ips"`
+	}{
+		alias: (*alias)(ic),
+	}
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+	ic.Ips = jsonStringFieldFromRaw(aux.Ips)
+	return nil
+}
+
 // HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.
 type HistoryOfSeeders struct {
 	Id         int    `json:"id" gorm:"primaryKey;autoIncrement"`
@@ -96,19 +147,86 @@ type ApiToken struct {
 	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime"`
 }
 
+// MarshalJSON emits settings, streamSettings, and sniffing as nested JSON
+// objects rather than escaped strings, so API consumers don't need to JSON.parse
+// a string inside a string. Empty fields render as null; fields whose stored
+// text isn't valid JSON fall back to a JSON-encoded string so no data is lost.
+func (i Inbound) MarshalJSON() ([]byte, error) {
+	type alias Inbound
+	return json.Marshal(struct {
+		alias
+		Settings       json.RawMessage `json:"settings"`
+		StreamSettings json.RawMessage `json:"streamSettings"`
+		Sniffing       json.RawMessage `json:"sniffing"`
+	}{
+		alias:          alias(i),
+		Settings:       jsonStringFieldToRaw(i.Settings),
+		StreamSettings: jsonStringFieldToRaw(i.StreamSettings),
+		Sniffing:       jsonStringFieldToRaw(i.Sniffing),
+	})
+}
+
+// UnmarshalJSON accepts settings, streamSettings, and sniffing as either a raw
+// JSON object/array (the modern shape MarshalJSON emits) or a JSON-encoded
+// string (the legacy shape). Either form is normalised back to the JSON-text
+// string the DB column stores.
+func (i *Inbound) UnmarshalJSON(data []byte) error {
+	type alias Inbound
+	aux := struct {
+		*alias
+		Settings       json.RawMessage `json:"settings"`
+		StreamSettings json.RawMessage `json:"streamSettings"`
+		Sniffing       json.RawMessage `json:"sniffing"`
+	}{
+		alias: (*alias)(i),
+	}
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+	i.Settings = jsonStringFieldFromRaw(aux.Settings)
+	i.StreamSettings = jsonStringFieldFromRaw(aux.StreamSettings)
+	i.Sniffing = jsonStringFieldFromRaw(aux.Sniffing)
+	return nil
+}
+
+func jsonStringFieldToRaw(s string) json.RawMessage {
+	trimmed := strings.TrimSpace(s)
+	if trimmed == "" {
+		return json.RawMessage("null")
+	}
+	if json.Valid([]byte(trimmed)) {
+		return json.RawMessage(trimmed)
+	}
+	b, _ := json.Marshal(s)
+	return b
+}
+
+func jsonStringFieldFromRaw(r json.RawMessage) string {
+	trimmed := bytes.TrimSpace(r)
+	if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
+		return ""
+	}
+	if trimmed[0] == '"' {
+		var s string
+		if err := json.Unmarshal(trimmed, &s); err == nil {
+			return s
+		}
+	}
+	return string(trimmed)
+}
+
 // GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
 func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
 	listen := i.Listen
-	// Default to 0.0.0.0 (all interfaces) when listen is empty
-	// This ensures proper dual-stack IPv4/IPv6 binding in systems where bindv6only=0
 	if listen == "" {
 		listen = "0.0.0.0"
 	}
 	listen = fmt.Sprintf("\"%v\"", listen)
+	protocol := string(i.Protocol)
 	return &xray.InboundConfig{
 		Listen:         json_util.RawMessage(listen),
 		Port:           i.Port,
-		Protocol:       string(i.Protocol),
+		Protocol:       protocol,
 		Settings:       json_util.RawMessage(i.Settings),
 		StreamSettings: json_util.RawMessage(i.StreamSettings),
 		Tag:            i.Tag,
@@ -146,11 +264,17 @@ type Node struct {
 	LastHeartbeat int64   `json:"lastHeartbeat"`                 // unix seconds, 0 = never
 	LatencyMs     int     `json:"latencyMs"`
 	XrayVersion   string  `json:"xrayVersion"`
+	PanelVersion  string  `json:"panelVersion" gorm:"column:panel_version"`
 	CpuPct        float64 `json:"cpuPct"`
 	MemPct        float64 `json:"memPct"`
 	UptimeSecs    uint64  `json:"uptimeSecs"`
 	LastError     string  `json:"lastError"`
 
+	InboundCount  int `json:"inboundCount" gorm:"-"`
+	ClientCount   int `json:"clientCount" gorm:"-"`
+	OnlineCount   int `json:"onlineCount" gorm:"-"`
+	DepletedCount int `json:"depletedCount" gorm:"-"`
+
 	CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
 	UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
 }
@@ -191,3 +315,264 @@ type Client struct {
 	CreatedAt  int64          `json:"created_at,omitempty"`         // Creation timestamp
 	UpdatedAt  int64          `json:"updated_at,omitempty"`         // Last update timestamp
 }
+
+type ClientRecord struct {
+	Id         int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	Email      string `json:"email" gorm:"uniqueIndex;not null"`
+	SubID      string `json:"subId" gorm:"index;column:sub_id"`
+	UUID       string `json:"uuid" gorm:"column:uuid"`
+	Password   string `json:"password"`
+	Auth       string `json:"auth"`
+	Flow       string `json:"flow"`
+	Security   string `json:"security"`
+	Reverse    string `json:"reverse" gorm:"column:reverse"`
+	LimitIP    int    `json:"limitIp" gorm:"column:limit_ip"`
+	TotalGB    int64  `json:"totalGB" gorm:"column:total_gb"`
+	ExpiryTime int64  `json:"expiryTime" gorm:"column:expiry_time"`
+	Enable     bool   `json:"enable" gorm:"default:true"`
+	TgID       int64  `json:"tgId" gorm:"column:tg_id"`
+	Comment    string `json:"comment"`
+	Reset      int    `json:"reset" gorm:"default:0"`
+	CreatedAt  int64  `json:"createdAt" gorm:"autoCreateTime"`
+	UpdatedAt  int64  `json:"updatedAt" gorm:"autoUpdateTime"`
+}
+
+func (ClientRecord) TableName() string { return "clients" }
+
+// MarshalJSON emits the reverse column as a nested JSON object rather than an
+// escaped JSON-text string, matching the same convention Inbound uses for its
+// JSON-text columns. Empty storage renders as null.
+func (r ClientRecord) MarshalJSON() ([]byte, error) {
+	type alias ClientRecord
+	return json.Marshal(struct {
+		alias
+		Reverse json.RawMessage `json:"reverse"`
+	}{
+		alias:   alias(r),
+		Reverse: jsonStringFieldToRaw(r.Reverse),
+	})
+}
+
+// UnmarshalJSON accepts reverse as either a JSON object (modern shape) or a
+// JSON-encoded string (legacy shape).
+func (r *ClientRecord) UnmarshalJSON(data []byte) error {
+	type alias ClientRecord
+	aux := struct {
+		*alias
+		Reverse json.RawMessage `json:"reverse"`
+	}{
+		alias: (*alias)(r),
+	}
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+	r.Reverse = jsonStringFieldFromRaw(aux.Reverse)
+	return nil
+}
+
+type ClientInbound struct {
+	ClientId     int    `json:"clientId" gorm:"primaryKey;column:client_id;index"`
+	InboundId    int    `json:"inboundId" gorm:"primaryKey;column:inbound_id;index"`
+	FlowOverride string `json:"flowOverride" gorm:"column:flow_override"`
+	CreatedAt    int64  `json:"createdAt" gorm:"autoCreateTime"`
+}
+
+func (ClientInbound) TableName() string { return "client_inbounds" }
+
+// InboundFallback is one routing rule on a master inbound's
+// settings.fallbacks array. The master is always a VLESS or Trojan
+// inbound on TCP transport with TLS or Reality. The child is any other
+// inbound — its listen+port becomes the fallback dest, with optional
+// SNI/ALPN/path match criteria pulled from the same row.
+type InboundFallback struct {
+	Id        int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	MasterId  int    `json:"masterId" gorm:"index;not null;column:master_id"`
+	ChildId   int    `json:"childId" gorm:"index;not null;column:child_id"`
+	Name      string `json:"name"`
+	Alpn      string `json:"alpn"`
+	Path      string `json:"path"`
+	Xver      int    `json:"xver"`
+	SortOrder int    `json:"sortOrder" gorm:"default:0;column:sort_order"`
+}
+
+func (InboundFallback) TableName() string { return "inbound_fallbacks" }
+
+func (c *Client) ToRecord() *ClientRecord {
+	rec := &ClientRecord{
+		Email:      c.Email,
+		SubID:      c.SubID,
+		UUID:       c.ID,
+		Password:   c.Password,
+		Auth:       c.Auth,
+		Flow:       c.Flow,
+		Security:   c.Security,
+		LimitIP:    c.LimitIP,
+		TotalGB:    c.TotalGB,
+		ExpiryTime: c.ExpiryTime,
+		Enable:     c.Enable,
+		TgID:       c.TgID,
+		Comment:    c.Comment,
+		Reset:      c.Reset,
+		CreatedAt:  c.CreatedAt,
+		UpdatedAt:  c.UpdatedAt,
+	}
+	if c.Reverse != nil {
+		if b, err := json.Marshal(c.Reverse); err == nil {
+			rec.Reverse = string(b)
+		}
+	}
+	return rec
+}
+
+func (r *ClientRecord) ToClient() *Client {
+	c := &Client{
+		ID:         r.UUID,
+		Email:      r.Email,
+		SubID:      r.SubID,
+		Password:   r.Password,
+		Auth:       r.Auth,
+		Flow:       r.Flow,
+		Security:   r.Security,
+		LimitIP:    r.LimitIP,
+		TotalGB:    r.TotalGB,
+		ExpiryTime: r.ExpiryTime,
+		Enable:     r.Enable,
+		TgID:       r.TgID,
+		Comment:    r.Comment,
+		Reset:      r.Reset,
+		CreatedAt:  r.CreatedAt,
+		UpdatedAt:  r.UpdatedAt,
+	}
+	if r.Reverse != "" {
+		var rev ClientReverse
+		if err := json.Unmarshal([]byte(r.Reverse), &rev); err == nil {
+			c.Reverse = &rev
+		}
+	}
+	return c
+}
+
+type ClientMergeConflict struct {
+	Field string
+	Old   any
+	New   any
+	Kept  any
+}
+
+func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
+	var conflicts []ClientMergeConflict
+	keep := func(field string, oldV, newV, kept any) {
+		conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: oldV, New: newV, Kept: kept})
+	}
+
+	incomingNewer := incoming.UpdatedAt > existing.UpdatedAt ||
+		(incoming.UpdatedAt == existing.UpdatedAt && incoming.CreatedAt > existing.CreatedAt)
+
+	if existing.UUID != incoming.UUID && incoming.UUID != "" {
+		if incomingNewer || existing.UUID == "" {
+			keep("uuid", existing.UUID, incoming.UUID, incoming.UUID)
+			existing.UUID = incoming.UUID
+		} else {
+			keep("uuid", existing.UUID, incoming.UUID, existing.UUID)
+		}
+	}
+	if existing.Password != incoming.Password && incoming.Password != "" {
+		if incomingNewer || existing.Password == "" {
+			keep("password", existing.Password, incoming.Password, incoming.Password)
+			existing.Password = incoming.Password
+		}
+	}
+	if existing.Auth != incoming.Auth && incoming.Auth != "" {
+		if incomingNewer || existing.Auth == "" {
+			keep("auth", existing.Auth, incoming.Auth, incoming.Auth)
+			existing.Auth = incoming.Auth
+		}
+	}
+	if existing.Flow != incoming.Flow && incoming.Flow != "" {
+		if incomingNewer || existing.Flow == "" {
+			keep("flow", existing.Flow, incoming.Flow, incoming.Flow)
+			existing.Flow = incoming.Flow
+		}
+	}
+	if existing.Security != incoming.Security && incoming.Security != "" {
+		if incomingNewer || existing.Security == "" {
+			keep("security", existing.Security, incoming.Security, incoming.Security)
+			existing.Security = incoming.Security
+		}
+	}
+	if existing.SubID != incoming.SubID && incoming.SubID != "" {
+		if incomingNewer || existing.SubID == "" {
+			keep("subId", existing.SubID, incoming.SubID, incoming.SubID)
+			existing.SubID = incoming.SubID
+		}
+	}
+	if existing.TotalGB != incoming.TotalGB {
+		picked := existing.TotalGB
+		if existing.TotalGB == 0 || (incoming.TotalGB != 0 && incoming.TotalGB > existing.TotalGB) {
+			picked = incoming.TotalGB
+		}
+		if picked != existing.TotalGB {
+			keep("totalGB", existing.TotalGB, incoming.TotalGB, picked)
+			existing.TotalGB = picked
+		}
+	}
+	if existing.ExpiryTime != incoming.ExpiryTime {
+		picked := existing.ExpiryTime
+		if existing.ExpiryTime == 0 || (incoming.ExpiryTime != 0 && incoming.ExpiryTime > existing.ExpiryTime) {
+			picked = incoming.ExpiryTime
+		}
+		if picked != existing.ExpiryTime {
+			keep("expiryTime", existing.ExpiryTime, incoming.ExpiryTime, picked)
+			existing.ExpiryTime = picked
+		}
+	}
+	if existing.LimitIP != incoming.LimitIP && incoming.LimitIP != 0 {
+		picked := existing.LimitIP
+		if existing.LimitIP == 0 || incoming.LimitIP > existing.LimitIP {
+			picked = incoming.LimitIP
+		}
+		if picked != existing.LimitIP {
+			keep("limitIp", existing.LimitIP, incoming.LimitIP, picked)
+			existing.LimitIP = picked
+		}
+	}
+	if existing.TgID != incoming.TgID && incoming.TgID != 0 {
+		if incomingNewer || existing.TgID == 0 {
+			keep("tgId", existing.TgID, incoming.TgID, incoming.TgID)
+			existing.TgID = incoming.TgID
+		}
+	}
+	if existing.Reset != incoming.Reset && incoming.Reset != 0 {
+		if incomingNewer || existing.Reset == 0 {
+			keep("reset", existing.Reset, incoming.Reset, incoming.Reset)
+			existing.Reset = incoming.Reset
+		}
+	}
+	if existing.Reverse != incoming.Reverse && incoming.Reverse != "" {
+		if incomingNewer || existing.Reverse == "" {
+			keep("reverse", existing.Reverse, incoming.Reverse, incoming.Reverse)
+			existing.Reverse = incoming.Reverse
+		}
+	}
+	if existing.Comment != incoming.Comment && incoming.Comment != "" {
+		if incomingNewer || existing.Comment == "" {
+			keep("comment", existing.Comment, incoming.Comment, incoming.Comment)
+			existing.Comment = incoming.Comment
+		}
+	}
+	if existing.Enable != incoming.Enable {
+		if incoming.Enable {
+			if !existing.Enable {
+				keep("enable", existing.Enable, incoming.Enable, true)
+				existing.Enable = true
+			}
+		}
+	}
+	if incoming.CreatedAt != 0 && (existing.CreatedAt == 0 || incoming.CreatedAt < existing.CreatedAt) {
+		existing.CreatedAt = incoming.CreatedAt
+	}
+	if incoming.UpdatedAt > existing.UpdatedAt {
+		existing.UpdatedAt = incoming.UpdatedAt
+	}
+	return conflicts
+}

+ 188 - 1
database/model/model_test.go

@@ -1,6 +1,193 @@
 package model
 
-import "testing"
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+)
+
+func TestInboundMarshalJSONNestsObjectFields(t *testing.T) {
+	in := Inbound{
+		Id:             7,
+		Protocol:       VLESS,
+		Port:           443,
+		Settings:       `{"clients":[],"decryption":"none"}`,
+		StreamSettings: `{"network":"tcp"}`,
+		Sniffing:       `{"enabled":true}`,
+	}
+	out, err := json.Marshal(in)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatalf("output is not valid JSON: %v", err)
+	}
+	for _, field := range []string{"settings", "streamSettings", "sniffing"} {
+		if _, ok := parsed[field].(map[string]any); !ok {
+			t.Errorf("expected %s to marshal as a JSON object, got %T", field, parsed[field])
+		}
+	}
+	if strings.Contains(string(out), `"settings":"`) {
+		t.Errorf("settings should not be emitted as a JSON string: %s", out)
+	}
+}
+
+func TestInboundMarshalJSONEmptyFieldsBecomeNull(t *testing.T) {
+	in := Inbound{Id: 1, Protocol: VLESS}
+	out, err := json.Marshal(in)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatalf("output is not valid JSON: %v", err)
+	}
+	for _, field := range []string{"settings", "streamSettings", "sniffing"} {
+		if parsed[field] != nil {
+			t.Errorf("expected %s to be null, got %v", field, parsed[field])
+		}
+	}
+}
+
+func TestInboundUnmarshalJSONAcceptsBothShapes(t *testing.T) {
+	cases := []struct {
+		name string
+		body string
+	}{
+		{
+			name: "nested objects (modern)",
+			body: `{"id":1,"settings":{"clients":[],"decryption":"none"},"streamSettings":{"network":"tcp"},"sniffing":{"enabled":true}}`,
+		},
+		{
+			name: "JSON-encoded strings (legacy)",
+			body: `{"id":1,"settings":"{\"clients\":[],\"decryption\":\"none\"}","streamSettings":"{\"network\":\"tcp\"}","sniffing":"{\"enabled\":true}"}`,
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			var in Inbound
+			if err := json.Unmarshal([]byte(tc.body), &in); err != nil {
+				t.Fatalf("Unmarshal failed: %v", err)
+			}
+			if !strings.Contains(in.Settings, `"decryption":"none"`) {
+				t.Errorf("Settings not normalised: %q", in.Settings)
+			}
+			if !strings.Contains(in.StreamSettings, `"network":"tcp"`) {
+				t.Errorf("StreamSettings not normalised: %q", in.StreamSettings)
+			}
+			if !strings.Contains(in.Sniffing, `"enabled":true`) {
+				t.Errorf("Sniffing not normalised: %q", in.Sniffing)
+			}
+		})
+	}
+}
+
+func TestInboundMarshalJSONInvalidTextFallsBackToString(t *testing.T) {
+	in := Inbound{Id: 1, Settings: "not json at all"}
+	out, err := json.Marshal(in)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	if !strings.Contains(string(out), `"settings":"not json at all"`) {
+		t.Errorf("expected invalid settings text to be wrapped as a JSON string, got %s", out)
+	}
+}
+
+func TestClientRecordMarshalJSONNestsReverse(t *testing.T) {
+	rec := ClientRecord{Id: 1, Email: "[email protected]", Reverse: `{"tag":"vless-in"}`}
+	out, err := json.Marshal(rec)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatalf("output is not valid JSON: %v", err)
+	}
+	obj, ok := parsed["reverse"].(map[string]any)
+	if !ok {
+		t.Fatalf("expected reverse to marshal as a JSON object, got %T", parsed["reverse"])
+	}
+	if obj["tag"] != "vless-in" {
+		t.Errorf("expected tag to be preserved, got %v", obj["tag"])
+	}
+}
+
+func TestClientRecordMarshalJSONEmptyReverseIsNull(t *testing.T) {
+	rec := ClientRecord{Id: 1, Email: "[email protected]"}
+	out, err := json.Marshal(rec)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatalf("output is not valid JSON: %v", err)
+	}
+	if parsed["reverse"] != nil {
+		t.Errorf("expected reverse to be null, got %v", parsed["reverse"])
+	}
+}
+
+func TestClientRecordUnmarshalJSONAcceptsBothShapes(t *testing.T) {
+	cases := []struct {
+		name string
+		body string
+	}{
+		{name: "nested object", body: `{"id":1,"reverse":{"tag":"vless-in"}}`},
+		{name: "legacy string", body: `{"id":1,"reverse":"{\"tag\":\"vless-in\"}"}`},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			var rec ClientRecord
+			if err := json.Unmarshal([]byte(tc.body), &rec); err != nil {
+				t.Fatalf("Unmarshal failed: %v", err)
+			}
+			if !strings.Contains(rec.Reverse, `"tag":"vless-in"`) {
+				t.Errorf("Reverse not normalised: %q", rec.Reverse)
+			}
+		})
+	}
+}
+
+func TestInboundClientIpsMarshalJSONNestsArray(t *testing.T) {
+	row := InboundClientIps{Id: 1, ClientEmail: "[email protected]", Ips: `[{"ip":"1.2.3.4","timestamp":1700000000}]`}
+	out, err := json.Marshal(row)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatalf("output is not valid JSON: %v", err)
+	}
+	arr, ok := parsed["ips"].([]any)
+	if !ok {
+		t.Fatalf("expected ips to marshal as a JSON array, got %T", parsed["ips"])
+	}
+	if len(arr) != 1 {
+		t.Errorf("expected 1 entry, got %d", len(arr))
+	}
+}
+
+func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) {
+	cases := []struct {
+		name string
+		body string
+	}{
+		{name: "nested array", body: `{"id":1,"ips":[{"ip":"1.2.3.4","timestamp":1}]}`},
+		{name: "legacy string", body: `{"id":1,"ips":"[{\"ip\":\"1.2.3.4\",\"timestamp\":1}]"}`},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			var row InboundClientIps
+			if err := json.Unmarshal([]byte(tc.body), &row); err != nil {
+				t.Fatalf("Unmarshal failed: %v", err)
+			}
+			if !strings.Contains(row.Ips, `"ip":"1.2.3.4"`) {
+				t.Errorf("Ips not normalised: %q", row.Ips)
+			}
+		})
+	}
+}
 
 func TestIsHysteria(t *testing.T) {
 	cases := []struct {

+ 17 - 0
docker-compose.yml

@@ -11,7 +11,24 @@ services:
     environment:
       XRAY_VMESS_AEAD_FORCED: "false"
       XUI_ENABLE_FAIL2BAN: "true"
+      # To use PostgreSQL instead of the default SQLite, run:
+      #   docker compose --profile postgres up -d
+      # and uncomment the two lines below.
+      # XUI_DB_TYPE: "postgres"
+      # XUI_DB_DSN: "postgres://xui:xui@postgres:5432/xui?sslmode=disable"
     tty: true
     ports:
       - "2053:2053"
+    restart: unless-stopped
+
+  postgres:
+    image: postgres:16-alpine
+    container_name: 3xui_postgres
+    profiles: ["postgres"]
+    environment:
+      POSTGRES_USER: xui
+      POSTGRES_PASSWORD: xui
+      POSTGRES_DB: xui
+    volumes:
+      - $PWD/pgdata/:/var/lib/postgresql/data
     restart: unless-stopped

+ 13 - 0
frontend/clients.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Clients</title>
+  </head>
+  <body>
+    <div id="message"></div>
+    <div id="app"></div>
+    <script type="module" src="/src/entries/clients.js"></script>
+  </body>
+</html>

+ 46 - 53
frontend/package-lock.json

@@ -334,9 +334,9 @@
       }
     },
     "node_modules/@eslint/config-helpers": {
-      "version": "0.5.5",
-      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz",
-      "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==",
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
+      "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
@@ -471,61 +471,61 @@
       }
     },
     "node_modules/@intlify/core-base": {
-      "version": "11.4.2",
-      "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.2.tgz",
-      "integrity": "sha512-7fpuCcVmeLv2T9qHsARqGvh8xt+sV2fH+Q+gMHFwB/rPXzo85DpbJFKn7dBH1L5p0c2cSh2DW+2h/64EKrISmA==",
+      "version": "11.4.4",
+      "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.4.tgz",
+      "integrity": "sha512-w/vItlylrAmhebkIbVl5YY8XMCtj8Mb2g70ttxktMYuf5AuRahgEHL2iLgLIsZBIbTSgs4hkUo7ucCL0uTJvOg==",
       "license": "MIT",
       "dependencies": {
-        "@intlify/devtools-types": "11.4.2",
-        "@intlify/message-compiler": "11.4.2",
-        "@intlify/shared": "11.4.2"
+        "@intlify/devtools-types": "11.4.4",
+        "@intlify/message-compiler": "11.4.4",
+        "@intlify/shared": "11.4.4"
       },
       "engines": {
-        "node": ">= 16"
+        "node": ">= 22"
       },
       "funding": {
         "url": "https://github.com/sponsors/kazupon"
       }
     },
     "node_modules/@intlify/devtools-types": {
-      "version": "11.4.2",
-      "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.2.tgz",
-      "integrity": "sha512-3u8EN1kB6EMSi96KXs5k7a8y2X2g4+h3X6iwVZU47cP4n+mTuq//WMjG588BzSp/2XQ/dTXo2BLUXX+XS+PNfA==",
+      "version": "11.4.4",
+      "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.4.tgz",
+      "integrity": "sha512-PcBLmGmDQsTSVV911P8upzpcLJO1CNVYi/IH6bGnLR2nA+0L963+kXN1ZrisTEnbtw2ewN6HMMSldqzjronA0Q==",
       "license": "MIT",
       "dependencies": {
-        "@intlify/core-base": "11.4.2",
-        "@intlify/shared": "11.4.2"
+        "@intlify/core-base": "11.4.4",
+        "@intlify/shared": "11.4.4"
       },
       "engines": {
-        "node": ">= 16"
+        "node": ">= 22"
       },
       "funding": {
         "url": "https://github.com/sponsors/kazupon"
       }
     },
     "node_modules/@intlify/message-compiler": {
-      "version": "11.4.2",
-      "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.2.tgz",
-      "integrity": "sha512-a6CDSGSMTGrg0BjD97x8TBYPf7qQMDlZipJ6UDfv/pd4OIym8TMlHu3MsH0bTNnRdAG2D6EFEykIgiQPqvtTkA==",
+      "version": "11.4.4",
+      "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.4.tgz",
+      "integrity": "sha512-vn0OAV9pYkJlPPmgnsSm5eAG3mL0+9C/oaded2JY9jmxBbhmUXT3TcAUY8WRgLY9Hte7lkUJKpXrVlYjMXBD2w==",
       "license": "MIT",
       "dependencies": {
-        "@intlify/shared": "11.4.2",
+        "@intlify/shared": "11.4.4",
         "source-map-js": "^1.0.2"
       },
       "engines": {
-        "node": ">= 16"
+        "node": ">= 22"
       },
       "funding": {
         "url": "https://github.com/sponsors/kazupon"
       }
     },
     "node_modules/@intlify/shared": {
-      "version": "11.4.2",
-      "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.2.tgz",
-      "integrity": "sha512-NzpHbguRCsOHDwxmlBa9qu/imc+/QWgsYUaK6FZeNC0wK8QfAbhqrktEp/haVzxU1aikH8IX4ytD+mfFEMi/9A==",
+      "version": "11.4.4",
+      "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.4.tgz",
+      "integrity": "sha512-QRUCHqda1U6aR14FR0vvXD4+4gj6+fm0AhAozvSuRCw0fCvrmCugWpgiR4xH2NI6s8am6N9p5OhirplsX8ZS3g==",
       "license": "MIT",
       "engines": {
-        "node": ">= 16"
+        "node": ">= 22"
       },
       "funding": {
         "url": "https://github.com/sponsors/kazupon"
@@ -895,9 +895,9 @@
       }
     },
     "node_modules/@rolldown/pluginutils": {
-      "version": "1.0.0-rc.13",
-      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
-      "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
       "dev": true,
       "license": "MIT"
     },
@@ -944,13 +944,13 @@
       "license": "MIT"
     },
     "node_modules/@vitejs/plugin-vue": {
-      "version": "6.0.6",
-      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
-      "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
+      "version": "6.0.7",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
+      "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@rolldown/pluginutils": "1.0.0-rc.13"
+        "@rolldown/pluginutils": "^1.0.1"
       },
       "engines": {
         "node": "^20.19.0 || >=22.12.0"
@@ -1477,16 +1477,16 @@
       }
     },
     "node_modules/eslint": {
-      "version": "10.3.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz",
-      "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==",
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz",
+      "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.8.0",
         "@eslint-community/regexpp": "^4.12.2",
         "@eslint/config-array": "^0.23.5",
-        "@eslint/config-helpers": "^0.5.5",
+        "@eslint/config-helpers": "^0.6.0",
         "@eslint/core": "^1.2.1",
         "@eslint/plugin-kit": "^0.7.1",
         "@humanfs/node": "^0.16.6",
@@ -2694,9 +2694,9 @@
       }
     },
     "node_modules/qs": {
-      "version": "6.15.1",
-      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
-      "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+      "version": "6.15.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+      "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
       "license": "BSD-3-Clause",
       "dependencies": {
         "side-channel": "^1.1.0"
@@ -2748,13 +2748,6 @@
         "@rolldown/binding-win32-x64-msvc": "1.0.1"
       }
     },
-    "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
-      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/scroll-into-view-if-needed": {
       "version": "2.2.31",
       "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
@@ -3087,18 +3080,18 @@
       }
     },
     "node_modules/vue-i18n": {
-      "version": "11.4.2",
-      "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.2.tgz",
-      "integrity": "sha512-sADDeKXqAGsPX6tK3t3y2ZiMpbVWN12tG+MhTiJ06rVoh58eGtM4wFyw3uWGbVkXByVp9Ne/AP+nSSzI+J9OAQ==",
+      "version": "11.4.4",
+      "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.4.tgz",
+      "integrity": "sha512-gIbXVSFQV4jcSJxfwdZ5zSZmZ+12CnX0K3vBkRSd6Zn+HSzCp+QwUgPwpD/uN0oKNKI9RzlUXPKVedEuMgNG0A==",
       "license": "MIT",
       "dependencies": {
-        "@intlify/core-base": "11.4.2",
-        "@intlify/devtools-types": "11.4.2",
-        "@intlify/shared": "11.4.2",
+        "@intlify/core-base": "11.4.4",
+        "@intlify/devtools-types": "11.4.4",
+        "@intlify/shared": "11.4.4",
         "@vue/devtools-api": "^6.5.0"
       },
       "engines": {
-        "node": ">= 16"
+        "node": ">= 22"
       },
       "funding": {
         "url": "https://github.com/sponsors/kazupon"

+ 16 - 4
frontend/src/api/axios-init.js

@@ -76,7 +76,14 @@ export function setupAxios() {
       if (config.data instanceof FormData) {
         config.headers['Content-Type'] = 'multipart/form-data';
       } else {
-        config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
+        const declaredType = String(config.headers['Content-Type'] || config.headers['content-type'] || '');
+        if (declaredType.toLowerCase().startsWith('application/json')) {
+          if (config.data !== undefined && typeof config.data !== 'string') {
+            config.data = JSON.stringify(config.data);
+          }
+        } else {
+          config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
+        }
       }
       return config;
     },
@@ -104,9 +111,14 @@ export function setupAxios() {
         if (token) {
           cfg.headers = cfg.headers || {};
           cfg.headers['X-CSRF-Token'] = token;
-          // axios re-stringifies on retry, so unwind our qs.stringify before
-          // letting the same request flow through the interceptor again.
-          if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data);
+          const declaredType = String(cfg.headers['Content-Type'] || cfg.headers['content-type'] || '');
+          if (typeof cfg.data === 'string') {
+            if (declaredType.toLowerCase().startsWith('application/json')) {
+              try { cfg.data = JSON.parse(cfg.data); } catch (_e) { /* keep as-is */ }
+            } else {
+              cfg.data = qs.parse(cfg.data);
+            }
+          }
           return axios(cfg);
         }
       }

+ 3 - 0
frontend/src/components/AppSidebar.vue

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
 import {
   DashboardOutlined,
   UserOutlined,
+  TeamOutlined,
   SettingOutlined,
   ToolOutlined,
   ClusterOutlined,
@@ -30,6 +31,7 @@ const props = defineProps({
 const iconByName = {
   dashboard: DashboardOutlined,
   user: UserOutlined,
+  team: TeamOutlined,
   setting: SettingOutlined,
   tool: ToolOutlined,
   cluster: ClusterOutlined,
@@ -42,6 +44,7 @@ const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.base
 const tabs = computed(() => [
   { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
   { key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
+  { key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') },
   { key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
   { key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
   { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },

+ 21 - 0
frontend/src/entries/clients.js

@@ -0,0 +1,21 @@
+import { createApp } from 'vue';
+import Antd, { message } from 'ant-design-vue';
+import 'ant-design-vue/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+import '@/composables/useTheme.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
+import { applyDocumentTitle } from '@/utils';
+import ClientsPage from '@/pages/clients/ClientsPage.vue';
+
+setupAxios();
+applyDocumentTitle();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+readyI18n().then(() => {
+  createApp(ClientsPage).use(Antd).use(i18n).mount('#app');
+});

+ 19 - 15
frontend/src/models/dbinbound.js

@@ -2,6 +2,19 @@ import dayjs from 'dayjs';
 import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
 import { Inbound, Protocols } from './inbound.js';
 
+export function coerceInboundJsonField(value) {
+    if (value == null) return {};
+    if (typeof value === 'object') return value;
+    if (typeof value !== 'string') return {};
+    const trimmed = value.trim();
+    if (trimmed === '') return {};
+    try {
+        return JSON.parse(trimmed);
+    } catch (_e) {
+        return {};
+    }
+}
+
 export class DBInbound {
 
     constructor(data) {
@@ -10,7 +23,6 @@ export class DBInbound {
         this.up = 0;
         this.down = 0;
         this.total = 0;
-        this.allTime = 0;
         this.remark = "";
         this.enable = true;
         this.expiryTime = 0;
@@ -28,6 +40,9 @@ export class DBInbound {
         // Optional FK to web/runtime registered Node. null/undefined =
         // local panel; otherwise the inbound lives on the named node.
         this.nodeId = null;
+        // Populated by the API when this inbound is a fallback child of
+        // a VLESS/Trojan TCP-TLS master. Shape: { masterId, path }.
+        this.fallbackParent = null;
         if (data == null) {
             return;
         }
@@ -111,20 +126,9 @@ export class DBInbound {
             return this._cachedInbound;
         }
 
-        let settings = {};
-        if (!ObjectUtil.isEmpty(this.settings)) {
-            settings = JSON.parse(this.settings);
-        }
-
-        let streamSettings = {};
-        if (!ObjectUtil.isEmpty(this.streamSettings)) {
-            streamSettings = JSON.parse(this.streamSettings);
-        }
-
-        let sniffing = {};
-        if (!ObjectUtil.isEmpty(this.sniffing)) {
-            sniffing = JSON.parse(this.sniffing);
-        }
+        const settings = coerceInboundJsonField(this.settings);
+        const streamSettings = coerceInboundJsonField(this.streamSettings);
+        const sniffing = coerceInboundJsonField(this.sniffing);
 
         const config = {
             port: this.port,

+ 30 - 18
frontend/src/models/inbound.js

@@ -16,6 +16,7 @@ export const Protocols = {
 };
 
 export const SSMethods = {
+    AES_256_GCM: 'aes-256-gcm',
     CHACHA20_POLY1305: 'chacha20-poly1305',
     CHACHA20_IETF_POLY1305: 'chacha20-ietf-poly1305',
     XCHACHA20_IETF_POLY1305: 'xchacha20-ietf-poly1305',
@@ -232,14 +233,20 @@ export class TcpStreamSettings extends XrayCommonClass {
     }
 
     toJson() {
-        return {
-            acceptProxyProtocol: this.acceptProxyProtocol,
-            header: {
-                type: this.type,
-                request: this.type === 'http' ? this.request.toJson() : undefined,
-                response: this.type === 'http' ? this.response.toJson() : undefined,
-            },
-        };
+        const json = {};
+        if (this.acceptProxyProtocol) {
+            json.acceptProxyProtocol = true;
+        }
+        if (this.type === 'http') {
+            json.header = {
+                type: 'http',
+                request: this.request.toJson(),
+                response: this.response.toJson(),
+            };
+        } else if (this.type && this.type !== 'none') {
+            json.header = { type: this.type };
+        }
+        return json;
     }
 }
 
@@ -1465,7 +1472,9 @@ export class StreamSettings extends XrayCommonClass {
         return {
             network: network,
             security: this.security,
-            externalProxy: this.externalProxy,
+            externalProxy: Array.isArray(this.externalProxy) && this.externalProxy.length > 0
+                ? this.externalProxy
+                : undefined,
             tlsSettings: this.isTls ? this.tls.toJson() : undefined,
             realitySettings: this.isReality ? this.reality.toJson() : undefined,
             tcpSettings: network === 'tcp' ? this.tcp.toJson() : undefined,
@@ -1514,11 +1523,14 @@ export class Sniffing extends XrayCommonClass {
     }
 
     toJson() {
+        if (!this.enabled) {
+            return { enabled: false };
+        }
         return {
-            enabled: this.enabled,
+            enabled: true,
             destOverride: this.destOverride,
-            metadataOnly: this.metadataOnly,
-            routeOnly: this.routeOnly,
+            metadataOnly: this.metadataOnly || undefined,
+            routeOnly: this.routeOnly || undefined,
             ipsExcluded: this.ipsExcluded.length > 0 ? this.ipsExcluded : undefined,
             domainsExcluded: this.domainsExcluded.length > 0 ? this.domainsExcluded : undefined,
         };
@@ -2567,7 +2579,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
 
 Inbound.VmessSettings = class extends Inbound.Settings {
     constructor(protocol,
-        vmesses = [new Inbound.VmessSettings.VMESS()]) {
+        vmesses = []) {
         super(protocol);
         this.vmesses = vmesses;
     }
@@ -2635,7 +2647,7 @@ Inbound.VmessSettings.VMESS = class extends Inbound.ClientBase {
 Inbound.VLESSSettings = class extends Inbound.Settings {
     constructor(
         protocol,
-        vlesses = [new Inbound.VLESSSettings.VLESS()],
+        vlesses = [],
         decryption = "none",
         encryption = "none",
         fallbacks = [],
@@ -2782,7 +2794,7 @@ Inbound.VLESSSettings.Fallback = class extends XrayCommonClass {
 
 Inbound.TrojanSettings = class extends Inbound.Settings {
     constructor(protocol,
-        trojans = [new Inbound.TrojanSettings.Trojan()],
+        trojans = [],
         fallbacks = [],) {
         super(protocol);
         this.trojans = trojans;
@@ -2864,8 +2876,8 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings {
     constructor(protocol,
         method = SSMethods.BLAKE3_AES_256_GCM,
         password = RandomUtil.randomShadowsocksPassword(),
-        network = 'tcp,udp',
-        shadowsockses = [new Inbound.ShadowsocksSettings.Shadowsocks()],
+        network = 'tcp',
+        shadowsockses = [],
         ivCheck = false,
     ) {
         super(protocol);
@@ -2927,7 +2939,7 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends Inbound.ClientBase {
 };
 
 Inbound.HysteriaSettings = class extends Inbound.Settings {
-    constructor(protocol, version = 2, hysterias = [new Inbound.HysteriaSettings.Hysteria()]) {
+    constructor(protocol, version = 2, hysterias = []) {
         super(protocol);
         this.version = version;
         this.hysterias = hysterias;

+ 1 - 1
frontend/src/pages/api-docs/EndpointSection.vue

@@ -9,7 +9,7 @@ import { safeInlineHtml } from './endpoints.js';
 
 const props = defineProps({
   section: { type: Object, required: true },
-  icon: { type: Object, default: null },
+  icon: { type: [Object, Function], default: null },
   collapsed: { type: Boolean, default: false },
 });
 

+ 195 - 149
frontend/src/pages/api-docs/endpoints.js

@@ -76,42 +76,31 @@ export const sections = [
       {
         method: 'GET',
         path: '/panel/api/inbounds/list',
-        summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters.',
+        summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.',
         response:
-          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "userId": 1,\n      "up": 0,\n      "down": 0,\n      "total": 0,\n      "remark": "VLESS-443",\n      "enable": true,\n      "expiryTime": 0,\n      "listen": "",\n      "port": 443,\n      "protocol": "vless",\n      "settings": "{\\"clients\\":[...]}",\n      "streamSettings": "{...}",\n      "tag": "inbound-443",\n      "sniffing": "{...}",\n      "clientStats": [...]\n    }\n  ]\n}',
+          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "userId": 1,\n      "up": 0,\n      "down": 0,\n      "total": 0,\n      "remark": "VLESS-443",\n      "enable": true,\n      "expiryTime": 0,\n      "listen": "",\n      "port": 443,\n      "protocol": "vless",\n      "settings": {\n        "clients": [],\n        "decryption": "none"\n      },\n      "streamSettings": {\n        "network": "tcp",\n        "security": "reality",\n        "realitySettings": { "show": false, "dest": "..." }\n      },\n      "tag": "inbound-443",\n      "sniffing": {\n        "enabled": true,\n        "destOverride": ["http", "tls"]\n      },\n      "clientStats": []\n    }\n  ]\n}',
       },
       {
         method: 'GET',
-        path: '/panel/api/inbounds/get/:id',
-        summary: 'Fetch a single inbound by numeric ID.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
-        ],
-      },
-      {
-        method: 'GET',
-        path: '/panel/api/inbounds/getClientTraffics/:email',
-        summary: 'Traffic counters for a client identified by email.',
-        params: [
-          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
-        ],
-        response: '{\n  "success": true,\n  "obj": {\n    "email": "user1",\n    "up": 1048576,\n    "down": 2097152,\n    "total": 10737418240,\n    "expiryTime": 1735689600000\n  }\n}',
+        path: '/panel/api/inbounds/options',
+        summary: 'Lightweight picker projection of the authenticated user’s inbounds. Returns only id, remark, protocol, port, and a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
+        response:
+          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "remark": "VLESS-443",\n      "protocol": "vless",\n      "port": 443,\n      "tlsFlowCapable": true\n    }\n  ]\n}',
       },
       {
         method: 'GET',
-        path: '/panel/api/inbounds/getClientTrafficsById/:id',
-        summary: 'Traffic counters for a client identified by its UUID/password.',
+        path: '/panel/api/inbounds/get/:id',
+        summary: 'Fetch a single inbound by numeric ID.',
         params: [
-          { name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' },
+          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
         ],
-        response: '{\n  "success": true,\n  "obj": {\n    "email": "user1",\n    "up": 1048576,\n    "down": 2097152,\n    "total": 10737418240,\n    "expiryTime": 1735689600000\n  }\n}',
       },
       {
         method: 'POST',
         path: '/panel/api/inbounds/add',
-        summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings JSON, streamSettings JSON, sniffing JSON, remark, expiryTime, total, enable).',
+        summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).',
         body:
-          '{\n  "enable": true,\n  "remark": "VLESS-443",\n  "listen": "",\n  "port": 443,\n  "protocol": "vless",\n  "expiryTime": 0,\n  "total": 0,\n  "settings": "{\\"clients\\":[{\\"id\\":\\"...\\",\\"email\\":\\"user1\\"}],\\"decryption\\":\\"none\\",\\"fallbacks\\":[]}",\n  "streamSettings": "{\\"network\\":\\"tcp\\",\\"security\\":\\"reality\\",\\"realitySettings\\":{...}}",\n  "sniffing": "{\\"enabled\\":true,\\"destOverride\\":[\\"http\\",\\"tls\\"]}"\n}',
+          '{\n  "enable": true,\n  "remark": "VLESS-443",\n  "listen": "",\n  "port": 443,\n  "protocol": "vless",\n  "expiryTime": 0,\n  "total": 0,\n  "settings": {\n    "clients": [{ "id": "...", "email": "user1" }],\n    "decryption": "none",\n    "fallbacks": []\n  },\n  "streamSettings": {\n    "network": "tcp",\n    "security": "reality",\n    "realitySettings": { "show": false, "dest": "..." }\n  },\n  "sniffing": {\n    "enabled": true,\n    "destOverride": ["http", "tls"]\n  }\n}',
         errorResponse:
           '{\n  "success": false,\n  "msg": "Port 443 is already in use"\n}',
       },
@@ -140,59 +129,6 @@ export const sections = [
         ],
         body: '{\n  "enable": false\n}',
       },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/clientIps/:email',
-        summary: 'List source IPs that have connected with the given client’s credentials. Returns an array of "ip (timestamp)" strings.',
-        params: [
-          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/clearClientIps/:email',
-        summary: 'Reset the recorded IP list for a client.',
-        params: [
-          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/addClient',
-        summary: 'Add one or more clients to an existing inbound. The settings field is the JSON-encoded settings.clients array of the target inbound.',
-        body:
-          '{\n  "id": 1,\n  "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"newuser\\",\\"limitIp\\":0,\\"totalGB\\":0,\\"expiryTime\\":0,\\"enable\\":true,\\"flow\\":\\"\\"}]}"\n}',
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/:id/copyClients',
-        summary: 'Copy selected clients from one inbound into another. Useful for duplicating user lists across protocols.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Target inbound ID.' },
-          { name: 'sourceInboundId', in: 'body', type: 'number', desc: 'Inbound ID to read clients from.' },
-          { name: 'clientEmails', in: 'body', type: 'string[]', desc: 'Emails of clients to copy. Empty means all clients.' },
-          { name: 'flow', in: 'body', type: 'string', desc: 'Override the flow field on copied clients (e.g. "xtls-rprx-vision"). Empty to keep source flow.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/:id/delClient/:clientId',
-        summary: 'Delete a client by its UUID/password from a specific inbound.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
-          { name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/updateClient/:clientId',
-        summary: 'Update a single client without rewriting the whole settings JSON. Send the target inbound payload with the new client values.',
-        params: [
-          { name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
-        ],
-        body:
-          '{\n  "id": 1,\n  "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
-      },
       {
         method: 'POST',
         path: '/panel/api/inbounds/:id/resetTraffic',
@@ -201,36 +137,11 @@ export const sections = [
           { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
         ],
       },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
-        summary: 'Zero out upload + download counters for one client.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
-          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
-        ],
-      },
       {
         method: 'POST',
         path: '/panel/api/inbounds/resetAllTraffics',
         summary: 'Reset upload + download counters on every inbound. Destructive — accounting history is lost.',
       },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/resetAllClientTraffics/:id',
-        summary: 'Reset traffic for every client in one inbound.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/delDepletedClients/:id',
-        summary: 'Delete clients in this inbound whose traffic cap or expiry has elapsed. Pass id=-1 to sweep every inbound.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID, or -1 for all inbounds.' },
-        ],
-      },
       {
         method: 'POST',
         path: '/panel/api/inbounds/import',
@@ -239,58 +150,26 @@ export const sections = [
           { name: 'data', in: 'body (form)', type: 'string', desc: 'JSON-encoded inbound payload.' },
         ],
       },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/onlines',
-        summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
-        response: '{\n  "success": true,\n  "obj": ["user1", "user2"]\n}',
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/lastOnline',
-        summary: 'Map of client email → last-seen unix timestamp.',
-        response: '{\n  "success": true,\n  "obj": [\n    { "email": "user1", "lastOnline": 1700000000 },\n    { "email": "user2", "lastOnline": 1699999000 }\n  ]\n}',
-      },
-      {
-        method: 'GET',
-        path: '/panel/api/inbounds/getSubLinks/:subId',
-        summary:
-          'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
-        params: [
-          { name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." },
-        ],
-        response:
-          '{\n  "success": true,\n  "obj": [\n    "vless://uuid@host:443?security=reality&...#user1",\n    "vmess://eyJ2IjoyLC..."\n  ]\n}',
-      },
       {
         method: 'GET',
-        path: '/panel/api/inbounds/getClientLinks/:id/:email',
-        summary:
-          "Return the URL(s) for one client on one inbound — the same string the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) return an empty array.",
+        path: '/panel/api/inbounds/:id/fallbacks',
+        summary: 'List the fallback rules attached to a master VLESS/Trojan TCP-TLS inbound. Each rule links one child inbound (the dest) to optional SNI/ALPN/path/xver match criteria.',
         params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
-          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+          { name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
         ],
         response:
-          '{\n  "success": true,\n  "obj": [\n    "vless://uuid@host:443?...#user1"\n  ]\n}',
+          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "masterId": 10,\n      "childId": 11,\n      "name": "",\n      "alpn": "",\n      "path": "/vlws",\n      "xver": 2,\n      "sortOrder": 0\n    }\n  ]\n}',
       },
       {
         method: 'POST',
-        path: '/panel/api/inbounds/updateClientTraffic/:email',
-        summary: 'Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems.',
+        path: '/panel/api/inbounds/:id/fallbacks',
+        summary: 'Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.',
         params: [
-          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
-        ],
-        body: '{\n  "upload": 1073741824,\n  "download": 5368709120\n}',
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/inbounds/:id/delClientByEmail/:email',
-        summary: 'Delete a client identified by email rather than UUID.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
-          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+          { name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
+          { name: 'fallbacks', in: 'body (json)', type: 'object[]', desc: 'Array of {childId, name, alpn, path, xver, sortOrder} entries.' },
         ],
+        body: '{\n  "fallbacks": [\n    { "childId": 11, "path": "/vlws", "xver": 2 },\n    { "childId": 12, "alpn": "h2" }\n  ]\n}',
+        response: '{\n  "success": true,\n  "msg": "Inbound updated"\n}',
       },
     ],
   },
@@ -494,6 +373,173 @@ export const sections = [
     ],
   },
 
+  {
+    id: 'clients',
+    title: 'Clients',
+    description:
+      'Manage clients as first-class entities that can be attached to one or more inbounds. A single client row drives the settings.clients entry in every inbound it belongs to. Endpoints live under /panel/api/clients.',
+    endpoints: [
+      {
+        method: 'GET',
+        path: '/panel/api/clients/list',
+        summary: 'List every client with its attached inbound IDs and traffic record. The reverse field, if set, is returned as a nested JSON object (legacy JSON-encoded-string form is still accepted on write).',
+        response:
+          '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "email": "[email protected]",\n      "subId": "abcd1234",\n      "uuid": "...",\n      "totalGB": 53687091200,\n      "expiryTime": 1735689600000,\n      "enable": true,\n      "reverse": null,\n      "inboundIds": [3, 5],\n      "traffic": { "up": 1024, "down": 4096, "enable": true }\n    }\n  ]\n}',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/clients/get/:email',
+        summary: 'Fetch one client by email, including the inbound IDs it is attached to.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
+        ],
+        response:
+          '{\n  "success": true,\n  "obj": {\n    "client": { "id": 1, "email": "[email protected]", ... },\n    "inboundIds": [3, 5]\n  }\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/add',
+        summary: 'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets (UUID for VLESS/VMess, password for Trojan/Shadowsocks, auth for Hysteria) are generated server-side when omitted, so callers can send only the universal fields.',
+        params: [
+          { name: 'client', in: 'body (json)', type: 'object', desc: 'Client fields: email, subId, id (uuid), password, auth, flow, totalGB, expiryTime, limitIp, tgId (numeric Telegram user ID, 0 = none), comment, enable.' },
+          { name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach the client to. At least one required.' },
+        ],
+        body: '{\n  "client": {\n    "email": "[email protected]",\n    "totalGB": 53687091200,\n    "expiryTime": 1735689600000,\n    "tgId": 0,\n    "limitIp": 0,\n    "enable": true\n  },\n  "inboundIds": [3, 5]\n}',
+        response: '{\n  "success": true,\n  "msg": "Client added"\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/update/:email',
+        summary: 'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Current client email (unique identifier).' },
+        ],
+        body: '{\n  "email": "[email protected]",\n  "totalGB": 107374182400,\n  "expiryTime": 1767225600000,\n  "tgId": 123456789,\n  "enable": true\n}',
+        response: '{\n  "success": true,\n  "msg": "Client updated"\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/del/:email',
+        summary: 'Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
+          { name: 'keepTraffic', in: 'query', type: 'integer', desc: 'Pass 1 to retain the xray_client_traffic row after deletion.' },
+        ],
+        response: '{\n  "success": true,\n  "msg": "Client deleted"\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/:email/attach',
+        summary: 'Attach an existing client to one or more additional inbounds. Body is JSON.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
+          { name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach.' },
+        ],
+        body: '{\n  "inboundIds": [7, 9]\n}',
+        response: '{\n  "success": true\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/:email/detach',
+        summary: 'Detach a client from one or more inbounds without deleting the client.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
+          { name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to detach.' },
+        ],
+        body: '{\n  "inboundIds": [5]\n}',
+        response: '{\n  "success": true\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/resetAllTraffics',
+        summary: 'Reset the up/down counters for every client globally. Quotas and expiry are not affected. Triggers an Xray restart if any counter actually moved.',
+        response: '{\n  "success": true\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/delDepleted',
+        summary: 'Delete every client whose traffic quota is exhausted (used >= total, when reset is disabled) or whose expiry has passed. Returns the deleted count and triggers an Xray restart when any client was on a running inbound.',
+        response: '{\n  "success": true,\n  "obj": {\n    "deleted": 0\n  }\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/resetTraffic/:email',
+        summary: 'Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/updateTraffic/:email',
+        summary: 'Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+        body: '{\n  "upload": 1073741824,\n  "download": 5368709120\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/ips/:email',
+        summary: 'List source IPs that have connected with the given client’s credentials. Returns an array of "ip (timestamp)" strings.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/clearIps/:email',
+        summary: 'Reset the recorded IP list for a client.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/onlines',
+        summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
+        response: '{\n  "success": true,\n  "obj": ["user1", "user2"]\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/lastOnline',
+        summary: 'Map of client email → last-seen unix timestamp.',
+        response: '{\n  "success": true,\n  "obj": {\n    "user1": 1700000000,\n    "user2": 1699999000\n  }\n}',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/clients/traffic/:email',
+        summary: 'Traffic counters for a client identified by email.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
+        ],
+        response: '{\n  "success": true,\n  "obj": {\n    "email": "user1",\n    "up": 1048576,\n    "down": 2097152,\n    "total": 10737418240,\n    "expiryTime": 1735689600000\n  }\n}',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/clients/subLinks/:subId',
+        summary:
+          'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
+        params: [
+          { name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." },
+        ],
+        response:
+          '{\n  "success": true,\n  "obj": [\n    "vless://uuid@host:443?security=reality&...#user1",\n    "vmess://eyJ2IjoyLC..."\n  ]\n}',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/clients/links/:email',
+        summary:
+          "Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
+        ],
+        response:
+          '{\n  "success": true,\n  "obj": [\n    "vless://uuid@host:443?...#user1"\n  ]\n}',
+      },
+    ],
+  },
+
   {
     id: 'nodes',
     title: 'Nodes',
@@ -504,7 +550,7 @@ export const sections = [
         method: 'GET',
         path: '/panel/api/nodes/list',
         summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
-        response: '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "name": "de-fra-1",\n      "scheme": "https",\n      "host": "node1.example.com",\n      "port": 2053,\n      "status": "online",\n      "cpu": 23.5,\n      "mem": 45.1\n    }\n  ]\n}',
+        response: '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "name": "de-fra-1",\n      "remark": "",\n      "scheme": "https",\n      "address": "node1.example.com",\n      "port": 2053,\n      "basePath": "/",\n      "apiToken": "abcdef...",\n      "enable": true,\n      "allowPrivateAddress": false,\n      "status": "online",\n      "lastHeartbeat": 1700000000,\n      "latencyMs": 42,\n      "xrayVersion": "25.x.x",\n      "panelVersion": "v3.x.x",\n      "cpuPct": 23.5,\n      "memPct": 45.1,\n      "uptimeSecs": 86400,\n      "lastError": "",\n      "inboundCount": 5,\n      "clientCount": 27,\n      "onlineCount": 3,\n      "depletedCount": 1,\n      "createdAt": 1700000000,\n      "updatedAt": 1700000000\n    }\n  ]\n}',
       },
       {
         method: 'GET',
@@ -517,9 +563,9 @@ export const sections = [
       {
         method: 'POST',
         path: '/panel/api/nodes/add',
-        summary: 'Register a new remote node. Provide its URL, apiToken, and optional label/notes.',
+        summary: 'Register a new remote node. Provide its URL, apiToken, and optional remark / allowPrivateAddress flag.',
         body:
-          '{\n  "name": "de-fra-1",\n  "scheme": "https",\n  "host": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef..."\n}',
+          '{\n  "name": "de-fra-1",\n  "remark": "",\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef...",\n  "enable": true,\n  "allowPrivateAddress": false\n}',
       },
       {
         method: 'POST',
@@ -528,7 +574,7 @@ export const sections = [
         params: [
           { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
         ],
-        body: '{\n  "name": "de-fra-1",\n  "scheme": "https",\n  "host": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef..."\n}',
+        body: '{\n  "name": "de-fra-1",\n  "remark": "",\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef...",\n  "enable": true,\n  "allowPrivateAddress": false\n}',
       },
       {
         method: 'POST',
@@ -550,9 +596,9 @@ export const sections = [
       {
         method: 'POST',
         path: '/panel/api/nodes/test',
-        summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.',
-        body: '{\n  "scheme": "https",\n  "host": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef..."\n}',
-        response: '{\n  "success": true,\n  "obj": {\n    "status": "online",\n    "cpu": 12.5,\n    "mem": 45.2\n  }\n}',
+        summary: 'Probe a node without saving it. Uses the body as connection details and returns the same heartbeat snapshot a registered node would have.',
+        body: '{\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef..."\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "status": "online",\n    "latencyMs": 42,\n    "xrayVersion": "25.x.x",\n    "panelVersion": "v3.x.x",\n    "cpuPct": 12.5,\n    "memPct": 45.2,\n    "uptimeSecs": 86400,\n    "error": ""\n  }\n}',
       },
       {
         method: 'POST',

+ 267 - 0
frontend/src/pages/clients/ClientBulkAddModal.vue

@@ -0,0 +1,267 @@
+<script setup>
+import { computed, reactive, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import dayjs from 'dayjs';
+import { SyncOutlined } from '@ant-design/icons-vue';
+import { message } from 'ant-design-vue';
+
+import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
+import DateTimePicker from '@/components/DateTimePicker.vue';
+import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
+
+const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
+
+const { t } = useI18n();
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  inbounds: { type: Array, default: () => [] },
+  ipLimitEnable: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(['update:open', 'saved']);
+
+const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
+
+const saving = ref(false);
+const delayedStart = ref(false);
+
+const form = reactive({
+  emailMethod: 0,
+  firstNum: 1,
+  lastNum: 1,
+  emailPrefix: '',
+  emailPostfix: '',
+  quantity: 1,
+  subId: '',
+  comment: '',
+  flow: '',
+  limitIp: 0,
+  totalGB: 0,
+  expiryTime: 0,
+  inboundIds: [],
+});
+
+const flowCapableIds = computed(() => {
+  const ids = new Set();
+  for (const row of props.inbounds || []) {
+    if (row?.tlsFlowCapable) ids.add(row.id);
+  }
+  return ids;
+});
+
+const showFlow = computed(() =>
+  (form.inboundIds || []).some((id) => flowCapableIds.value.has(id)),
+);
+
+watch(showFlow, (next) => {
+  if (!next) form.flow = '';
+});
+
+const expiryDate = computed({
+  get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
+  set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
+});
+
+const delayedExpireDays = computed({
+  get: () => (form.expiryTime < 0 ? form.expiryTime / -86400000 : 0),
+  set: (days) => { form.expiryTime = -86400000 * (days || 0); },
+});
+
+const MULTI_CLIENT_PROTOCOLS = new Set([
+  'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
+]);
+
+const inboundOptions = computed(() =>
+  (props.inbounds || [])
+    .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol))
+    .map((ib) => ({
+      label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
+      value: ib.id,
+    })),
+);
+
+watch(() => props.open, (next) => {
+  if (!next) return;
+  form.emailMethod = 0;
+  form.firstNum = 1;
+  form.lastNum = 1;
+  form.emailPrefix = '';
+  form.emailPostfix = '';
+  form.quantity = 1;
+  form.subId = '';
+  form.comment = '';
+  form.flow = '';
+  form.limitIp = 0;
+  form.totalGB = 0;
+  form.expiryTime = 0;
+  form.inboundIds = [];
+  delayedStart.value = false;
+});
+
+function close() {
+  emit('update:open', false);
+}
+
+function buildEmails() {
+  const method = form.emailMethod;
+  const out = [];
+  let start;
+  let end;
+  if (method > 1) {
+    start = form.firstNum;
+    end = form.lastNum + 1;
+  } else {
+    start = 0;
+    end = form.quantity;
+  }
+  const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
+  const useNum = method > 1;
+  const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
+  for (let i = start; i < end; i++) {
+    let email = '';
+    if (method !== 4) email = RandomUtil.randomLowerAndNum(6);
+    email += useNum ? prefix + String(i) + postfix : prefix + postfix;
+    out.push(email);
+  }
+  return out;
+}
+
+async function submit() {
+  if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) {
+    message.error(t('pages.clients.selectInbound'));
+    return;
+  }
+  const emails = buildEmails();
+  if (emails.length === 0) return;
+
+  saving.value = true;
+  const silentJsonOpts = { ...JSON_HEADERS, silent: true };
+  try {
+    const results = await Promise.all(emails.map((email) => {
+      const client = {
+        email,
+        subId: form.subId || RandomUtil.randomLowerAndNum(16),
+        id: RandomUtil.randomUUID(),
+        password: RandomUtil.randomLowerAndNum(16),
+        auth: RandomUtil.randomLowerAndNum(16),
+        flow: showFlow.value ? (form.flow || '') : '',
+        totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
+        expiryTime: form.expiryTime,
+        limitIp: Number(form.limitIp) || 0,
+        comment: form.comment,
+        enable: true,
+      };
+      const payload = { client, inboundIds: form.inboundIds };
+      return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts);
+    }));
+    let ok = 0;
+    let failed = 0;
+    let firstError = '';
+    for (const msg of results) {
+      if (msg?.success) ok++;
+      else {
+        failed++;
+        if (!firstError && msg?.msg) firstError = msg.msg;
+      }
+    }
+    if (failed === 0) {
+      message.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
+    } else {
+      message.warning(firstError
+        ? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
+        : t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
+    }
+    emit('saved');
+    close();
+  } finally {
+    saving.value = false;
+  }
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="t('pages.clients.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
+    :confirm-loading="saving" :mask-closable="false" :width="640" @ok="submit" @cancel="close">
+    <a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
+      <a-form-item :label="t('pages.clients.attachedInbounds')" required>
+        <a-select v-model:value="form.inboundIds" mode="multiple" :options="inboundOptions"
+          :placeholder="t('pages.clients.selectInbound')" :show-search="true"
+          :filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />
+      </a-form-item>
+
+      <a-form-item :label="t('pages.clients.method')">
+        <a-select v-model:value="form.emailMethod">
+          <a-select-option :value="0">Random</a-select-option>
+          <a-select-option :value="1">Random + Prefix</a-select-option>
+          <a-select-option :value="2">Random + Prefix + Num</a-select-option>
+          <a-select-option :value="3">Random + Prefix + Num + Postfix</a-select-option>
+          <a-select-option :value="4">Prefix + Num + Postfix</a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-form-item v-if="form.emailMethod > 1" :label="t('pages.clients.first')">
+        <a-input-number v-model:value="form.firstNum" :min="1" />
+      </a-form-item>
+      <a-form-item v-if="form.emailMethod > 1" :label="t('pages.clients.last')">
+        <a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
+      </a-form-item>
+      <a-form-item v-if="form.emailMethod > 0" :label="t('pages.clients.prefix')">
+        <a-input v-model:value="form.emailPrefix" />
+      </a-form-item>
+      <a-form-item v-if="form.emailMethod > 2" :label="t('pages.clients.postfix')">
+        <a-input v-model:value="form.emailPostfix" />
+      </a-form-item>
+      <a-form-item v-if="form.emailMethod < 2" :label="t('pages.clients.clientCount')">
+        <a-input-number v-model:value="form.quantity" :min="1" :max="100" />
+      </a-form-item>
+
+      <a-form-item>
+        <template #label>
+          {{ t('subscription.title') }}
+          <SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
+        </template>
+        <a-input v-model:value="form.subId" />
+      </a-form-item>
+
+      <a-form-item :label="t('comment')">
+        <a-input v-model:value="form.comment" />
+      </a-form-item>
+
+      <a-form-item v-if="showFlow" :label="t('pages.clients.flow')">
+        <a-select v-model:value="form.flow" :style="{ width: '220px' }">
+          <a-select-option value="">{{ t('none') }}</a-select-option>
+          <a-select-option v-for="k in FLOW_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-form-item v-if="ipLimitEnable" :label="t('pages.clients.limitIp')">
+        <a-input-number v-model:value="form.limitIp" :min="0" />
+      </a-form-item>
+
+      <a-form-item :label="t('pages.clients.totalGB')">
+        <a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
+      </a-form-item>
+
+      <a-form-item :label="t('pages.clients.delayedStart')">
+        <a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
+      </a-form-item>
+
+      <a-form-item v-if="delayedStart" :label="t('pages.clients.expireDays')">
+        <a-input-number v-model:value="delayedExpireDays" :min="0" />
+      </a-form-item>
+
+      <a-form-item v-else :label="t('pages.inbounds.expireDate')">
+        <DateTimePicker v-model:value="expiryDate" />
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>
+
+<style scoped>
+.random-icon {
+  margin-left: 4px;
+  cursor: pointer;
+  color: var(--ant-color-primary, #1677ff);
+}
+</style>

+ 402 - 0
frontend/src/pages/clients/ClientFormModal.vue

@@ -0,0 +1,402 @@
+<script setup>
+import { computed, reactive, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { message } from 'ant-design-vue';
+import dayjs from 'dayjs';
+import { HttpUtil, RandomUtil } from '@/utils';
+import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
+
+const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  mode: { type: String, default: 'add' },
+  client: { type: Object, default: null },
+  inbounds: { type: Array, default: () => [] },
+  attachedIds: { type: Array, default: () => [] },
+  ipLimitEnable: { type: Boolean, default: false },
+  tgBotEnable: { type: Boolean, default: false },
+  save: { type: Function, required: true },
+});
+
+const emit = defineEmits(['update:open']);
+const { t } = useI18n();
+
+const submitting = ref(false);
+const form = reactive(emptyForm());
+
+function emptyForm() {
+  return {
+    email: '',
+    subId: '',
+    uuid: '',
+    password: '',
+    auth: '',
+    flow: '',
+    reverseTag: '',
+    totalGB: 0,
+    expiryDate: null,
+    delayedStart: false,
+    delayedDays: 0,
+    limitIp: 0,
+    tgId: 0,
+    comment: '',
+    enable: true,
+    inboundIds: [],
+  };
+}
+
+const isEdit = computed(() => props.mode === 'edit');
+
+watch(
+  () => props.open,
+  (next) => {
+    if (!next) return;
+    Object.assign(form, emptyForm());
+    if (isEdit.value && props.client) {
+      form.email = props.client.email || '';
+      form.subId = props.client.subId || '';
+      form.uuid = props.client.uuid || '';
+      form.password = props.client.password || '';
+      form.auth = props.client.auth || '';
+      form.flow = props.client.flow || '';
+      form.reverseTag = props.client.reverse?.tag || '';
+      form.totalGB = bytesToGB(props.client.totalGB || 0);
+      const et = Number(props.client.expiryTime) || 0;
+      if (et < 0) {
+        form.delayedStart = true;
+        form.delayedDays = Math.round(et / -86400000);
+        form.expiryDate = null;
+      } else {
+        form.delayedStart = false;
+        form.delayedDays = 0;
+        form.expiryDate = et > 0 ? dayjs(et) : null;
+      }
+      form.limitIp = props.client.limitIp || 0;
+      form.tgId = Number(props.client.tgId) || 0;
+      form.comment = props.client.comment || '';
+      form.enable = !!props.client.enable;
+      form.inboundIds = Array.isArray(props.attachedIds) ? [...props.attachedIds] : [];
+      void loadIps();
+    } else {
+      form.email = RandomUtil.randomLowerAndNum(9);
+      form.uuid = RandomUtil.randomUUID();
+      form.subId = RandomUtil.randomLowerAndNum(16);
+      form.password = RandomUtil.randomLowerAndNum(16);
+      form.auth = RandomUtil.randomLowerAndNum(16);
+    }
+  },
+);
+
+function bytesToGB(bytes) {
+  if (!bytes || bytes <= 0) return 0;
+  return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
+}
+
+function gbToBytes(gb) {
+  if (!gb || gb <= 0) return 0;
+  return Math.round(gb * 1024 * 1024 * 1024);
+}
+
+const MULTI_CLIENT_PROTOCOLS = new Set([
+  'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
+]);
+
+const inboundOptions = computed(() =>
+  (props.inbounds || [])
+    .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol))
+    .map((ib) => ({
+      label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
+      value: ib.id,
+      title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`,
+    })),
+);
+
+const flowCapableIds = computed(() => {
+  const ids = new Set();
+  for (const row of props.inbounds || []) {
+    if (row?.tlsFlowCapable) ids.add(row.id);
+  }
+  return ids;
+});
+
+const showFlow = computed(() =>
+  (form.inboundIds || []).some((id) => flowCapableIds.value.has(id)),
+);
+
+watch(showFlow, (next) => {
+  if (!next) form.flow = '';
+});
+
+const vlessLikeIds = computed(() => {
+  const ids = new Set();
+  for (const row of props.inbounds || []) {
+    if (row && row.protocol === 'vless') {
+      ids.add(row.id);
+    }
+  }
+  return ids;
+});
+
+const showReverseTag = computed(() =>
+  (form.inboundIds || []).some((id) => vlessLikeIds.value.has(id)),
+);
+
+watch(showReverseTag, (next) => {
+  if (!next) form.reverseTag = '';
+});
+
+const clientIps = ref([]);
+const ipsLoading = ref(false);
+const ipsClearing = ref(false);
+
+async function loadIps() {
+  if (!isEdit.value || !props.client?.email) return;
+  ipsLoading.value = true;
+  try {
+    const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(props.client.email)}`);
+    if (!msg?.success) { clientIps.value = []; return; }
+    const arr = Array.isArray(msg.obj) ? msg.obj : [];
+    clientIps.value = arr.filter((x) => typeof x === 'string' && x.length > 0);
+  } finally {
+    ipsLoading.value = false;
+  }
+}
+
+async function clearIps() {
+  if (!isEdit.value || !props.client?.email) return;
+  ipsClearing.value = true;
+  try {
+    const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(props.client.email)}`);
+    if (msg?.success) clientIps.value = [];
+  } finally {
+    ipsClearing.value = false;
+  }
+}
+
+function close() {
+  emit('update:open', false);
+}
+
+function regenerateUUID() {
+  form.uuid = RandomUtil.randomUUID();
+}
+
+function regeneratePassword() {
+  form.password = RandomUtil.randomLowerAndNum(16);
+}
+
+function regenerateAuth() {
+  form.auth = RandomUtil.randomLowerAndNum(16);
+}
+
+function regenerateSubId() {
+  form.subId = RandomUtil.randomLowerAndNum(16);
+}
+
+function regenerateEmail() {
+  form.email = RandomUtil.randomLowerAndNum(12);
+}
+
+function onDelayedStartToggle(next) {
+  if (next) {
+    form.expiryDate = null;
+  } else {
+    form.delayedDays = 0;
+  }
+}
+
+async function onSubmit() {
+  if (!form.email || form.email.trim() === '') {
+    message.error(`${t('pages.clients.email')} *`);
+    return;
+  }
+  if (!isEdit.value && (!form.inboundIds || form.inboundIds.length === 0)) {
+    message.error(t('pages.clients.selectInbound'));
+    return;
+  }
+  const expiryTime = form.delayedStart
+    ? -86400000 * (Number(form.delayedDays) || 0)
+    : (form.expiryDate ? form.expiryDate.valueOf() : 0);
+  const clientPayload = {
+    email: form.email.trim(),
+    subId: form.subId,
+    id: form.uuid,
+    password: form.password,
+    auth: form.auth,
+    flow: showFlow.value ? (form.flow || '') : '',
+    totalGB: gbToBytes(form.totalGB),
+    expiryTime,
+    limitIp: Number(form.limitIp) || 0,
+    tgId: Number(form.tgId) || 0,
+    comment: form.comment,
+    enable: !!form.enable,
+  };
+  const reverseTag = showReverseTag.value ? (form.reverseTag || '').trim() : '';
+  if (reverseTag) {
+    clientPayload.reverse = { tag: reverseTag };
+  }
+
+  submitting.value = true;
+  try {
+    let msg;
+    if (isEdit.value) {
+      const original = new Set(props.attachedIds || []);
+      const next = new Set(form.inboundIds || []);
+      const toAttach = [...next].filter((id) => !original.has(id));
+      const toDetach = [...original].filter((id) => !next.has(id));
+      msg = await props.save(clientPayload, {
+        isEdit: true,
+        email: props.client.email,
+        attach: toAttach,
+        detach: toDetach,
+      });
+    } else {
+      msg = await props.save(
+        { client: clientPayload, inboundIds: form.inboundIds },
+        { isEdit: false },
+      );
+    }
+    if (msg?.success) close();
+  } finally {
+    submitting.value = false;
+  }
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')"
+    :destroy-on-close="true" :ok-text="isEdit ? t('save') : t('create')" :cancel-text="t('cancel')"
+    :ok-button-props="{ loading: submitting }" :width="720" @ok="onSubmit" @cancel="close">
+    <a-form layout="vertical" :model="form">
+      <a-row :gutter="16">
+        <a-col :span="12">
+          <a-form-item :label="t('pages.clients.email')" required>
+            <a-input-group compact style="display: flex">
+              <a-input v-model:value="form.email" :placeholder="t('pages.clients.email')" style="flex: 1" />
+              <a-button @click="regenerateEmail">↻</a-button>
+            </a-input-group>
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item :label="t('pages.clients.subId')">
+            <a-input-group compact style="display: flex">
+              <a-input v-model:value="form.subId" style="flex: 1" />
+              <a-button @click="regenerateSubId">↻</a-button>
+            </a-input-group>
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row :gutter="16">
+        <a-col :span="12">
+          <a-form-item :label="t('pages.clients.hysteriaAuth')">
+            <a-input-group compact style="display: flex">
+              <a-input v-model:value="form.auth" style="flex: 1" />
+              <a-button @click="regenerateAuth">↻</a-button>
+            </a-input-group>
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item :label="t('pages.clients.password')">
+            <a-input-group compact style="display: flex">
+              <a-input v-model:value="form.password" style="flex: 1" />
+              <a-button @click="regeneratePassword">↻</a-button>
+            </a-input-group>
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row :gutter="16">
+        <a-col :span="12">
+          <a-form-item :label="t('pages.clients.uuid')">
+            <a-input-group compact style="display: flex">
+              <a-input v-model:value="form.uuid" style="flex: 1" />
+              <a-button @click="regenerateUUID">↻</a-button>
+            </a-input-group>
+          </a-form-item>
+        </a-col>
+        <a-col :span="ipLimitEnable ? 8 : 12">
+          <a-form-item :label="t('pages.clients.totalGB')">
+            <a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" style="width: 100%" />
+          </a-form-item>
+        </a-col>
+        <a-col v-if="ipLimitEnable" :span="4">
+          <a-form-item :label="t('pages.clients.limitIp')">
+            <a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row :gutter="16">
+        <a-col :span="12">
+          <a-form-item v-if="form.delayedStart" :label="t('pages.clients.expireDays')">
+            <a-input-number v-model:value="form.delayedDays" :min="0" style="width: 100%" />
+          </a-form-item>
+          <a-form-item v-else :label="t('pages.clients.expiryTime')">
+            <a-date-picker v-model:value="form.expiryDate" show-time style="width: 100%" />
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item :label="t('pages.clients.delayedStart')">
+            <a-switch v-model:checked="form.delayedStart" @change="onDelayedStartToggle" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row v-if="showFlow || showReverseTag" :gutter="16">
+        <a-col v-if="showFlow" :span="12">
+          <a-form-item :label="t('pages.clients.flow')">
+            <a-select v-model:value="form.flow">
+              <a-select-option value="">{{ t('none') }}</a-select-option>
+              <a-select-option v-for="k in FLOW_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
+            </a-select>
+          </a-form-item>
+        </a-col>
+        <a-col v-if="showReverseTag" :span="12">
+          <a-form-item :label="t('pages.clients.reverseTag')">
+            <a-input v-model:value="form.reverseTag" :placeholder="t('pages.clients.reverseTagPlaceholder')" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row :gutter="16">
+        <a-col v-if="tgBotEnable" :span="12">
+          <a-form-item :label="t('pages.clients.telegramId')">
+            <a-input-number v-model:value="form.tgId" :min="0" :controls="false"
+              :placeholder="t('pages.clients.telegramIdPlaceholder')" style="width: 100%" />
+          </a-form-item>
+        </a-col>
+        <a-col :span="tgBotEnable ? 12 : 24">
+          <a-form-item :label="t('pages.clients.comment')">
+            <a-input v-model:value="form.comment" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-form-item :label="t('pages.clients.attachedInbounds')" :required="!isEdit">
+        <a-select v-model:value="form.inboundIds" mode="multiple" :options="inboundOptions" :show-search="true"
+          :placeholder="t('pages.clients.selectInbound')"
+          :filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />
+      </a-form-item>
+
+      <a-form-item>
+        <a-switch v-model:checked="form.enable" />
+        <span style="margin-left: 8px">{{ t('enable') }}</span>
+      </a-form-item>
+
+      <a-form-item v-if="isEdit && ipLimitEnable" :label="t('pages.clients.ipLog')">
+        <a-space style="margin-bottom: 8px">
+          <a-button size="small" :loading="ipsLoading" @click="loadIps">{{ t('refresh') }}</a-button>
+          <a-button size="small" danger :loading="ipsClearing" :disabled="clientIps.length === 0" @click="clearIps">
+            {{ t('pages.clients.clearAll') }}
+          </a-button>
+        </a-space>
+        <div v-if="clientIps.length > 0">
+          <a-tag v-for="(ip, idx) in clientIps" :key="idx" color="blue" style="margin-bottom: 4px">{{ ip }}</a-tag>
+        </div>
+        <a-tag v-else>{{ t('tgbot.noIpRecord') }}</a-tag>
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>

+ 411 - 0
frontend/src/pages/clients/ClientInfoModal.vue

@@ -0,0 +1,411 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { CopyOutlined } from '@ant-design/icons-vue';
+import { message } from 'ant-design-vue';
+import { SizeFormatter, IntlUtil, ClipboardManager, HttpUtil } from '@/utils';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  client: { type: Object, default: null },
+  inboundsById: { type: Object, default: () => ({}) },
+  isOnline: { type: Boolean, default: false },
+  subSettings: {
+    type: Object,
+    default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
+  },
+});
+
+const emit = defineEmits(['update:open']);
+
+const links = ref([]);
+const linksLoading = ref(false);
+
+const traffic = computed(() => props.client?.traffic || null);
+const totalBytes = computed(() => props.client?.totalGB || 0);
+const used = computed(() => (traffic.value?.up || 0) + (traffic.value?.down || 0));
+const remaining = computed(() => {
+  if (totalBytes.value <= 0) return -1;
+  const r = totalBytes.value - used.value;
+  return r > 0 ? r : 0;
+});
+
+const subLink = computed(() => {
+  if (!props.client?.subId || !props.subSettings?.subURI) return '';
+  return props.subSettings.subURI + props.client.subId;
+});
+
+const subJsonLink = computed(() => {
+  if (!props.client?.subId) return '';
+  if (!props.subSettings?.subJsonEnable || !props.subSettings?.subJsonURI) return '';
+  return props.subSettings.subJsonURI + props.client.subId;
+});
+
+const showSubscription = computed(
+  () => !!(props.subSettings?.enable && props.client?.subId),
+);
+
+function expiryLabel(ts) {
+  if (!ts || ts <= 0) return '∞';
+  return IntlUtil.formatDate(ts);
+}
+
+function expiryRelative(ts) {
+  if (!ts || ts <= 0) return '';
+  return IntlUtil.formatRelativeTime(ts);
+}
+
+function lastOnlineLabel(ts) {
+  if (!ts || ts <= 0) return '-';
+  return IntlUtil.formatDate(ts);
+}
+
+function dateLabel(ts) {
+  if (!ts || ts <= 0) return '-';
+  return IntlUtil.formatDate(ts);
+}
+
+async function copyValue(text) {
+  if (!text) return;
+  const ok = await ClipboardManager.copyText(String(text));
+  if (ok) message.success(t('copied'));
+}
+
+async function loadLinks() {
+  if (!props.client?.subId) {
+    links.value = [];
+    return;
+  }
+  linksLoading.value = true;
+  try {
+    const msg = await HttpUtil.get(
+      `/panel/api/clients/subLinks/${encodeURIComponent(props.client.subId)}`,
+    );
+    links.value = msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
+  } finally {
+    linksLoading.value = false;
+  }
+}
+
+watch(() => props.open, (next) => {
+  if (next) loadLinks();
+  else links.value = [];
+});
+
+function close() {
+  emit('update:open', false);
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="client ? client.email : t('info')" :footer="null" :width="640" @cancel="close">
+    <template v-if="client">
+      <table class="info-table block">
+        <tbody>
+          <tr>
+            <td>{{ t('pages.clients.online') }}</td>
+            <td>
+              <a-tag v-if="client.enable && isOnline" color="green">{{ t('pages.clients.online') }}</a-tag>
+              <a-tag v-else>{{ t('pages.clients.offline') }}</a-tag>
+              <span class="hint">{{ t('lastOnline') }}: {{ lastOnlineLabel(traffic?.lastOnline) }}</span>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('status') }}</td>
+            <td>
+              <a-tag :color="client.enable ? 'green' : 'default'">
+                {{ client.enable ? t('enabled') : t('disabled') }}
+              </a-tag>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.clients.email') }}</td>
+            <td>
+              <a-tag v-if="client.email" color="green">{{ client.email }}</a-tag>
+              <a-tag v-else color="red">{{ t('none') }}</a-tag>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.clients.subId') }}</td>
+            <td>
+              <a-tag class="info-large-tag">{{ client.subId || '-' }}</a-tag>
+              <a-button v-if="client.subId" size="small" type="text" @click="copyValue(client.subId)">
+                <CopyOutlined />
+              </a-button>
+            </td>
+          </tr>
+
+          <tr v-if="client.uuid">
+            <td>{{ t('pages.clients.uuid') }}</td>
+            <td>
+              <a-tag class="info-large-tag">{{ client.uuid }}</a-tag>
+              <a-button size="small" type="text" @click="copyValue(client.uuid)">
+                <CopyOutlined />
+              </a-button>
+            </td>
+          </tr>
+
+          <tr v-if="client.password">
+            <td>{{ t('password') }}</td>
+            <td>
+              <a-tag class="info-large-tag">{{ client.password }}</a-tag>
+              <a-button size="small" type="text" @click="copyValue(client.password)">
+                <CopyOutlined />
+              </a-button>
+            </td>
+          </tr>
+
+          <tr v-if="client.auth">
+            <td>{{ t('pages.clients.auth') }}</td>
+            <td>
+              <a-tag class="info-large-tag">{{ client.auth }}</a-tag>
+              <a-button size="small" type="text" @click="copyValue(client.auth)">
+                <CopyOutlined />
+              </a-button>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.clients.flow') }}</td>
+            <td>
+              <a-tag v-if="client.flow">{{ client.flow }}</a-tag>
+              <a-tag v-else color="orange">{{ t('none') }}</a-tag>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.inbounds.traffic') }}</td>
+            <td>
+              <a-tag>
+                ↑ {{ SizeFormatter.sizeFormat(traffic?.up || 0) }}
+                / ↓ {{ SizeFormatter.sizeFormat(traffic?.down || 0) }}
+              </a-tag>
+              <span class="hint">
+                {{ SizeFormatter.sizeFormat(used) }}
+                /
+                {{ totalBytes > 0 ? SizeFormatter.sizeFormat(totalBytes) : '∞' }}
+              </span>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('remained') }}</td>
+            <td>
+              <a-tag v-if="remaining < 0" color="purple">∞</a-tag>
+              <a-tag v-else :color="remaining > 0 ? '' : 'red'">
+                {{ SizeFormatter.sizeFormat(remaining) }}
+              </a-tag>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.inbounds.expireDate') }}</td>
+            <td>
+              <a-tag v-if="!client.expiryTime || client.expiryTime <= 0" color="purple">∞</a-tag>
+              <a-tag v-else>{{ expiryLabel(client.expiryTime) }}</a-tag>
+              <span v-if="client.expiryTime > 0" class="hint">{{ expiryRelative(client.expiryTime) }}</span>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.clients.ipLimit') }}</td>
+            <td>
+              <a-tag v-if="!client.limitIp">∞</a-tag>
+              <a-tag v-else>{{ client.limitIp }}</a-tag>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.inbounds.createdAt') }}</td>
+            <td>
+              <a-tag>{{ dateLabel(client.createdAt) }}</a-tag>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.inbounds.updatedAt') }}</td>
+            <td>
+              <a-tag>{{ dateLabel(client.updatedAt) }}</a-tag>
+            </td>
+          </tr>
+
+          <tr v-if="client.comment">
+            <td>{{ t('pages.clients.comment') }}</td>
+            <td>
+              <a-tag class="info-large-tag">{{ client.comment }}</a-tag>
+            </td>
+          </tr>
+
+          <tr>
+            <td>{{ t('pages.clients.attachedInbounds') }}</td>
+            <td>
+              <div class="chips">
+                <a-tag v-for="id in (client.inboundIds || [])" :key="id" color="blue">
+                  <template v-if="inboundsById[id]">
+                    {{ inboundsById[id].remark || `#${id}` }} ({{ inboundsById[id].protocol }}:{{ inboundsById[id].port }})
+                  </template>
+                  <template v-else>#{{ id }}</template>
+                </a-tag>
+                <span v-if="!client.inboundIds || client.inboundIds.length === 0" class="hint">—</span>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+      <template v-if="links.length > 0">
+        <a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
+        <div v-for="(link, idx) in links" :key="idx" class="link-panel">
+          <div class="link-panel-header">
+            <a-tag color="green">{{ `${t('pages.clients.link')} ${idx + 1}` }}</a-tag>
+            <a-tooltip :title="t('copy')">
+              <a-button size="small" @click="copyValue(link)">
+                <template #icon>
+                  <CopyOutlined />
+                </template>
+              </a-button>
+            </a-tooltip>
+          </div>
+          <code class="link-panel-text">{{ link }}</code>
+        </div>
+      </template>
+
+      <template v-if="showSubscription && subLink">
+        <a-divider>{{ t('subscription.title') }}</a-divider>
+        <div class="link-panel">
+          <div class="link-panel-header">
+            <a-tag color="green">{{ t('subscription.title') }}</a-tag>
+            <a-tooltip :title="t('copy')">
+              <a-button size="small" @click="copyValue(subLink)">
+                <template #icon>
+                  <CopyOutlined />
+                </template>
+              </a-button>
+            </a-tooltip>
+          </div>
+          <a :href="subLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subLink }}</a>
+        </div>
+
+        <div v-if="subJsonLink" class="link-panel">
+          <div class="link-panel-header">
+            <a-tag color="green">JSON</a-tag>
+            <a-tooltip :title="t('copy')">
+              <a-button size="small" @click="copyValue(subJsonLink)">
+                <template #icon>
+                  <CopyOutlined />
+                </template>
+              </a-button>
+            </a-tooltip>
+          </div>
+          <a :href="subJsonLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subJsonLink }}</a>
+        </div>
+      </template>
+    </template>
+  </a-modal>
+</template>
+
+<style scoped>
+.info-table {
+  width: 100%;
+  border-collapse: collapse;
+}
+
+.info-table.block {
+  margin-bottom: 10px;
+}
+
+.info-table td {
+  padding: 4px 8px;
+  vertical-align: top;
+}
+
+.info-table td:first-child {
+  width: 140px;
+  font-size: 13px;
+  opacity: 0.75;
+  white-space: nowrap;
+}
+
+.info-large-tag {
+  max-width: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  display: inline-block;
+}
+
+.hint {
+  font-size: 12px;
+  opacity: 0.55;
+  margin-left: 6px;
+}
+
+.chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
+.link-panel {
+  border: 1px solid rgba(128, 128, 128, 0.2);
+  border-radius: 8px;
+  padding: 10px;
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.link-panel-header {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+
+.link-panel-text {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 11px;
+  word-break: break-all;
+  white-space: pre-wrap;
+  padding: 6px 8px;
+  background: rgba(0, 0, 0, 0.04);
+  border-radius: 4px;
+  user-select: all;
+}
+
+:global(body.dark) .link-panel-text {
+  background: rgba(255, 255, 255, 0.05);
+}
+
+.link-panel-anchor {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 11px;
+  word-break: break-all;
+  padding: 6px 8px;
+  background: rgba(0, 0, 0, 0.04);
+  border-radius: 4px;
+  color: var(--ant-color-primary, #1677ff);
+  text-decoration: underline;
+  text-decoration-color: rgba(22, 119, 255, 0.4);
+  transition: background 120ms ease, text-decoration-color 120ms ease;
+}
+
+.link-panel-anchor:hover {
+  background: rgba(22, 119, 255, 0.08);
+  text-decoration-color: var(--ant-color-primary, #1677ff);
+}
+
+:global(body.dark) .link-panel-anchor {
+  background: rgba(255, 255, 255, 0.05);
+}
+
+:global(body.dark) .link-panel-anchor:hover {
+  background: rgba(22, 119, 255, 0.16);
+}
+</style>

+ 97 - 0
frontend/src/pages/clients/ClientQrModal.vue

@@ -0,0 +1,97 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { HttpUtil } from '@/utils';
+import QrPanel from '@/pages/inbounds/QrPanel.vue';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  client: { type: Object, default: null },
+  subSettings: {
+    type: Object,
+    default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
+  },
+});
+
+const emit = defineEmits(['update:open']);
+
+const links = ref([]);
+const loading = ref(false);
+
+const subLink = computed(() => {
+  if (!props.client?.subId || !props.subSettings?.enable || !props.subSettings?.subURI) return '';
+  return props.subSettings.subURI + props.client.subId;
+});
+
+const subJsonLink = computed(() => {
+  if (!props.client?.subId || !props.subSettings?.enable) return '';
+  if (!props.subSettings?.subJsonEnable || !props.subSettings?.subJsonURI) return '';
+  return props.subSettings.subJsonURI + props.client.subId;
+});
+
+const activeKeys = computed(() => {
+  const keys = [];
+  if (subLink.value) keys.push('sub');
+  if (subJsonLink.value) keys.push('subJson');
+  if (links.value.length > 0) keys.push('l0');
+  return keys;
+});
+
+const hasAnything = computed(
+  () => !!subLink.value || !!subJsonLink.value || links.value.length > 0,
+);
+
+watch(() => props.open, async (next) => {
+  if (!next || !props.client?.subId) {
+    links.value = [];
+    return;
+  }
+  loading.value = true;
+  try {
+    const msg = await HttpUtil.get(`/panel/api/clients/subLinks/${encodeURIComponent(props.client.subId)}`);
+    links.value = msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
+  } finally {
+    loading.value = false;
+  }
+});
+
+function close() {
+  emit('update:open', false);
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="client ? client.email : t('qrCode')" :footer="null" :width="520" centered
+    @cancel="close">
+    <a-spin :spinning="loading">
+      <div v-if="!client?.subId && !loading" class="empty">
+        {{ t('pages.clients.noSubId') }}
+      </div>
+      <div v-else-if="!hasAnything && !loading" class="empty">
+        {{ t('pages.clients.noLinks') }}
+      </div>
+      <a-collapse v-else :active-key="activeKeys" accordion>
+        <a-collapse-panel v-if="subLink" key="sub" :header="t('subscription.title')">
+          <QrPanel :value="subLink" :remark="`${client?.email || ''} — ${t('subscription.title')}`" />
+        </a-collapse-panel>
+        <a-collapse-panel v-if="subJsonLink" key="subJson" :header="`${t('subscription.title')} (JSON)`">
+          <QrPanel :value="subJsonLink" :remark="`${client?.email || ''} — JSON`" />
+        </a-collapse-panel>
+        <a-collapse-panel v-for="(link, idx) in links" :key="`l${idx}`"
+          :header="`${t('pages.clients.link')} ${idx + 1}`">
+          <QrPanel :value="link" :remark="`${client?.email || ''} #${idx + 1}`" />
+        </a-collapse-panel>
+      </a-collapse>
+    </a-spin>
+  </a-modal>
+</template>
+
+<style scoped>
+.empty {
+  padding: 24px;
+  text-align: center;
+  opacity: 0.6;
+}
+</style>

+ 1067 - 0
frontend/src/pages/clients/ClientsPage.vue

@@ -0,0 +1,1067 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { Modal, message } from 'ant-design-vue';
+import {
+  PlusOutlined,
+  UserOutlined,
+  EditOutlined,
+  DeleteOutlined,
+  InfoCircleOutlined,
+  QrcodeOutlined,
+  RetweetOutlined,
+  RestOutlined,
+  MoreOutlined,
+  UsergroupAddOutlined,
+  SearchOutlined,
+  FilterOutlined,
+  TeamOutlined,
+} from '@ant-design/icons-vue';
+
+import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
+import { useMediaQuery } from '@/composables/useMediaQuery.js';
+import { useWebSocket } from '@/composables/useWebSocket.js';
+import AppSidebar from '@/components/AppSidebar.vue';
+import CustomStatistic from '@/components/CustomStatistic.vue';
+import { ObjectUtil, SizeFormatter, IntlUtil } from '@/utils';
+import { useClients } from './useClients.js';
+import ClientFormModal from './ClientFormModal.vue';
+import ClientInfoModal from './ClientInfoModal.vue';
+import ClientQrModal from './ClientQrModal.vue';
+import ClientBulkAddModal from './ClientBulkAddModal.vue';
+
+const { t } = useI18n();
+
+const {
+  clients,
+  inbounds,
+  onlines,
+  loading,
+  fetched,
+  subSettings,
+  ipLimitEnable,
+  tgBotEnable,
+  expireDiff,
+  trafficDiff,
+  create,
+  update,
+  remove,
+  removeMany,
+  attach,
+  detach,
+  resetTraffic,
+  resetAllTraffics,
+  delDepleted,
+  setEnable,
+  applyTrafficEvent,
+  applyClientStatsEvent,
+  applyInvalidate,
+} = useClients();
+
+useWebSocket({
+  traffic: applyTrafficEvent,
+  client_stats: applyClientStatsEvent,
+  invalidate: applyInvalidate,
+});
+
+const togglingEmail = ref(null);
+
+async function onToggleEnable(row, next) {
+  togglingEmail.value = row.email;
+  try {
+    const msg = await setEnable(row, next);
+    if (!msg?.success) {
+      message.error(msg?.msg || t('somethingWentWrong'));
+    }
+  } finally {
+    togglingEmail.value = null;
+  }
+}
+
+const { isMobile } = useMediaQuery();
+const basePath = window.X_UI_BASE_PATH || '';
+const requestUri = window.location.pathname;
+
+const formOpen = ref(false);
+const formMode = ref('add');
+const editingClient = ref(null);
+const editingAttachedIds = ref([]);
+
+const infoOpen = ref(false);
+const infoClient = ref(null);
+
+const qrOpen = ref(false);
+const qrClient = ref(null);
+
+const bulkAddOpen = ref(false);
+const selectedRowKeys = ref([]);
+
+const rowSelection = computed(() => ({
+  selectedRowKeys: selectedRowKeys.value,
+  onChange: (keys) => { selectedRowKeys.value = keys; },
+}));
+
+function toggleSelect(email, checked) {
+  const cur = new Set(selectedRowKeys.value);
+  if (checked) cur.add(email);
+  else cur.delete(email);
+  selectedRowKeys.value = Array.from(cur);
+}
+
+function isSelected(email) {
+  return selectedRowKeys.value.includes(email);
+}
+
+function selectAll(checked) {
+  selectedRowKeys.value = checked ? filteredClients.value.map((c) => c.email) : [];
+}
+
+const allSelected = computed(
+  () => filteredClients.value.length > 0 && selectedRowKeys.value.length === filteredClients.value.length,
+);
+
+const someSelected = computed(
+  () => selectedRowKeys.value.length > 0 && selectedRowKeys.value.length < filteredClients.value.length,
+);
+
+function onBulkAdd() {
+  bulkAddOpen.value = true;
+}
+
+function onBulkDelete() {
+  const emails = [...selectedRowKeys.value];
+  if (emails.length === 0) return;
+  Modal.confirm({
+    title: t('pages.clients.bulkDeleteConfirmTitle', { count: emails.length }),
+    content: t('pages.clients.bulkDeleteConfirmContent'),
+    okText: t('delete'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    onOk: async () => {
+      const results = await removeMany(emails);
+      selectedRowKeys.value = [];
+      let ok = 0;
+      let failed = 0;
+      let firstError = '';
+      for (const msg of results) {
+        if (msg?.success) ok++;
+        else {
+          failed++;
+          if (!firstError && msg?.msg) firstError = msg.msg;
+        }
+      }
+      if (failed === 0) {
+        message.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
+      } else {
+        message.warning(firstError
+          ? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
+          : t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
+      }
+    },
+  });
+}
+
+async function onBulkAddSaved() {
+  bulkAddOpen.value = false;
+}
+
+function onDelDepleted() {
+  Modal.confirm({
+    title: t('pages.clients.delDepletedConfirmTitle'),
+    content: t('pages.clients.delDepletedConfirmContent'),
+    okText: t('delete'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    onOk: async () => {
+      const msg = await delDepleted();
+      if (msg?.success) {
+        const deleted = msg.obj?.deleted ?? 0;
+        message.success(t('pages.clients.toasts.delDepleted', { count: deleted }));
+      }
+    },
+  });
+}
+
+const FILTER_STATE_KEY = 'clientsFilterState';
+const savedFilterState = (() => {
+  try { return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}'); }
+  catch (_e) { return {}; }
+})();
+const enableFilter = ref(!!savedFilterState.enableFilter);
+const searchKey = ref(savedFilterState.searchKey || '');
+const filterBy = ref(savedFilterState.filterBy || '');
+const protocolFilter = ref(savedFilterState.protocolFilter || undefined);
+
+watch([enableFilter, searchKey, filterBy, protocolFilter], () => {
+  localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
+    enableFilter: enableFilter.value,
+    searchKey: searchKey.value,
+    filterBy: filterBy.value,
+    protocolFilter: protocolFilter.value,
+  }));
+});
+
+function onToggleFilter() {
+  if (enableFilter.value) searchKey.value = '';
+  else filterBy.value = '';
+}
+
+const protocolOptions = computed(() => {
+  const values = new Set((inbounds.value || []).map((i) => i.protocol).filter(Boolean));
+  return [...values].sort();
+});
+
+const onlineSet = computed(() => new Set(onlines.value || []));
+const inboundsById = computed(() => {
+  const out = {};
+  for (const ib of inbounds.value) out[ib.id] = ib;
+  return out;
+});
+
+function isOnline(email) {
+  return !!email && onlineSet.value.has(email);
+}
+
+function inboundLabel(id) {
+  const ib = inboundsById.value[id];
+  if (!ib) return `#${id}`;
+  return ib.remark ? `${ib.remark} (${ib.protocol}:${ib.port})` : `${ib.protocol}:${ib.port}`;
+}
+
+function clientBucket(row) {
+  if (!row) return null;
+  const traffic = row.traffic || {};
+  const used = (traffic.up || 0) + (traffic.down || 0);
+  const total = row.totalGB || 0;
+  const now = Date.now();
+  const expired = row.expiryTime > 0 && row.expiryTime <= now;
+  const exhausted = total > 0 && used >= total;
+  if (expired || exhausted) return 'depleted';
+  if (!row.enable) return 'deactive';
+  const nearExpiry = row.expiryTime > 0 && row.expiryTime - now < (expireDiff.value || 0);
+  const nearLimit = total > 0 && total - used < (trafficDiff.value || 0);
+  if (nearExpiry || nearLimit) return 'expiring';
+  return 'active';
+}
+
+function bucketTagColor(bucket) {
+  switch (bucket) {
+    case 'depleted': return 'red';
+    case 'expiring': return 'orange';
+    case 'deactive': return 'default';
+    case 'active': return 'green';
+    default: return 'default';
+  }
+}
+
+function clientMatchesProtocol(row, protocol) {
+  if (!protocol) return true;
+  const ids = Array.isArray(row.inboundIds) ? row.inboundIds : [];
+  for (const id of ids) {
+    const ib = inboundsById.value[id];
+    if (ib && ib.protocol === protocol) return true;
+  }
+  return false;
+}
+
+const filteredClients = computed(() => {
+  let rows = clients.value || [];
+  if (enableFilter.value) {
+    if (filterBy.value === 'online') {
+      rows = rows.filter((r) => r.enable && isOnline(r.email));
+    } else if (filterBy.value) {
+      rows = rows.filter((r) => clientBucket(r) === filterBy.value);
+    }
+  } else if (!ObjectUtil.isEmpty(searchKey.value)) {
+    rows = rows.filter((r) => ObjectUtil.deepSearch(r, searchKey.value));
+  }
+  if (protocolFilter.value) {
+    rows = rows.filter((r) => clientMatchesProtocol(r, protocolFilter.value));
+  }
+  return rows;
+});
+
+const summary = computed(() => {
+  const rows = clients.value || [];
+  const deactive = [];
+  const depleted = [];
+  const expiring = [];
+  const online = [];
+  let active = 0;
+  for (const row of rows) {
+    const bucket = clientBucket(row);
+    if (bucket === 'deactive') deactive.push(row.email);
+    else if (bucket === 'depleted') depleted.push(row.email);
+    else if (bucket === 'expiring') expiring.push(row.email);
+    else if (bucket === 'active') active++;
+    if (row.enable && isOnline(row.email)) online.push(row.email);
+  }
+  return { total: rows.length, active, deactive, depleted, expiring, online };
+});
+
+function onAdd() {
+  formMode.value = 'add';
+  editingClient.value = null;
+  editingAttachedIds.value = [];
+  formOpen.value = true;
+}
+
+function onEdit(row) {
+  formMode.value = 'edit';
+  editingClient.value = { ...row };
+  editingAttachedIds.value = Array.isArray(row.inboundIds) ? [...row.inboundIds] : [];
+  formOpen.value = true;
+}
+
+function onDelete(row) {
+  Modal.confirm({
+    title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
+    content: t('pages.clients.deleteConfirmContent'),
+    okText: t('delete'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    onOk: async () => {
+      const msg = await remove(row.email);
+      if (msg?.success) message.success(t('pages.clients.toasts.deleted'));
+    },
+  });
+}
+
+function onResetTraffic(row) {
+  if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) {
+    message.warning(t('pages.clients.resetNotPossible'));
+    return;
+  }
+  Modal.confirm({
+    title: `${t('pages.inbounds.resetTraffic')} — ${row.email}`,
+    content: t('pages.inbounds.resetTrafficContent'),
+    okText: t('reset'),
+    cancelText: t('cancel'),
+    onOk: async () => {
+      const msg = await resetTraffic(row);
+      if (msg?.success) message.success(t('pages.clients.toasts.trafficReset'));
+    },
+  });
+}
+
+function onShowInfo(row) {
+  infoClient.value = row;
+  infoOpen.value = true;
+}
+
+function onShowQr(row) {
+  qrClient.value = row;
+  qrOpen.value = true;
+}
+
+function onResetAllTraffics() {
+  Modal.confirm({
+    title: t('pages.clients.resetAllTrafficsTitle'),
+    content: t('pages.clients.resetAllTrafficsContent'),
+    okText: t('reset'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    onOk: async () => {
+      const msg = await resetAllTraffics();
+      if (msg?.success) message.success(t('pages.clients.toasts.allTrafficsReset'));
+    },
+  });
+}
+
+async function onSave(payload, meta) {
+  if (!meta?.isEdit) {
+    return create(payload);
+  }
+  const updateMsg = await update(meta.email, payload);
+  if (!updateMsg?.success) return updateMsg;
+  if (Array.isArray(meta.attach) && meta.attach.length > 0) {
+    const r = await attach(meta.email, meta.attach);
+    if (!r?.success) return r;
+  }
+  if (Array.isArray(meta.detach) && meta.detach.length > 0) {
+    const r = await detach(meta.email, meta.detach);
+    if (!r?.success) return r;
+  }
+  return updateMsg;
+}
+
+function trafficLabel(row) {
+  const t0 = row.traffic;
+  if (!t0) return '-';
+  const used = (t0.up || 0) + (t0.down || 0);
+  const total = row.totalGB || 0;
+  if (total <= 0) return `${SizeFormatter.sizeFormat(used)} / ∞`;
+  return `${SizeFormatter.sizeFormat(used)} / ${SizeFormatter.sizeFormat(total)}`;
+}
+
+function remainingLabel(row) {
+  const total = row.totalGB || 0;
+  if (total <= 0) return '∞';
+  const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
+  const r = total - used;
+  return r > 0 ? SizeFormatter.sizeFormat(r) : '0';
+}
+
+function remainingColor(row) {
+  const total = row.totalGB || 0;
+  if (total <= 0) return 'purple';
+  const used = (row.traffic?.up || 0) + (row.traffic?.down || 0);
+  const ratio = used / total;
+  if (ratio >= 1) return 'red';
+  if (ratio >= 0.85) return 'orange';
+  return 'green';
+}
+
+function expiryLabel(row) {
+  if (!row.expiryTime) return '∞';
+  if (row.expiryTime < 0) {
+    const days = Math.round(row.expiryTime / -86400000);
+    return `${t('pages.clients.delayedStart')}: ${days}d`;
+  }
+  return IntlUtil.formatDate(row.expiryTime);
+}
+
+function expiryRelative(row) {
+  if (!row.expiryTime) return '';
+  if (row.expiryTime < 0) {
+    const days = Math.round(row.expiryTime / -86400000);
+    return `${days}d`;
+  }
+  return IntlUtil.formatRelativeTime(row.expiryTime);
+}
+
+function expiryColor(row) {
+  if (!row.expiryTime) return 'purple';
+  if (row.expiryTime < 0) return 'blue';
+  const now = Date.now();
+  if (row.expiryTime <= now) return 'red';
+  if (row.expiryTime - now < 86400 * 1000 * 3) return 'orange';
+  return 'green';
+}
+
+const sortState = ref({ column: null, order: null });
+const paginationState = ref({ current: 1, pageSize: 20 });
+
+function sortableCol(col, key) {
+  return {
+    ...col,
+    sorter: true,
+    showSorterTooltip: false,
+    sortOrder: sortState.value.column === key ? sortState.value.order : null,
+    sortDirections: ['ascend', 'descend'],
+  };
+}
+
+const sortFns = {
+  enable: (a, b) => Number(a.enable) - Number(b.enable),
+  email: (a, b) => (a.email || '').localeCompare(b.email || ''),
+  inboundIds: (a, b) => (a.inboundIds?.length || 0) - (b.inboundIds?.length || 0),
+  traffic: (a, b) => {
+    const ua = (a.traffic?.up || 0) + (a.traffic?.down || 0);
+    const ub = (b.traffic?.up || 0) + (b.traffic?.down || 0);
+    return ua - ub;
+  },
+  remaining: (a, b) => {
+    const ra = a.totalGB > 0 ? a.totalGB - ((a.traffic?.up || 0) + (a.traffic?.down || 0)) : Infinity;
+    const rb = b.totalGB > 0 ? b.totalGB - ((b.traffic?.up || 0) + (b.traffic?.down || 0)) : Infinity;
+    return ra - rb;
+  },
+  expiryTime: (a, b) => {
+    const ea = a.expiryTime > 0 ? a.expiryTime : Infinity;
+    const eb = b.expiryTime > 0 ? b.expiryTime : Infinity;
+    return ea - eb;
+  },
+};
+
+const sortedClients = computed(() => {
+  const { column, order } = sortState.value;
+  const rows = filteredClients.value;
+  if (!column || !order) return rows;
+  const fn = sortFns[column];
+  if (!fn) return rows;
+  const sorted = [...rows].sort(fn);
+  return order === 'descend' ? sorted.reverse() : sorted;
+});
+
+function onTableChange(pag, _filters, sorter) {
+  if (pag) {
+    paginationState.value = {
+      current: pag.current || 1,
+      pageSize: pag.pageSize || paginationState.value.pageSize,
+    };
+  }
+  sortState.value = {
+    column: sorter?.columnKey || sorter?.field || null,
+    order: sorter?.order || null,
+  };
+}
+
+const tablePagination = computed(() => ({
+  current: paginationState.value.current,
+  pageSize: paginationState.value.pageSize,
+  total: sortedClients.value.length,
+  showSizeChanger: sortedClients.value.length > 10,
+  pageSizeOptions: ['10', '20', '50', '100'],
+  hideOnSinglePage: sortedClients.value.length <= paginationState.value.pageSize,
+}));
+
+const columns = computed(() => [
+  { title: t('pages.clients.actions'), key: 'actions', width: 200 },
+  sortableCol({ title: t('pages.clients.enabled'), key: 'enable', width: 80 }, 'enable'),
+  { title: t('pages.clients.online'), key: 'online', width: 90 },
+  sortableCol({ title: t('pages.clients.client'), key: 'email' }, 'email'),
+  sortableCol({ title: t('pages.clients.attachedInbounds'), key: 'inboundIds' }, 'inboundIds'),
+  sortableCol({ title: t('pages.clients.traffic'), key: 'traffic' }, 'traffic'),
+  sortableCol({ title: t('pages.clients.remaining'), key: 'remaining', width: 130 }, 'remaining'),
+  sortableCol({ title: t('pages.clients.duration'), key: 'expiryTime' }, 'expiryTime'),
+]);
+</script>
+
+<template>
+  <a-config-provider :theme="antdThemeConfig">
+    <a-layout class="clients-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
+      <AppSidebar :base-path="basePath" :request-uri="requestUri" />
+
+      <a-layout class="content-shell">
+        <a-layout-content id="content-layout" class="content-area">
+          <a-spin :spinning="!fetched" :delay="200" :tip="t('loading')" size="large">
+            <div v-if="!fetched" class="loading-spacer" />
+
+            <a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
+              <a-col :span="24">
+                <a-card size="small" hoverable class="summary-card">
+                  <a-row :gutter="[16, 12]">
+                    <a-col :xs="12" :sm="8" :md="4">
+                      <CustomStatistic :title="t('clients')" :value="String(summary.total)">
+                        <template #prefix>
+                          <TeamOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                    <a-col :xs="12" :sm="8" :md="4">
+                      <a-popover :title="t('online')" :open="summary.online.length ? undefined : false">
+                        <template #content>
+                          <div class="client-email-list">
+                            <div v-for="email in summary.online" :key="email">{{ email }}</div>
+                          </div>
+                        </template>
+                        <CustomStatistic :title="t('online')" :value="String(summary.online.length)">
+                          <template #prefix>
+                            <span class="dot dot-blue" />
+                          </template>
+                        </CustomStatistic>
+                      </a-popover>
+                    </a-col>
+                    <a-col :xs="12" :sm="8" :md="4">
+                      <a-popover :title="t('depleted')" :open="summary.depleted.length ? undefined : false">
+                        <template #content>
+                          <div class="client-email-list">
+                            <div v-for="email in summary.depleted" :key="email">{{ email }}</div>
+                          </div>
+                        </template>
+                        <CustomStatistic :title="t('depleted')" :value="String(summary.depleted.length)">
+                          <template #prefix>
+                            <span class="dot dot-red" />
+                          </template>
+                        </CustomStatistic>
+                      </a-popover>
+                    </a-col>
+                    <a-col :xs="12" :sm="8" :md="4">
+                      <a-popover :title="t('depletingSoon')" :open="summary.expiring.length ? undefined : false">
+                        <template #content>
+                          <div class="client-email-list">
+                            <div v-for="email in summary.expiring" :key="email">{{ email }}</div>
+                          </div>
+                        </template>
+                        <CustomStatistic :title="t('depletingSoon')" :value="String(summary.expiring.length)">
+                          <template #prefix>
+                            <span class="dot dot-orange" />
+                          </template>
+                        </CustomStatistic>
+                      </a-popover>
+                    </a-col>
+                    <a-col :xs="12" :sm="8" :md="4">
+                      <a-popover :title="t('disabled')" :open="summary.deactive.length ? undefined : false">
+                        <template #content>
+                          <div class="client-email-list">
+                            <div v-for="email in summary.deactive" :key="email">{{ email }}</div>
+                          </div>
+                        </template>
+                        <CustomStatistic :title="t('disabled')" :value="String(summary.deactive.length)">
+                          <template #prefix>
+                            <span class="dot dot-gray" />
+                          </template>
+                        </CustomStatistic>
+                      </a-popover>
+                    </a-col>
+                    <a-col :xs="12" :sm="8" :md="4">
+                      <CustomStatistic :title="t('subscription.active')" :value="String(summary.active)">
+                        <template #prefix>
+                          <span class="dot dot-green" />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                  </a-row>
+                </a-card>
+              </a-col>
+
+              <a-col :span="24">
+                <a-card size="small">
+                  <template #title>
+                    <div class="card-toolbar">
+                      <a-button type="primary" size="small" @click="onAdd">
+                        <template #icon>
+                          <PlusOutlined />
+                        </template>
+                        <template v-if="!isMobile">{{ t('pages.clients.addClients') }}</template>
+                      </a-button>
+                      <a-button size="small" @click="onBulkAdd">
+                        <template #icon>
+                          <UsergroupAddOutlined />
+                        </template>
+                        <template v-if="!isMobile">{{ t('pages.clients.bulk') }}</template>
+                      </a-button>
+                      <a-button v-if="selectedRowKeys.length > 0" danger size="small" @click="onBulkDelete">
+                        <template #icon>
+                          <DeleteOutlined />
+                        </template>
+                        {{ t('pages.clients.deleteSelected', { count: selectedRowKeys.length }) }}
+                      </a-button>
+                      <a-button size="small" @click="onResetAllTraffics">
+                        <template #icon>
+                          <RetweetOutlined />
+                        </template>
+                        <template v-if="!isMobile">{{ t('pages.clients.resetAllTraffics') }}</template>
+                      </a-button>
+                      <a-button size="small" danger @click="onDelDepleted">
+                        <template #icon>
+                          <RestOutlined />
+                        </template>
+                        <template v-if="!isMobile">{{ t('pages.clients.delDepleted') }}</template>
+                      </a-button>
+                    </div>
+                  </template>
+
+                  <div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
+                    <a-switch v-model:checked="enableFilter" @change="onToggleFilter">
+                      <template #checkedChildren>
+                        <SearchOutlined />
+                      </template>
+                      <template #unCheckedChildren>
+                        <FilterOutlined />
+                      </template>
+                    </a-switch>
+                    <a-input v-if="!enableFilter" v-model:value="searchKey" :placeholder="t('search')" autofocus
+                      :size="isMobile ? 'small' : 'middle'" :style="{ maxWidth: '300px' }" />
+                    <a-radio-group v-if="enableFilter" v-model:value="filterBy" button-style="solid"
+                      :size="isMobile ? 'small' : 'middle'">
+                      <a-radio-button value="">{{ t('none') }}</a-radio-button>
+                      <a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
+                      <a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
+                      <a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
+                      <a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
+                      <a-radio-button value="online">{{ t('online') }}</a-radio-button>
+                    </a-radio-group>
+                    <a-select v-model:value="protocolFilter" allow-clear :placeholder="t('pages.inbounds.protocol')"
+                      :size="isMobile ? 'small' : 'middle'" :style="{ width: '150px' }">
+                      <a-select-option v-for="protocol in protocolOptions" :key="protocol" :value="protocol">
+                        {{ protocol }}
+                      </a-select-option>
+                    </a-select>
+                  </div>
+
+                  <a-table v-if="!isMobile" :columns="columns" :data-source="sortedClients" :loading="loading" row-key="email"
+                    :row-selection="rowSelection" :pagination="tablePagination" size="small" @change="onTableChange">
+                    <template #bodyCell="{ column, record }">
+                      <template v-if="column.key === 'email'">
+                        <div class="email-cell">
+                          <span class="email">{{ record.email }}</span>
+                          <span v-if="record.subId" class="sub" :title="record.subId">{{ record.subId }}</span>
+                        </div>
+                      </template>
+                      <template v-else-if="column.key === 'online'">
+                        <a-tag v-if="clientBucket(record) === 'depleted'" color="red">
+                          {{ t('depleted') }}
+                        </a-tag>
+                        <a-tag v-else-if="record.enable && isOnline(record.email)" color="green">
+                          {{ t('pages.clients.online') }}
+                        </a-tag>
+                        <a-tag v-else-if="!record.enable">{{ t('disabled') }}</a-tag>
+                        <a-tag v-else-if="clientBucket(record) === 'expiring'" color="orange">
+                          {{ t('depletingSoon') }}
+                        </a-tag>
+                        <a-tag v-else>{{ t('pages.clients.offline') }}</a-tag>
+                      </template>
+                      <template v-else-if="column.key === 'inboundIds'">
+                        <a-tag v-for="id in record.inboundIds" :key="id" color="blue" style="margin: 2px">
+                          {{ inboundLabel(id) }}
+                        </a-tag>
+                        <span v-if="!record.inboundIds || record.inboundIds.length === 0"
+                          style="color: rgba(0,0,0,0.45)">—</span>
+                      </template>
+                      <template v-else-if="column.key === 'traffic'">
+                        {{ trafficLabel(record) }}
+                      </template>
+                      <template v-else-if="column.key === 'remaining'">
+                        <a-tag :color="remainingColor(record)">{{ remainingLabel(record) }}</a-tag>
+                      </template>
+                      <template v-else-if="column.key === 'expiryTime'">
+                        <a-tooltip :title="expiryLabel(record)">
+                          <a-tag :color="expiryColor(record)">
+                            {{ record.expiryTime ? expiryRelative(record) : '∞' }}
+                          </a-tag>
+                        </a-tooltip>
+                      </template>
+                      <template v-else-if="column.key === 'enable'">
+                        <a-switch :checked="record.enable" size="small" :loading="togglingEmail === record.email"
+                          @change="(next) => onToggleEnable(record, next)" />
+                      </template>
+                      <template v-else-if="column.key === 'actions'">
+                        <a-space :size="4">
+                          <a-tooltip :title="t('pages.clients.qrCode')">
+                            <a-button size="small" type="text" @click="onShowQr(record)">
+                              <QrcodeOutlined />
+                            </a-button>
+                          </a-tooltip>
+                          <a-tooltip :title="t('pages.clients.moreInformation')">
+                            <a-button size="small" type="text" @click="onShowInfo(record)">
+                              <InfoCircleOutlined />
+                            </a-button>
+                          </a-tooltip>
+                          <a-tooltip :title="t('pages.inbounds.resetTraffic')">
+                            <a-button size="small" type="text" @click="onResetTraffic(record)">
+                              <RetweetOutlined />
+                            </a-button>
+                          </a-tooltip>
+                          <a-tooltip :title="t('edit')">
+                            <a-button size="small" type="text" @click="onEdit(record)">
+                              <EditOutlined />
+                            </a-button>
+                          </a-tooltip>
+                          <a-tooltip :title="t('delete')">
+                            <a-button size="small" type="text" danger @click="onDelete(record)">
+                              <DeleteOutlined />
+                            </a-button>
+                          </a-tooltip>
+                        </a-space>
+                      </template>
+                    </template>
+
+                    <template #emptyText>
+                      <div class="clients-empty">
+                        <UserOutlined style="font-size: 32px; margin-bottom: 8px" />
+                        <div>{{ t('pages.clients.empty') }}</div>
+                      </div>
+                    </template>
+                  </a-table>
+
+                  <a-spin v-else :spinning="loading">
+                    <div class="client-cards">
+                      <div v-if="filteredClients.length > 0" class="card-bulk-bar">
+                        <a-checkbox :checked="allSelected" :indeterminate="someSelected"
+                          @change="(e) => selectAll(e.target.checked)">
+                          {{ t('pages.clients.selectAll') }}
+                        </a-checkbox>
+                        <span v-if="selectedRowKeys.length > 0" class="bulk-count">
+                          {{ selectedRowKeys.length }}
+                        </span>
+                      </div>
+
+                      <div v-if="filteredClients.length === 0" class="card-empty">
+                        <UserOutlined style="font-size: 28px; opacity: 0.5" />
+                        <div>{{ t('pages.clients.empty') }}</div>
+                      </div>
+
+                      <div v-for="row in filteredClients" :key="row.email" class="client-card"
+                        :class="{ 'is-selected': isSelected(row.email) }">
+                        <div class="card-head">
+                          <a-checkbox :checked="isSelected(row.email)"
+                            @change="(e) => toggleSelect(row.email, e.target.checked)" />
+                          <a-badge :color="bucketTagColor(clientBucket(row))" />
+                          <span class="tag-name">{{ row.email }}</span>
+                          <a-tag v-if="clientBucket(row) === 'depleted'" color="red" class="status-tag">
+                            {{ t('depleted') }}
+                          </a-tag>
+                          <a-tag v-else-if="clientBucket(row) === 'expiring'" color="orange" class="status-tag">
+                            {{ t('depletingSoon') }}
+                          </a-tag>
+                          <div class="card-actions" @click.stop>
+                            <a-tooltip :title="t('pages.clients.moreInformation')">
+                              <InfoCircleOutlined class="row-action-trigger" @click="onShowInfo(row)" />
+                            </a-tooltip>
+                            <a-switch :checked="row.enable" size="small" :loading="togglingEmail === row.email"
+                              @change="(next) => onToggleEnable(row, next)" />
+                            <a-dropdown :trigger="['click']" placement="bottomRight">
+                              <MoreOutlined class="row-action-trigger" @click.prevent />
+                              <template #overlay>
+                                <a-menu>
+                                  <a-menu-item key="qr" @click="onShowQr(row)">
+                                    <QrcodeOutlined /> {{ t('pages.clients.qrCode') }}
+                                  </a-menu-item>
+                                  <a-menu-item key="reset" @click="onResetTraffic(row)">
+                                    <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
+                                  </a-menu-item>
+                                  <a-menu-item key="edit" @click="onEdit(row)">
+                                    <EditOutlined /> {{ t('edit') }}
+                                  </a-menu-item>
+                                  <a-menu-item key="delete" class="danger-item" @click="onDelete(row)">
+                                    <DeleteOutlined /> {{ t('delete') }}
+                                  </a-menu-item>
+                                </a-menu>
+                              </template>
+                            </a-dropdown>
+                          </div>
+                        </div>
+                      </div>
+                    </div>
+                  </a-spin>
+                </a-card>
+              </a-col>
+            </a-row>
+          </a-spin>
+        </a-layout-content>
+      </a-layout>
+
+      <ClientFormModal v-model:open="formOpen" :mode="formMode" :client="editingClient"
+        :attached-ids="editingAttachedIds" :inbounds="inbounds" :ip-limit-enable="ipLimitEnable"
+        :tg-bot-enable="tgBotEnable" :save="onSave" />
+      <ClientInfoModal v-model:open="infoOpen" :client="infoClient" :inbounds-by-id="inboundsById"
+        :is-online="infoClient ? isOnline(infoClient.email) : false" :sub-settings="subSettings" />
+      <ClientQrModal v-model:open="qrOpen" :client="qrClient" :sub-settings="subSettings" />
+      <ClientBulkAddModal v-model:open="bulkAddOpen" :inbounds="inbounds" :ip-limit-enable="ipLimitEnable"
+        @saved="onBulkAddSaved" />
+    </a-layout>
+  </a-config-provider>
+</template>
+
+<style scoped>
+.clients-page {
+  --bg-page: #e6e8ec;
+  --bg-card: #ffffff;
+  min-height: 100vh;
+  background: var(--bg-page);
+}
+
+.clients-page.is-dark {
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
+}
+
+.clients-page.is-dark.is-ultra {
+  --bg-page: #050505;
+  --bg-card: #0c0e12;
+}
+
+.clients-page :deep(.ant-layout),
+.clients-page :deep(.ant-layout-content) {
+  background: transparent;
+}
+
+.content-shell {
+  background: transparent;
+}
+
+.filter-bar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 12px;
+}
+
+.filter-bar.mobile {
+  gap: 6px;
+  margin-bottom: 8px;
+}
+
+.filter-bar.mobile > * {
+  flex: 0 0 auto;
+}
+
+.content-area {
+  padding: 24px;
+}
+
+@media (max-width: 768px) {
+  .content-area {
+    padding: 8px;
+  }
+}
+
+.loading-spacer {
+  min-height: calc(100vh - 120px);
+}
+
+.summary-card {
+  padding: 16px;
+}
+
+@media (max-width: 768px) {
+  .summary-card {
+    padding: 8px;
+  }
+}
+
+.dot {
+  display: inline-block;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  margin-right: 4px;
+  vertical-align: middle;
+}
+
+.dot-green { background: #52c41a; }
+.dot-blue { background: #1677ff; }
+.dot-red { background: #ff4d4f; }
+.dot-orange { background: #fa8c16; }
+.dot-gray { background: rgba(128, 128, 128, 0.6); }
+
+.status-tag {
+  margin: 0 0 0 4px;
+  font-size: 11px;
+  padding: 0 6px;
+  line-height: 18px;
+}
+
+.card-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.card-title {
+  font-weight: 600;
+  margin-right: 4px;
+}
+
+.email-cell {
+  display: flex;
+  flex-direction: column;
+}
+
+.email {
+  font-weight: 500;
+}
+
+.sub {
+  font-size: 11px;
+  opacity: 0.55;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 220px;
+}
+
+.client-cards {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin-top: 4px;
+}
+
+.card-bulk-bar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 4px 4px 8px;
+}
+
+.bulk-count {
+  font-size: 12px;
+  background: rgba(22, 119, 255, 0.12);
+  color: var(--ant-color-primary, #1677ff);
+  padding: 1px 8px;
+  border-radius: 10px;
+}
+
+.client-card {
+  border: 1px solid rgba(128, 128, 128, 0.2);
+  border-radius: 10px;
+  padding: 10px 12px;
+  background: rgba(255, 255, 255, 0.02);
+}
+
+.client-card.is-selected {
+  border-color: var(--ant-color-primary, #1677ff);
+  background: rgba(22, 119, 255, 0.06);
+}
+
+:global(body.dark) .client-card {
+  background: rgba(255, 255, 255, 0.03);
+  border-color: rgba(255, 255, 255, 0.1);
+}
+
+.card-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  user-select: none;
+}
+
+.card-head .tag-name {
+  font-weight: 600;
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.card-actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-shrink: 0;
+}
+
+.row-action-trigger {
+  font-size: 18px;
+  cursor: pointer;
+  opacity: 0.75;
+  transition: opacity 120ms ease;
+}
+
+.row-action-trigger:hover {
+  opacity: 1;
+}
+
+.card-empty {
+  text-align: center;
+  padding: 40px 16px;
+  opacity: 0.55;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
+}
+
+.clients-empty {
+  padding: 32px 0;
+  text-align: center;
+  opacity: 0.55;
+}
+
+.danger-item {
+  color: #ff4d4f;
+}
+</style>
+
+<style>
+/* AD-Vue popovers teleport their content to <body>, so scoped styles
+   don't reach them — this block has to be unscoped. */
+.client-email-list {
+  max-height: 280px;
+  min-width: 160px;
+  overflow-y: auto;
+  padding-right: 4px;
+}
+
+.client-email-list > div {
+  padding: 2px 0;
+  font-size: 12px;
+  white-space: nowrap;
+}
+</style>

+ 217 - 0
frontend/src/pages/clients/useClients.js

@@ -0,0 +1,217 @@
+import { onMounted, ref, shallowRef } from 'vue';
+import { HttpUtil } from '@/utils';
+
+const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
+
+export function useClients() {
+  const clients = shallowRef([]);
+  const inbounds = shallowRef([]);
+  const onlines = ref([]);
+  const loading = ref(false);
+  const fetched = ref(false);
+  const subSettings = ref({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false });
+  const ipLimitEnable = ref(false);
+  const tgBotEnable = ref(false);
+  const expireDiff = ref(0);
+  const trafficDiff = ref(0);
+
+  async function refresh() {
+    loading.value = true;
+    try {
+      const [clientsMsg, inboundsMsg] = await Promise.all([
+        HttpUtil.get('/panel/api/clients/list'),
+        HttpUtil.get('/panel/api/inbounds/options'),
+      ]);
+      if (clientsMsg?.success) {
+        clients.value = Array.isArray(clientsMsg.obj) ? clientsMsg.obj : [];
+      }
+      if (inboundsMsg?.success) {
+        inbounds.value = Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : [];
+      }
+      fetched.value = true;
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  async function fetchSubSettings() {
+    const msg = await HttpUtil.post('/panel/setting/defaultSettings');
+    if (!msg?.success) return;
+    const s = msg.obj || {};
+    subSettings.value = {
+      enable: !!s.subEnable,
+      subURI: s.subURI || '',
+      subJsonURI: s.subJsonURI || '',
+      subJsonEnable: !!s.subJsonEnable,
+    };
+    ipLimitEnable.value = !!s.ipLimitEnable;
+    tgBotEnable.value = !!s.tgBotEnable;
+    expireDiff.value = (s.expireDiff ?? 0) * 86400000;
+    trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
+  }
+
+  async function create(payload) {
+    const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function update(email, client) {
+    if (!email) return null;
+    const encoded = encodeURIComponent(email);
+    const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function remove(email, keepTraffic = false) {
+    if (!email) return null;
+    const encoded = encodeURIComponent(email);
+    const url = keepTraffic
+      ? `/panel/api/clients/del/${encoded}?keepTraffic=1`
+      : `/panel/api/clients/del/${encoded}`;
+    const msg = await HttpUtil.post(url);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function removeMany(emails, keepTraffic = false) {
+    if (!Array.isArray(emails) || emails.length === 0) return [];
+    const suffix = keepTraffic ? '?keepTraffic=1' : '';
+    const silentOpts = { silent: true };
+    const results = await Promise.all(emails.map((email) => {
+      const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
+      return HttpUtil.post(url, undefined, silentOpts);
+    }));
+    await refresh();
+    return results;
+  }
+
+  async function attach(email, inboundIds) {
+    if (!email) return null;
+    const encoded = encodeURIComponent(email);
+    const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function detach(email, inboundIds) {
+    if (!email) return null;
+    const encoded = encodeURIComponent(email);
+    const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function resetTraffic(client) {
+    if (!client?.email) return null;
+    const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`;
+    const msg = await HttpUtil.post(url);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function resetAllTraffics() {
+    const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics');
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function delDepleted() {
+    const msg = await HttpUtil.post('/panel/api/clients/delDepleted');
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function setEnable(client, enable) {
+    if (!client?.email) return null;
+    const payload = {
+      email: client.email,
+      subId: client.subId,
+      id: client.uuid,
+      password: client.password,
+      auth: client.auth,
+      totalGB: client.totalGB || 0,
+      expiryTime: client.expiryTime || 0,
+      limitIp: client.limitIp || 0,
+      comment: client.comment || '',
+      enable: !!enable,
+    };
+    return update(client.email, payload);
+  }
+
+  function applyTrafficEvent(payload) {
+    if (!payload || typeof payload !== 'object') return;
+    if (Array.isArray(payload.onlineClients)) {
+      onlines.value = payload.onlineClients;
+    }
+  }
+
+  function applyClientStatsEvent(payload) {
+    if (!payload || typeof payload !== 'object') return;
+    if (!Array.isArray(payload.clients) || payload.clients.length === 0) return;
+    const byEmail = new Map();
+    for (const row of payload.clients) {
+      if (row && row.email) byEmail.set(row.email, row);
+    }
+    let touched = false;
+    const next = clients.value || [];
+    for (let i = 0; i < next.length; i++) {
+      const row = next[i];
+      const upd = byEmail.get(row?.email);
+      if (!upd) continue;
+      const merged = { ...(row.traffic || {}) };
+      if (typeof upd.up === 'number') merged.up = upd.up;
+      if (typeof upd.down === 'number') merged.down = upd.down;
+      if (typeof upd.total === 'number') merged.total = upd.total;
+      if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
+      if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
+      if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
+      next[i] = { ...row, traffic: merged };
+      touched = true;
+    }
+    if (touched) clients.value = [...next];
+  }
+
+  let invalidateTimer = null;
+  function applyInvalidate(payload) {
+    if (!payload || typeof payload !== 'object') return;
+    if (payload.type !== 'inbounds' && payload.type !== 'clients') return;
+    if (invalidateTimer) clearTimeout(invalidateTimer);
+    invalidateTimer = setTimeout(() => {
+      invalidateTimer = null;
+      refresh();
+    }, 200);
+  }
+
+  onMounted(async () => {
+    await Promise.all([refresh(), fetchSubSettings()]);
+  });
+
+  return {
+    clients,
+    inbounds,
+    onlines,
+    loading,
+    fetched,
+    subSettings,
+    ipLimitEnable,
+    tgBotEnable,
+    expireDiff,
+    trafficDiff,
+    refresh,
+    create,
+    update,
+    remove,
+    removeMany,
+    attach,
+    detach,
+    resetTraffic,
+    resetAllTraffics,
+    delDepleted,
+    setEnable,
+    applyTrafficEvent,
+    applyClientStatsEvent,
+    applyInvalidate,
+  };
+}

+ 0 - 280
frontend/src/pages/inbounds/ClientBulkModal.vue

@@ -1,280 +0,0 @@
-<script setup>
-import { computed, reactive, ref, watch } from 'vue';
-import { useI18n } from 'vue-i18n';
-import dayjs from 'dayjs';
-import { SyncOutlined } from '@ant-design/icons-vue';
-
-import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
-
-const { t } = useI18n();
-import {
-  Inbound,
-  Protocols,
-  USERS_SECURITY,
-  TLS_FLOW_CONTROL,
-} from '@/models/inbound.js';
-import DateTimePicker from '@/components/DateTimePicker.vue';
-
-// Bulk-add up to 500 clients in one go. The legacy panel offers five
-// generation modes — this component preserves them all:
-//   0: Random         — N fully-random emails (no prefix)
-//   1: Random+Prefix  — N random emails preceded by `prefix`
-//   2: Random+Prefix+Num     — emails like `<rand><prefix><num>` for num in [first..last]
-//   3: Random+Prefix+Num+Postfix — same + appended postfix
-//   4: Prefix+Num+Postfix    — no random part, just `<prefix><num><postfix>`
-
-const props = defineProps({
-  open: { type: Boolean, default: false },
-  dbInbound: { type: Object, default: null },
-  subEnable: { type: Boolean, default: false },
-  tgBotEnable: { type: Boolean, default: false },
-  ipLimitEnable: { type: Boolean, default: false },
-});
-
-const emit = defineEmits(['update:open', 'saved']);
-
-const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
-const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
-
-// === Reactive form state ===========================================
-// Cloned inbound (so canEnableTlsFlow() works).
-const inbound = ref(null);
-const saving = ref(false);
-const delayedStart = ref(false);
-
-const form = reactive({
-  emailMethod: 0,
-  firstNum: 1,
-  lastNum: 1,
-  emailPrefix: '',
-  emailPostfix: '',
-  quantity: 1,
-  security: USERS_SECURITY.AUTO,
-  flow: '',
-  subId: '',
-  tgId: 0,
-  comment: '',
-  limitIp: 0,
-  totalGB: 0,
-  expiryTime: 0, // ms epoch; negative => delayed start days
-  reset: 0,
-});
-
-const expiryDate = computed({
-  get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
-  set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
-});
-
-const delayedExpireDays = computed({
-  get: () => (form.expiryTime < 0 ? form.expiryTime / -86400000 : 0),
-  set: (days) => { form.expiryTime = -86400000 * (days || 0); },
-});
-
-watch(() => props.open, (next) => {
-  if (!next) return;
-  if (!props.dbInbound) return;
-  inbound.value = Inbound.fromJson(props.dbInbound.toInbound().toJson());
-  // Reset all form fields on every open — bulk add is intentionally
-  // stateless between sessions (legacy resets on .show()).
-  form.emailMethod = 0;
-  form.firstNum = 1;
-  form.lastNum = 1;
-  form.emailPrefix = '';
-  form.emailPostfix = '';
-  form.quantity = 1;
-  form.security = USERS_SECURITY.AUTO;
-  form.flow = '';
-  form.subId = '';
-  form.tgId = 0;
-  form.comment = '';
-  form.limitIp = 0;
-  form.totalGB = 0;
-  form.expiryTime = 0;
-  form.reset = 0;
-  delayedStart.value = false;
-});
-
-function close() {
-  emit('update:open', false);
-}
-
-function makeNewClient(parsed) {
-  switch (parsed.protocol) {
-    case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
-    case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
-    case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
-    case Protocols.SHADOWSOCKS: {
-      const method = parsed.settings.shadowsockses[0]?.method || parsed.settings.method;
-      return new Inbound.ShadowsocksSettings.Shadowsocks(method);
-    }
-    case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
-    default: return null;
-  }
-}
-
-function buildClients() {
-  if (!inbound.value) return [];
-  const out = [];
-  const method = form.emailMethod;
-  let start;
-  let end;
-  if (method > 1) {
-    start = form.firstNum;
-    end = form.lastNum + 1;
-  } else {
-    start = 0;
-    end = form.quantity;
-  }
-  const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
-  const useNum = method > 1;
-  const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
-
-  for (let i = start; i < end; i++) {
-    const c = makeNewClient(inbound.value);
-    if (!c) continue;
-    if (method === 4) c.email = '';
-    c.email += useNum ? prefix + String(i) + postfix : prefix + postfix;
-
-    if (form.subId.length > 0) c.subId = form.subId;
-    c.tgId = form.tgId;
-    if (form.comment.length > 0) c.comment = form.comment;
-    c.security = form.security;
-    c.limitIp = form.limitIp;
-    // Use the clien's totalGB setter (ms epoch and bytes already handled
-    // identically for bulk and single client paths).
-    c.totalGB = Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB);
-    c.expiryTime = form.expiryTime;
-    if (inbound.value.canEnableTlsFlow()) c.flow = form.flow;
-    c.reset = form.reset;
-    out.push(c);
-  }
-  return out;
-}
-
-async function submit() {
-  const clients = buildClients();
-  if (clients.length === 0) return;
-
-  saving.value = true;
-  try {
-    const payload = {
-      id: props.dbInbound.id,
-      // Clients all serialize via toString() — same shape the single-
-      // client modal posts. Joining with `,` lets the Go side parse the
-      // outer array directly.
-      settings: `{"clients": [${clients.map((c) => c.toString()).join(',')}]}`,
-    };
-    const msg = await HttpUtil.post('/panel/api/inbounds/addClient', payload);
-    if (msg?.success) {
-      emit('saved');
-      close();
-    }
-  } finally {
-    saving.value = false;
-  }
-}
-</script>
-
-<template>
-  <a-modal :open="open" :title="t('pages.client.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
-    :confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
-    <a-form v-if="inbound" :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
-      <a-form-item :label="t('pages.client.method')">
-        <a-select v-model:value="form.emailMethod">
-          <a-select-option :value="0">Random</a-select-option>
-          <a-select-option :value="1">Random + Prefix</a-select-option>
-          <a-select-option :value="2">Random + Prefix + Num</a-select-option>
-          <a-select-option :value="3">Random + Prefix + Num + Postfix</a-select-option>
-          <a-select-option :value="4">Prefix + Num + Postfix</a-select-option>
-        </a-select>
-      </a-form-item>
-
-      <a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.first')">
-        <a-input-number v-model:value="form.firstNum" :min="1" />
-      </a-form-item>
-      <a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.last')">
-        <a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
-      </a-form-item>
-      <a-form-item v-if="form.emailMethod > 0" :label="t('pages.client.prefix')">
-        <a-input v-model:value="form.emailPrefix" />
-      </a-form-item>
-      <a-form-item v-if="form.emailMethod > 2" :label="t('pages.client.postfix')">
-        <a-input v-model:value="form.emailPostfix" />
-      </a-form-item>
-      <a-form-item v-if="form.emailMethod < 2" :label="t('pages.client.clientCount')">
-        <a-input-number v-model:value="form.quantity" :min="1" :max="500" />
-      </a-form-item>
-
-      <a-form-item v-if="inbound.protocol === Protocols.VMESS" :label="t('security')">
-        <a-select v-model:value="form.security">
-          <a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
-        </a-select>
-      </a-form-item>
-
-      <a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
-        <a-select v-model:value="form.flow">
-          <a-select-option value="">{{ t('none') }}</a-select-option>
-          <a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
-        </a-select>
-      </a-form-item>
-
-      <a-form-item v-if="subEnable">
-        <template #label>
-          {{ t('subscription.title') }}
-          <SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
-        </template>
-        <a-input v-model:value="form.subId" />
-      </a-form-item>
-
-      <a-form-item v-if="tgBotEnable" label="Telegram ID">
-        <a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
-      </a-form-item>
-
-      <a-form-item :label="t('comment')">
-        <a-input v-model:value="form.comment" />
-      </a-form-item>
-
-      <a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
-        <a-input-number v-model:value="form.limitIp" :min="0" />
-      </a-form-item>
-
-      <a-form-item>
-        <template #label>
-          <a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
-        </template>
-        <a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
-      </a-form-item>
-
-      <a-form-item :label="t('pages.client.delayedStart')">
-        <a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
-      </a-form-item>
-
-      <a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
-        <a-input-number v-model:value="delayedExpireDays" :min="0" />
-      </a-form-item>
-
-      <a-form-item v-else>
-        <template #label>
-          <a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
-          }}</a-tooltip>
-        </template>
-        <DateTimePicker v-model:value="expiryDate" />
-      </a-form-item>
-
-      <a-form-item v-if="form.expiryTime !== 0">
-        <template #label>
-          <a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
-        </template>
-        <a-input-number v-model:value="form.reset" :min="0" />
-      </a-form-item>
-    </a-form>
-  </a-modal>
-</template>
-
-<style scoped>
-.random-icon {
-  margin-left: 4px;
-  cursor: pointer;
-  color: var(--ant-primary-color, #1890ff);
-}
-</style>

+ 0 - 394
frontend/src/pages/inbounds/ClientFormModal.vue

@@ -1,394 +0,0 @@
-<script setup>
-import { computed, ref, watch } from 'vue';
-import { useI18n } from 'vue-i18n';
-import dayjs from 'dayjs';
-import { SyncOutlined, RetweetOutlined, DeleteOutlined } from '@ant-design/icons-vue';
-
-import {
-  HttpUtil,
-  RandomUtil,
-  SizeFormatter,
-  ColorUtils,
-} from '@/utils';
-import { Inbound, Protocols, USERS_SECURITY, TLS_FLOW_CONTROL } from '@/models/inbound.js';
-import DateTimePicker from '@/components/DateTimePicker.vue';
-
-const { t } = useI18n();
-
-// Add OR edit a single client on a multi-user inbound (VMess / VLess /
-// Trojan / Shadowsocks-multi / Hysteria). The legacy panel routes both
-// flows through the same modal — same here.
-//
-// On submit we serialize the client via its toString() (which is just
-// JSON.stringify of toJson()) and post it inside a one-element clients
-// array so the Go side reuses the same parsing path as the inbound
-// settings update.
-
-const props = defineProps({
-  open: { type: Boolean, default: false },
-  mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
-  dbInbound: { type: Object, default: null },
-  clientIndex: { type: Number, default: null },
-  // Sidecar config from the inbounds page — controls visibility of
-  // the Subscription, Telegram, and IP-limit fields.
-  subEnable: { type: Boolean, default: false },
-  tgBotEnable: { type: Boolean, default: false },
-  ipLimitEnable: { type: Boolean, default: false },
-  trafficDiff: { type: Number, default: 0 },
-});
-
-const emit = defineEmits(['update:open', 'saved']);
-
-// === Reactive draft =================================================
-const inbound = ref(null);
-const client = ref(null);
-const oldClientId = ref('');
-const clientStats = ref(null);
-
-const saving = ref(false);
-const delayedStart = ref(false);
-
-const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
-const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
-
-const protocol = computed(() => inbound.value?.protocol);
-const isVmessOrVless = computed(() =>
-  protocol.value === Protocols.VMESS || protocol.value === Protocols.VLESS,
-);
-const isTrojanOrSS = computed(() =>
-  protocol.value === Protocols.TROJAN || protocol.value === Protocols.SHADOWSOCKS,
-);
-
-const expiryDate = computed({
-  get: () => (client.value?.expiryTime > 0 ? dayjs(client.value.expiryTime) : null),
-  set: (next) => { if (client.value) client.value.expiryTime = next ? next.valueOf() : 0; },
-});
-
-const delayedExpireDays = computed({
-  get: () => {
-    if (!client.value || client.value.expiryTime >= 0) return 0;
-    return client.value.expiryTime / -86400000;
-  },
-  set: (days) => {
-    if (!client.value) return;
-    client.value.expiryTime = -86400000 * (days || 0);
-  },
-});
-
-const totalGB = computed({
-  get: () => {
-    if (!client.value || !client.value.totalGB) return 0;
-    return Math.round((client.value.totalGB / SizeFormatter.ONE_GB) * 100) / 100;
-  },
-  set: (gb) => {
-    if (!client.value) return;
-    client.value.totalGB = Math.round((gb || 0) * SizeFormatter.ONE_GB);
-  },
-});
-
-const isExpired = computed(() => {
-  if (props.mode !== 'edit' || !client.value) return false;
-  return client.value.expiryTime > 0 && client.value.expiryTime < Date.now();
-});
-const isTrafficExhausted = computed(() => {
-  if (!clientStats.value || clientStats.value.total <= 0) return false;
-  return clientStats.value.up + clientStats.value.down >= clientStats.value.total;
-});
-
-function getClientId(proto, c) {
-  switch (proto) {
-    case Protocols.TROJAN: return c.password;
-    case Protocols.SHADOWSOCKS: return c.email;
-    case Protocols.HYSTERIA: return c.auth;
-    default: return c.id;
-  }
-}
-
-function makeNewClient(proto, parsed) {
-  switch (proto) {
-    case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
-    case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
-    case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
-    case Protocols.SHADOWSOCKS: {
-      const method = parsed.settings.method;
-      return new Inbound.ShadowsocksSettings.Shadowsocks(
-        method,
-        RandomUtil.randomShadowsocksPassword(method),
-      );
-    }
-    case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
-    default: return null;
-  }
-}
-
-watch(() => props.open, (next) => {
-  if (!next) return;
-  if (!props.dbInbound) return;
-  const parsed = Inbound.fromJson(props.dbInbound.toInbound().toJson());
-  inbound.value = parsed;
-  delayedStart.value = false;
-
-  if (props.mode === 'edit') {
-    const idx = props.clientIndex ?? 0;
-    client.value = parsed.clients[idx];
-    if (client.value && client.value.expiryTime < 0) delayedStart.value = true;
-    oldClientId.value = getClientId(parsed.protocol, client.value);
-  } else {
-    const c = makeNewClient(parsed.protocol, parsed);
-    if (c) parsed.clients.push(c);
-    client.value = parsed.clients[parsed.clients.length - 1];
-    oldClientId.value = '';
-  }
-
-  clientStats.value = (props.dbInbound.clientStats || []).find(
-    (s) => s.email === client.value?.email,
-  ) || null;
-});
-
-function close() {
-  emit('update:open', false);
-}
-
-function randomEmail() {
-  if (client.value) client.value.email = RandomUtil.randomLowerAndNum(9);
-}
-function randomId() {
-  if (client.value) client.value.id = RandomUtil.randomUUID();
-}
-function randomPassword() {
-  if (!client.value || !inbound.value) return;
-  if (inbound.value.protocol === Protocols.SHADOWSOCKS) {
-    client.value.password = RandomUtil.randomShadowsocksPassword(
-      inbound.value.settings.method,
-    );
-  } else {
-    client.value.password = RandomUtil.randomSeq(10);
-  }
-}
-function randomAuth() {
-  if (client.value) client.value.auth = RandomUtil.randomSeq(10);
-}
-function randomSubId() {
-  if (client.value) client.value.subId = RandomUtil.randomLowerAndNum(16);
-}
-
-const clientIpsText = ref('');
-async function loadClientIps() {
-  if (!client.value?.email) return;
-  const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${client.value.email}`);
-  if (!msg?.success) {
-    clientIpsText.value = msg?.obj || '';
-    return;
-  }
-  let ips = msg.obj;
-  if (typeof ips === 'string' && ips.startsWith('[') && ips.endsWith(']')) {
-    try {
-      const parsed = JSON.parse(ips);
-      ips = Array.isArray(parsed) ? parsed.join('\n') : ips;
-    } catch (_e) {
-      // leave as raw
-    }
-  }
-  clientIpsText.value = ips || '';
-}
-async function clearClientIps() {
-  if (!client.value?.email) return;
-  const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${client.value.email}`);
-  if (msg?.success) clientIpsText.value = '';
-}
-
-async function resetClientTraffic() {
-  if (!clientStats.value || !client.value?.email) return;
-  const msg = await HttpUtil.post(
-    `/panel/api/inbounds/${props.dbInbound.id}/resetClientTraffic/${client.value.email}`,
-  );
-  if (msg?.success) {
-    clientStats.value.up = 0;
-    clientStats.value.down = 0;
-  }
-}
-
-async function submit() {
-  if (!client.value || !inbound.value) return;
-  saving.value = true;
-  try {
-    const payload = {
-      id: props.dbInbound.id,
-      settings: `{"clients": [${client.value.toString()}]}`,
-    };
-    const url = props.mode === 'edit'
-      ? `/panel/api/inbounds/updateClient/${oldClientId.value}`
-      : '/panel/api/inbounds/addClient';
-    const msg = await HttpUtil.post(url, payload);
-    if (msg?.success) {
-      emit('saved');
-      close();
-    }
-  } finally {
-    saving.value = false;
-  }
-}
-
-const title = computed(() =>
-  props.mode === 'edit' ? t('pages.client.edit') : t('pages.client.add'),
-);
-</script>
-
-<template>
-  <a-modal :open="open" :title="title"
-    :ok-text="mode === 'edit' ? t('pages.client.submitEdit') : t('pages.client.submitAdd')" :cancel-text="t('close')"
-    :confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
-    <a-tag v-if="mode === 'edit' && (isExpired || isTrafficExhausted)" color="red" class="status-banner">
-      {{ t('depleted') }}
-    </a-tag>
-
-    <a-form v-if="client && inbound" layout="horizontal" :colon="false" :label-col="{ sm: { span: 8 } }"
-      :wrapper-col="{ sm: { span: 14 } }">
-      <a-form-item :label="t('enable')">
-        <a-switch v-model:checked="client.enable" />
-      </a-form-item>
-
-      <a-form-item>
-        <template #label>
-          {{ t('pages.inbounds.email') }}
-          <SyncOutlined class="random-icon" @click="randomEmail" />
-        </template>
-        <a-input v-model:value="client.email" />
-      </a-form-item>
-
-      <a-form-item v-if="isTrojanOrSS">
-        <template #label>
-          {{ t('password') }}
-          <SyncOutlined class="random-icon" @click="randomPassword" />
-        </template>
-        <a-input v-model:value="client.password" />
-      </a-form-item>
-
-      <a-form-item v-if="protocol === Protocols.HYSTERIA">
-        <template #label>
-          {{ t('password') }}
-          <SyncOutlined class="random-icon" @click="randomAuth" />
-        </template>
-        <a-input v-model:value="client.auth" />
-      </a-form-item>
-
-      <a-form-item v-if="isVmessOrVless">
-        <template #label>
-          ID
-          <SyncOutlined class="random-icon" @click="randomId" />
-        </template>
-        <a-input v-model:value="client.id" />
-      </a-form-item>
-
-      <a-form-item v-if="protocol === Protocols.VMESS" :label="t('security')">
-        <a-select v-model:value="client.security">
-          <a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">
-            {{ key }}
-          </a-select-option>
-        </a-select>
-      </a-form-item>
-
-      <a-form-item v-if="client.email && subEnable">
-        <template #label>
-          {{ t('subscription.title') }}
-          <SyncOutlined class="random-icon" @click="randomSubId" />
-        </template>
-        <a-input v-model:value="client.subId" />
-      </a-form-item>
-
-      <a-form-item v-if="client.email && tgBotEnable" label="Telegram ID">
-        <a-input-number v-model:value="client.tgId" :min="0" :style="{ width: '50%' }" />
-      </a-form-item>
-
-      <a-form-item v-if="client.email" :label="t('comment')">
-        <a-input v-model:value="client.comment" />
-      </a-form-item>
-
-      <a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
-        <a-input-number v-model:value="client.limitIp" :min="0" />
-      </a-form-item>
-
-      <a-form-item v-if="ipLimitEnable && client.limitIp > 0 && client.email && mode === 'edit'"
-        :label="t('pages.inbounds.IPLimitlog')">
-        <a-textarea v-model:value="clientIpsText" readonly :placeholder="t('pages.inbounds.IPLimitlogDesc')"
-          :auto-size="{ minRows: 3, maxRows: 8 }" @click="loadClientIps" />
-        <a-button type="link" size="small" danger @click="clearClientIps">
-          <template #icon>
-            <DeleteOutlined />
-          </template>
-          {{ t('pages.inbounds.IPLimitlogclear') }}
-        </a-button>
-      </a-form-item>
-
-      <a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
-        <a-select v-model:value="client.flow">
-          <a-select-option value="">{{ t('none') }}</a-select-option>
-          <a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">
-            {{ key }}
-          </a-select-option>
-        </a-select>
-      </a-form-item>
-
-      <a-form-item v-if="protocol === Protocols.VLESS" label="Reverse tag">
-        <a-input v-model:value="client.reverseTag" placeholder="Optional reverse tag" />
-      </a-form-item>
-
-      <a-form-item>
-        <template #label>
-          <a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
-        </template>
-        <a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
-      </a-form-item>
-
-      <a-form-item v-if="mode === 'edit' && clientStats" :label="t('usage')">
-        <a-tag :color="ColorUtils.clientUsageColor(clientStats, trafficDiff)">
-          {{ SizeFormatter.sizeFormat(clientStats.up) }} /
-          {{ SizeFormatter.sizeFormat(clientStats.down) }}
-          ({{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }})
-        </a-tag>
-        <a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
-          <RetweetOutlined class="action-icon" @click="resetClientTraffic" />
-        </a-tooltip>
-      </a-form-item>
-
-      <a-form-item :label="t('pages.client.delayedStart')">
-        <a-switch v-model:checked="delayedStart" @click="client.expiryTime = 0" />
-      </a-form-item>
-
-      <a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
-        <a-input-number v-model:value="delayedExpireDays" :min="0" />
-      </a-form-item>
-
-      <a-form-item v-else>
-        <template #label>
-          <a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
-          }}</a-tooltip>
-        </template>
-        <DateTimePicker v-model:value="expiryDate" />
-        <a-tag v-if="mode === 'edit' && isExpired" color="red">{{ t('depleted') }}</a-tag>
-      </a-form-item>
-
-      <a-form-item v-if="client.expiryTime !== 0">
-        <template #label>
-          <a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
-        </template>
-        <a-input-number v-model:value="client.reset" :min="0" />
-      </a-form-item>
-    </a-form>
-  </a-modal>
-</template>
-
-<style scoped>
-.status-banner {
-  display: block;
-  margin-bottom: 10px;
-  text-align: center;
-}
-
-.random-icon,
-.action-icon {
-  margin-left: 4px;
-  cursor: pointer;
-  color: var(--ant-primary-color, #1890ff);
-}
-</style>

+ 0 - 841
frontend/src/pages/inbounds/ClientRowTable.vue

@@ -1,841 +0,0 @@
-<script setup>
-import { computed, ref, watch } from 'vue';
-import { useI18n } from 'vue-i18n';
-import {
-  EditOutlined,
-  InfoCircleOutlined,
-  QrcodeOutlined,
-  RetweetOutlined,
-  DeleteOutlined,
-  EllipsisOutlined,
-} from '@ant-design/icons-vue';
-import { Modal } from 'ant-design-vue';
-
-import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
-import InfinityIcon from '@/components/InfinityIcon.vue';
-import { useDatepicker } from '@/composables/useDatepicker.js';
-
-const { datepicker } = useDatepicker();
-
-const { t } = useI18n();
-
-// Per-inbound expand-row content. CSS-grid layout (not a nested
-// <a-table>) so it sits flush inside the parent's expanded cell.
-// No API calls here — events bubble to the parent's modals.
-
-const props = defineProps({
-  dbInbound: { type: Object, required: true },
-  isMobile: { type: Boolean, default: false },
-  trafficDiff: { type: Number, default: 0 },
-  expireDiff: { type: Number, default: 0 },
-  onlineClients: { type: Array, default: () => [] },
-  lastOnlineMap: { type: Object, default: () => ({}) },
-  isDarkTheme: { type: Boolean, default: false },
-  pageSize: { type: Number, default: 0 },
-  totalClientCount: { type: Number, default: 0 },
-  statsVersion: { type: Number, default: 0 },
-});
-
-const emit = defineEmits([
-  'edit-client',
-  'qrcode-client',
-  'info-client',
-  'reset-traffic-client',
-  'delete-client',
-  'delete-clients',
-  'toggle-enable-client',
-]);
-
-const inbound = computed(() => props.dbInbound.toInbound());
-const clients = computed(() => inbound.value?.clients || []);
-
-const currentPage = ref(1);
-const paginatedClients = computed(() => {
-  if (!props.pageSize || props.pageSize <= 0) return clients.value;
-  const start = (currentPage.value - 1) * props.pageSize;
-  return clients.value.slice(start, start + props.pageSize);
-});
-
-watch([clients, () => props.pageSize], () => {
-  const total = clients.value.length;
-  const size = props.pageSize > 0 ? props.pageSize : (total || 1);
-  const maxPage = Math.max(1, Math.ceil(total / size));
-  if (currentPage.value > maxPage) currentPage.value = maxPage;
-});
-
-// === Per-client stats lookup =======================================
-// statsVersion bumps on every ws merge so this computed re-evaluates
-// (DBInbound isn't reactive — the in-place stat mutations alone don't
-// trigger Vue's tracking).
-const statsMap = computed(() => {
-  void props.statsVersion;
-  const m = new Map();
-  for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
-  return m;
-});
-function statsFor(email) {
-  return email ? statsMap.value.get(email) : null;
-}
-
-function getUp(email) { return statsFor(email)?.up || 0; }
-function getDown(email) { return statsFor(email)?.down || 0; }
-function getSum(email) { const s = statsFor(email); return s ? s.up + s.down : 0; }
-function getRem(email) {
-  const s = statsFor(email);
-  if (!s) return 0;
-  const r = s.total - s.up - s.down;
-  return r > 0 ? r : 0;
-}
-function getAllTime(email) {
-  const s = statsFor(email);
-  if (!s) return 0;
-  // allTime is the cumulative-historical counter; never let it dip
-  // below up+down (manual edits / partial migrations can push it under).
-  const current = (s.up || 0) + (s.down || 0);
-  return s.allTime > current ? s.allTime : current;
-}
-function isClientDepleted(email) {
-  const s = statsFor(email);
-  if (!s) return false;
-  const total = s.total ?? 0;
-  const used = (s.up ?? 0) + (s.down ?? 0);
-  if (total > 0 && used >= total) return true;
-  const exp = s.expiryTime ?? 0;
-  if (exp > 0 && Date.now() >= exp) return true;
-  return false;
-}
-function isClientOnline(email) {
-  return !!email && props.onlineClients.includes(email);
-}
-function lastOnlineLabel(email) {
-  const ts = props.lastOnlineMap[email];
-  if (!ts) return '-';
-  return IntlUtil.formatDate(ts, datepicker.value);
-}
-
-function statsProgress(email) {
-  const s = statsFor(email);
-  if (!s) return 0;
-  if (s.total === 0) return 100;
-  return (100 * (s.down + s.up)) / s.total;
-}
-function expireProgress(expTime, reset) {
-  const now = Date.now();
-  const remainedSec = expTime < 0 ? -expTime / 1000 : (expTime - now) / 1000;
-  const resetSec = reset * 86400;
-  if (remainedSec >= resetSec) return 0;
-  return 100 * (1 - remainedSec / resetSec);
-}
-function clientStatsColor(email) {
-  return ColorUtils.clientUsageColor(statsFor(email), props.trafficDiff);
-}
-function statsExpColor(email) {
-  // AD-Vue 4 semantic palette mirrors ColorUtils.* so the badge dot
-  // matches the row's traffic/expiry tags.
-  const PURPLE = '#722ed1', SUCCESS = '#52c41a', WARN = '#faad14', DANGER = '#ff4d4f';
-  if (!email) return PURPLE;
-  const s = statsFor(email);
-  if (!s) return PURPLE;
-  const a = ColorUtils.usageColor(s.down + s.up, props.trafficDiff, s.total);
-  const b = ColorUtils.usageColor(Date.now(), props.expireDiff, s.expiryTime);
-  if (a === 'red' || b === 'red') return DANGER;
-  if (a === 'orange' || b === 'orange') return WARN;
-  if (a === 'green' || b === 'green') return SUCCESS;
-  return PURPLE;
-}
-
-const isRemovable = computed(() => (props.totalClientCount || clients.value.length) > 1);
-
-function totalGbDisplay(client) {
-  if (!client.totalGB || client.totalGB <= 0) return '';
-  return `${Math.round((client.totalGB / 1073741824) * 100) / 100} GB`;
-}
-
-const isUnlimitedTotal = (client) => !client.totalGB || client.totalGB <= 0;
-
-function statusBadgeColor(client) {
-  if (!client.enable) return props.isDarkTheme ? '#2c3950' : '#bcbcbc';
-  return statsExpColor(client.email);
-}
-
-// === Action confirms ==============================================
-function confirmReset(client) {
-  Modal.confirm({
-    title: `${t('pages.inbounds.resetTraffic')} — ${client.email}`,
-    content: t('pages.inbounds.resetTrafficContent'),
-    okText: t('reset'),
-    cancelText: t('cancel'),
-    onOk: () => emit('reset-traffic-client', { dbInbound: props.dbInbound, client }),
-  });
-}
-function confirmDelete(client) {
-  Modal.confirm({
-    title: `${t('pages.inbounds.deleteClient')} — ${client.email}`,
-    content: t('pages.inbounds.deleteClientContent'),
-    okText: t('delete'),
-    okType: 'danger',
-    cancelText: t('cancel'),
-    onOk: () => emit('delete-client', { dbInbound: props.dbInbound, client }),
-  });
-}
-
-// Stable row key for v-for — falls back through email/id/password
-// because not every protocol fills the same field.
-function rowKey(client) {
-  return client.email || client.id || client.password || JSON.stringify(client);
-}
-
-const selected = ref(new Set());
-
-const allSelected = computed(() =>
-  clients.value.length > 0 && clients.value.every((c) => selected.value.has(rowKey(c))),
-);
-const someSelected = computed(() =>
-  clients.value.some((c) => selected.value.has(rowKey(c))),
-);
-const selectedCount = computed(() => selected.value.size);
-
-function isSelected(key) {
-  return selected.value.has(key);
-}
-function toggleSelect(key, next) {
-  const s = new Set(selected.value);
-  if (next) s.add(key); else s.delete(key);
-  selected.value = s;
-}
-function selectAll(next) {
-  if (next) {
-    selected.value = new Set(clients.value.map(rowKey));
-  } else {
-    selected.value = new Set();
-  }
-}
-function clearSelection() {
-  selected.value = new Set();
-}
-
-watch(clients, (list) => {
-  if (selected.value.size === 0) return;
-  const valid = new Set(list.map(rowKey));
-  const next = new Set();
-  for (const k of selected.value) if (valid.has(k)) next.add(k);
-  if (next.size !== selected.value.size) selected.value = next;
-});
-
-const statsClient = ref(null);
-function openStats(client) {
-  statsClient.value = client;
-}
-function closeStats() {
-  statsClient.value = null;
-}
-
-function confirmBulkDelete() {
-  const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
-  if (picked.length === 0) return;
-
-  const total = clients.value.length;
-  const keepLast = picked.length === total;
-  const toDelete = keepLast ? picked.slice(0, -1) : picked;
-
-  if (toDelete.length === 0) {
-    Modal.warning({
-      title: t('pages.inbounds.deleteClient'),
-      content: 'Inbound must keep at least one client — delete the inbound to remove all.',
-      okText: t('confirm'),
-    });
-    return;
-  }
-
-  Modal.confirm({
-    title: `${t('pages.inbounds.deleteClient')} — ${toDelete.length}${keepLast ? ` / ${total}` : ''}`,
-    content: keepLast
-      ? 'Inbound must keep at least one client — the last selected will remain. Delete the inbound to remove all.'
-      : t('pages.inbounds.deleteClientContent'),
-    okText: t('delete'),
-    okType: 'danger',
-    cancelText: t('cancel'),
-    onOk: () => {
-      emit('delete-clients', { dbInbound: props.dbInbound, clients: toDelete });
-      clearSelection();
-    },
-  });
-}
-</script>
-
-<template>
-  <div class="client-list"
-    :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
-    <div v-if="isRemovable && selectedCount > 0" class="bulk-bar">
-      <span class="bulk-count">{{ selectedCount }} selected</span>
-      <a-button size="small" type="link" @click="clearSelection">{{ t('cancel') }}</a-button>
-      <a-button size="small" danger @click="confirmBulkDelete">
-        <DeleteOutlined /> {{ t('delete') }}
-      </a-button>
-    </div>
-
-    <!-- ====================== Desktop: grid table ===================== -->
-    <template v-if="!isMobile">
-      <div class="client-row client-list-header">
-        <div v-if="isRemovable" class="cell cell-select">
-          <a-checkbox :checked="allSelected" :indeterminate="someSelected && !allSelected"
-            @change="(e) => selectAll(e.target.checked)" />
-        </div>
-        <div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
-        <div class="cell cell-enable">{{ t('enable') }}</div>
-        <div class="cell cell-online">{{ t('online') }}</div>
-        <div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
-        <div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
-        <div class="cell cell-remained">{{ t('remained') }}</div>
-        <div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
-        <div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
-      </div>
-
-      <div v-for="client in paginatedClients" :key="rowKey(client)" class="client-row"
-        :class="{ 'is-selected': isSelected(rowKey(client)) }">
-        <div v-if="isRemovable" class="cell cell-select">
-          <a-checkbox :checked="isSelected(rowKey(client))"
-            @change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
-        </div>
-        <div class="cell cell-actions">
-          <a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
-            <QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
-          </a-tooltip>
-          <a-tooltip :title="t('edit')">
-            <EditOutlined class="row-icon" @click="emit('edit-client', { dbInbound, client })" />
-          </a-tooltip>
-          <a-tooltip :title="t('info')">
-            <InfoCircleOutlined class="row-icon" @click="emit('info-client', { dbInbound, client })" />
-          </a-tooltip>
-          <a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
-            <RetweetOutlined class="row-icon" @click="confirmReset(client)" />
-          </a-tooltip>
-          <a-tooltip v-if="isRemovable" :title="t('delete')">
-            <DeleteOutlined class="row-icon danger" @click="confirmDelete(client)" />
-          </a-tooltip>
-        </div>
-
-        <div class="cell cell-enable">
-          <a-switch :checked="client.enable" size="small"
-            @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
-        </div>
-
-        <div class="cell cell-online">
-          <a-popover>
-            <template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(client.email) }}</template>
-            <a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
-            <a-tag v-else>{{ t('offline') }}</a-tag>
-          </a-popover>
-        </div>
-
-        <div class="cell cell-client">
-          <a-tooltip>
-            <template #title>
-              <template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
-              <template v-else-if="!client.enable">{{ t('disabled') }}</template>
-              <template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
-              <template v-else>{{ t('offline') }}</template>
-            </template>
-            <a-badge :color="statusBadgeColor(client)" />
-          </a-tooltip>
-          <div class="client-id-stack">
-            <a-tooltip :title="client.email">
-              <span class="client-email">{{ client.email }}</span>
-            </a-tooltip>
-            <span v-if="client.comment && client.comment.trim()" class="client-comment">
-              {{ client.comment.length > 50 ? client.comment.substring(0, 47) + '…' : client.comment }}
-            </span>
-          </div>
-        </div>
-
-        <div class="cell cell-traffic">
-          <a-popover>
-            <template v-if="client.email" #content>
-              <table cellpadding="2">
-                <tbody>
-                  <tr>
-                    <td>↑ {{ SizeFormatter.sizeFormat(getUp(client.email)) }}</td>
-                    <td>↓ {{ SizeFormatter.sizeFormat(getDown(client.email)) }}</td>
-                  </tr>
-                  <tr v-if="client.totalGB > 0">
-                    <td>{{ t('remained') }}</td>
-                    <td>{{ SizeFormatter.sizeFormat(getRem(client.email)) }}</td>
-                  </tr>
-                </tbody>
-              </table>
-            </template>
-            <div class="usage-bar">
-              <span class="usage-text">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</span>
-              <a-progress v-if="!client.enable" :stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
-                :show-info="false" :percent="statsProgress(client.email)" size="small" />
-              <a-progress v-else-if="client.totalGB > 0" :stroke-color="clientStatsColor(client.email)"
-                :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
-                :percent="statsProgress(client.email)" size="small" />
-              <a-progress v-else :show-info="false" :percent="100" stroke-color="#722ed1" size="small" />
-              <span class="usage-text">
-                <InfinityIcon v-if="isUnlimitedTotal(client)" />
-                <template v-else>{{ totalGbDisplay(client) }}</template>
-              </span>
-            </div>
-          </a-popover>
-        </div>
-
-        <div class="cell cell-remained">
-          <a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
-            <InfinityIcon />
-          </a-tag>
-          <a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
-            {{ SizeFormatter.sizeFormat(getRem(client.email)) }}
-          </a-tag>
-        </div>
-
-        <div class="cell cell-alltime">
-          <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
-        </div>
-
-        <div class="cell cell-expiry">
-          <template v-if="client.expiryTime !== 0 && client.reset > 0">
-            <a-popover>
-              <template #content>
-                <span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
-                <span v-else>{{ IntlUtil.formatDate(client.expiryTime, datepicker) }}</span>
-              </template>
-              <div class="usage-bar">
-                <span class="usage-text">{{ IntlUtil.formatRelativeTime(client.expiryTime) }}</span>
-                <a-progress :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
-                  :percent="expireProgress(client.expiryTime, client.reset)" size="small" />
-                <span class="usage-text">{{ client.reset }}d</span>
-              </div>
-            </a-popover>
-          </template>
-          <a-popover v-else-if="client.expiryTime !== 0">
-            <template #content>
-              <span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
-              <span v-else>{{ IntlUtil.formatDate(client.expiryTime) }}</span>
-            </template>
-            <a-tag :style="{ minWidth: '50px', border: 'none' }"
-              :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
-              {{ IntlUtil.formatRelativeTime(client.expiryTime) }}
-            </a-tag>
-          </a-popover>
-          <a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)" :style="{ border: 'none' }"
-            class="infinite-tag">
-            <InfinityIcon />
-          </a-tag>
-        </div>
-      </div>
-    </template>
-
-    <!-- ====================== Mobile: card list ======================= -->
-    <template v-else>
-      <div v-for="client in paginatedClients" :key="rowKey(client)" class="client-card"
-        :class="{ 'is-selected': isSelected(rowKey(client)) }">
-        <div class="client-card-head">
-          <a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
-            @change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
-          <a-tooltip>
-            <template #title>
-              <template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
-              <template v-else-if="!client.enable">{{ t('disabled') }}</template>
-              <template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
-              <template v-else>{{ t('offline') }}</template>
-            </template>
-            <a-badge :color="statusBadgeColor(client)" />
-          </a-tooltip>
-          <a-tooltip :title="client.email">
-            <span class="client-email">{{ client.email }}</span>
-          </a-tooltip>
-          <div class="client-card-actions">
-            <a-tooltip :title="t('info')">
-              <InfoCircleOutlined class="row-icon" @click="openStats(client)" />
-            </a-tooltip>
-            <a-switch :checked="client.enable" size="small"
-              @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
-            <a-dropdown :trigger="['click']" placement="bottomRight">
-              <EllipsisOutlined class="row-icon" @click.prevent />
-              <template #overlay>
-                <a-menu>
-                  <a-menu-item v-if="dbInbound.hasLink()" @click="emit('qrcode-client', { dbInbound, client })">
-                    <QrcodeOutlined /> {{ t('qrCode') }}
-                  </a-menu-item>
-                  <a-menu-item @click="emit('edit-client', { dbInbound, client })">
-                    <EditOutlined /> {{ t('edit') }}
-                  </a-menu-item>
-                  <a-menu-item @click="emit('info-client', { dbInbound, client })">
-                    <InfoCircleOutlined /> {{ t('info') }}
-                  </a-menu-item>
-                  <a-menu-item v-if="client.email" @click="confirmReset(client)">
-                    <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
-                  </a-menu-item>
-                  <a-menu-item v-if="isRemovable" @click="confirmDelete(client)">
-                    <DeleteOutlined /> <span class="danger">{{ t('delete') }}</span>
-                  </a-menu-item>
-                </a-menu>
-              </template>
-            </a-dropdown>
-          </div>
-        </div>
-      </div>
-
-      <a-modal :open="!!statsClient" :footer="null" :width="360" centered
-        :title="statsClient ? statsClient.email || t('info') : ''" @cancel="closeStats">
-        <div v-if="statsClient" class="client-card-foot">
-          <div v-if="statsClient.comment && statsClient.comment.trim()" class="client-comment-line">
-            {{ statsClient.comment }}
-          </div>
-          <div class="stat-row">
-            <span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
-            <a-tag :color="clientStatsColor(statsClient.email)">
-              {{ SizeFormatter.sizeFormat(getSum(statsClient.email)) }} /
-              <InfinityIcon v-if="isUnlimitedTotal(statsClient)" />
-              <template v-else>{{ totalGbDisplay(statsClient) }}</template>
-            </a-tag>
-          </div>
-          <div class="stat-row">
-            <span class="stat-label">{{ t('remained') }}</span>
-            <a-tag v-if="isUnlimitedTotal(statsClient)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
-              <InfinityIcon />
-            </a-tag>
-            <a-tag v-else :color="isClientDepleted(statsClient.email) ? 'red' : ''">
-              {{ SizeFormatter.sizeFormat(getRem(statsClient.email)) }}
-            </a-tag>
-          </div>
-          <div class="stat-row">
-            <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
-            <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(statsClient.email)) }}</a-tag>
-          </div>
-          <div class="stat-row">
-            <span class="stat-label">{{ t('online') }}</span>
-            <a-tag v-if="statsClient.enable && isClientOnline(statsClient.email)" color="green">{{ t('online') }}</a-tag>
-            <a-tag v-else>{{ t('offline') }}</a-tag>
-          </div>
-          <div class="stat-row">
-            <span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
-            <a-tag v-if="statsClient.expiryTime > 0"
-              :color="ColorUtils.userExpiryColor(expireDiff, statsClient, isDarkTheme)">
-              {{ IntlUtil.formatRelativeTime(statsClient.expiryTime) }}
-            </a-tag>
-            <a-tag v-else-if="statsClient.expiryTime < 0" color="green">
-              {{ -statsClient.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
-            </a-tag>
-            <a-tag v-else color="purple">
-              <InfinityIcon />
-            </a-tag>
-          </div>
-        </div>
-      </a-modal>
-    </template>
-
-    <a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"
-      :page-size="pageSize" :total="clients.length" :show-size-changer="false" size="small"
-      class="client-list-pagination" />
-  </div>
-</template>
-
-<style scoped>
-.client-list {
-  margin: -8px 0;
-  font-size: 13px;
-}
-
-.bulk-bar {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  padding: 6px 16px;
-  background: rgba(22, 119, 255, 0.08);
-  border-bottom: 1px solid rgba(22, 119, 255, 0.18);
-}
-
-.bulk-count {
-  font-weight: 500;
-  font-size: 13px;
-}
-
-.is-selected {
-  background: rgba(22, 119, 255, 0.06);
-}
-
-.client-row {
-  display: grid;
-  /* Default — no select column (single-client inbounds). The .has-select
-   * modifier below prepends the 40px checkbox column. */
-  grid-template-columns:
-    140px
-    /* actions */
-    60px
-    /* enable */
-    80px
-    /* online */
-    minmax(160px, 2fr)
-    /* client identity */
-    minmax(160px, 2fr)
-    /* traffic */
-    130px
-    /* all-time */
-    130px
-    /* remained */
-    140px;
-  /* expiry */
-  gap: 12px;
-  align-items: center;
-  padding: 8px 16px;
-  border-top: 1px solid rgba(128, 128, 128, 0.12);
-}
-
-.client-list.has-select .client-row {
-  grid-template-columns:
-    40px
-    /* select */
-    140px
-    /* actions */
-    60px
-    /* enable */
-    80px
-    /* online */
-    minmax(160px, 2fr)
-    /* client identity */
-    minmax(160px, 2fr)
-    /* traffic */
-    130px
-    /* all-time */
-    130px
-    /* remained */
-    140px;
-  /* expiry */
-}
-
-.client-row:last-child {
-  border-bottom: 1px solid rgba(128, 128, 128, 0.12);
-}
-
-.client-list-header {
-  font-weight: 500;
-  font-size: 12px;
-  opacity: 0.65;
-  padding-top: 6px;
-  padding-bottom: 6px;
-  border-top: none;
-  text-transform: uppercase;
-  letter-spacing: 0.02em;
-}
-
-.cell {
-  min-width: 0;
-  /* allow grid children to shrink instead of overflowing */
-}
-
-.cell-select,
-.cell-actions,
-.cell-enable,
-.cell-online,
-.cell-alltime,
-.cell-remained {
-  text-align: center;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  gap: 6px;
-  flex-wrap: wrap;
-}
-
-.cell-actions {
-  justify-content: flex-start;
-}
-
-.cell-client {
-  display: inline-flex;
-  align-items: center;
-  gap: 6px;
-  min-width: 0;
-}
-
-.cell-traffic,
-.cell-expiry {
-  text-align: center;
-}
-
-.client-list-header .cell {
-  text-align: center;
-}
-
-.client-list-header .cell-actions,
-.client-list-header .cell-client {
-  text-align: left;
-}
-
-/* Action icons */
-.row-icon {
-  font-size: 16px;
-  cursor: pointer;
-  padding: 0 2px;
-  color: inherit;
-  transition: color 120ms ease;
-}
-
-.row-icon:hover {
-  color: var(--ant-color-primary, #1677ff);
-}
-
-.row-icon.danger {
-  color: #ff4d4f;
-}
-
-.danger {
-  color: #ff4d4f;
-}
-
-/* Client identity stack (badge + email + comment) */
-.client-id-stack {
-  display: flex;
-  flex-direction: column;
-  gap: 2px;
-  min-width: 0;
-  overflow: hidden;
-}
-
-.client-email {
-  font-weight: 500;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  display: inline-block;
-}
-
-.client-comment {
-  font-size: 11px;
-  opacity: 0.7;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  display: inline-block;
-}
-
-/* Traffic / expiry inline bar:  text  |  progress  |  text */
-.usage-bar {
-  display: grid;
-  grid-template-columns: minmax(50px, auto) minmax(40px, 1fr) minmax(40px, auto);
-  align-items: center;
-  gap: 6px;
-}
-
-.usage-text {
-  font-size: 12px;
-  white-space: nowrap;
-}
-
-.usage-bar :deep(.ant-progress) {
-  margin: 0;
-  line-height: 1;
-}
-
-.infinite-tag {
-  min-width: 50px;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-}
-
-/* Strip AD-Vue's default expanded-cell padding so the desktop grid
- * sits flush against the inbound row's left/right edges. */
-:deep(.ant-table-expanded-row > .ant-table-cell) {
-  padding: 0 !important;
-}
-
-.client-list-pagination {
-  display: flex;
-  justify-content: center;
-  padding: 10px 16px 4px;
-}
-
-/* ===== Mobile card list =========================================== */
-.client-list.is-mobile {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-  margin: 0;
-}
-
-.client-card {
-  border: 1px solid rgba(128, 128, 128, 0.18);
-  border-radius: 8px;
-  padding: 10px 12px;
-  display: flex;
-  flex-direction: column;
-  gap: 6px;
-}
-
-:global(body.dark) .client-card {
-  border-color: rgba(255, 255, 255, 0.1);
-}
-
-.client-card-head {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  min-width: 0;
-}
-
-.client-card-head .client-email {
-  flex: 1;
-  min-width: 0;
-  font-size: 14px;
-  font-weight: 500;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-
-.client-card-actions {
-  margin-left: auto;
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  flex-shrink: 0;
-}
-
-.client-card-actions .row-icon {
-  font-size: 20px;
-  padding: 4px;
-}
-
-.client-comment-line {
-  font-size: 11px;
-  opacity: 0.7;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-
-.client-card-foot {
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-}
-
-.client-card-foot .stat-row {
-  display: flex;
-  align-items: center;
-  flex-wrap: wrap;
-  gap: 6px;
-}
-
-.client-card-foot .stat-label {
-  font-size: 10px;
-  text-transform: uppercase;
-  letter-spacing: 0.04em;
-  opacity: 0.6;
-  min-width: 96px;
-  flex-shrink: 0;
-}
-
-.client-card-foot :deep(.ant-tag) {
-  margin: 0;
-}
-
-/* Bigger status badge for thumb-readable state at a glance. */
-.client-card-head :deep(.ant-badge-status-dot) {
-  width: 9px;
-  height: 9px;
-}
-</style>

+ 0 - 185
frontend/src/pages/inbounds/CopyClientsModal.vue

@@ -1,185 +0,0 @@
-<script setup>
-import { computed, ref, watch } from 'vue';
-import { useI18n } from 'vue-i18n';
-import { message } from 'ant-design-vue';
-
-import { HttpUtil, SizeFormatter, IntlUtil } from '@/utils';
-import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
-
-const { t } = useI18n();
-
-const props = defineProps({
-  open: { type: Boolean, default: false },
-  dbInbound: { type: Object, default: null },
-  dbInbounds: { type: Array, default: () => [] },
-});
-
-const emit = defineEmits(['update:open', 'saved']);
-
-const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
-
-const sourceInboundId = ref(null);
-const selectedEmails = ref([]);
-const flow = ref('');
-const saving = ref(false);
-
-const sources = computed(() => {
-  if (!props.dbInbound) return [];
-  return props.dbInbounds
-    .filter(
-      (row) =>
-        row.id !== props.dbInbound.id &&
-        typeof row.isMultiUser === 'function' &&
-        row.isMultiUser(),
-    )
-    .map((row) => {
-      let count = 0;
-      try { count = (row.toInbound().clients || []).length; } catch (_e) { /* ignore */ }
-      return { id: row.id, label: `${row.remark || `#${row.id}`} (${row.protocol}, ${count})` };
-    });
-});
-
-const sourceInbound = computed(() => {
-  if (!sourceInboundId.value) return null;
-  return props.dbInbounds.find((r) => r.id === sourceInboundId.value) || null;
-});
-
-const sourceClients = computed(() => {
-  const sb = sourceInbound.value;
-  if (!sb) return [];
-  let list = [];
-  try { list = sb.toInbound().clients || []; } catch (_e) { /* ignore */ }
-  const stats = new Map((sb.clientStats || []).map((s) => [s.email, s]));
-  return list
-    .filter((c) => c.email)
-    .map((c) => {
-      const s = stats.get(c.email);
-      const used = s ? (s.up || 0) + (s.down || 0) : 0;
-      let expiryLabel = t('unlimited');
-      if (c.expiryTime > 0) expiryLabel = IntlUtil.formatDate(c.expiryTime);
-      else if (c.expiryTime < 0) expiryLabel = `${-c.expiryTime / 86400000}d`;
-      return { email: c.email, trafficLabel: SizeFormatter.sizeFormat(used), expiryLabel };
-    });
-});
-
-const showFlow = computed(() => {
-  if (!props.dbInbound) return false;
-  try {
-    const inb = props.dbInbound.toInbound();
-    return !!(inb && typeof inb.canEnableTlsFlow === 'function' && inb.canEnableTlsFlow());
-  } catch (_e) { return false; }
-});
-
-const columns = computed(() => [
-  { title: t('pages.inbounds.email'), dataIndex: 'email', width: 280 },
-  { title: t('pages.inbounds.traffic'), dataIndex: 'trafficLabel', width: 140 },
-  { title: t('pages.inbounds.expireDate'), dataIndex: 'expiryLabel', width: 160 },
-]);
-
-const rowSelection = computed(() => ({
-  selectedRowKeys: selectedEmails.value,
-  onChange: (keys) => { selectedEmails.value = keys; },
-}));
-
-const title = computed(() => {
-  if (!props.dbInbound) return t('pages.client.copyFromInbound');
-  const target = props.dbInbound.remark || `#${props.dbInbound.id}`;
-  return `${t('pages.client.copyToInbound')} ${target}`;
-});
-
-watch(() => props.open, (next) => {
-  if (!next) return;
-  sourceInboundId.value = null;
-  selectedEmails.value = [];
-  flow.value = '';
-  saving.value = false;
-});
-
-watch(sourceInboundId, () => {
-  selectedEmails.value = [];
-});
-
-function selectAll() {
-  selectedEmails.value = sourceClients.value.map((c) => c.email);
-}
-function clearAll() {
-  selectedEmails.value = [];
-}
-
-async function ok() {
-  if (!sourceInboundId.value) {
-    message.error(t('pages.client.copySelectSourceFirst'));
-    return;
-  }
-  if (!props.dbInbound) return;
-  saving.value = true;
-  try {
-    const payload = {
-      sourceInboundId: sourceInboundId.value,
-      clientEmails: selectedEmails.value,
-    };
-    if (showFlow.value && flow.value) payload.flow = flow.value;
-    const msg = await HttpUtil.post(
-      `/panel/api/inbounds/${props.dbInbound.id}/copyClients`,
-      payload,
-    );
-    if (!msg?.success) return;
-    const obj = msg.obj || {};
-    const addedCount = (obj.added || []).length;
-    const errorList = obj.errors || [];
-    if (addedCount > 0) {
-      message.success(`${t('pages.client.copyResultSuccess')}: ${addedCount}`);
-    } else {
-      message.warning(t('pages.client.copyResultNone'));
-    }
-    if (errorList.length > 0) {
-      message.error(`${t('pages.client.copyResultErrors')}: ${errorList.join('; ')}`);
-    }
-    emit('saved');
-    emit('update:open', false);
-  } finally {
-    saving.value = false;
-  }
-}
-
-function close() {
-  if (saving.value) return;
-  emit('update:open', false);
-}
-</script>
-
-<template>
-  <a-modal :open="open" :title="title" :ok-text="t('pages.client.copySelected')" :cancel-text="t('close')"
-    :confirm-loading="saving" :mask-closable="false" width="720px" @ok="ok" @cancel="close">
-    <a-space direction="vertical" :style="{ width: '100%' }">
-      <div>
-        <div :style="{ marginBottom: '6px' }">{{ t('pages.client.copySource') }}</div>
-        <a-select v-model:value="sourceInboundId" :style="{ width: '100%' }" allow-clear>
-          <a-select-option v-for="item in sources" :key="item.id" :value="item.id">
-            {{ item.label }}
-          </a-select-option>
-        </a-select>
-      </div>
-
-      <div v-if="sourceInboundId">
-        <a-space :style="{ marginBottom: '8px' }">
-          <a-button size="small" @click="selectAll">{{ t('pages.client.selectAll') }}</a-button>
-          <a-button size="small" @click="clearAll">{{ t('pages.client.clearAll') }}</a-button>
-        </a-space>
-        <a-table :columns="columns" :data-source="sourceClients" :pagination="false" size="small"
-          :row-key="(r) => r.email" :row-selection="rowSelection" :scroll="{ y: 280 }" />
-      </div>
-
-      <div v-if="showFlow">
-        <div :style="{ marginBottom: '6px' }">{{ t('pages.client.copyFlowLabel') }}</div>
-        <a-select v-model:value="flow" :style="{ width: '100%' }" allow-clear>
-          <a-select-option value="">{{ t('none') }}</a-select-option>
-          <a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
-        </a-select>
-        <div :style="{ marginTop: '4px', fontSize: '12px', opacity: 0.7 }">
-          {{ t('pages.client.copyFlowHint') }}
-        </div>
-      </div>
-    </a-space>
-  </a-modal>
-</template>

File diff suppressed because it is too large
+ 577 - 353
frontend/src/pages/inbounds/InboundFormModal.vue


+ 2 - 2
frontend/src/pages/inbounds/InboundInfoModal.vue

@@ -137,7 +137,7 @@ async function loadClientIps() {
   if (!clientStats.value?.email) return;
   refreshing.value = true;
   try {
-    const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${clientStats.value.email}`);
+    const msg = await HttpUtil.post(`/panel/api/clients/ips/${clientStats.value.email}`);
     if (!msg?.success) {
       clientIpsText.value = msg?.obj || 'No IP record';
       clientIpsArray.value = [];
@@ -164,7 +164,7 @@ async function loadClientIps() {
 
 async function clearClientIps() {
   if (!clientStats.value?.email) return;
-  const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${clientStats.value.email}`);
+  const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${clientStats.value.email}`);
   if (msg?.success) {
     clientIpsArray.value = [];
     clientIpsText.value = t('tgbot.noIpRecord');

+ 23 - 325
frontend/src/pages/inbounds/InboundList.vue

@@ -1,34 +1,24 @@
 <script setup>
-import { computed, ref, watch } from 'vue';
+import { computed, ref } from 'vue';
 import { useI18n } from 'vue-i18n';
 import {
   PlusOutlined,
   MenuOutlined,
-  SearchOutlined,
-  FilterOutlined,
   MoreOutlined,
   EditOutlined,
   QrcodeOutlined,
-  UserAddOutlined,
-  UsergroupAddOutlined,
   CopyOutlined,
-  FileDoneOutlined,
   ExportOutlined,
   ImportOutlined,
   ReloadOutlined,
-  RestOutlined,
   RetweetOutlined,
   BlockOutlined,
   DeleteOutlined,
   InfoCircleOutlined,
-  RightOutlined,
 } from '@ant-design/icons-vue';
 
-import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
-import { DBInbound } from '@/models/dbinbound.js';
-import { Inbound } from '@/models/inbound.js';
+import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
 import InfinityIcon from '@/components/InfinityIcon.vue';
-import ClientRowTable from './ClientRowTable.vue';
 import { useDatepicker } from '@/composables/useDatepicker.js';
 
 const { datepicker } = useDatepicker();
@@ -58,117 +48,8 @@ const emit = defineEmits([
   'add-inbound',
   'general-action',
   'row-action',
-  // Per-client events surfaced from the expand-row table.
-  'edit-client',
-  'qrcode-client',
-  'info-client',
-  'reset-traffic-client',
-  'delete-client',
-  'delete-clients',
-  'toggle-enable-client',
 ]);
 
-// ============ Toolbar / search & filter =============================
-const FILTER_STATE_KEY = 'inboundsFilterState';
-const savedFilterState = (() => {
-  try {
-    return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
-  } catch (_e) {
-    return {};
-  }
-})();
-const enableFilter = ref(!!savedFilterState.enableFilter);
-const searchKey = ref(savedFilterState.searchKey || '');
-const filterBy = ref(savedFilterState.filterBy || '');
-const protocolFilter = ref(savedFilterState.protocolFilter || undefined);
-const nodeFilter = ref(savedFilterState.nodeFilter || '');
-
-watch([enableFilter, searchKey, filterBy, protocolFilter, nodeFilter], () => {
-  localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
-    enableFilter: enableFilter.value,
-    searchKey: searchKey.value,
-    filterBy: filterBy.value,
-    protocolFilter: protocolFilter.value,
-    nodeFilter: nodeFilter.value,
-  }));
-});
-
-// Toggle the filter mode — flip cleans the other input.
-function onToggleFilter() {
-  if (enableFilter.value) searchKey.value = '';
-  else filterBy.value = '';
-}
-
-const protocolOptions = computed(() => {
-  const values = new Set(props.dbInbounds.map((i) => i.protocol).filter(Boolean));
-  return [...values].sort();
-});
-
-const nodeOptions = computed(() => {
-  const values = new Map();
-  if (props.dbInbounds.some((i) => i.nodeId == null)) {
-    values.set('local', t('pages.inbounds.localPanel'));
-  }
-  for (const dbInbound of props.dbInbounds) {
-    if (dbInbound.nodeId == null) continue;
-    const node = props.nodesById.get(dbInbound.nodeId);
-    values.set(String(dbInbound.nodeId), node?.name || `#${dbInbound.nodeId}`);
-  }
-  return [...values.entries()].map(([value, label]) => ({ value, label }));
-});
-
-function applySecondaryFilters(rows) {
-  return rows.filter((dbInbound) => {
-    if (protocolFilter.value && dbInbound.protocol !== protocolFilter.value) return false;
-    if (nodeFilter.value) {
-      const nodeValue = dbInbound.nodeId == null ? 'local' : String(dbInbound.nodeId);
-      if (nodeValue !== nodeFilter.value) return false;
-    }
-    return true;
-  });
-}
-
-// ============ Search / filter projection =============================
-// Mirrors the legacy logic: when searching, keep inbounds that match
-// anywhere (deep search); when filtering, keep inbounds that have at
-// least one client in the requested bucket and reduce their settings
-// to that bucket.
-function projectInbound(dbInbound, predicate) {
-  const next = new DBInbound(dbInbound);
-  let settings;
-  try {
-    settings = JSON.parse(dbInbound.settings || '{}');
-  } catch (_e) {
-    settings = {};
-  }
-  if (!Array.isArray(settings.clients)) return next;
-  const filtered = settings.clients.filter(predicate);
-  next.settings = Inbound.Settings.fromJson(dbInbound.protocol, { clients: filtered });
-  next.invalidateCache();
-  return next;
-}
-
-const visibleInbounds = computed(() => {
-  if (enableFilter.value) {
-    if (ObjectUtil.isEmpty(filterBy.value)) return applySecondaryFilters([...props.dbInbounds]);
-    const out = [];
-    for (const dbInbound of props.dbInbounds) {
-      const c = props.clientCount[dbInbound.id];
-      if (!c || !c[filterBy.value] || c[filterBy.value].length === 0) continue;
-      const list = c[filterBy.value];
-      out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
-    }
-    return applySecondaryFilters(out);
-  }
-  if (ObjectUtil.isEmpty(searchKey.value)) return applySecondaryFilters([...props.dbInbounds]);
-  const out = [];
-  for (const dbInbound of props.dbInbounds) {
-    if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
-    out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
-  }
-  return applySecondaryFilters(out);
-});
-
 // ============ Sorting =================================================
 const sortState = ref({ column: null, order: null });
 
@@ -189,7 +70,6 @@ const sortFns = {
   port: (a, b) => a.port - b.port,
   protocol: (a, b) => a.protocol.localeCompare(b.protocol),
   traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
-  allTimeInbound: (a, b) => (a.allTime || 0) - (b.allTime || 0),
   expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
   node: (a, b) => {
     const nameA = props.nodesById.get(a.nodeId)?.name ?? (a.nodeId == null ? '\uffff' : `node #${a.nodeId}`);
@@ -201,10 +81,10 @@ const sortFns = {
 
 const sortedInbounds = computed(() => {
   const { column, order } = sortState.value;
-  if (!column || !order) return visibleInbounds.value;
+  if (!column || !order) return props.dbInbounds;
   const fn = sortFns[column];
-  if (!fn) return visibleInbounds.value;
-  const sorted = [...visibleInbounds.value].sort(fn);
+  if (!fn) return props.dbInbounds;
+  const sorted = [...props.dbInbounds].sort(fn);
   return order === 'descend' ? sorted.reverse() : sorted;
 });
 
@@ -215,10 +95,6 @@ function onTableChange(_pag, _filters, sorter) {
   };
 }
 
-watch([searchKey, filterBy], () => {
-  sortState.value = { column: null, order: null };
-});
-
 // ============ Columns =================================================
 // `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
 // `responsive` array still works on column defs. Computed so column
@@ -244,26 +120,12 @@ const desktopColumns = computed(() => {
     sortableCol({ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, 'protocol'),
     sortableCol({ title: t('clients'), key: 'clients', align: 'left', width: 50 }, 'clients'),
     sortableCol({ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, 'traffic'),
-    sortableCol({ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 }, 'allTimeInbound'),
     sortableCol({ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, 'expiryTime'),
   );
   return cols;
 });
 const columns = computed(() => desktopColumns.value);
 
-// Mobile expansion state — replaces a-table's expandable() since the
-// mobile branch renders a hand-rolled card list rather than a table.
-const expandedIds = ref(new Set());
-function toggleExpanded(id) {
-  const next = new Set(expandedIds.value);
-  if (next.has(id)) next.delete(id);
-  else next.add(id);
-  expandedIds.value = next;
-}
-function isExpanded(id) {
-  return expandedIds.value.has(id);
-}
-
 const statsRecord = ref(null);
 function openStats(record) {
   statsRecord.value = record;
@@ -344,12 +206,6 @@ function showQrCodeMenu(dbInbound) {
               <a-menu-item key="resetInbounds">
                 <ReloadOutlined /> {{ t('pages.inbounds.resetAllTraffic') }}
               </a-menu-item>
-              <a-menu-item key="resetClients">
-                <FileDoneOutlined /> {{ t('pages.inbounds.resetAllClientTraffics') }}
-              </a-menu-item>
-              <a-menu-item key="delDepletedClients" class="danger-item">
-                <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
-              </a-menu-item>
             </a-menu>
           </template>
         </a-dropdown>
@@ -357,50 +213,13 @@ function showQrCodeMenu(dbInbound) {
     </template>
 
     <a-space direction="vertical" :style="{ width: '100%' }">
-      <!-- Search / filter toolbar -->
-      <div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
-        <a-switch v-model:checked="enableFilter" @change="onToggleFilter">
-          <template #checkedChildren>
-            <SearchOutlined />
-          </template>
-          <template #unCheckedChildren>
-            <FilterOutlined />
-          </template>
-        </a-switch>
-        <a-input v-if="!enableFilter" v-model:value="searchKey" :placeholder="t('search')" autofocus
-          :size="isMobile ? 'small' : 'middle'" :style="{ maxWidth: '300px' }" />
-        <a-radio-group v-if="enableFilter" v-model:value="filterBy" button-style="solid"
-          :size="isMobile ? 'small' : 'middle'">
-          <a-radio-button value="">{{ t('none') }}</a-radio-button>
-          <a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
-          <a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
-          <a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
-          <a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
-          <a-radio-button value="online">{{ t('online') }}</a-radio-button>
-        </a-radio-group>
-        <a-select v-model:value="protocolFilter" allow-clear :placeholder="t('pages.inbounds.protocol')"
-          :size="isMobile ? 'small' : 'middle'" :style="{ width: '150px' }">
-          <a-select-option v-for="protocol in protocolOptions" :key="protocol" :value="protocol">
-            {{ protocol }}
-          </a-select-option>
-        </a-select>
-        <a-select v-if="hasActiveNode && nodeOptions.length > 0" v-model:value="nodeFilter" allow-clear
-          :placeholder="t('pages.inbounds.node')" :size="isMobile ? 'small' : 'middle'" :style="{ width: '170px' }">
-          <a-select-option v-for="node in nodeOptions" :key="node.value" :value="node.value">
-            {{ node.label }}
-          </a-select-option>
-        </a-select>
-      </div>
-
       <!-- ====================== Mobile: card list ======================= -->
       <div v-if="isMobile" class="inbound-cards">
-        <div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
+        <div v-if="sortedInbounds.length === 0" class="card-empty">—</div>
 
         <div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
-          <!-- Header: chevron (multi-user only) + id + remark + info + enable + actions -->
-          <div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
-            <RightOutlined v-if="record.isMultiUser()" class="card-expand"
-              :class="{ 'is-expanded': isExpanded(record.id) }" />
+          <!-- Header: id + remark + info + enable + actions -->
+          <div class="card-head">
             <span class="card-id">#{{ record.id }}</span>
             <span class="tag-name">{{ record.remark }}</span>
             <div class="card-actions" @click.stop>
@@ -419,27 +238,12 @@ function showQrCodeMenu(dbInbound) {
                       <QrcodeOutlined /> {{ t('qrCode') }}
                     </a-menu-item>
                     <template v-if="record.isMultiUser()">
-                      <a-menu-item key="addClient">
-                        <UserAddOutlined /> {{ t('pages.client.add') }}
-                      </a-menu-item>
-                      <a-menu-item key="addBulkClient">
-                        <UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
-                      </a-menu-item>
-                      <a-menu-item key="copyClients">
-                        <CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
-                      </a-menu-item>
-                      <a-menu-item key="resetClients">
-                        <FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
-                      </a-menu-item>
                       <a-menu-item key="export">
                         <ExportOutlined /> {{ t('pages.inbounds.export') }}
                       </a-menu-item>
                       <a-menu-item v-if="subEnable" key="subs">
                         <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
                       </a-menu-item>
-                      <a-menu-item key="delDepletedClients" class="danger-item">
-                        <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
-                      </a-menu-item>
                     </template>
                     <template v-else>
                       <a-menu-item key="showInfo">
@@ -463,20 +267,6 @@ function showQrCodeMenu(dbInbound) {
               </a-dropdown>
             </div>
           </div>
-
-          <!-- Expanded client list (multi-user only) -->
-          <div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
-            <ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
-              :online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
-              :page-size="pageSize" :total-client-count="clientCount[record.id]?.clients || 0"
-              :stats-version="statsVersion"
-              @edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
-              @info-client="(p) => emit('info-client', p)"
-              @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
-              @delete-client="(p) => emit('delete-client', p)"
-              @delete-clients="(p) => emit('delete-clients', p)"
-              @toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
-          </div>
         </div>
       </div>
 
@@ -517,10 +307,6 @@ function showQrCodeMenu(dbInbound) {
               <InfinityIcon v-else />
             </a-tag>
           </div>
-          <div class="stat-row">
-            <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
-            <a-tag>{{ SizeFormatter.sizeFormat(statsRecord.allTime || 0) }}</a-tag>
-          </div>
           <div v-if="clientCount[statsRecord.id]" class="stat-row">
             <span class="stat-label">{{ t('clients') }}</span>
             <a-tag color="green" class="client-count-tag">{{ clientCount[statsRecord.id].clients }}</a-tag>
@@ -550,29 +336,12 @@ function showQrCodeMenu(dbInbound) {
       <!-- ====================== Desktop: a-table ======================== -->
       <a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
         :pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
-        :row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')" @change="onTableChange">
-        <!-- Per-inbound client list, expanded by clicking the row's
-             default expand chevron. Hidden via row-class-name for
-             non-multi-user inbounds (matches legacy behavior). -->
-        <template #expandedRowRender="{ record }">
-          <ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
-            :traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
-            :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
-            :total-client-count="clientCount[record.id]?.clients || 0"
-            :stats-version="statsVersion"
-            @edit-client="(p) => emit('edit-client', p)"
-            @qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
-            @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
-            @delete-client="(p) => emit('delete-client', p)"
-            @delete-clients="(p) => emit('delete-clients', p)"
-            @toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
-        </template>
-
+        @change="onTableChange">
         <template #bodyCell="{ column, record }">
           <!-- ============== Action dropdown ============== -->
           <template v-if="column.key === 'action'">
             <div class="action-buttons">
-              <a-button type="text" size="small" @click.prevent="emit('row-action', {key: 'edit', dbInbound: record})">
+              <a-button type="text" size="small" @click.prevent="emit('row-action', { key: 'edit', dbInbound: record })">
                 <template #icon>
                   <EditOutlined />
                 </template>
@@ -590,27 +359,12 @@ function showQrCodeMenu(dbInbound) {
                       <QrcodeOutlined /> {{ t('qrCode') }}
                     </a-menu-item>
                     <template v-if="record.isMultiUser()">
-                      <a-menu-item key="addClient">
-                        <UserAddOutlined /> {{ t('pages.client.add') }}
-                      </a-menu-item>
-                      <a-menu-item key="addBulkClient">
-                        <UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
-                      </a-menu-item>
-                      <a-menu-item key="copyClients">
-                        <CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
-                      </a-menu-item>
-                      <a-menu-item key="resetClients">
-                        <FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
-                      </a-menu-item>
                       <a-menu-item key="export">
                         <ExportOutlined /> {{ t('pages.inbounds.export') }}
                       </a-menu-item>
                       <a-menu-item v-if="subEnable" key="subs">
                         <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
                       </a-menu-item>
-                      <a-menu-item key="delDepletedClients" class="danger-item">
-                        <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
-                      </a-menu-item>
                     </template>
                     <template v-else>
                       <a-menu-item key="showInfo">
@@ -671,14 +425,17 @@ function showQrCodeMenu(dbInbound) {
           <!-- ============== Clients tag + popovers ============== -->
           <template v-else-if="column.key === 'clients'">
             <template v-if="clientCount[record.id]">
-              <a-tag color="green" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].clients }}</a-tag>
+              <a-tag color="green" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
+                clientCount[record.id].clients }}</a-tag>
               <a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
                 <template #content>
                   <div class="client-email-list">
                     <div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
                   </div>
                 </template>
-                <a-tag class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
+                <a-tag class="client-count-tag" style="margin: 0; padding: 0 2px">{{
+                  clientCount[record.id].deactive.length
+                  }}</a-tag>
               </a-popover>
               <a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
                 <template #content>
@@ -686,8 +443,9 @@ function showQrCodeMenu(dbInbound) {
                     <div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
                   </div>
                 </template>
-                <a-tag color="red" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
-                }}</a-tag>
+                <a-tag color="red" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
+                  clientCount[record.id].depleted.length
+                  }}</a-tag>
               </a-popover>
               <a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
                 <template #content>
@@ -695,8 +453,9 @@ function showQrCodeMenu(dbInbound) {
                     <div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
                   </div>
                 </template>
-                <a-tag color="orange" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
-                }}</a-tag>
+                <a-tag color="orange" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
+                  clientCount[record.id].expiring.length
+                  }}</a-tag>
               </a-popover>
               <a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
                 <template #content>
@@ -704,7 +463,8 @@ function showQrCodeMenu(dbInbound) {
                     <div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
                   </div>
                 </template>
-                <a-tag color="blue" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
+                <a-tag color="blue" class="client-count-tag" style="margin: 0; padding: 0 2px">{{
+                  clientCount[record.id].online.length }}</a-tag>
               </a-popover>
             </template>
           </template>
@@ -734,11 +494,6 @@ function showQrCodeMenu(dbInbound) {
             </a-popover>
           </template>
 
-          <!-- ============== All-time inbound traffic ============== -->
-          <template v-else-if="column.key === 'allTimeInbound'">
-            <a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
-          </template>
-
           <!-- ============== Expiry ============== -->
           <template v-else-if="column.key === 'expiryTime'">
             <a-popover v-if="record.expiryTime > 0">
@@ -759,20 +514,6 @@ function showQrCodeMenu(dbInbound) {
 </template>
 
 <style scoped>
-.filter-bar {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-
-.filter-bar.mobile {
-  display: block;
-}
-
-.filter-bar.mobile>* {
-    margin-bottom: 4px;
-}
-
 .action-buttons {
   display: flex;
   align-items: center;
@@ -799,23 +540,6 @@ function showQrCodeMenu(dbInbound) {
   color: #ff4d4f;
 }
 
-/* Hide the expand chevron on rows whose inbound has no client list
- * (HTTP/Mixed/Tunnel/WireGuard single-config). */
-:deep(.hide-expand-icon .ant-table-row-expand-icon) {
-  visibility: hidden;
-}
-
-/* Push the expand chevron away from the table's left edge so it has
- * a little breathing room instead of being flush against the corner. */
-:deep(.ant-table-tbody .ant-table-cell-with-append) {
-  padding-left: 12px;
-}
-
-:deep(.ant-table-row-expand-icon) {
-  margin-inline-end: 10px;
-  margin-inline-start: 4px;
-}
-
 /* Round the table's outer corners — AD-Vue gives .ant-table the radius
  * token, but the inner header strip and footer touch the edges, so clip
  * them here. */
@@ -900,17 +624,6 @@ function showQrCodeMenu(dbInbound) {
   flex-shrink: 0;
 }
 
-.card-expand {
-  font-size: 12px;
-  opacity: 0.6;
-  transition: transform 150ms ease;
-  flex-shrink: 0;
-}
-
-.card-expand.is-expanded {
-  transform: rotate(90deg);
-}
-
 .card-stats {
   display: flex;
   flex-direction: column;
@@ -937,11 +650,6 @@ function showQrCodeMenu(dbInbound) {
   margin: 0;
 }
 
-.card-clients {
-  margin-top: 4px;
-  padding-top: 8px;
-  border-top: 1px solid rgba(128, 128, 128, 0.15);
-}
 
 .card-empty {
   text-align: center;
@@ -964,16 +672,6 @@ function showQrCodeMenu(dbInbound) {
     padding: 8px;
   }
 
-  .filter-bar.mobile {
-    display: flex;
-    flex-wrap: wrap;
-    gap: 6px;
-  }
-
-  .filter-bar.mobile>* {
-    margin-bottom: 0;
-  }
-
   .row-action-trigger {
     font-size: 22px;
     padding: 4px;

+ 55 - 250
frontend/src/pages/inbounds/InboundsPage.vue

@@ -5,13 +5,12 @@ import { Modal, message } from 'ant-design-vue';
 import {
   SwapOutlined,
   PieChartOutlined,
-  HistoryOutlined,
   BarsOutlined,
-  TeamOutlined,
 } from '@ant-design/icons-vue';
 
 import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
 import { Inbound } from '@/models/inbound.js';
+import { coerceInboundJsonField } from '@/models/dbinbound.js';
 import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
 import { useMediaQuery } from '@/composables/useMediaQuery.js';
 import AppSidebar from '@/components/AppSidebar.vue';
@@ -19,9 +18,6 @@ import CustomStatistic from '@/components/CustomStatistic.vue';
 import { useNodeList } from '@/composables/useNodeList.js';
 import InboundList from './InboundList.vue';
 import InboundFormModal from './InboundFormModal.vue';
-import ClientFormModal from './ClientFormModal.vue';
-import ClientBulkModal from './ClientBulkModal.vue';
-import CopyClientsModal from './CopyClientsModal.vue';
 import InboundInfoModal from './InboundInfoModal.vue';
 import QrCodeModal from './QrCodeModal.vue';
 import TextModal from '@/components/TextModal.vue';
@@ -65,9 +61,11 @@ useWebSocket({
   inbounds: applyInboundsEvent,
 });
 const { isMobile } = useMediaQuery();
-// Node list lives on the central panel; the Inbounds page consumes
-// the id→node map for the new "Node" column. Fetched once on mount.
 const { byId: nodesById, hasActive: hasActiveNode } = useNodeList();
+const hasNodeAttachedInbound = computed(() =>
+  (dbInbounds.value || []).some((ib) => ib?.nodeId != null),
+);
+const showNodeInfo = computed(() => hasNodeAttachedInbound.value || hasActiveNode.value);
 
 const basePath = window.X_UI_BASE_PATH || '';
 const requestUri = window.location.pathname;
@@ -82,17 +80,6 @@ const formOpen = ref(false);
 const formMode = ref('add');
 const formDbInbound = ref(null);
 
-// === Client modal (single + bulk) =====================================
-const clientOpen = ref(false);
-const clientMode = ref('add');
-const clientDbInbound = ref(null);
-const clientIndex = ref(null);
-
-const bulkOpen = ref(false);
-const bulkDbInbound = ref(null);
-const copyOpen = ref(false);
-const copyDbInbound = ref(null);
-
 // === Info / QR-code modals ===========================================
 const infoOpen = ref(false);
 const infoDbInbound = ref(null);
@@ -191,7 +178,8 @@ function exportInboundSubs(dbInbound) {
 function exportAllLinks() {
   const out = [];
   for (const ib of dbInbounds.value) {
-    out.push(ib.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
+    const projected = checkFallback(ib);
+    out.push(projected.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
   }
   openText({
     title: 'Export all inbound links',
@@ -240,8 +228,18 @@ function importInbound() {
 // the root inbound that owns the listen address so QRs/links carry
 // the externally-reachable host:port and the right TLS state.
 function checkFallback(dbInbound) {
-  // We don't keep parsed Inbounds in state right now (the page works
-  // off DBInbounds); compute on the fly.
+  // Path 1: panel-tracked fallback relationship (inbound_fallbacks row).
+  // The backend annotates each child inbound with fallbackParent so the
+  // child's client-share link advertises the master's reachable endpoint
+  // and inherits its TLS / Reality state.
+  const parent = dbInbound.fallbackParent;
+  if (parent?.masterId) {
+    const master = dbInbounds.value.find((ib) => ib.id === parent.masterId);
+    if (master) return projectChildThroughMaster(dbInbound, master);
+  }
+  // Path 2: legacy unix-socket convention (`@vless-ws` etc.) — walk the
+  // VLESS/Trojan TCP inbounds and look for one whose settings.fallbacks
+  // references this child's listen address.
   if (!dbInbound.listen?.startsWith?.('@')) return dbInbound;
   for (const candidate of dbInbounds.value) {
     if (candidate.id === dbInbound.id) continue;
@@ -250,23 +248,30 @@ function checkFallback(dbInbound) {
     if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
     const fallbacks = parsed.settings.fallbacks || [];
     if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
-    // Build a one-off DBInbound copy with the parent's listen/port +
-    // copied stream so the link gen sees the public endpoint.
-    const projected = JSON.parse(JSON.stringify(dbInbound));
-    projected.listen = candidate.listen;
-    projected.port = candidate.port;
-    const inheritedStream = parsed.stream;
-    const ownInbound = dbInbound.toInbound();
-    ownInbound.stream.security = inheritedStream.security;
-    ownInbound.stream.tls = inheritedStream.tls;
-    ownInbound.stream.externalProxy = inheritedStream.externalProxy;
-    projected.streamSettings = ownInbound.stream.toString();
-    // Re-wrap so callers get the same DBInbound shape they had.
-    return new dbInbound.constructor(projected);
+    return projectChildThroughMaster(dbInbound, candidate);
   }
   return dbInbound;
 }
 
+// projectChildThroughMaster returns a one-off DBInbound copy whose
+// listen/port + TLS/Reality state come from the master, while the
+// protocol/transport/clients stay the child's. This is what makes a
+// `vless://uuid@server:443?type=ws&path=/vlws&security=tls` link work
+// for a child VLESS-WS bound to 127.0.0.1.
+function projectChildThroughMaster(child, master) {
+  const projected = JSON.parse(JSON.stringify(child));
+  projected.listen = master.listen;
+  projected.port = master.port;
+  const masterStream = master.toInbound().stream;
+  const childInbound = child.toInbound();
+  childInbound.stream.security = masterStream.security;
+  childInbound.stream.tls = masterStream.tls;
+  childInbound.stream.reality = masterStream.reality;
+  childInbound.stream.externalProxy = masterStream.externalProxy;
+  projected.streamSettings = childInbound.stream.toString();
+  return new child.constructor(projected);
+}
+
 function findClientIndex(dbInbound, client) {
   if (!client) return 0;
   const inbound = dbInbound.toInbound();
@@ -284,73 +289,6 @@ function findClientIndex(dbInbound, client) {
   return idx >= 0 ? idx : 0;
 }
 
-function getClientId(protocol, client) {
-  switch (protocol) {
-    case 'trojan': return client.password;
-    case 'shadowsocks': return client.email;
-    case 'hysteria': return client.auth;
-    default: return client.id;
-  }
-}
-
-// === Per-client handlers (called from the expand-row table) =========
-function onEditClient({ dbInbound, client }) {
-  clientMode.value = 'edit';
-  clientDbInbound.value = dbInbound;
-  clientIndex.value = findClientIndex(dbInbound, client);
-  clientOpen.value = true;
-}
-
-function onQrcodeClient({ dbInbound, client }) {
-  qrDbInbound.value = checkFallback(dbInbound);
-  qrClient.value = client || null;
-  qrOpen.value = true;
-}
-
-function onInfoClient({ dbInbound, client }) {
-  infoDbInbound.value = checkFallback(dbInbound);
-  infoClientIndex.value = findClientIndex(dbInbound, client);
-  infoOpen.value = true;
-}
-
-async function onResetTrafficClient({ dbInbound, client }) {
-  const msg = await HttpUtil.post(
-    `/panel/api/inbounds/${dbInbound.id}/resetClientTraffic/${client.email}`,
-  );
-  if (msg?.success) await refresh();
-}
-
-async function onDeleteClient({ dbInbound, client }) {
-  const clientId = getClientId(dbInbound.protocol, client);
-  const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
-  if (msg?.success) await refresh();
-}
-
-async function onDeleteClients({ dbInbound, clients }) {
-  for (const client of clients) {
-    const clientId = getClientId(dbInbound.protocol, client);
-    await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
-  }
-  await refresh();
-}
-
-async function onToggleEnableClient({ dbInbound, client, next }) {
-  // Mirror legacy: clone the parsed inbound, flip enable on the matching
-  // client, and post the whole client back through updateClient. This
-  // keeps the wire shape identical to the modal save path.
-  const inbound = dbInbound.toInbound();
-  const clients = inbound?.clients || [];
-  const idx = findClientIndex(dbInbound, client);
-  if (idx < 0 || !clients[idx]) return;
-  clients[idx].enable = next;
-  const clientId = getClientId(dbInbound.protocol, clients[idx]);
-  const msg = await HttpUtil.post(`/panel/api/inbounds/updateClient/${clientId}`, {
-    id: dbInbound.id,
-    settings: `{"clients": [${clients[idx].toString()}]}`,
-  });
-  if (msg?.success) await refresh();
-}
-
 function onAddInbound() {
   formMode.value = 'add';
   formDbInbound.value = null;
@@ -363,18 +301,6 @@ function openEdit(dbInbound) {
   formOpen.value = true;
 }
 
-function openAddClient(dbInbound) {
-  clientMode.value = 'add';
-  clientDbInbound.value = dbInbound;
-  clientIndex.value = null;
-  clientOpen.value = true;
-}
-
-function openAddBulkClient(dbInbound) {
-  bulkDbInbound.value = dbInbound;
-  bulkOpen.value = true;
-}
-
 // Per-row destructive actions go through Modal.confirm (matches legacy).
 function confirmDelete(dbInbound) {
   Modal.confirm({
@@ -403,20 +329,6 @@ function confirmResetTraffic(dbInbound) {
   });
 }
 
-function confirmDelDepleted(dbInboundId) {
-  Modal.confirm({
-    title: 'Delete depleted clients?',
-    content: 'Removes every client whose traffic is exhausted or whose expiry has passed.',
-    okText: 'Delete',
-    okType: 'danger',
-    cancelText: 'Cancel',
-    onOk: async () => {
-      const msg = await HttpUtil.post(`/panel/api/inbounds/delDepletedClients/${dbInboundId}`);
-      if (msg?.success) await refresh();
-    },
-  });
-}
-
 // Clone — adds a new inbound with the same protocol+stream+sniffing
 // but a fresh remark/port and an empty client list.
 function confirmClone(dbInbound) {
@@ -427,6 +339,14 @@ function confirmClone(dbInbound) {
     cancelText: 'Cancel',
     onOk: async () => {
       const baseInbound = dbInbound.toInbound();
+      let clonedSettings;
+      try {
+        const raw = coerceInboundJsonField(dbInbound.settings);
+        raw.clients = [];
+        clonedSettings = JSON.stringify(raw);
+      } catch (_e) {
+        clonedSettings = Inbound.Settings.getSettings(baseInbound.protocol).toString();
+      }
       const data = {
         up: 0,
         down: 0,
@@ -437,7 +357,7 @@ function confirmClone(dbInbound) {
         listen: '',
         port: RandomUtil.randomInteger(10000, 60000),
         protocol: baseInbound.protocol,
-        settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
+        settings: clonedSettings,
         streamSettings: baseInbound.stream.toString(),
         sniffing: baseInbound.sniffing.toString(),
       };
@@ -469,20 +389,6 @@ function onGeneralAction(key) {
         },
       });
       break;
-    case 'resetClients':
-      Modal.confirm({
-        title: 'Reset all client traffic across all inbounds?',
-        okText: 'Reset',
-        cancelText: 'Cancel',
-        onOk: async () => {
-          const msg = await HttpUtil.post('/panel/api/inbounds/resetAllClientTraffics/-1');
-          if (msg?.success) await refresh();
-        },
-      });
-      break;
-    case 'delDepletedClients':
-      confirmDelDepleted(-1);
-      break;
     default:
       message.info(`General action "${key}" — coming in a later 5f subphase`);
   }
@@ -493,12 +399,6 @@ function onRowAction({ key, dbInbound }) {
     case 'edit':
       openEdit(dbInbound);
       break;
-    case 'addClient':
-      openAddClient(dbInbound);
-      break;
-    case 'addBulkClient':
-      openAddBulkClient(dbInbound);
-      break;
     case 'showInfo':
       infoDbInbound.value = checkFallback(dbInbound);
       infoClientIndex.value = findClientIndex(dbInbound, null);
@@ -518,10 +418,6 @@ function onRowAction({ key, dbInbound }) {
     case 'clipboard':
       exportInboundClipboard(dbInbound);
       break;
-    case 'copyClients':
-      copyDbInbound.value = dbInbound;
-      copyOpen.value = true;
-      break;
     case 'delete':
       confirmDelete(dbInbound);
       break;
@@ -531,20 +427,6 @@ function onRowAction({ key, dbInbound }) {
     case 'clone':
       confirmClone(dbInbound);
       break;
-    case 'resetClients':
-      Modal.confirm({
-        title: `Reset client traffic on "${dbInbound.remark}"?`,
-        okText: 'Reset',
-        cancelText: 'Cancel',
-        onOk: async () => {
-          const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllClientTraffics/${dbInbound.id}`);
-          if (msg?.success) await refresh();
-        },
-      });
-      break;
-    case 'delDepletedClients':
-      confirmDelDepleted(dbInbound.id);
-      break;
     default:
       message.info(`Action "${key}" — coming in a later 5f subphase`);
   }
@@ -566,7 +448,7 @@ function onRowAction({ key, dbInbound }) {
               <a-col :span="24">
                 <a-card size="small" hoverable class="summary-card">
                   <a-row :gutter="[16, 12]">
-                    <a-col :xs="12" :sm="12" :md="5">
+                    <a-col :xs="12" :sm="12" :md="8">
                       <CustomStatistic :title="t('pages.inbounds.totalDownUp')"
                         :value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
                         <template #prefix>
@@ -574,7 +456,7 @@ function onRowAction({ key, dbInbound }) {
                         </template>
                       </CustomStatistic>
                     </a-col>
-                    <a-col :xs="12" :sm="12" :md="5">
+                    <a-col :xs="12" :sm="12" :md="8">
                       <CustomStatistic :title="t('pages.inbounds.totalUsage')"
                         :value="SizeFormatter.sizeFormat(totals.up + totals.down)">
                         <template #prefix>
@@ -582,63 +464,13 @@ function onRowAction({ key, dbInbound }) {
                         </template>
                       </CustomStatistic>
                     </a-col>
-                    <a-col :xs="12" :sm="12" :md="5">
-                      <CustomStatistic :title="t('pages.inbounds.allTimeTrafficUsage')"
-                        :value="SizeFormatter.sizeFormat(totals.allTime)">
-                        <template #prefix>
-                          <HistoryOutlined />
-                        </template>
-                      </CustomStatistic>
-                    </a-col>
-                    <a-col :xs="12" :sm="12" :md="5">
+                    <a-col :xs="24" :sm="24" :md="8">
                       <CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
                         <template #prefix>
                           <BarsOutlined />
                         </template>
                       </CustomStatistic>
                     </a-col>
-                    <a-col :xs="24" :sm="24" :md="4">
-                      <CustomStatistic :title="t('clients')" value=" ">
-                        <template #prefix>
-                          <a-space direction="horizontal">
-                            <TeamOutlined />
-                            <a-tag color="green">{{ totals.clients }}</a-tag>
-                            <a-popover v-if="totals.deactive.length" :title="t('disabled')">
-                              <template #content>
-                                <div class="client-email-list">
-                                  <div v-for="email in totals.deactive" :key="email">{{ email }}</div>
-                                </div>
-                              </template>
-                              <a-tag>{{ totals.deactive.length }}</a-tag>
-                            </a-popover>
-                            <a-popover v-if="totals.depleted.length" :title="t('depleted')">
-                              <template #content>
-                                <div class="client-email-list">
-                                  <div v-for="email in totals.depleted" :key="email">{{ email }}</div>
-                                </div>
-                              </template>
-                              <a-tag color="red">{{ totals.depleted.length }}</a-tag>
-                            </a-popover>
-                            <a-popover v-if="totals.expiring.length" :title="t('depletingSoon')">
-                              <template #content>
-                                <div class="client-email-list">
-                                  <div v-for="email in totals.expiring" :key="email">{{ email }}</div>
-                                </div>
-                              </template>
-                              <a-tag color="orange">{{ totals.expiring.length }}</a-tag>
-                            </a-popover>
-                            <a-popover v-if="totals.online.length" :title="t('online')">
-                              <template #content>
-                                <div class="client-email-list">
-                                  <div v-for="email in totals.online" :key="email">{{ email }}</div>
-                                </div>
-                              </template>
-                              <a-tag color="blue">{{ totals.online.length }}</a-tag>
-                            </a-popover>
-                          </a-space>
-                        </template>
-                      </CustomStatistic>
-                    </a-col>
                   </a-row>
                 </a-card>
               </a-col>
@@ -648,26 +480,16 @@ function onRowAction({ key, dbInbound }) {
                 <InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
                   :last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
                   :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
-                  :sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
-                  :stats-version="statsVersion"
-                  @refresh="refresh"
-                  @add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
-                  @edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
-                  @reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
-                  @delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
+                  :sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="showNodeInfo"
+                  :stats-version="statsVersion" @refresh="refresh" @add-inbound="onAddInbound"
+                  @general-action="onGeneralAction" @row-action="onRowAction" />
               </a-col>
             </a-row>
           </a-spin>
         </a-layout-content>
       </a-layout>
 
-      <InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" @saved="refresh" />
-      <ClientFormModal v-model:open="clientOpen" :mode="clientMode" :db-inbound="clientDbInbound"
-        :client-index="clientIndex" :sub-enable="subSettings.enable" :tg-bot-enable="tgBotEnable"
-        :ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
-      <ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
-        :tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
-      <CopyClientsModal v-model:open="copyOpen" :db-inbound="copyDbInbound" :db-inbounds="dbInbounds"
+      <InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" :db-inbounds="dbInbounds"
         @saved="refresh" />
       <InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
         :remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
@@ -735,20 +557,3 @@ function onRowAction({ key, dbInbound }) {
   }
 }
 </style>
-
-<style>
-/* AD-Vue popovers teleport their content to <body>, so scoped styles
-   don't reach them — this block has to be unscoped. */
-.client-email-list {
-  max-height: 280px;
-  min-width: 160px;
-  overflow-y: auto;
-  padding-right: 4px;
-}
-
-.client-email-list > div {
-  padding: 2px 0;
-  font-size: 12px;
-  white-space: nowrap;
-}
-</style>

+ 11 - 23
frontend/src/pages/inbounds/useInbounds.js

@@ -55,7 +55,14 @@ export function useInbounds() {
   // (HTTP, MIXED, WireGuard) since their settings have no client list.
   function rollupClients(dbInbound, inbound) {
     const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
-    const clients = inbound?.clients || [];
+    const allClients = inbound?.clients || [];
+    const statsEmails = new Set();
+    for (const s of clientStats) {
+      if (s && s.email) statsEmails.add(s.email);
+    }
+    const clients = clientStats.length > 0
+      ? allClients.filter((c) => c && c.email && statsEmails.has(c.email))
+      : allClients;
     const active = [];
     const deactive = [];
     const depleted = [];
@@ -126,12 +133,12 @@ export function useInbounds() {
   }
 
   async function fetchOnlineUsers() {
-    const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
+    const msg = await HttpUtil.post('/panel/api/clients/onlines');
     if (msg?.success) onlineClients.value = msg.obj || [];
   }
 
   async function fetchLastOnlineMap() {
-    const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
+    const msg = await HttpUtil.post('/panel/api/clients/lastOnline');
     if (msg?.success && msg.obj) lastOnlineMap.value = msg.obj;
   }
 
@@ -195,7 +202,6 @@ export function useInbounds() {
         if (!upd) continue;
         if (typeof upd.up === 'number') ib.up = upd.up;
         if (typeof upd.down === 'number') ib.down = upd.down;
-        if (typeof upd.allTime === 'number') ib.allTime = upd.allTime;
         if (typeof upd.total === 'number') ib.total = upd.total;
         if (typeof upd.enable === 'boolean') ib.enable = upd.enable;
         touched = true;
@@ -216,7 +222,6 @@ export function useInbounds() {
           if (typeof upd.up === 'number') stat.up = upd.up;
           if (typeof upd.down === 'number') stat.down = upd.down;
           if (typeof upd.total === 'number') stat.total = upd.total;
-          if (typeof upd.allTime === 'number') stat.allTime = upd.allTime;
           if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
           if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
           touched = true;
@@ -283,31 +288,14 @@ export function useInbounds() {
     }
   }
 
-  // Aggregate totals shown in the dashboard summary card. allTime falls
-  // back to up+down when the per-inbound counter isn't populated yet.
   const totals = computed(() => {
     let up = 0;
     let down = 0;
-    let allTime = 0;
-    let clients = 0;
-    const deactive = [];
-    const depleted = [];
-    const expiring = [];
-    const online = [];
     for (const ib of dbInbounds.value) {
       up += ib.up || 0;
       down += ib.down || 0;
-      allTime += ib.allTime || (ib.up + ib.down) || 0;
-      const c = clientCount.value[ib.id];
-      if (c) {
-        clients += c.clients;
-        deactive.push(...c.deactive);
-        depleted.push(...c.depleted);
-        expiring.push(...c.expiring);
-        online.push(...c.online);
-      }
     }
-    return { up, down, allTime, clients, deactive, depleted, expiring, online };
+    return { up, down };
   });
 
   // ObjectUtil reference is wired at module load — keeping a no-op import

+ 34 - 0
frontend/src/pages/nodes/NodeList.vue

@@ -184,6 +184,10 @@ function isExpanded(id) {
           <span class="stat-label">{{ t('pages.nodes.xrayVersion') }}</span>
           <a-tag>{{ statsNode.xrayVersion || '-' }}</a-tag>
         </div>
+        <div class="stat-row">
+          <span class="stat-label">{{ t('pages.nodes.panelVersion') || 'Panel version' }}</span>
+          <a-tag>{{ statsNode.panelVersion || '-' }}</a-tag>
+        </div>
         <div class="stat-row">
           <span class="stat-label">{{ t('pages.nodes.uptime') }}</span>
           <a-tag>{{ formatUptime(statsNode.uptimeSecs) }}</a-tag>
@@ -195,6 +199,16 @@ function isExpanded(id) {
             <template v-else>-</template>
           </a-tag>
         </div>
+        <div class="stat-row">
+          <span class="stat-label">{{ t('clients') }}</span>
+          <a-tag color="green">{{ statsNode.clientCount || 0 }}</a-tag>
+          <a-tag v-if="statsNode.onlineCount" color="blue">
+            {{ statsNode.onlineCount }} {{ t('online') }}
+          </a-tag>
+          <a-tag v-if="statsNode.depletedCount" color="red">
+            {{ statsNode.depletedCount }} {{ t('depleted') }}
+          </a-tag>
+        </div>
         <div class="stat-row">
           <span class="stat-label">{{ t('pages.nodes.lastHeartbeat') }}</span>
           <a-tag>{{ relativeTime(statsNode.lastHeartbeat) }}</a-tag>
@@ -260,10 +274,30 @@ function isExpanded(id) {
         </template>
       </a-table-column>
 
+      <a-table-column :title="t('pages.nodes.panelVersion') || 'Panel version'" data-index="panelVersion" align="center">
+        <template #default="{ record }">
+          {{ record.panelVersion || '-' }}
+        </template>
+      </a-table-column>
+
       <a-table-column :title="t('pages.nodes.uptime')" data-index="uptimeSecs" align="center">
         <template #default="{ record }">{{ formatUptime(record.uptimeSecs) }}</template>
       </a-table-column>
 
+      <a-table-column :title="t('clients')" align="center" :width="160">
+        <template #default="{ record }">
+          <a-space :size="4">
+            <a-tag color="green">{{ record.clientCount || 0 }}</a-tag>
+            <a-tag v-if="record.onlineCount" color="blue">
+              {{ record.onlineCount }} {{ t('online') }}
+            </a-tag>
+            <a-tag v-if="record.depletedCount" color="red">
+              {{ record.depletedCount }} {{ t('depleted') }}
+            </a-tag>
+          </a-space>
+        </template>
+      </a-table-column>
+
       <a-table-column :title="t('pages.nodes.latency')" data-index="latencyMs" align="center" :width="100">
         <template #default="{ record }">
           <span v-if="record.latencyMs > 0">{{ record.latencyMs }} ms</span>

+ 12 - 2
frontend/src/pages/nodes/useNodes.js

@@ -71,15 +71,21 @@ export function useNodes() {
     return msg;
   }
 
-  // Aggregate cards on the dashboard. Computed off the live list so a
-  // refresh (or a WS push) picks up new totals automatically.
   const totals = computed(() => {
     const list = nodes.value;
     let online = 0;
     let offline = 0;
     let latencySum = 0;
     let latencyCount = 0;
+    let inbounds = 0;
+    let clients = 0;
+    let onlineClients = 0;
+    let depleted = 0;
     for (const n of list) {
+      inbounds += n.inboundCount || 0;
+      clients += n.clientCount || 0;
+      onlineClients += n.onlineCount || 0;
+      depleted += n.depletedCount || 0;
       if (!n.enable) continue;
       if (n.status === 'online') {
         online += 1;
@@ -96,6 +102,10 @@ export function useNodes() {
       online,
       offline,
       avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
+      inbounds,
+      clients,
+      onlineClients,
+      depleted,
     };
   });
 

+ 3 - 14
frontend/src/pages/xray/BalancerFormModal.vue

@@ -61,16 +61,6 @@ const isValid = computed(
   () => !tagEmpty.value && !duplicateTag.value && !emptySelector.value,
 );
 
-const fallbackSupported = computed(
-  () => form.strategy === 'leastPing' || form.strategy === 'leastLoad',
-);
-
-watch(() => form.strategy, (next) => {
-  if (next !== 'leastPing' && next !== 'leastLoad') {
-    form.fallbackTag = '';
-  }
-});
-
 const tagValidateStatus = computed(() => {
   if (tagEmpty.value) return 'error';
   if (duplicateTag.value) return 'warning';
@@ -97,7 +87,7 @@ const title = computed(() =>
     : `+ ${t('pages.xray.Balancers')}`,
 );
 const okText = computed(() =>
-  isEdit.value ? t('pages.client.submitEdit') : t('create'),
+  isEdit.value ? t('pages.clients.submitEdit') : t('create'),
 );
 </script>
 
@@ -121,9 +111,8 @@ const okText = computed(() =>
         </a-select>
       </a-form-item>
 
-      <a-form-item label="Fallback"
-        :help="fallbackSupported ? '' : 'Available only with Least ping / Least load'">
-        <a-select v-model:value="form.fallbackTag" allow-clear :disabled="!fallbackSupported">
+      <a-form-item label="Fallback">
+        <a-select v-model:value="form.fallbackTag" allow-clear>
           <a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
             {{ tag || `(${t('none')})` }}
           </a-select-option>

+ 12 - 10
frontend/src/pages/xray/BalancersTab.vue

@@ -133,23 +133,25 @@ function syncObservatories() {
     delete t.observatory;
   }
 
-  const leastLoads = balancers.filter((b) => b.strategy?.type === 'leastLoad');
-  if (leastLoads.length > 0) {
+  const burstFeeders = balancers.filter((b) => {
+    const type = b.strategy?.type || 'random';
+    return type === 'leastLoad' || type === 'random' || type === 'roundRobin';
+  });
+  if (burstFeeders.length > 0) {
     if (!t.burstObservatory) {
       t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
     }
-    t.burstObservatory.subjectSelector = collectSelectors(leastLoads);
+    t.burstObservatory.subjectSelector = collectSelectors(burstFeeders);
   } else {
     delete t.burstObservatory;
   }
 }
 
 function buildWireBalancer(form) {
-  const supportsFallback = form.strategy === 'leastPing' || form.strategy === 'leastLoad';
   const out = {
     tag: form.tag,
     selector: [...form.selector],
-    fallbackTag: supportsFallback ? form.fallbackTag : '',
+    fallbackTag: form.fallbackTag || '',
   };
   if (form.strategy && form.strategy !== 'random') {
     out.strategy = { type: form.strategy };
@@ -218,11 +220,11 @@ const showObsEditor = computed(() => hasObservatory.value || hasBurstObservatory
 
 const obsView = ref('observatory');
 
-// Keep the radio selection valid as observatories appear/disappear —
-// e.g. deleting the last leastPing balancer should flip the editor to
-// the burstObservatory pane instead of leaving it pointing at the
-// (now-removed) observatory key.
-watch(showObsEditor, () => {
+// Watch each flag individually — watching showObsEditor (OR of the two)
+// misses the case where one observatory swaps for the other in the same
+// tick, leaving obsView pointing at a now-deleted key and JsonEditor
+// trying to parse an empty string.
+watch([hasObservatory, hasBurstObservatory], () => {
   if (obsView.value === 'observatory' && !hasObservatory.value && hasBurstObservatory.value) {
     obsView.value = 'burstObservatory';
   } else if (obsView.value === 'burstObservatory' && !hasBurstObservatory.value && hasObservatory.value) {

+ 2 - 3
frontend/src/pages/xray/BasicsTab.vue

@@ -340,7 +340,6 @@ const localOutboundTestUrl = computed({
         <template #description>{{ t('pages.xray.accessLogDesc') }}</template>
         <template #control>
           <a-select v-model:value="accessLog" :style="{ width: '100%' }">
-            <a-select-option value="">{{ t('none') }}</a-select-option>
             <a-select-option v-for="s in ACCESS_LOG" :key="s" :value="s">{{ s }}</a-select-option>
           </a-select>
         </template>
@@ -351,7 +350,7 @@ const localOutboundTestUrl = computed({
         <template #description>{{ t('pages.xray.errorLogDesc') }}</template>
         <template #control>
           <a-select v-model:value="errorLog" :style="{ width: '100%' }">
-            <a-select-option value="">{{ t('none') }}</a-select-option>
+            <a-select-option value="">{{ t('empty') }}</a-select-option>
             <a-select-option v-for="s in ERROR_LOG" :key="s" :value="s">{{ s }}</a-select-option>
           </a-select>
         </template>
@@ -362,7 +361,7 @@ const localOutboundTestUrl = computed({
         <template #description>{{ t('pages.xray.maskAddressDesc') }}</template>
         <template #control>
           <a-select v-model:value="maskAddressLog" :style="{ width: '100%' }">
-            <a-select-option value="">{{ t('none') }}</a-select-option>
+            <a-select-option value="">{{ t('empty') }}</a-select-option>
             <a-select-option v-for="s in MASK_ADDRESS" :key="s" :value="s">{{ s }}</a-select-option>
           </a-select>
         </template>

+ 2 - 3
frontend/src/pages/xray/OutboundFormModal.vue

@@ -210,7 +210,7 @@ const title = computed(() =>
     : `+ ${t('pages.xray.Outbounds')}`,
 );
 const okText = computed(() =>
-  isEdit.value ? t('pages.client.submitEdit') : t('create'),
+  isEdit.value ? t('pages.clients.submitEdit') : t('create'),
 );
 
 // Helper getters / shortcuts used by the template.
@@ -343,8 +343,7 @@ function regenerateWgKeys() {
           <!-- ============== Loopback ============== -->
           <template v-if="isLoopback">
             <a-form-item label="Inbound tag">
-              <a-input v-model:value="outbound.settings.inboundTag"
-                placeholder="inbound tag using in routing rules" />
+              <a-input v-model:value="outbound.settings.inboundTag" placeholder="inbound tag using in routing rules" />
             </a-form-item>
           </template>
 

+ 3 - 3
frontend/src/pages/xray/RuleFormModal.vue

@@ -129,7 +129,7 @@ const title = computed(() =>
     : `+ ${t('pages.xray.Routings')}`,
 );
 const okText = computed(() =>
-  isEdit.value ? t('pages.client.submitEdit') : t('create'),
+  isEdit.value ? t('pages.clients.submitEdit') : t('create'),
 );
 
 const NETWORKS = ['', 'TCP', 'UDP', 'TCP,UDP'];
@@ -248,7 +248,7 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
       <a-form-item label="Outbound tag">
         <a-select v-model:value="form.outboundTag">
           <a-select-option v-for="tag in outboundTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)'
-            }}</a-select-option>
+          }}</a-select-option>
         </a-select>
       </a-form-item>
 
@@ -261,7 +261,7 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
         </template>
         <a-select v-model:value="form.balancerTag">
           <a-select-option v-for="tag in balancerTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)'
-            }}</a-select-option>
+          }}</a-select-option>
         </a-select>
       </a-form-item>
     </a-form>

+ 8 - 6
frontend/src/utils/index.js

@@ -33,29 +33,31 @@ export class HttpUtil {
     }
 
     static async get(url, params, options = {}) {
+        const { silent, ...axiosOpts } = options;
         try {
-            const resp = await axios.get(url, { params, ...options });
+            const resp = await axios.get(url, { params, ...axiosOpts });
             const msg = this._respToMsg(resp);
-            this._handleMsg(msg);
+            if (!silent) this._handleMsg(msg);
             return msg;
         } catch (error) {
             console.error('GET request failed:', error);
             const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
-            this._handleMsg(errorMsg);
+            if (!silent) this._handleMsg(errorMsg);
             return errorMsg;
         }
     }
 
     static async post(url, data, options = {}) {
+        const { silent, ...axiosOpts } = options;
         try {
-            const resp = await axios.post(url, data, options);
+            const resp = await axios.post(url, data, axiosOpts);
             const msg = this._respToMsg(resp);
-            this._handleMsg(msg);
+            if (!silent) this._handleMsg(msg);
             return msg;
         } catch (error) {
             console.error('POST request failed:', error);
             const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
-            this._handleMsg(errorMsg);
+            if (!silent) this._handleMsg(errorMsg);
             return errorMsg;
         }
     }

+ 15 - 10
frontend/vite.config.js

@@ -9,7 +9,14 @@ const BACKEND_TARGET = 'http://localhost:2053';
 
 function resolveDBPath() {
   const envFolder = process.env.XUI_DB_FOLDER;
-  if (envFolder) return path.join(envFolder, 'x-ui.db');
+  if (envFolder) {
+    const abs = path.isAbsolute(envFolder)
+      ? envFolder
+      : path.resolve(__dirname, '..', envFolder);
+    return path.join(abs, 'x-ui.db');
+  }
+  const repoSubDB = path.resolve(__dirname, '..', 'x-ui', 'x-ui.db');
+  if (fs.existsSync(repoSubDB)) return repoSubDB;
   const repoDB = path.resolve(__dirname, '..', 'x-ui.db');
   if (fs.existsSync(repoDB)) return repoDB;
   return '/etc/x-ui/x-ui.db';
@@ -22,6 +29,8 @@ const BASE_MIGRATED_ROUTES = {
   'panel/settings/': '/settings.html',
   'panel/inbounds': '/inbounds.html',
   'panel/inbounds/': '/inbounds.html',
+  'panel/clients': '/clients.html',
+  'panel/clients/': '/clients.html',
   'panel/xray': '/xray.html',
   'panel/xray/': '/xray.html',
   'panel/nodes': '/nodes.html',
@@ -76,19 +85,14 @@ function injectBasePathPlugin() {
 function bypassMigratedRoute(req) {
   if (req.method !== 'GET') return undefined;
   const url = req.url.split('?')[0];
+  const basePath = refreshBasePath();
 
-  for (const [key, value] of Object.entries(BASE_MIGRATED_ROUTES)) {
-    if (url === '/' + key) return value;
-  }
+  if (url === basePath) return '/login.html';
 
-  const m = url.match(/^\/[^/]+\/(.+)$/);
-  if (m) {
-    const stripped = m[1];
+  if (url.startsWith(basePath)) {
+    const stripped = url.slice(basePath.length);
     if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped];
   }
-
-  if (url === '/' || /^\/[^/]+\/$/.test(url)) return '/login.html';
-
   return undefined;
 }
 
@@ -150,6 +154,7 @@ export default defineConfig({
         login: path.resolve(__dirname, 'login.html'),
         settings: path.resolve(__dirname, 'settings.html'),
         inbounds: path.resolve(__dirname, 'inbounds.html'),
+        clients: path.resolve(__dirname, 'clients.html'),
         xray: path.resolve(__dirname, 'xray.html'),
         nodes: path.resolve(__dirname, 'nodes.html'),
         apiDocs: path.resolve(__dirname, 'api-docs.html'),

+ 7 - 2
go.mod

@@ -12,7 +12,7 @@ require (
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.3
 	github.com/joho/godotenv v1.5.1
-	github.com/mymmrac/telego v1.8.0
+	github.com/mymmrac/telego v1.9.0
 	github.com/nicksnyder/go-i18n/v2 v2.6.1
 	github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
 	github.com/robfig/cron/v3 v3.0.1
@@ -25,8 +25,9 @@ require (
 	golang.org/x/crypto v0.51.0
 	golang.org/x/sys v0.44.0
 	golang.org/x/text v0.37.0
-	google.golang.org/grpc v1.81.0
+	google.golang.org/grpc v1.81.1
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
+	gorm.io/driver/postgres v1.6.0
 	gorm.io/driver/sqlite v1.6.0
 	gorm.io/gorm v1.31.1
 )
@@ -53,6 +54,10 @@ require (
 	github.com/gorilla/securecookie v1.1.2 // indirect
 	github.com/gorilla/sessions v1.4.0 // indirect
 	github.com/grbit/go-json v0.11.0 // indirect
+	github.com/jackc/pgpassfile v1.0.0 // indirect
+	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+	github.com/jackc/pgx/v5 v5.9.2 // indirect
+	github.com/jackc/puddle/v2 v2.2.2 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect

+ 15 - 4
go.sum

@@ -85,6 +85,14 @@ github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
 github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
 github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
 github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
+github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
 github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
 github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
 github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -130,8 +138,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow=
-github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
+github.com/mymmrac/telego v1.9.0 h1:ZUJxZaPx/1IgRvVb5lXnUB8FgW5rNYfRe6Q2EJ4OJ+Y=
+github.com/mymmrac/telego v1.9.0/go.mod h1:tVEB7OqiOPx8elRk9+ETkwiDQrUhWSB2XmAKIY9KmWY=
 github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
 github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
@@ -169,6 +177,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@@ -258,8 +267,8 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
-google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
-google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
+google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
+google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
 google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -272,6 +281,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
+gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
 gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
 gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
 gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=

+ 252 - 307
install.sh

@@ -6,13 +6,39 @@ blue='\033[0;34m'
 yellow='\033[0;33m'
 plain='\033[0m'
 
-cur_dir=$(pwd)
-
 xui_folder="${XUI_MAIN_FOLDER:=/usr/local/x-ui}"
 xui_service="${XUI_SERVICE:=/etc/systemd/system}"
 
+# Don't edit this config
+b_source="${BASH_SOURCE[0]}"
+while [ -h "$b_source" ]; do
+    b_dir="$(cd -P "$(dirname "$b_source")" > /dev/null 2>&1 && pwd || pwd -P)"
+    b_source="$(readlink "$b_source")"
+    [[ $b_source != /* ]] && b_source="$b_dir/$b_source"
+done
+cur_dir="$(cd -P "$(dirname "$b_source")" > /dev/null 2>&1 && pwd || pwd -P)"
+script_name=$(basename "$0")
+
+# Check command exist function
+_command_exists() {
+    type "$1" &> /dev/null
+}
+
+# Fail, log and exit script function
+_fail() {
+    local msg=${1}
+    echo -e "${red}${msg}${plain}"
+    exit 2
+}
+
 # check root
-[[ $EUID -ne 0 ]] && echo -e "${red}Fatal error: ${plain} Please run this script with root privilege \n " && exit 1
+[[ $EUID -ne 0 ]] && _fail "FATAL ERROR: Please run this script with root privilege."
+
+if _command_exists curl; then
+    curl_bin=$(which curl)
+else
+    _fail "ERROR: Command 'curl' not found."
+fi
 
 # Check OS and set release variable
 if [[ -f /etc/os-release ]]; then
@@ -22,8 +48,7 @@ elif [[ -f /usr/lib/os-release ]]; then
     source /usr/lib/os-release
     release=$ID
 else
-    echo "Failed to check the system OS, please contact the author!" >&2
-    exit 1
+    _fail "Failed to check the system OS, please contact the author!"
 fi
 echo "The OS release is: $release"
 
@@ -36,7 +61,7 @@ arch() {
         armv6* | armv6) echo 'armv6' ;;
         armv5* | armv5) echo 'armv5' ;;
         s390x) echo 's390x' ;;
-        *) echo -e "${green}Unsupported CPU architecture! ${plain}" && rm -f install.sh && exit 1 ;;
+        *) echo -e "${red}Unsupported CPU architecture!${plain}" && rm -f "${cur_dir}/${script_name}" > /dev/null 2>&1 && exit 2 ;;
     esac
 }
 
@@ -73,43 +98,44 @@ is_port_in_use() {
     return 1
 }
 
+gen_random_string() {
+    local length="$1"
+    openssl rand -base64 $((length * 2)) \
+        | tr -dc 'a-zA-Z0-9' \
+        | head -c "$length"
+}
+
 install_base() {
+    echo -e "${green}Updating and install dependency packages...${plain}"
     case "${release}" in
         ubuntu | debian | armbian)
-            apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl
+            apt-get update > /dev/null 2>&1 && apt-get install -y -q cron curl tar tzdata socat openssl > /dev/null 2>&1
             ;;
         fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
-            dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl
+            dnf -y update > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
             ;;
         centos)
             if [[ "${VERSION_ID}" =~ ^7 ]]; then
-                yum -y update && yum install -y cronie curl tar tzdata socat ca-certificates openssl
+                yum -y update > /dev/null 2>&1 && yum install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
             else
-                dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl
+                dnf -y update > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
             fi
             ;;
         arch | manjaro | parch)
-            pacman -Syu && pacman -Syu --noconfirm cronie curl tar tzdata socat ca-certificates openssl
+            pacman -Syu > /dev/null 2>&1 && pacman -Syu --noconfirm cronie curl tar tzdata socat openssl > /dev/null 2>&1
             ;;
         opensuse-tumbleweed | opensuse-leap)
-            zypper refresh && zypper -q install -y cron curl tar timezone socat ca-certificates openssl
+            zypper refresh > /dev/null 2>&1 && zypper -q install -y cron curl tar timezone socat openssl > /dev/null 2>&1
             ;;
         alpine)
-            apk update && apk add dcron curl tar tzdata socat ca-certificates openssl
+            apk update > /dev/null 2>&1 && apk add dcron curl tar tzdata socat openssl > /dev/null 2>&1
             ;;
         *)
-            apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl
+            apt-get update > /dev/null 2>&1 && apt install -y -q cron curl tar tzdata socat openssl > /dev/null 2>&1
             ;;
     esac
 }
 
-gen_random_string() {
-    local length="$1"
-    openssl rand -base64 $((length * 2)) \
-        | tr -dc 'a-zA-Z0-9' \
-        | head -c "$length"
-}
-
 install_acme() {
     echo -e "${green}Installing acme.sh for SSL certificate management...${plain}"
     cd ~ || return 1
@@ -172,7 +198,6 @@ setup_ssl_certificate() {
 
     # Enable auto-renew
     ~/.acme.sh/acme.sh --upgrade --auto-upgrade > /dev/null 2>&1
-    # Secure permissions: private key readable only by owner
     chmod 600 $certPath/privkey.pem 2> /dev/null
     chmod 644 $certPath/fullchain.pem 2> /dev/null
 
@@ -231,7 +256,7 @@ setup_ip_certificate() {
         echo -e "${green}Including IPv6 address: ${ipv6}${plain}"
     fi
 
-    # Set reload command for auto-renewal (add || true so it doesn't fail during first install)
+    # Set reload command for auto-renewal (add || true so it doesn't fail if service stopped)
     local reloadCmd="systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null || true"
 
     # Choose port for HTTP-01 listener (default 80, prompt override)
@@ -250,7 +275,7 @@ setup_ip_certificate() {
     # Ensure chosen port is available
     while true; do
         if is_port_in_use "${WebPort}"; then
-            echo -e "${yellow}Port ${WebPort} is in use.${plain}"
+            echo -e "${yellow}Port ${WebPort} is currently in use.${plain}"
 
             local alt_port=""
             read -rp "Enter another port for acme.sh standalone listener (leave empty to abort): " alt_port
@@ -319,26 +344,24 @@ setup_ip_certificate() {
     # Enable auto-upgrade for acme.sh (ensures cron job runs)
     ~/.acme.sh/acme.sh --upgrade --auto-upgrade > /dev/null 2>&1
 
-    # Secure permissions: private key readable only by owner
     chmod 600 ${certDir}/privkey.pem 2> /dev/null
     chmod 644 ${certDir}/fullchain.pem 2> /dev/null
 
     # Configure panel to use the certificate
     echo -e "${green}Setting certificate paths for the panel...${plain}"
     ${xui_folder}/x-ui cert -webCert "${certDir}/fullchain.pem" -webCertKey "${certDir}/privkey.pem"
-
     if [ $? -ne 0 ]; then
-        echo -e "${yellow}Warning: Could not set certificate paths automatically${plain}"
-        echo -e "${yellow}Certificate files are at:${plain}"
-        echo -e "  Cert: ${certDir}/fullchain.pem"
-        echo -e "  Key:  ${certDir}/privkey.pem"
+        echo -e "${yellow}Warning: Could not set certificate paths automatically.${plain}"
+        echo -e "${yellow}You may need to set them manually in the panel settings.${plain}"
+        echo -e "${yellow}Cert path: ${certDir}/fullchain.pem${plain}"
+        echo -e "${yellow}Key path: ${certDir}/privkey.pem${plain}"
     else
-        echo -e "${green}Certificate paths configured successfully${plain}"
+        echo -e "${green}Certificate paths set successfully!${plain}"
     fi
 
     echo -e "${green}IP certificate installed and configured successfully!${plain}"
     echo -e "${green}Certificate valid for ~6 days, auto-renews via acme.sh cron job.${plain}"
-    echo -e "${yellow}acme.sh will automatically renew and reload x-ui before expiry.${plain}"
+    echo -e "${yellow}Panel will automatically restart after each renewal.${plain}"
     return 0
 }
 
@@ -485,18 +508,16 @@ ssl_cert_issue() {
     if [ $? -ne 0 ]; then
         echo -e "${yellow}Auto renew setup had issues, certificate details:${plain}"
         ls -lah /root/cert/${domain}/
-        # Secure permissions: private key readable only by owner
-        chmod 600 $certPath/privkey.pem 2> /dev/null
-        chmod 644 $certPath/fullchain.pem 2> /dev/null
+        chmod 600 $certPath/privkey.pem
+        chmod 644 $certPath/fullchain.pem
     else
         echo -e "${green}Auto renew succeeded, certificate details:${plain}"
         ls -lah /root/cert/${domain}/
-        # Secure permissions: private key readable only by owner
-        chmod 600 $certPath/privkey.pem 2> /dev/null
-        chmod 644 $certPath/fullchain.pem 2> /dev/null
+        chmod 600 $certPath/privkey.pem
+        chmod 644 $certPath/fullchain.pem
     fi
 
-    # start panel
+    # Restart panel
     systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
 
     # Prompt user to set panel paths after successful certificate installation
@@ -523,29 +544,25 @@ ssl_cert_issue() {
 
     return 0
 }
-
-# Reusable interactive SSL setup (domain or IP)
-# Sets global `SSL_HOST` to the chosen domain/IP for Access URL usage
+# Unified interactive SSL setup (domain or IP)
+# Sets global `SSL_HOST` to the chosen domain/IP
 prompt_and_setup_ssl() {
     local panel_port="$1"
-    local web_base_path="$2"
+    local web_base_path="$2" # expected without leading slash
     local server_ip="$3"
 
     local ssl_choice=""
-    SSL_SCHEME="https"
 
     echo -e "${yellow}Choose SSL certificate setup method:${plain}"
     echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)"
     echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)"
     echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
-    echo -e "${green}4.${plain} Skip SSL (advanced — behind reverse proxy / SSH tunnel only)"
     echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
-    echo -e "${blue}Note:${plain} Option 4 serves the panel over plain HTTP — only safe behind nginx/Caddy or an SSH tunnel."
     read -rp "Choose an option (default 2 for IP): " ssl_choice
     ssl_choice="${ssl_choice// /}" # Trim whitespace
 
-    # Default to 2 (IP cert) if input is empty or invalid (not 1, 3 or 4)
-    if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" && "$ssl_choice" != "4" ]]; then
+    # Default to 2 (IP cert) if input is empty or invalid (not 1 or 3)
+    if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then
         ssl_choice="2"
     fi
 
@@ -595,6 +612,14 @@ prompt_and_setup_ssl() {
                 echo -e "${red}✗ IP certificate setup failed. Please check port 80 is open.${plain}"
                 SSL_HOST="${server_ip}"
             fi
+
+            # Restart panel after SSL is configured (restart applies new cert settings)
+            if [[ $release == "alpine" ]]; then
+                rc-service x-ui restart > /dev/null 2>&1
+            else
+                systemctl restart x-ui > /dev/null 2>&1
+            fi
+
             ;;
         3)
             # User chose Custom Paths (User Provided) option
@@ -656,41 +681,6 @@ prompt_and_setup_ssl() {
 
             systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
             ;;
-        4)
-            echo ""
-            echo -e "${red}⚠ Panel will be installed WITHOUT SSL/TLS.${plain}"
-            echo -e "${yellow}Login credentials and cookies will travel as plain HTTP.${plain}"
-            echo -e "${yellow}Only safe when:${plain}"
-            echo -e "${yellow}  • A reverse proxy (nginx, Caddy, Traefik) terminates TLS for you, or${plain}"
-            echo -e "${yellow}  • You access the panel exclusively via SSH tunnel${plain}"
-            echo ""
-
-            SSL_SCHEME="http"
-            SSL_HOST="${server_ip}"
-
-            local bind_local=""
-            read -rp "Bind the panel to 127.0.0.1 only? (recommended — forces SSH tunnel / reverse-proxy access) [y/N]: " bind_local
-            if [[ "$bind_local" == "y" || "$bind_local" == "Y" ]]; then
-                ${xui_folder}/x-ui setting -listenIP "127.0.0.1" > /dev/null 2>&1
-                SSL_HOST="127.0.0.1"
-                echo -e "${green}✓ Panel bound to 127.0.0.1 only. It is now unreachable from the public internet.${plain}"
-                echo ""
-                echo -e "${green}SSH Port Forwarding — open the panel from your local machine via:${plain}"
-                echo -e "  Standard SSH command:"
-                echo -e "  ${yellow}ssh -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
-                echo -e "  If using an SSH key:"
-                echo -e "  ${yellow}ssh -i <sshkeypath> -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
-                echo -e "  Then open in your browser:"
-                echo -e "  ${yellow}http://localhost:2222/${web_base_path}${plain}"
-                echo ""
-                echo -e "${yellow}Alternative: point a reverse proxy (nginx/Caddy) at 127.0.0.1:${panel_port} and let it terminate TLS.${plain}"
-            else
-                echo -e "${yellow}Panel will listen on all interfaces over plain HTTP. Make sure something else is terminating TLS in front of it.${plain}"
-            fi
-
-            systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
-            echo -e "${green}✓ SSL setup skipped.${plain}"
-            ;;
         *)
             echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
             SSL_HOST="${server_ip}"
@@ -698,12 +688,17 @@ prompt_and_setup_ssl() {
     esac
 }
 
-config_after_install() {
-    local existing_hasDefaultCredential=$(${xui_folder}/x-ui setting -show true | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}')
-    local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##')
-    local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
+config_after_update() {
+    echo -e "${yellow}x-ui settings:${plain}"
+    ${xui_folder}/x-ui setting -show true
+    ${xui_folder}/x-ui migrate
+
     # Properly detect empty cert by checking if cert: line exists and has content after it
-    local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
+    local existing_cert=$(${xui_folder}/x-ui setting -getCert true 2> /dev/null | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
+    local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
+    local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##')
+
+    # Get server IP
     local URL_lists=(
         "https://api4.ipify.org"
         "https://ipv4.icanhazip.com"
@@ -735,227 +730,183 @@ config_after_install() {
         done
     fi
 
+    # Handle missing/short webBasePath
     if [[ ${#existing_webBasePath} -lt 4 ]]; then
-        if [[ "$existing_hasDefaultCredential" == "true" ]]; then
-            local config_webBasePath=$(gen_random_string 18)
-            local config_username=$(gen_random_string 10)
-            local config_password=$(gen_random_string 10)
-
-            read -rp "Would you like to customize the Panel Port settings? (If not, a random port will be applied) [y/n]: " config_confirm
-            if [[ "${config_confirm}" == "y" || "${config_confirm}" == "Y" ]]; then
-                read -rp "Please set up the panel port: " config_port
-                echo -e "${yellow}Your Panel Port is: ${config_port}${plain}"
-            else
-                local config_port=$(shuf -i 1024-62000 -n 1)
-                echo -e "${yellow}Generated random port: ${config_port}${plain}"
-            fi
-
-            ${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}" -port "${config_port}" -webBasePath "${config_webBasePath}"
-
-            echo ""
-            echo -e "${green}═══════════════════════════════════════════${plain}"
-            echo -e "${green}     SSL Certificate Setup (RECOMMENDED)   ${plain}"
-            echo -e "${green}═══════════════════════════════════════════${plain}"
-            echo -e "${yellow}SSL is strongly recommended. Skip only if a reverse proxy${plain}"
-            echo -e "${yellow}or SSH tunnel handles TLS for you.${plain}"
-            echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
-            echo ""
-
-            prompt_and_setup_ssl "${config_port}" "${config_webBasePath}" "${server_ip}"
-
-            # Retrieve the API token for display
-            local config_apiToken=$(${xui_folder}/x-ui setting -getApiToken true | grep -Eo 'apiToken: .+' | awk '{print $2}')
-
-            # Display final credentials and access information
-            echo ""
-            echo -e "${green}═══════════════════════════════════════════${plain}"
-            echo -e "${green}     Panel Installation Complete!         ${plain}"
-            echo -e "${green}═══════════════════════════════════════════${plain}"
-            echo -e "${green}Username:    ${config_username}${plain}"
-            echo -e "${green}Password:    ${config_password}${plain}"
-            echo -e "${green}Port:        ${config_port}${plain}"
-            echo -e "${green}WebBasePath: ${config_webBasePath}${plain}"
-            echo -e "${green}Access URL:  ${SSL_SCHEME}://${SSL_HOST}:${config_port}/${config_webBasePath}${plain}"
-            echo -e "${green}API Token:   ${config_apiToken}${plain}"
-            echo -e "${green}═══════════════════════════════════════════${plain}"
-            echo -e "${yellow}⚠ IMPORTANT: Save these credentials securely!${plain}"
-            if [[ "$SSL_SCHEME" == "https" ]]; then
-                echo -e "${yellow}⚠ SSL Certificate: Enabled and configured${plain}"
-            else
-                echo -e "${yellow}⚠ SSL Certificate: Skipped — panel is HTTP-only. Use a reverse proxy or SSH tunnel.${plain}"
-            fi
-        else
-            local config_webBasePath=$(gen_random_string 18)
-            echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}"
-            ${xui_folder}/x-ui setting -webBasePath "${config_webBasePath}"
-            echo -e "${green}New WebBasePath: ${config_webBasePath}${plain}"
-
-            # If the panel is already installed but no certificate is configured, prompt for SSL now
-            if [[ -z "${existing_cert}" ]]; then
-                echo ""
-                echo -e "${green}═══════════════════════════════════════════${plain}"
-                echo -e "${green}     SSL Certificate Setup (RECOMMENDED)   ${plain}"
-                echo -e "${green}═══════════════════════════════════════════${plain}"
-                echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
-                echo ""
-                prompt_and_setup_ssl "${existing_port}" "${config_webBasePath}" "${server_ip}"
-                echo -e "${green}Access URL:  ${SSL_SCHEME}://${SSL_HOST}:${existing_port}/${config_webBasePath}${plain}"
-            else
-                # If a cert already exists, just show the access URL
-                echo -e "${green}Access URL: https://${server_ip}:${existing_port}/${config_webBasePath}${plain}"
-            fi
-        fi
+        echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}"
+        local config_webBasePath=$(gen_random_string 18)
+        ${xui_folder}/x-ui setting -webBasePath "${config_webBasePath}"
+        existing_webBasePath="${config_webBasePath}"
+        echo -e "${green}New WebBasePath: ${config_webBasePath}${plain}"
+    fi
+
+    # Check and prompt for SSL if missing
+    if [[ -z "$existing_cert" ]]; then
+        echo ""
+        echo -e "${red}═══════════════════════════════════════════${plain}"
+        echo -e "${red}      ⚠ NO SSL CERTIFICATE DETECTED ⚠     ${plain}"
+        echo -e "${red}═══════════════════════════════════════════${plain}"
+        echo -e "${yellow}For security, SSL certificate is MANDATORY for all panels.${plain}"
+        echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
+        echo ""
+
+        # Prompt and setup SSL (domain or IP)
+        prompt_and_setup_ssl "${existing_port}" "${existing_webBasePath}" "${server_ip}"
+
+        echo ""
+        echo -e "${green}═══════════════════════════════════════════${plain}"
+        echo -e "${green}     Panel Access Information              ${plain}"
+        echo -e "${green}═══════════════════════════════════════════${plain}"
+        echo -e "${green}Access URL: https://${SSL_HOST}:${existing_port}/${existing_webBasePath}${plain}"
+        echo -e "${green}═══════════════════════════════════════════${plain}"
+        echo -e "${yellow}⚠ SSL Certificate: Enabled and configured${plain}"
     else
-        if [[ "$existing_hasDefaultCredential" == "true" ]]; then
-            local config_username=$(gen_random_string 10)
-            local config_password=$(gen_random_string 10)
-
-            echo -e "${yellow}Default credentials detected. Security update required...${plain}"
-            ${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}"
-            echo -e "Generated new random login credentials:"
-            echo -e "###############################################"
-            echo -e "${green}Username: ${config_username}${plain}"
-            echo -e "${green}Password: ${config_password}${plain}"
-            echo -e "###############################################"
-        else
-            echo -e "${green}Username, Password, and WebBasePath are properly set.${plain}"
-        fi
-
-        # Existing install: if no cert configured, prompt user for SSL setup
-        # Properly detect empty cert by checking if cert: line exists and has content after it
-        existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
-        if [[ -z "$existing_cert" ]]; then
-            echo ""
-            echo -e "${green}═══════════════════════════════════════════${plain}"
-            echo -e "${green}     SSL Certificate Setup (RECOMMENDED)   ${plain}"
-            echo -e "${green}═══════════════════════════════════════════${plain}"
-            echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
-            echo ""
-            prompt_and_setup_ssl "${existing_port}" "${existing_webBasePath}" "${server_ip}"
-            echo -e "${green}Access URL:  ${SSL_SCHEME}://${SSL_HOST}:${existing_port}/${existing_webBasePath}${plain}"
-        else
-            echo -e "${green}SSL certificate already configured. No action needed.${plain}"
-        fi
+        echo -e "${green}SSL certificate is already configured${plain}"
+        # Show access URL with existing certificate
+        local cert_domain=$(basename "$(dirname "$existing_cert")")
+        echo ""
+        echo -e "${green}═══════════════════════════════════════════${plain}"
+        echo -e "${green}     Panel Access Information              ${plain}"
+        echo -e "${green}═══════════════════════════════════════════${plain}"
+        echo -e "${green}Access URL: https://${cert_domain}:${existing_port}/${existing_webBasePath}${plain}"
+        echo -e "${green}═══════════════════════════════════════════${plain}"
     fi
-
-    ${xui_folder}/x-ui migrate
 }
 
-install_x-ui() {
+update_x-ui() {
     cd ${xui_folder%/x-ui}/
 
-    # Download resources
-    if [ $# == 0 ]; then
-        tag_version=$(curl -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
-        if [[ ! -n "$tag_version" ]]; then
-            echo -e "${yellow}Trying to fetch version with IPv4...${plain}"
-            tag_version=$(curl -4 -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
-            if [[ ! -n "$tag_version" ]]; then
-                echo -e "${red}Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later${plain}"
-                exit 1
-            fi
-        fi
-        echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..."
-        curl -4fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz
-        if [[ $? -ne 0 ]]; then
-            echo -e "${red}Downloading x-ui failed, please be sure that your server can access GitHub ${plain}"
-            exit 1
-        fi
+    if [ -f "${xui_folder}/x-ui" ]; then
+        current_xui_version=$(${xui_folder}/x-ui -v)
+        echo -e "${green}Current x-ui version: ${current_xui_version}${plain}"
     else
-        tag_version=$1
-        tag_version_numeric=${tag_version#v}
-        min_version="2.3.5"
+        _fail "ERROR: Current x-ui version: unknown"
+    fi
 
-        if [[ "$(printf '%s\n' "$min_version" "$tag_version_numeric" | sort -V | head -n1)" != "$min_version" ]]; then
-            echo -e "${red}Please use a newer version (at least v2.3.5). Exiting installation.${plain}"
-            exit 1
-        fi
+    echo -e "${green}Downloading new x-ui version...${plain}"
 
-        url="https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz"
-        echo -e "Beginning to install x-ui $1"
-        curl -4fLRo ${xui_folder}-linux-$(arch).tar.gz ${url}
-        if [[ $? -ne 0 ]]; then
-            echo -e "${red}Download x-ui $1 failed, please check if the version exists ${plain}"
-            exit 1
+    tag_version=$(${curl_bin} -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" 2> /dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
+    if [[ ! -n "$tag_version" ]]; then
+        echo -e "${yellow}Trying to fetch version with IPv4...${plain}"
+        tag_version=$(${curl_bin} -4 -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
+        if [[ ! -n "$tag_version" ]]; then
+            _fail "ERROR: Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later"
         fi
     fi
-    curl -4fLRo /usr/bin/x-ui-temp https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh
+    echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..."
+    ${curl_bin} -fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2> /dev/null
     if [[ $? -ne 0 ]]; then
-        echo -e "${red}Failed to download x-ui.sh${plain}"
-        exit 1
+        echo -e "${yellow}Trying to fetch version with IPv4...${plain}"
+        ${curl_bin} -4fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2> /dev/null
+        if [[ $? -ne 0 ]]; then
+            _fail "ERROR: Failed to download x-ui, please be sure that your server can access GitHub"
+        fi
     fi
 
-    # Stop x-ui service and remove old resources
     if [[ -e ${xui_folder}/ ]]; then
+        echo -e "${green}Stopping x-ui...${plain}"
         if [[ $release == "alpine" ]]; then
-            rc-service x-ui stop
+            if [ -f "/etc/init.d/x-ui" ]; then
+                rc-service x-ui stop > /dev/null 2>&1
+                rc-update del x-ui > /dev/null 2>&1
+                echo -e "${green}Removing old service unit version...${plain}"
+                rm -f /etc/init.d/x-ui > /dev/null 2>&1
+            else
+                rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
+                _fail "ERROR: x-ui service unit not installed."
+            fi
         else
-            systemctl stop x-ui
+            if [ -f "${xui_service}/x-ui.service" ]; then
+                systemctl stop x-ui > /dev/null 2>&1
+                systemctl disable x-ui > /dev/null 2>&1
+                echo -e "${green}Removing old systemd unit version...${plain}"
+                rm ${xui_service}/x-ui.service -f > /dev/null 2>&1
+                systemctl daemon-reload > /dev/null 2>&1
+            else
+                rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
+                _fail "ERROR: x-ui systemd unit not installed."
+            fi
         fi
-        rm ${xui_folder}/ -rf
+        echo -e "${green}Removing old x-ui version...${plain}"
+        rm ${xui_folder} -f > /dev/null 2>&1
+        rm ${xui_folder}/x-ui.service -f > /dev/null 2>&1
+        rm ${xui_folder}/x-ui.service.debian -f > /dev/null 2>&1
+        rm ${xui_folder}/x-ui.service.arch -f > /dev/null 2>&1
+        rm ${xui_folder}/x-ui.service.rhel -f > /dev/null 2>&1
+        rm ${xui_folder}/x-ui -f > /dev/null 2>&1
+        rm ${xui_folder}/x-ui.sh -f > /dev/null 2>&1
+        echo -e "${green}Removing old xray version...${plain}"
+        rm ${xui_folder}/bin/xray-linux-amd64 -f > /dev/null 2>&1
+        echo -e "${green}Removing old README and LICENSE file...${plain}"
+        rm ${xui_folder}/bin/README.md -f > /dev/null 2>&1
+        rm ${xui_folder}/bin/LICENSE -f > /dev/null 2>&1
+    else
+        rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
+        _fail "ERROR: x-ui not installed."
     fi
 
-    # Extract resources and set permissions
-    tar zxvf x-ui-linux-$(arch).tar.gz
-    rm x-ui-linux-$(arch).tar.gz -f
-
-    cd x-ui
-    chmod +x x-ui
-    chmod +x x-ui.sh
+    echo -e "${green}Installing new x-ui version...${plain}"
+    tar zxvf x-ui-linux-$(arch).tar.gz > /dev/null 2>&1
+    rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
+    cd x-ui > /dev/null 2>&1
+    chmod +x x-ui > /dev/null 2>&1
 
     # Check the system's architecture and rename the file accordingly
     if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
-        mv bin/xray-linux-$(arch) bin/xray-linux-arm
-        chmod +x bin/xray-linux-arm
+        mv bin/xray-linux-$(arch) bin/xray-linux-arm > /dev/null 2>&1
+        chmod +x bin/xray-linux-arm > /dev/null 2>&1
     fi
-    chmod +x x-ui bin/xray-linux-$(arch)
-
-    # Update x-ui cli and se set permission
-    mv -f /usr/bin/x-ui-temp /usr/bin/x-ui
-    chmod +x /usr/bin/x-ui
-    mkdir -p /var/log/x-ui
-    config_after_install
-
-    # Etckeeper compatibility
-    if [ -d "/etc/.git" ]; then
-        if [ -f "/etc/.gitignore" ]; then
-            if ! grep -q "x-ui/x-ui.db" "/etc/.gitignore"; then
-                echo "" >> "/etc/.gitignore"
-                echo "x-ui/x-ui.db" >> "/etc/.gitignore"
-                echo -e "${green}Added x-ui.db to /etc/.gitignore for etckeeper${plain}"
-            fi
-        else
-            echo "x-ui/x-ui.db" > "/etc/.gitignore"
-            echo -e "${green}Created /etc/.gitignore and added x-ui.db for etckeeper${plain}"
+
+    chmod +x x-ui bin/xray-linux-$(arch) > /dev/null 2>&1
+
+    echo -e "${green}Downloading and installing x-ui.sh script...${plain}"
+    ${curl_bin} -fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1
+    if [[ $? -ne 0 ]]; then
+        echo -e "${yellow}Trying to fetch x-ui with IPv4...${plain}"
+        ${curl_bin} -4fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1
+        if [[ $? -ne 0 ]]; then
+            _fail "ERROR: Failed to download x-ui.sh script, please be sure that your server can access GitHub"
         fi
     fi
 
+    chmod +x ${xui_folder}/x-ui.sh > /dev/null 2>&1
+    chmod +x /usr/bin/x-ui > /dev/null 2>&1
+    mkdir -p /var/log/x-ui > /dev/null 2>&1
+
+    echo -e "${green}Changing owner...${plain}"
+    chown -R root:root ${xui_folder} > /dev/null 2>&1
+
+    if [ -f "${xui_folder}/bin/config.json" ]; then
+        echo -e "${green}Changing on config file permissions...${plain}"
+        chmod 640 ${xui_folder}/bin/config.json > /dev/null 2>&1
+    fi
+
     if [[ $release == "alpine" ]]; then
-        curl -4fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
+        echo -e "${green}Downloading and installing startup unit x-ui.rc...${plain}"
+        ${curl_bin} -fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1
         if [[ $? -ne 0 ]]; then
-            echo -e "${red}Failed to download x-ui.rc${plain}"
-            exit 1
+            ${curl_bin} -4fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1
+            if [[ $? -ne 0 ]]; then
+                _fail "ERROR: Failed to download startup unit x-ui.rc, please be sure that your server can access GitHub"
+            fi
         fi
-        chmod +x /etc/init.d/x-ui
-        rc-update add x-ui
-        rc-service x-ui start
+        chmod +x /etc/init.d/x-ui > /dev/null 2>&1
+        chown root:root /etc/init.d/x-ui > /dev/null 2>&1
+        rc-update add x-ui > /dev/null 2>&1
+        rc-service x-ui start > /dev/null 2>&1
     else
-        # Install systemd service file
-        service_installed=false
-
         if [ -f "x-ui.service" ]; then
-            echo -e "${green}Found x-ui.service in extracted files, installing...${plain}"
+            echo -e "${green}Installing systemd unit...${plain}"
             cp -f x-ui.service ${xui_service}/ > /dev/null 2>&1
-            if [[ $? -eq 0 ]]; then
-                service_installed=true
+            if [[ $? -ne 0 ]]; then
+                echo -e "${red}Failed to copy x-ui.service${plain}"
+                exit 1
             fi
-        fi
-
-        if [ "$service_installed" = false ]; then
+        else
+            service_installed=false
             case "${release}" in
                 ubuntu | debian | armbian)
                     if [ -f "x-ui.service.debian" ]; then
-                        echo -e "${green}Found x-ui.service.debian in extracted files, installing...${plain}"
+                        echo -e "${green}Installing debian-like systemd unit...${plain}"
                         cp -f x-ui.service.debian ${xui_service}/x-ui.service > /dev/null 2>&1
                         if [[ $? -eq 0 ]]; then
                             service_installed=true
@@ -964,7 +915,7 @@ install_x-ui() {
                     ;;
                 arch | manjaro | parch)
                     if [ -f "x-ui.service.arch" ]; then
-                        echo -e "${green}Found x-ui.service.arch in extracted files, installing...${plain}"
+                        echo -e "${green}Installing arch-like systemd unit...${plain}"
                         cp -f x-ui.service.arch ${xui_service}/x-ui.service > /dev/null 2>&1
                         if [[ $? -eq 0 ]]; then
                             service_installed=true
@@ -973,7 +924,7 @@ install_x-ui() {
                     ;;
                 *)
                     if [ -f "x-ui.service.rhel" ]; then
-                        echo -e "${green}Found x-ui.service.rhel in extracted files, installing...${plain}"
+                        echo -e "${green}Installing rhel-like systemd unit...${plain}"
                         cp -f x-ui.service.rhel ${xui_service}/x-ui.service > /dev/null 2>&1
                         if [[ $? -eq 0 ]]; then
                             service_installed=true
@@ -981,44 +932,38 @@ install_x-ui() {
                     fi
                     ;;
             esac
-        fi
 
-        # If service file not found in tar.gz, download from GitHub
-        if [ "$service_installed" = false ]; then
-            echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
-            case "${release}" in
-                ubuntu | debian | armbian)
-                    curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1
-                    ;;
-                arch | manjaro | parch)
-                    curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1
-                    ;;
-                *)
-                    curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1
-                    ;;
-            esac
-
-            if [[ $? -ne 0 ]]; then
-                echo -e "${red}Failed to install x-ui.service from GitHub${plain}"
-                exit 1
+            # If service file not found in tar.gz, download from GitHub
+            if [ "$service_installed" = false ]; then
+                echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
+                case "${release}" in
+                    ubuntu | debian | armbian)
+                        ${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1
+                        ;;
+                    arch | manjaro | parch)
+                        ${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1
+                        ;;
+                    *)
+                        ${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1
+                        ;;
+                esac
+
+                if [[ $? -ne 0 ]]; then
+                    echo -e "${red}Failed to install x-ui.service from GitHub${plain}"
+                    exit 1
+                fi
             fi
-            service_installed=true
-        fi
-
-        if [ "$service_installed" = true ]; then
-            echo -e "${green}Setting up systemd unit...${plain}"
-            chown root:root ${xui_service}/x-ui.service > /dev/null 2>&1
-            chmod 644 ${xui_service}/x-ui.service > /dev/null 2>&1
-            systemctl daemon-reload
-            systemctl enable x-ui
-            systemctl start x-ui
-        else
-            echo -e "${red}Failed to install x-ui.service file${plain}"
-            exit 1
         fi
+        chown root:root ${xui_service}/x-ui.service > /dev/null 2>&1
+        chmod 644 ${xui_service}/x-ui.service > /dev/null 2>&1
+        systemctl daemon-reload > /dev/null 2>&1
+        systemctl enable x-ui > /dev/null 2>&1
+        systemctl start x-ui > /dev/null 2>&1
     fi
 
-    echo -e "${green}x-ui ${tag_version}${plain} installation finished, it is running now..."
+    config_after_update
+
+    echo -e "${green}x-ui ${tag_version}${plain} updating finished, it is running now..."
     echo -e ""
     echo -e "┌───────────────────────────────────────────────────────┐
 │  ${blue}x-ui control menu usages (subcommands):${plain}              │
@@ -1042,4 +987,4 @@ install_x-ui() {
 
 echo -e "${green}Running...${plain}"
 install_base
-install_x-ui $1
+update_x-ui $1

+ 31 - 1
main.go

@@ -73,7 +73,13 @@ func runWebServer() {
 
 	sigCh := make(chan os.Signal, 1)
 	// Trap shutdown signals
-	signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, sys.SIGUSR1)
+	signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, sys.SIGUSR1, os.Interrupt)
+	global.SetRestartHook(func() {
+		select {
+		case sigCh <- syscall.SIGHUP:
+		default:
+		}
+	})
 	for {
 		sig := <-sigCh
 
@@ -439,6 +445,12 @@ func main() {
 
 	runCmd := flag.NewFlagSet("run", flag.ExitOnError)
 
+	migrateDbCmd := flag.NewFlagSet("migrate-db", flag.ExitOnError)
+	var migrateDsn string
+	var migrateSrc string
+	migrateDbCmd.StringVar(&migrateDsn, "dsn", "", "Destination PostgreSQL DSN (postgres://user:pass@host:port/db?sslmode=disable)")
+	migrateDbCmd.StringVar(&migrateSrc, "src", "", "Source SQLite file (defaults to the configured x-ui.db)")
+
 	settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
 	var port int
 	var username string
@@ -482,6 +494,7 @@ func main() {
 		fmt.Println("Commands:")
 		fmt.Println("    run            run web panel")
 		fmt.Println("    migrate        migrate form other/old x-ui")
+		fmt.Println("    migrate-db     copy data from the SQLite file into a PostgreSQL database")
 		fmt.Println("    setting        set settings")
 	}
 
@@ -501,6 +514,23 @@ func main() {
 		runWebServer()
 	case "migrate":
 		migrateDb()
+	case "migrate-db":
+		if err := migrateDbCmd.Parse(os.Args[2:]); err != nil {
+			fmt.Println(err)
+			return
+		}
+		src := migrateSrc
+		if src == "" {
+			src = config.GetDBPath()
+		}
+		if migrateDsn == "" {
+			fmt.Println("--dsn is required: postgres://user:pass@host:port/dbname?sslmode=disable")
+			return
+		}
+		if err := database.MigrateData(src, migrateDsn); err != nil {
+			fmt.Println("migration failed:", err)
+			os.Exit(1)
+		}
 	case "setting":
 		err := settingCmd.Parse(os.Args[2:])
 		if err != nil {

+ 1 - 0
sub/links.go

@@ -41,6 +41,7 @@ func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) {
 
 func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
 	svc := p.build(host)
+	svc.projectThroughFallbackMaster(inbound)
 	return splitLinkLines(svc.GetLink(inbound, email))
 }
 

+ 40 - 0
sub/links_test.go

@@ -0,0 +1,40 @@
+package sub
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestSplitLinkLines(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want []string
+	}{
+		{"single_line", "vless://abc", []string{"vless://abc"}},
+		{"two_lines", "vless://abc\nvmess://xyz", []string{"vless://abc", "vmess://xyz"}},
+		{"trims_each_line", "  vless://abc  \n\tvmess://xyz\t", []string{"vless://abc", "vmess://xyz"}},
+		{"skips_blank_lines", "vless://abc\n\n\nvmess://xyz\n", []string{"vless://abc", "vmess://xyz"}},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got := splitLinkLines(c.in)
+			if !reflect.DeepEqual(got, c.want) {
+				t.Fatalf("splitLinkLines(%q) = %#v, want %#v", c.in, got, c.want)
+			}
+		})
+	}
+}
+
+func TestSplitLinkLines_EmptyInputIsNil(t *testing.T) {
+	if got := splitLinkLines(""); got != nil {
+		t.Fatalf("splitLinkLines(\"\") = %#v, want nil", got)
+	}
+}
+
+func TestSplitLinkLines_WhitespaceOnlyHasNoEntries(t *testing.T) {
+	got := splitLinkLines("   \n\t  \n")
+	if len(got) != 0 {
+		t.Fatalf("splitLinkLines(whitespace) = %#v, want empty slice", got)
+	}
+}

+ 3 - 3
sub/sub.go

@@ -207,9 +207,9 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 				path := c.Request.URL.Path
 				pathPrefix := strings.TrimRight(LinksPath, "/") + "/"
 				if strings.HasPrefix(path, pathPrefix) && strings.Contains(path, "/assets/") {
-					assetsIndex := strings.Index(path, "/assets/")
-					if assetsIndex != -1 {
-						assetPath := path[assetsIndex+8:] // +8 to skip "/assets/"
+					_, after, ok := strings.Cut(path, "/assets/")
+					if ok {
+						assetPath := after // +8 to skip "/assets/"
 						if assetPath != "" {
 							c.FileFromFS(assetPath, assetsFS)
 							c.Abort()

+ 3 - 11
sub/subClashService.go

@@ -2,6 +2,7 @@ package sub
 
 import (
 	"fmt"
+	"maps"
 	"strings"
 
 	"github.com/goccy/go-json"
@@ -49,14 +50,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 		if clients == nil {
 			continue
 		}
-		if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
-			listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
-			if err == nil {
-				inbound.Listen = listen
-				inbound.Port = port
-				inbound.StreamSettings = streamSettings
-			}
-		}
+		s.SubService.projectThroughFallbackMaster(inbound)
 		for _, client := range clients {
 			if client.SubID == subId {
 				_, clientTraffics = s.SubService.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
@@ -471,8 +465,6 @@ func cloneMap(src map[string]any) map[string]any {
 		return nil
 	}
 	dst := make(map[string]any, len(src))
-	for k, v := range src {
-		dst[k] = v
-	}
+	maps.Copy(dst, src)
 	return dst
 }

+ 15 - 3
sub/subController.go

@@ -15,6 +15,18 @@ import (
 	"github.com/gin-gonic/gin"
 )
 
+// writeSubError translates a service-layer result into an HTTP response.
+// A nil error with no rows means the subId doesn't match anything (deleted
+// client, never-existed id) and becomes 404. A real error becomes 500. No
+// body — VPN clients only look at the status.
+func writeSubError(c *gin.Context, err error) {
+	if err == nil {
+		c.Status(http.StatusNotFound)
+		return
+	}
+	c.Status(http.StatusInternalServerError)
+}
+
 // SUBController handles HTTP requests for subscription links and JSON configurations.
 type SUBController struct {
 	subTitle         string
@@ -105,7 +117,7 @@ func (a *SUBController) subs(c *gin.Context) {
 	scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
 	subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
 	if err != nil || len(subs) == 0 {
-		c.String(400, "Error!")
+		writeSubError(c, err)
 	} else {
 		result := ""
 		for _, sub := range subs {
@@ -240,7 +252,7 @@ func (a *SUBController) subJsons(c *gin.Context) {
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	jsonSub, header, err := a.subJsonService.GetJson(subId, host)
 	if err != nil || len(jsonSub) == 0 {
-		c.String(400, "Error!")
+		writeSubError(c, err)
 	} else {
 		profileUrl := a.subProfileUrl
 		if profileUrl == "" {
@@ -257,7 +269,7 @@ func (a *SUBController) subClashs(c *gin.Context) {
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
 	clashSub, header, err := a.subClashService.GetClash(subId, host)
 	if err != nil || len(clashSub) == 0 {
-		c.String(400, "Error!")
+		writeSubError(c, err)
 	} else {
 		profileUrl := a.subProfileUrl
 		if profileUrl == "" {

+ 1 - 8
sub/subJsonService.go

@@ -110,14 +110,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
 		if clients == nil {
 			continue
 		}
-		if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
-			listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
-			if err == nil {
-				inbound.Listen = listen
-				inbound.Port = port
-				inbound.StreamSettings = streamSettings
-			}
-		}
+		s.SubService.projectThroughFallbackMaster(inbound)
 
 		for _, client := range clients {
 			if client.SubID == subId {

+ 92 - 26
sub/subService.go

@@ -70,7 +70,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
 	}
 
 	if len(inbounds) == 0 {
-		return nil, 0, traffic, common.NewError("No inbounds found with ", subId)
+		return nil, 0, traffic, nil
 	}
 
 	s.datepicker, err = s.settingService.GetDatepicker()
@@ -92,14 +92,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
 		if clients == nil {
 			continue
 		}
-		if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
-			listen, port, streamSettings, err := s.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
-			if err == nil {
-				inbound.Listen = listen
-				inbound.Port = port
-				inbound.StreamSettings = streamSettings
-			}
-		}
+		s.projectThroughFallbackMaster(inbound)
 		for _, client := range clients {
 			if client.SubID == subId {
 				if client.Enable {
@@ -144,15 +137,14 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
 func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
 	db := database.GetDB()
 	var inbounds []*model.Inbound
-	// allow "hysteria2" so imports stored with the literal v2 protocol
-	// string still surface here (#4081)
 	err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in (
 		SELECT DISTINCT inbounds.id
-		FROM inbounds,
-			JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
+		FROM inbounds
+		JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
+		JOIN clients ON clients.id = client_inbounds.client_id
 		WHERE
-			protocol in ('vmess','vless','trojan','shadowsocks','hysteria','hysteria2')
-			AND JSON_EXTRACT(client.value, '$.subId') = ? AND enable = ?
+			inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','hysteria2')
+			AND clients.sub_id = ? AND inbounds.enable = ?
 	)`, subId, true).Find(&inbounds).Error
 	if err != nil {
 		return nil, err
@@ -193,16 +185,89 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
 		return "", 0, "", err
 	}
 
-	var stream map[string]any
-	json.Unmarshal([]byte(streamSettings), &stream)
-	var masterStream map[string]any
-	json.Unmarshal([]byte(inbound.StreamSettings), &masterStream)
-	stream["security"] = masterStream["security"]
-	stream["tlsSettings"] = masterStream["tlsSettings"]
-	stream["externalProxy"] = masterStream["externalProxy"]
-	modifiedStream, _ := json.MarshalIndent(stream, "", "  ")
+	return inbound.Listen, inbound.Port, mergeStreamFromMaster(streamSettings, inbound.StreamSettings), nil
+}
+
+// projectThroughFallbackMaster mutates the inbound in place so its
+// Listen/Port/StreamSettings reflect the externally reachable master
+// when applicable. Covers both fallback mechanisms:
+//   - panel-tracked: an inbound_fallbacks row where child_id = inbound.Id
+//   - legacy unix-socket: inbound.Listen begins with "@" and some VLESS/
+//     Trojan inbound's settings.fallbacks references that listen address
+//
+// Returns true when a projection happened; sub services call this before
+// generating links so a child VLESS-WS bound to 127.0.0.1 emits the
+// master's :443 + TLS state instead of its own loopback endpoint.
+func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool {
+	if inbound == nil {
+		return false
+	}
+	db := database.GetDB()
+	var master *model.Inbound
+
+	var rule model.InboundFallback
+	if err := db.Where("child_id = ?", inbound.Id).
+		Order("sort_order ASC, id ASC").
+		First(&rule).Error; err == nil {
+		var m model.Inbound
+		if err := db.Where("id = ?", rule.MasterId).First(&m).Error; err == nil {
+			master = &m
+		}
+	}
 
-	return inbound.Listen, inbound.Port, string(modifiedStream), nil
+	if master == nil && len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
+		var m model.Inbound
+		if err := db.Model(model.Inbound{}).
+			Where("JSON_TYPE(settings, '$.fallbacks') = 'array'").
+			Where("EXISTS (SELECT * FROM json_each(settings, '$.fallbacks') WHERE json_extract(value, '$.dest') = ?)", inbound.Listen).
+			First(&m).Error; err == nil {
+			master = &m
+		}
+	}
+
+	if master == nil {
+		return false
+	}
+	inbound.StreamSettings = mergeStreamFromMaster(inbound.StreamSettings, master.StreamSettings)
+	inbound.Listen = master.Listen
+	inbound.Port = master.Port
+	return true
+}
+
+// mergeStreamFromMaster copies the master's security + tlsSettings +
+// realitySettings + externalProxy onto the child's stream so the child's
+// link advertises the master's TLS / Reality state. Transport (network
+// + ws/grpc/etc. settings) stays the child's.
+func mergeStreamFromMaster(childStream, masterStream string) string {
+	var stream map[string]any
+	json.Unmarshal([]byte(childStream), &stream)
+	if stream == nil {
+		stream = map[string]any{}
+	}
+	var mst map[string]any
+	json.Unmarshal([]byte(masterStream), &mst)
+	if mst == nil {
+		return childStream
+	}
+	stream["security"] = mst["security"]
+	if v, ok := mst["tlsSettings"]; ok {
+		stream["tlsSettings"] = v
+	} else {
+		delete(stream, "tlsSettings")
+	}
+	if v, ok := mst["realitySettings"]; ok {
+		stream["realitySettings"] = v
+	} else {
+		delete(stream, "realitySettings")
+	}
+	if v, ok := mst["externalProxy"]; ok {
+		stream["externalProxy"] = v
+	}
+	out, err := json.MarshalIndent(stream, "", "  ")
+	if err != nil {
+		return childStream
+	}
+	return string(out)
 }
 
 // GetLink dispatches to the protocol-specific generator for one (inbound, client)
@@ -536,8 +601,9 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 		return strings.Join(links, "\n")
 	}
 
-	// No external proxy configured — fall back to the request host.
-	link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.address, inbound.Port)
+	// No external proxy configured — use the inbound's resolved address so
+	// node-managed inbounds get the node's host instead of the central panel's.
+	link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.resolveInboundAddress(inbound), inbound.Port)
 	url, _ := url.Parse(link)
 	q := url.Query()
 	for k, v := range params {

+ 480 - 0
sub/subService_test.go

@@ -0,0 +1,480 @@
+package sub
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+)
+
+func TestFindClientIndex(t *testing.T) {
+	clients := []model.Client{
+		{Email: "[email protected]"},
+		{Email: "[email protected]"},
+		{Email: "[email protected]"},
+	}
+	if got := findClientIndex(clients, "[email protected]"); got != 1 {
+		t.Fatalf("findClientIndex middle = %d, want 1", got)
+	}
+	if got := findClientIndex(clients, "[email protected]"); got != 0 {
+		t.Fatalf("findClientIndex first = %d, want 0", got)
+	}
+	if got := findClientIndex(clients, "[email protected]"); got != -1 {
+		t.Fatalf("findClientIndex missing = %d, want -1", got)
+	}
+	if got := findClientIndex(nil, "x"); got != -1 {
+		t.Fatalf("findClientIndex on nil slice = %d, want -1", got)
+	}
+}
+
+func TestUnmarshalStreamSettings(t *testing.T) {
+	got := unmarshalStreamSettings(`{"network":"ws","wsSettings":{"path":"/api"}}`)
+	if got["network"] != "ws" {
+		t.Fatalf("network = %v, want ws", got["network"])
+	}
+	ws, ok := got["wsSettings"].(map[string]any)
+	if !ok || ws["path"] != "/api" {
+		t.Fatalf("wsSettings = %v, want map with path=/api", got["wsSettings"])
+	}
+}
+
+func TestUnmarshalStreamSettings_InvalidJSON(t *testing.T) {
+	if got := unmarshalStreamSettings("not json"); got != nil {
+		t.Fatalf("invalid JSON should produce nil map, got %#v", got)
+	}
+}
+
+func TestSearchHost_StringValue(t *testing.T) {
+	headers := map[string]any{"Host": "example.com"}
+	if got := searchHost(headers); got != "example.com" {
+		t.Fatalf("searchHost = %q, want example.com", got)
+	}
+}
+
+func TestSearchHost_CaseInsensitiveKey(t *testing.T) {
+	headers := map[string]any{"host": "example.com"}
+	if got := searchHost(headers); got != "example.com" {
+		t.Fatalf("searchHost = %q, want example.com", got)
+	}
+	headers2 := map[string]any{"HOST": "example.com"}
+	if got := searchHost(headers2); got != "example.com" {
+		t.Fatalf("searchHost uppercase = %q, want example.com", got)
+	}
+}
+
+func TestSearchHost_ArrayValue(t *testing.T) {
+	headers := map[string]any{"Host": []any{"first.example.com", "second.example.com"}}
+	if got := searchHost(headers); got != "first.example.com" {
+		t.Fatalf("searchHost array = %q, want first.example.com", got)
+	}
+}
+
+func TestSearchHost_EmptyArray(t *testing.T) {
+	headers := map[string]any{"Host": []any{}}
+	if got := searchHost(headers); got != "" {
+		t.Fatalf("searchHost empty array = %q, want empty", got)
+	}
+}
+
+func TestSearchHost_NoHostKey(t *testing.T) {
+	headers := map[string]any{"X-Other": "value"}
+	if got := searchHost(headers); got != "" {
+		t.Fatalf("searchHost no host = %q, want empty", got)
+	}
+}
+
+func TestSearchHost_NotAMap(t *testing.T) {
+	if got := searchHost("not a map"); got != "" {
+		t.Fatalf("searchHost non-map = %q, want empty", got)
+	}
+	if got := searchHost(nil); got != "" {
+		t.Fatalf("searchHost nil = %q, want empty", got)
+	}
+}
+
+func TestSearchKey_FoundAtTopLevel(t *testing.T) {
+	data := map[string]any{"foo": 42, "bar": "x"}
+	got, ok := searchKey(data, "foo")
+	if !ok {
+		t.Fatal("expected to find foo")
+	}
+	if got != 42 {
+		t.Fatalf("got %v, want 42", got)
+	}
+}
+
+func TestSearchKey_FoundInNested(t *testing.T) {
+	data := map[string]any{
+		"outer": map[string]any{
+			"inner": map[string]any{
+				"target": "hit",
+			},
+		},
+	}
+	got, ok := searchKey(data, "target")
+	if !ok {
+		t.Fatal("expected to find target in nested map")
+	}
+	if got != "hit" {
+		t.Fatalf("got %v, want hit", got)
+	}
+}
+
+func TestSearchKey_FoundInsideArray(t *testing.T) {
+	data := map[string]any{
+		"list": []any{
+			map[string]any{"other": 1},
+			map[string]any{"needle": "found"},
+		},
+	}
+	got, ok := searchKey(data, "needle")
+	if !ok {
+		t.Fatal("expected to find needle in array element")
+	}
+	if got != "found" {
+		t.Fatalf("got %v, want found", got)
+	}
+}
+
+func TestSearchKey_NotFound(t *testing.T) {
+	data := map[string]any{"foo": "bar"}
+	if _, ok := searchKey(data, "missing"); ok {
+		t.Fatal("expected ok=false for missing key")
+	}
+}
+
+func TestSearchKey_OnScalar(t *testing.T) {
+	if _, ok := searchKey(42, "anything"); ok {
+		t.Fatal("expected ok=false searching on a scalar")
+	}
+}
+
+func TestCloneStringMap(t *testing.T) {
+	src := map[string]string{"a": "1", "b": "2"}
+	dst := cloneStringMap(src)
+	if len(dst) != len(src) {
+		t.Fatalf("clone length = %d, want %d", len(dst), len(src))
+	}
+	for k, v := range src {
+		if dst[k] != v {
+			t.Fatalf("clone[%q] = %q, want %q", k, dst[k], v)
+		}
+	}
+	dst["a"] = "changed"
+	if src["a"] == "changed" {
+		t.Fatal("modifying clone leaked into source")
+	}
+}
+
+func TestCloneStringMap_Empty(t *testing.T) {
+	dst := cloneStringMap(map[string]string{})
+	if dst == nil {
+		t.Fatal("clone of empty map should not be nil")
+	}
+	if len(dst) != 0 {
+		t.Fatalf("clone of empty map should be empty, got %v", dst)
+	}
+}
+
+func TestGetHostFromXFH_HostOnly(t *testing.T) {
+	got, err := getHostFromXFH("example.com")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if got != "example.com" {
+		t.Fatalf("got %q, want example.com", got)
+	}
+}
+
+func TestGetHostFromXFH_HostWithPort(t *testing.T) {
+	got, err := getHostFromXFH("example.com:8443")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if got != "example.com" {
+		t.Fatalf("got %q, want example.com", got)
+	}
+}
+
+func TestGetHostFromXFH_IPv6WithPort(t *testing.T) {
+	got, err := getHostFromXFH("[2606:4700::1111]:443")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if got != "2606:4700::1111" {
+		t.Fatalf("got %q, want 2606:4700::1111", got)
+	}
+}
+
+func TestGetHostFromXFH_BadHostPort(t *testing.T) {
+	if _, err := getHostFromXFH("example.com:8443:9999"); err == nil {
+		t.Fatal("expected error for malformed host:port")
+	}
+}
+
+func TestReadPositiveInt(t *testing.T) {
+	cases := []struct {
+		name    string
+		in      any
+		wantVal int
+		wantOk  bool
+	}{
+		{"int_positive", int(5), 5, true},
+		{"int_zero", int(0), 0, false},
+		{"int_negative", int(-3), -3, false},
+		{"int32_positive", int32(7), 7, true},
+		{"int64_positive", int64(99), 99, true},
+		{"float64_positive", float64(12), 12, true},
+		{"float64_zero", float64(0.0), 0, false},
+		{"float64_negative", float64(-1.5), -1, false},
+		{"float32_positive", float32(3), 3, true},
+		{"string", "not a number", 0, false},
+		{"nil", nil, 0, false},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			gotVal, gotOk := readPositiveInt(c.in)
+			if gotVal != c.wantVal || gotOk != c.wantOk {
+				t.Fatalf("readPositiveInt(%v) = (%d, %v), want (%d, %v)", c.in, gotVal, gotOk, c.wantVal, c.wantOk)
+			}
+		})
+	}
+}
+
+func TestSetStringParam(t *testing.T) {
+	p := map[string]string{"existing": "value"}
+
+	setStringParam(p, "new", "hello")
+	if p["new"] != "hello" {
+		t.Fatalf("missing key after set: %v", p)
+	}
+
+	setStringParam(p, "existing", "")
+	if _, ok := p["existing"]; ok {
+		t.Fatalf("empty value should delete the key, got %v", p)
+	}
+}
+
+func TestSetIntParam(t *testing.T) {
+	p := map[string]string{"existing": "10"}
+
+	setIntParam(p, "n", 42)
+	if p["n"] != "42" {
+		t.Fatalf("set positive int: got %v", p)
+	}
+
+	setIntParam(p, "existing", 0)
+	if _, ok := p["existing"]; ok {
+		t.Fatalf("zero value should delete the key, got %v", p)
+	}
+
+	p["other"] = "5"
+	setIntParam(p, "other", -1)
+	if _, ok := p["other"]; ok {
+		t.Fatalf("negative value should delete the key, got %v", p)
+	}
+}
+
+func TestSetStringField(t *testing.T) {
+	f := map[string]any{"existing": "value"}
+
+	setStringField(f, "new", "hello")
+	if f["new"] != "hello" {
+		t.Fatalf("missing key after set: %v", f)
+	}
+
+	setStringField(f, "existing", "")
+	if _, ok := f["existing"]; ok {
+		t.Fatalf("empty value should delete the key, got %v", f)
+	}
+}
+
+func TestSetIntField(t *testing.T) {
+	f := map[string]any{"existing": 10}
+
+	setIntField(f, "n", 7)
+	if f["n"] != 7 {
+		t.Fatalf("set positive int: got %v", f)
+	}
+
+	setIntField(f, "existing", 0)
+	if _, ok := f["existing"]; ok {
+		t.Fatalf("zero value should delete the key, got %v", f)
+	}
+}
+
+func TestBuildVmessLink(t *testing.T) {
+	obj := map[string]any{
+		"v":    "2",
+		"ps":   "remark",
+		"add":  "example.com",
+		"port": 443,
+		"net":  "tcp",
+	}
+	link := buildVmessLink(obj)
+	if !strings.HasPrefix(link, "vmess://") {
+		t.Fatalf("missing vmess:// prefix: %q", link)
+	}
+	payload := strings.TrimPrefix(link, "vmess://")
+	decoded, err := base64.StdEncoding.DecodeString(payload)
+	if err != nil {
+		t.Fatalf("base64 decode failed: %v", err)
+	}
+	var roundTrip map[string]any
+	if err := json.Unmarshal(decoded, &roundTrip); err != nil {
+		t.Fatalf("decoded payload is not JSON: %v\n%s", err, decoded)
+	}
+	if roundTrip["add"] != "example.com" {
+		t.Fatalf("round-trip add = %v, want example.com", roundTrip["add"])
+	}
+	if roundTrip["ps"] != "remark" {
+		t.Fatalf("round-trip ps = %v, want remark", roundTrip["ps"])
+	}
+}
+
+func TestCloneVmessShareObj_CopiesEverythingByDefault(t *testing.T) {
+	base := map[string]any{
+		"v":    "2",
+		"sni":  "example.com",
+		"alpn": "h2",
+		"fp":   "chrome",
+		"net":  "tcp",
+	}
+	out := cloneVmessShareObj(base, "tls")
+	for _, key := range []string{"sni", "alpn", "fp", "net", "v"} {
+		if _, ok := out[key]; !ok {
+			t.Fatalf("expected key %q to be preserved when security=tls, got %v", key, out)
+		}
+	}
+}
+
+func TestCloneVmessShareObj_NoneStripsTLSOnlyKeys(t *testing.T) {
+	base := map[string]any{
+		"v":    "2",
+		"sni":  "example.com",
+		"alpn": "h2",
+		"fp":   "chrome",
+		"net":  "tcp",
+	}
+	out := cloneVmessShareObj(base, "none")
+	for _, key := range []string{"sni", "alpn", "fp"} {
+		if _, ok := out[key]; ok {
+			t.Fatalf("security=none should strip %q, got %v", key, out)
+		}
+	}
+	if out["v"] != "2" || out["net"] != "tcp" {
+		t.Fatalf("non-TLS keys should remain, got %v", out)
+	}
+}
+
+func TestExtractKcpShareFields_Defaults(t *testing.T) {
+	stream := map[string]any{}
+	got := extractKcpShareFields(stream)
+	if got.headerType != "none" {
+		t.Fatalf("default headerType = %q, want none", got.headerType)
+	}
+	if got.seed != "" || got.mtu != 0 || got.tti != 0 {
+		t.Fatalf("default kcpShareFields should be zero except headerType, got %+v", got)
+	}
+}
+
+func TestExtractKcpShareFields_ReadsAllFields(t *testing.T) {
+	stream := map[string]any{
+		"kcpSettings": map[string]any{
+			"header": map[string]any{"type": "wechat-video"},
+			"seed":   "secret-seed",
+			"mtu":    float64(1350),
+			"tti":    float64(50),
+		},
+	}
+	got := extractKcpShareFields(stream)
+	if got.headerType != "wechat-video" {
+		t.Fatalf("headerType = %q, want wechat-video", got.headerType)
+	}
+	if got.seed != "secret-seed" {
+		t.Fatalf("seed = %q, want secret-seed", got.seed)
+	}
+	if got.mtu != 1350 {
+		t.Fatalf("mtu = %d, want 1350", got.mtu)
+	}
+	if got.tti != 50 {
+		t.Fatalf("tti = %d, want 50", got.tti)
+	}
+}
+
+func TestKcpShareFields_ApplyToParams(t *testing.T) {
+	params := map[string]string{}
+	kcpShareFields{headerType: "wechat-video", seed: "s", mtu: 1350, tti: 50}.applyToParams(params)
+	if params["headerType"] != "wechat-video" {
+		t.Fatalf("headerType param = %q", params["headerType"])
+	}
+	if params["seed"] != "s" {
+		t.Fatalf("seed param = %q", params["seed"])
+	}
+	if params["mtu"] != "1350" {
+		t.Fatalf("mtu param = %q", params["mtu"])
+	}
+	if params["tti"] != "50" {
+		t.Fatalf("tti param = %q", params["tti"])
+	}
+}
+
+func TestKcpShareFields_ApplyToParams_NoneHeaderNotAdded(t *testing.T) {
+	params := map[string]string{}
+	kcpShareFields{headerType: "none"}.applyToParams(params)
+	if _, ok := params["headerType"]; ok {
+		t.Fatalf("headerType=none should not be added, got %v", params)
+	}
+}
+
+func TestMarshalFinalMask_EmptyReturnsFalse(t *testing.T) {
+	if _, ok := marshalFinalMask(map[string]any{}); ok {
+		t.Fatal("expected ok=false for empty finalmask")
+	}
+	if _, ok := marshalFinalMask(nil); ok {
+		t.Fatal("expected ok=false for nil finalmask")
+	}
+}
+
+func TestMarshalFinalMask_WithContent(t *testing.T) {
+	fm := map[string]any{
+		"tcp": []any{
+			map[string]any{"type": "fragment"},
+		},
+	}
+	out, ok := marshalFinalMask(fm)
+	if !ok {
+		t.Fatal("expected ok=true for finalmask with valid tcp mask")
+	}
+	if !strings.Contains(out, `"tcp"`) {
+		t.Fatalf("marshaled finalmask missing tcp key: %s", out)
+	}
+	if !strings.Contains(out, "fragment") {
+		t.Fatalf("marshaled finalmask missing mask type: %s", out)
+	}
+}
+
+func TestMarshalFinalMask_UnknownTypeIsDropped(t *testing.T) {
+	fm := map[string]any{
+		"tcp": []any{
+			map[string]any{"type": "not-a-real-mask"},
+		},
+	}
+	if _, ok := marshalFinalMask(fm); ok {
+		t.Fatal("unknown mask types should be dropped, leaving nothing to marshal")
+	}
+}
+
+func TestHasFinalMaskContent(t *testing.T) {
+	if hasFinalMaskContent(nil) {
+		t.Fatal("nil should not count as content")
+	}
+	if hasFinalMaskContent(map[string]any{}) {
+		t.Fatal("empty map should not count as content")
+	}
+	if !hasFinalMaskContent(map[string]any{"x": 1}) {
+		t.Fatal("non-empty map should count as content")
+	}
+}

+ 28 - 0
util/common/format_test.go

@@ -0,0 +1,28 @@
+package common
+
+import "testing"
+
+func TestFormatTraffic(t *testing.T) {
+	cases := []struct {
+		name  string
+		bytes int64
+		want  string
+	}{
+		{"zero", 0, "0.00B"},
+		{"under_one_kb", 512, "512.00B"},
+		{"exactly_one_kb", 1024, "1.00KB"},
+		{"one_and_a_half_kb", 1536, "1.50KB"},
+		{"one_mb", 1024 * 1024, "1.00MB"},
+		{"one_gb", 1024 * 1024 * 1024, "1.00GB"},
+		{"one_tb", 1024 * 1024 * 1024 * 1024, "1.00TB"},
+		{"one_pb", 1024 * 1024 * 1024 * 1024 * 1024, "1.00PB"},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got := FormatTraffic(c.bytes)
+			if got != c.want {
+				t.Fatalf("FormatTraffic(%d) = %q, want %q", c.bytes, got, c.want)
+			}
+		})
+	}
+}

+ 44 - 0
util/common/multi_error_test.go

@@ -0,0 +1,44 @@
+package common
+
+import (
+	"errors"
+	"strings"
+	"testing"
+)
+
+func TestCombine_AllNilReturnsNil(t *testing.T) {
+	if err := Combine(); err != nil {
+		t.Fatalf("Combine() with no args = %v, want nil", err)
+	}
+	if err := Combine(nil, nil, nil); err != nil {
+		t.Fatalf("Combine(nil, nil, nil) = %v, want nil", err)
+	}
+}
+
+func TestCombine_SkipsNilErrors(t *testing.T) {
+	e1 := errors.New("boom one")
+	e2 := errors.New("boom two")
+
+	err := Combine(nil, e1, nil, e2, nil)
+	if err == nil {
+		t.Fatal("expected non-nil combined error")
+	}
+	msg := err.Error()
+	if !strings.Contains(msg, "boom one") || !strings.Contains(msg, "boom two") {
+		t.Fatalf("combined error %q does not contain both underlying messages", msg)
+	}
+	if !strings.HasPrefix(msg, "multierr: ") {
+		t.Fatalf("combined error %q missing %q prefix", msg, "multierr: ")
+	}
+}
+
+func TestCombine_SingleErrorStillWrapped(t *testing.T) {
+	e := errors.New("only one")
+	err := Combine(e)
+	if err == nil {
+		t.Fatal("expected non-nil error")
+	}
+	if !strings.Contains(err.Error(), "only one") {
+		t.Fatalf("combined error %q missing underlying message", err.Error())
+	}
+}

+ 69 - 0
util/crypto/crypto_test.go

@@ -0,0 +1,69 @@
+package crypto
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestHashPasswordAsBcrypt_RoundTrip(t *testing.T) {
+	password := "correct horse battery staple"
+
+	hash, err := HashPasswordAsBcrypt(password)
+	if err != nil {
+		t.Fatalf("HashPasswordAsBcrypt returned error: %v", err)
+	}
+	if hash == "" {
+		t.Fatal("expected non-empty hash")
+	}
+	if hash == password {
+		t.Fatal("hash must not equal the plaintext password")
+	}
+	if !strings.HasPrefix(hash, "$2") {
+		t.Fatalf("expected bcrypt prefix $2..., got %q", hash[:min(4, len(hash))])
+	}
+
+	if !CheckPasswordHash(hash, password) {
+		t.Fatal("CheckPasswordHash returned false for the matching password")
+	}
+}
+
+func TestCheckPasswordHash_WrongPassword(t *testing.T) {
+	hash, err := HashPasswordAsBcrypt("right-password")
+	if err != nil {
+		t.Fatalf("HashPasswordAsBcrypt returned error: %v", err)
+	}
+
+	if CheckPasswordHash(hash, "wrong-password") {
+		t.Fatal("CheckPasswordHash returned true for a wrong password")
+	}
+	if CheckPasswordHash(hash, "") {
+		t.Fatal("CheckPasswordHash returned true for an empty password")
+	}
+}
+
+func TestCheckPasswordHash_InvalidHash(t *testing.T) {
+	if CheckPasswordHash("", "anything") {
+		t.Fatal("empty hash must not validate")
+	}
+	if CheckPasswordHash("not-a-bcrypt-hash", "anything") {
+		t.Fatal("malformed hash must not validate")
+	}
+}
+
+func TestHashPasswordAsBcrypt_DifferentHashesForSamePassword(t *testing.T) {
+	password := "same-password"
+	h1, err := HashPasswordAsBcrypt(password)
+	if err != nil {
+		t.Fatalf("first hash failed: %v", err)
+	}
+	h2, err := HashPasswordAsBcrypt(password)
+	if err != nil {
+		t.Fatalf("second hash failed: %v", err)
+	}
+	if h1 == h2 {
+		t.Fatal("expected bcrypt to produce different hashes (random salt) for the same password")
+	}
+	if !CheckPasswordHash(h1, password) || !CheckPasswordHash(h2, password) {
+		t.Fatal("both hashes should still validate the original password")
+	}
+}

+ 76 - 0
util/json_util/json_test.go

@@ -0,0 +1,76 @@
+package json_util
+
+import (
+	"bytes"
+	"encoding/json"
+	"testing"
+)
+
+func TestRawMessage_MarshalEmptyIsNull(t *testing.T) {
+	var m RawMessage
+	out, err := m.MarshalJSON()
+	if err != nil {
+		t.Fatalf("MarshalJSON on empty returned error: %v", err)
+	}
+	if !bytes.Equal(out, []byte("null")) {
+		t.Fatalf("empty RawMessage marshaled to %q, want %q", out, "null")
+	}
+}
+
+func TestRawMessage_MarshalPassthrough(t *testing.T) {
+	payload := []byte(`{"a":1}`)
+	m := RawMessage(payload)
+	out, err := m.MarshalJSON()
+	if err != nil {
+		t.Fatalf("MarshalJSON returned error: %v", err)
+	}
+	if !bytes.Equal(out, payload) {
+		t.Fatalf("MarshalJSON = %q, want %q", out, payload)
+	}
+}
+
+func TestRawMessage_UnmarshalCopiesData(t *testing.T) {
+	var m RawMessage
+	src := []byte(`{"k":"v"}`)
+	if err := m.UnmarshalJSON(src); err != nil {
+		t.Fatalf("UnmarshalJSON returned error: %v", err)
+	}
+	if !bytes.Equal(m, src) {
+		t.Fatalf("UnmarshalJSON stored %q, want %q", []byte(m), src)
+	}
+
+	src[0] = 'X'
+	if m[0] == 'X' {
+		t.Fatal("UnmarshalJSON kept a reference to the caller's buffer; expected a copy")
+	}
+}
+
+func TestRawMessage_UnmarshalNilReceiverErrors(t *testing.T) {
+	var m *RawMessage
+	if err := m.UnmarshalJSON([]byte("123")); err == nil {
+		t.Fatal("expected error for nil receiver")
+	}
+}
+
+func TestRawMessage_RoundTripInsideStruct(t *testing.T) {
+	type wrapper struct {
+		Body RawMessage `json:"body"`
+	}
+	in := wrapper{Body: RawMessage(`{"x":42}`)}
+	encoded, err := json.Marshal(in)
+	if err != nil {
+		t.Fatalf("json.Marshal returned error: %v", err)
+	}
+	want := `{"body":{"x":42}}`
+	if string(encoded) != want {
+		t.Fatalf("Marshal = %s, want %s", encoded, want)
+	}
+
+	var out wrapper
+	if err := json.Unmarshal(encoded, &out); err != nil {
+		t.Fatalf("json.Unmarshal returned error: %v", err)
+	}
+	if string(out.Body) != `{"x":42}` {
+		t.Fatalf("round-trip Body = %s, want %s", out.Body, `{"x":42}`)
+	}
+}

+ 2 - 7
util/ldap/ldap.go

@@ -3,6 +3,7 @@ package ldaputil
 import (
 	"crypto/tls"
 	"fmt"
+	"slices"
 
 	"github.com/go-ldap/ldap/v3"
 )
@@ -82,13 +83,7 @@ func FetchVlessFlags(cfg Config) (map[string]bool, error) {
 			continue
 		}
 		val := e.GetAttributeValue(cfg.FlagField)
-		enabled := false
-		for _, t := range cfg.TruthyVals {
-			if val == t {
-				enabled = true
-				break
-			}
-		}
+		enabled := slices.Contains(cfg.TruthyVals, val)
 		if cfg.Invert {
 			enabled = !enabled
 		}

+ 127 - 0
util/netsafe/netsafe_test.go

@@ -0,0 +1,127 @@
+package netsafe
+
+import (
+	"context"
+	"net"
+	"strings"
+	"testing"
+)
+
+func TestIsBlockedIP(t *testing.T) {
+	cases := []struct {
+		ip   string
+		want bool
+	}{
+		{"127.0.0.1", true},
+		{"::1", true},
+		{"10.0.0.5", true},
+		{"172.16.0.1", true},
+		{"192.168.1.1", true},
+		{"169.254.0.1", true},
+		{"0.0.0.0", true},
+		{"::", true},
+		{"8.8.8.8", false},
+		{"1.1.1.1", false},
+		{"2606:4700:4700::1111", false},
+	}
+	for _, c := range cases {
+		t.Run(c.ip, func(t *testing.T) {
+			ip := net.ParseIP(c.ip)
+			if ip == nil {
+				t.Fatalf("could not parse %q", c.ip)
+			}
+			if got := IsBlockedIP(ip); got != c.want {
+				t.Fatalf("IsBlockedIP(%s) = %v, want %v", c.ip, got, c.want)
+			}
+		})
+	}
+}
+
+func TestAllowPrivateFromContext_Default(t *testing.T) {
+	if AllowPrivateFromContext(context.Background()) {
+		t.Fatal("default context should report AllowPrivate=false")
+	}
+}
+
+func TestAllowPrivateFromContext_RoundTrip(t *testing.T) {
+	ctx := ContextWithAllowPrivate(context.Background(), true)
+	if !AllowPrivateFromContext(ctx) {
+		t.Fatal("expected AllowPrivate=true after ContextWithAllowPrivate(true)")
+	}
+	ctx = ContextWithAllowPrivate(ctx, false)
+	if AllowPrivateFromContext(ctx) {
+		t.Fatal("expected AllowPrivate=false after overriding with false")
+	}
+}
+
+func TestNormalizeHost_Valid(t *testing.T) {
+	cases := []struct {
+		in   string
+		want string
+	}{
+		{"example.com", "example.com"},
+		{"  example.com  ", "example.com"},
+		{"a.b.c.example.com", "a.b.c.example.com"},
+		{"10.0.0.1", "10.0.0.1"},
+		{"[2606:4700:4700::1111]", "2606:4700:4700::1111"},
+		{"2606:4700:4700::1111", "2606:4700:4700::1111"},
+	}
+	for _, c := range cases {
+		t.Run(c.in, func(t *testing.T) {
+			got, err := NormalizeHost(c.in)
+			if err != nil {
+				t.Fatalf("NormalizeHost(%q) returned error: %v", c.in, err)
+			}
+			if !strings.EqualFold(got, c.want) {
+				t.Fatalf("NormalizeHost(%q) = %q, want %q", c.in, got, c.want)
+			}
+		})
+	}
+}
+
+func TestNormalizeHost_Invalid(t *testing.T) {
+	cases := []string{
+		"",
+		"   ",
+		"-leading-dash.com",
+		"trailing-dash-.com",
+		"bad host with spaces",
+		"under_score.example.com",
+		"exa$mple.com",
+		strings.Repeat("a", 254),
+	}
+	for _, in := range cases {
+		t.Run(in, func(t *testing.T) {
+			if _, err := NormalizeHost(in); err == nil {
+				t.Fatalf("NormalizeHost(%q) expected error, got nil", in)
+			}
+		})
+	}
+}
+
+func TestSSRFGuardedDialContext_BlocksLiteralPrivateIP(t *testing.T) {
+	_, err := SSRFGuardedDialContext(context.Background(), "tcp", "127.0.0.1:1")
+	if err == nil {
+		t.Fatal("expected dial to 127.0.0.1 to be blocked")
+	}
+	if !strings.Contains(err.Error(), "blocked") {
+		t.Fatalf("expected 'blocked' in error, got: %v", err)
+	}
+}
+
+func TestSSRFGuardedDialContext_AllowPrivateBypassesGuard(t *testing.T) {
+	ctx := ContextWithAllowPrivate(context.Background(), true)
+	_, err := SSRFGuardedDialContext(ctx, "tcp", "127.0.0.1:1")
+	if err == nil {
+		t.Fatal("dial to a closed loopback port should still fail at the connect step")
+	}
+	if strings.Contains(err.Error(), "blocked private/internal address") {
+		t.Fatalf("expected guard to be bypassed when AllowPrivate=true, got: %v", err)
+	}
+}
+
+func TestSSRFGuardedDialContext_BadAddress(t *testing.T) {
+	if _, err := SSRFGuardedDialContext(context.Background(), "tcp", "no-port"); err == nil {
+		t.Fatal("expected error for address without port")
+	}
+}

+ 12 - 0
util/random/random.go

@@ -3,6 +3,7 @@ package random
 
 import (
 	"crypto/rand"
+	"encoding/base64"
 	"math/big"
 )
 
@@ -59,3 +60,14 @@ func Num(n int) int {
 	}
 	return int(r.Int64())
 }
+
+// Base64Bytes returns n cryptographically-random bytes encoded as standard
+// base64 (with padding). Used for ss2022 keys, which xray expects as a
+// base64-encoded key of a specific byte length per cipher.
+func Base64Bytes(n int) string {
+	b := make([]byte, n)
+	if _, err := rand.Read(b); err != nil {
+		panic("crypto/rand failed: " + err.Error())
+	}
+	return base64.StdEncoding.EncodeToString(b)
+}

+ 63 - 0
util/random/random_test.go

@@ -0,0 +1,63 @@
+package random
+
+import (
+	"encoding/base64"
+	"testing"
+)
+
+func TestSeq_LengthAndAlphabet(t *testing.T) {
+	for _, n := range []int{0, 1, 8, 64, 256} {
+		s := Seq(n)
+		if len(s) != n {
+			t.Fatalf("Seq(%d) returned length %d", n, len(s))
+		}
+		for i, r := range s {
+			isDigit := r >= '0' && r <= '9'
+			isLower := r >= 'a' && r <= 'z'
+			isUpper := r >= 'A' && r <= 'Z'
+			if !(isDigit || isLower || isUpper) {
+				t.Fatalf("Seq(%d) byte %d = %q is not alphanumeric", n, i, r)
+			}
+		}
+	}
+}
+
+func TestSeq_NotConstant(t *testing.T) {
+	a := Seq(32)
+	b := Seq(32)
+	if a == b {
+		t.Fatalf("two consecutive Seq(32) calls produced identical output: %q", a)
+	}
+}
+
+func TestNum_InRange(t *testing.T) {
+	for _, upper := range []int{1, 2, 10, 1000} {
+		for range 200 {
+			v := Num(upper)
+			if v < 0 || v >= upper {
+				t.Fatalf("Num(%d) returned %d, out of [0, %d)", upper, v, upper)
+			}
+		}
+	}
+}
+
+func TestBase64Bytes_DecodesToRequestedSize(t *testing.T) {
+	for _, n := range []int{1, 16, 32, 64} {
+		out := Base64Bytes(n)
+		decoded, err := base64.StdEncoding.DecodeString(out)
+		if err != nil {
+			t.Fatalf("Base64Bytes(%d) produced invalid base64 %q: %v", n, out, err)
+		}
+		if len(decoded) != n {
+			t.Fatalf("Base64Bytes(%d) decoded to %d bytes", n, len(decoded))
+		}
+	}
+}
+
+func TestBase64Bytes_Random(t *testing.T) {
+	a := Base64Bytes(32)
+	b := Base64Bytes(32)
+	if a == b {
+		t.Fatalf("two consecutive Base64Bytes(32) calls produced identical output: %q", a)
+	}
+}

+ 11 - 18
util/sys/sys_darwin.go

@@ -1,5 +1,4 @@
 //go:build darwin
-// +build darwin
 
 package sys
 
@@ -33,8 +32,9 @@ func GetUDPCount() (int, error) {
 
 // --- CPU Utilization (macOS native) ---
 
-// sysctl kern.cp_time returns an array of 5 longs: user, nice, sys, idle, intr.
-// We compute utilization deltas without cgo.
+// sysctl kern.cp_time returns 5 longs in the BSD CPUSTATES order:
+// user, nice, sys, intr, idle (CP_INTR=3, CP_IDLE=4). gopsutil reads the
+// same layout in cpu_darwin_nocgo.go.
 var (
 	cpuMu       sync.Mutex
 	lastTotals  [5]uint64
@@ -61,13 +61,6 @@ func CPUPercentRaw() (float64, error) {
 		return 0, fmt.Errorf("unexpected kern.cp_time size: %d", len(raw))
 	}
 
-	// user, nice, sys, idle, intr
-	user := out[0]
-	nice := out[1]
-	sysv := out[2]
-	idle := out[3]
-	intr := out[4]
-
 	cpuMu.Lock()
 	defer cpuMu.Unlock()
 
@@ -77,19 +70,19 @@ func CPUPercentRaw() (float64, error) {
 		return 0, nil
 	}
 
-	dUser := user - lastTotals[0]
-	dNice := nice - lastTotals[1]
-	dSys := sysv - lastTotals[2]
-	dIdle := idle - lastTotals[3]
-	dIntr := intr - lastTotals[4]
-
+	var deltas [5]uint64
+	var totald uint64
+	for i := range 5 {
+		deltas[i] = out[i] - lastTotals[i]
+		totald += deltas[i]
+	}
 	lastTotals = out
 
-	totald := dUser + dNice + dSys + dIdle + dIntr
 	if totald == 0 {
 		return 0, nil
 	}
-	busy := totald - dIdle
+	idleDelta := deltas[4]
+	busy := totald - idleDelta
 	pct := float64(busy) / float64(totald) * 100.0
 	if pct > 100 {
 		pct = 100

+ 38 - 75
util/sys/sys_linux.go

@@ -1,11 +1,9 @@
 //go:build linux
-// +build linux
 
 package sys
 
 import (
 	"bufio"
-	"bytes"
 	"fmt"
 	"io"
 	"os"
@@ -17,80 +15,63 @@ import (
 
 var SIGUSR1 = syscall.SIGUSR1
 
-func getLinesNum(filename string) (int, error) {
-	file, err := os.Open(filename)
+// countConnections returns the number of entries in a /proc/net/{tcp,udp}[6]
+// file. Returns 0 if the file is absent (e.g. /proc/net/tcp6 when IPv6 is
+// disabled) and excludes the column header line.
+func countConnections(path string) (int, error) {
+	f, err := os.Open(path)
+	if os.IsNotExist(err) {
+		return 0, nil
+	}
 	if err != nil {
 		return 0, err
 	}
-	defer file.Close()
-
-	sum := 0
-	buf := make([]byte, 8192)
-	for {
-		n, err := file.Read(buf)
-
-		var buffPosition int
-		for {
-			i := bytes.IndexByte(buf[buffPosition:n], '\n')
-			if i < 0 {
-				break
-			}
-			buffPosition += i + 1
-			sum++
-		}
+	defer f.Close()
 
-		if err == io.EOF {
-			break
-		} else if err != nil {
-			return 0, err
-		}
+	sc := bufio.NewScanner(f)
+	n := 0
+	for sc.Scan() {
+		n++
 	}
-	return sum, nil
+	if err := sc.Err(); err != nil {
+		return 0, err
+	}
+	if n > 0 {
+		n-- // first line is the column header
+	}
+	return n, nil
 }
 
 // GetTCPCount returns the number of active TCP connections by reading
 // /proc/net/tcp and /proc/net/tcp6 when available.
 func GetTCPCount() (int, error) {
 	root := HostProc()
-
-	tcp4, err := safeGetLinesNum(fmt.Sprintf("%v/net/tcp", root))
+	tcp4, err := countConnections(root + "/net/tcp")
 	if err != nil {
 		return 0, err
 	}
-	tcp6, err := safeGetLinesNum(fmt.Sprintf("%v/net/tcp6", root))
+	tcp6, err := countConnections(root + "/net/tcp6")
 	if err != nil {
 		return 0, err
 	}
-
 	return tcp4 + tcp6, nil
 }
 
+// GetUDPCount returns the number of active UDP connections by reading
+// /proc/net/udp and /proc/net/udp6 when available.
 func GetUDPCount() (int, error) {
 	root := HostProc()
-
-	udp4, err := safeGetLinesNum(fmt.Sprintf("%v/net/udp", root))
+	udp4, err := countConnections(root + "/net/udp")
 	if err != nil {
 		return 0, err
 	}
-	udp6, err := safeGetLinesNum(fmt.Sprintf("%v/net/udp6", root))
+	udp6, err := countConnections(root + "/net/udp6")
 	if err != nil {
 		return 0, err
 	}
-
 	return udp4 + udp6, nil
 }
 
-// safeGetLinesNum returns 0 if the file does not exist, otherwise forwards
-// to getLinesNum to count the number of lines.
-func safeGetLinesNum(path string) (int, error) {
-	if _, err := os.Stat(path); os.IsNotExist(err) {
-		return 0, nil
-	} else if err != nil {
-		return 0, err
-	}
-	return getLinesNum(path)
-}
-
 // --- CPU Utilization (Linux native) ---
 
 var (
@@ -100,10 +81,11 @@ var (
 	hasLast     bool
 )
 
-// CPUPercentRaw returns instantaneous total CPU utilization by reading /proc/stat.
-// First call initializes and returns 0; subsequent calls return busy/total * 100.
+// CPUPercentRaw returns instantaneous total CPU utilization by reading
+// /proc/stat. First call initializes and returns 0; subsequent calls return
+// busy/total * 100. Uses HostProc so HOST_PROC overrides (containers) apply.
 func CPUPercentRaw() (float64, error) {
-	f, err := os.Open("/proc/stat")
+	f, err := os.Open(HostProc("stat"))
 	if err != nil {
 		return 0, err
 	}
@@ -114,13 +96,13 @@ func CPUPercentRaw() (float64, error) {
 	if err != nil && err != io.EOF {
 		return 0, err
 	}
-	// Expect line like: cpu  user nice system idle iowait irq softirq steal guest guest_nice
+	// Expect: cpu  user nice system idle iowait irq softirq steal guest guest_nice
 	fields := strings.Fields(line)
 	if len(fields) < 5 || fields[0] != "cpu" {
 		return 0, fmt.Errorf("unexpected /proc/stat format")
 	}
 
-	var nums []uint64
+	nums := make([]uint64, 0, len(fields)-1)
 	for i := 1; i < len(fields); i++ {
 		v, err := strconv.ParseUint(fields[i], 10, 64)
 		if err != nil {
@@ -128,35 +110,16 @@ func CPUPercentRaw() (float64, error) {
 		}
 		nums = append(nums, v)
 	}
-	if len(nums) < 4 { // need at least user,nice,system,idle
+	if len(nums) < 4 {
 		return 0, fmt.Errorf("insufficient cpu fields")
 	}
-
-	// Conform with standard Linux CPU accounting
-	var user, nice, system, idle, iowait, irq, softirq, steal uint64
-	user = nums[0]
-	if len(nums) > 1 {
-		nice = nums[1]
-	}
-	if len(nums) > 2 {
-		system = nums[2]
-	}
-	if len(nums) > 3 {
-		idle = nums[3]
-	}
-	if len(nums) > 4 {
-		iowait = nums[4]
-	}
-	if len(nums) > 5 {
-		irq = nums[5]
-	}
-	if len(nums) > 6 {
-		softirq = nums[6]
-	}
-	if len(nums) > 7 {
-		steal = nums[7]
+	for len(nums) < 8 {
+		nums = append(nums, 0)
 	}
 
+	user, nice, system, idle := nums[0], nums[1], nums[2], nums[3]
+	iowait, irq, softirq, steal := nums[4], nums[5], nums[6], nums[7]
+
 	idleAll := idle + iowait
 	nonIdle := user + nice + system + irq + softirq + steal
 	total := idleAll + nonIdle

+ 14 - 22
util/sys/sys_windows.go

@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package sys
 
@@ -10,6 +9,7 @@ import (
 	"unsafe"
 
 	"github.com/shirou/gopsutil/v4/net"
+	"golang.org/x/sys/windows"
 )
 
 var SIGUSR1 = syscall.Signal(0)
@@ -19,7 +19,6 @@ func GetConnectionCount(proto string) (int, error) {
 	if proto != "tcp" && proto != "udp" {
 		return 0, errors.New("invalid protocol")
 	}
-
 	stats, err := net.Connections(proto)
 	if err != nil {
 		return 0, err
@@ -40,7 +39,9 @@ func GetUDPCount() (int, error) {
 // --- CPU Utilization (Windows native) ---
 
 var (
-	modKernel32        = syscall.NewLazyDLL("kernel32.dll")
+	// NewLazySystemDLL forces the load from %SystemRoot%\System32 so a
+	// kernel32.dll planted next to the binary can't hijack the call.
+	modKernel32        = windows.NewLazySystemDLL("kernel32.dll")
 	procGetSystemTimes = modKernel32.NewProc("GetSystemTimes")
 
 	cpuMu      sync.Mutex
@@ -50,32 +51,25 @@ var (
 	hasLast    bool
 )
 
-type filetime struct {
-	LowDateTime  uint32
-	HighDateTime uint32
-}
-
-// ftToUint64 converts a Windows FILETIME-like struct to a uint64 for
-// arithmetic and delta calculations used by CPUPercentRaw.
-func ftToUint64(ft filetime) uint64 {
+func ftToUint64(ft windows.Filetime) uint64 {
 	return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
 }
 
-// CPUPercentRaw returns the instantaneous total CPU utilization percentage using
-// Windows GetSystemTimes across all logical processors. The first call returns 0
-// as it initializes the baseline. Subsequent calls compute deltas.
+// CPUPercentRaw returns instantaneous total CPU utilization across all
+// logical processors via Windows GetSystemTimes. The first call returns 0
+// while it initializes the baseline; subsequent calls compute deltas.
 func CPUPercentRaw() (float64, error) {
-	var idleFT, kernelFT, userFT filetime
+	var idleFT, kernelFT, userFT windows.Filetime
 	r1, _, e1 := procGetSystemTimes.Call(
 		uintptr(unsafe.Pointer(&idleFT)),
 		uintptr(unsafe.Pointer(&kernelFT)),
 		uintptr(unsafe.Pointer(&userFT)),
 	)
-	if r1 == 0 { // failure
-		if e1 != nil {
-			return 0, e1
+	if r1 == 0 {
+		if errno, _ := e1.(syscall.Errno); errno != 0 {
+			return 0, errno
 		}
-		return 0, syscall.GetLastError()
+		return 0, errors.New("GetSystemTimes failed")
 	}
 
 	idle := ftToUint64(idleFT)
@@ -97,7 +91,6 @@ func CPUPercentRaw() (float64, error) {
 	kernelDelta := kernel - lastKernel
 	userDelta := user - lastUser
 
-	// Update for next call
 	lastIdle = idle
 	lastKernel = kernel
 	lastUser = user
@@ -106,11 +99,10 @@ func CPUPercentRaw() (float64, error) {
 	if total == 0 {
 		return 0, nil
 	}
-	// On Windows, kernel time includes idle time; busy = total - idle
+	// kernel time includes idle on Windows; busy = total - idle
 	busy := total - idleDelta
 
 	pct := float64(busy) / float64(total) * 100.0
-	// lower bound not needed; ratios of uint64 are non-negative
 	if pct > 100 {
 		pct = 100
 	}

+ 5 - 2
web/controller/api.go

@@ -32,8 +32,8 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *
 
 func (a *APIController) checkAPIAuth(c *gin.Context) {
 	auth := c.GetHeader("Authorization")
-	if strings.HasPrefix(auth, "Bearer ") {
-		tok := strings.TrimPrefix(auth, "Bearer ")
+	if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
+		tok := after
 		if a.apiTokenService.Match(tok) {
 			if u, err := a.userService.GetFirstUser(); err == nil {
 				session.SetAPIAuthUser(c, u)
@@ -65,6 +65,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
 	inbounds := api.Group("/inbounds")
 	a.inboundController = NewInboundController(inbounds)
 
+	clients := api.Group("/clients")
+	NewClientController(clients)
+
 	// Server API
 	server := api.Group("/server")
 	a.serverController = NewServerController(server)

+ 4 - 1
web/controller/api_docs_test.go

@@ -87,6 +87,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
 			basePath = "/panel/api"
 		case "inbound.go":
 			basePath = "/panel/api/inbounds"
+		case "client.go":
+			basePath = "/panel/api/clients"
 		case "server.go":
 			basePath = "/panel/api/server"
 		case "node.go":
@@ -127,7 +129,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
 		// Skip SPA page routes (these are UI pages, not API endpoints)
 		spaPages := map[string]bool{
 			"/": true, "/panel/": true, "/panel/inbounds": true,
-			"/panel/nodes": true, "/panel/settings": true,
+			"/panel/clients": true,
+			"/panel/nodes":   true, "/panel/settings": true,
 			"/panel/xray": true, "/panel/api-docs": true,
 		}
 		if spaPages[r.Path] {

+ 311 - 0
web/controller/client.go

@@ -0,0 +1,311 @@
+package controller
+
+import (
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/websocket"
+
+	"github.com/gin-gonic/gin"
+)
+
+func notifyClientsChanged() {
+	websocket.BroadcastInvalidate(websocket.MessageTypeClients)
+}
+
+type ClientController struct {
+	clientService  service.ClientService
+	inboundService service.InboundService
+	xrayService    service.XrayService
+}
+
+func NewClientController(g *gin.RouterGroup) *ClientController {
+	a := &ClientController{}
+	a.initRouter(g)
+	return a
+}
+
+func (a *ClientController) initRouter(g *gin.RouterGroup) {
+	g.GET("/list", a.list)
+	g.GET("/get/:email", a.get)
+	g.GET("/traffic/:email", a.getTrafficByEmail)
+	g.GET("/subLinks/:subId", a.getSubLinks)
+	g.GET("/links/:email", a.getClientLinks)
+
+	g.POST("/add", a.create)
+	g.POST("/update/:email", a.update)
+	g.POST("/del/:email", a.delete)
+	g.POST("/:email/attach", a.attach)
+	g.POST("/:email/detach", a.detach)
+	g.POST("/resetAllTraffics", a.resetAllTraffics)
+	g.POST("/delDepleted", a.delDepleted)
+	g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
+	g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
+	g.POST("/ips/:email", a.getIps)
+	g.POST("/clearIps/:email", a.clearIps)
+	g.POST("/onlines", a.onlines)
+	g.POST("/lastOnline", a.lastOnline)
+}
+
+func (a *ClientController) list(c *gin.Context) {
+	rows, err := a.clientService.List()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, rows, nil)
+}
+
+func (a *ClientController) get(c *gin.Context) {
+	email := c.Param("email")
+	rec, err := a.clientService.GetRecordByEmail(nil, email)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
+	inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
+	jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds}, nil)
+}
+
+func (a *ClientController) create(c *gin.Context) {
+	var payload service.ClientCreatePayload
+	if err := c.ShouldBindJSON(&payload); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	needRestart, err := a.clientService.Create(&a.inboundService, &payload)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+func (a *ClientController) update(c *gin.Context) {
+	email := c.Param("email")
+	var updated model.Client
+	if err := c.ShouldBindJSON(&updated); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+func (a *ClientController) delete(c *gin.Context) {
+	email := c.Param("email")
+	keepTraffic := c.Query("keepTraffic") == "1"
+	needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+type attachDetachBody struct {
+	InboundIds []int `json:"inboundIds"`
+}
+
+func (a *ClientController) attach(c *gin.Context) {
+	email := c.Param("email")
+	var body attachDetachBody
+	if err := c.ShouldBindJSON(&body); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	needRestart, err := a.clientService.AttachByEmail(&a.inboundService, email, body.InboundIds)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+func (a *ClientController) resetAllTraffics(c *gin.Context) {
+	needRestart, err := a.clientService.ResetAllTraffics()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+func (a *ClientController) delDepleted(c *gin.Context) {
+	deleted, needRestart, err := a.clientService.DelDepleted(&a.inboundService)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, gin.H{"deleted": deleted}, nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+func (a *ClientController) resetTrafficByEmail(c *gin.Context) {
+	email := c.Param("email")
+	needRestart, err := a.clientService.ResetTrafficByEmail(&a.inboundService, email)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+type trafficUpdateRequest struct {
+	Upload   int64 `json:"upload"`
+	Download int64 `json:"download"`
+}
+
+func (a *ClientController) updateTrafficByEmail(c *gin.Context) {
+	email := c.Param("email")
+	var req trafficUpdateRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	if err := a.inboundService.UpdateClientTrafficByEmail(email, req.Upload, req.Download); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
+	notifyClientsChanged()
+}
+
+func (a *ClientController) getIps(c *gin.Context) {
+	email := c.Param("email")
+	ips, err := a.inboundService.GetInboundClientIps(email)
+	if err != nil || ips == "" {
+		jsonObj(c, "No IP Record", nil)
+		return
+	}
+	type ipWithTimestamp struct {
+		IP        string `json:"ip"`
+		Timestamp int64  `json:"timestamp"`
+	}
+	var ipsWithTime []ipWithTimestamp
+	if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
+		formatted := make([]string, 0, len(ipsWithTime))
+		for _, item := range ipsWithTime {
+			if item.IP == "" {
+				continue
+			}
+			if item.Timestamp > 0 {
+				ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
+				formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
+				continue
+			}
+			formatted = append(formatted, item.IP)
+		}
+		jsonObj(c, formatted, nil)
+		return
+	}
+	var oldIps []string
+	if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
+		jsonObj(c, oldIps, nil)
+		return
+	}
+	jsonObj(c, ips, nil)
+}
+
+func (a *ClientController) clearIps(c *gin.Context) {
+	email := c.Param("email")
+	if err := a.inboundService.ClearClientIps(email); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
+}
+
+func (a *ClientController) onlines(c *gin.Context) {
+	jsonObj(c, a.inboundService.GetOnlineClients(), nil)
+}
+
+func (a *ClientController) lastOnline(c *gin.Context) {
+	data, err := a.inboundService.GetClientsLastOnline()
+	jsonObj(c, data, err)
+}
+
+func (a *ClientController) getTrafficByEmail(c *gin.Context) {
+	email := c.Param("email")
+	traffic, err := a.inboundService.GetClientTrafficByEmail(email)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
+		return
+	}
+	jsonObj(c, traffic, nil)
+}
+
+func (a *ClientController) getSubLinks(c *gin.Context) {
+	links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, links, nil)
+}
+
+func (a *ClientController) getClientLinks(c *gin.Context) {
+	links, err := a.inboundService.GetAllClientLinks(resolveHost(c), c.Param("email"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, links, nil)
+}
+
+func (a *ClientController) detach(c *gin.Context) {
+	email := c.Param("email")
+	var body attachDetachBody
+	if err := c.ShouldBindJSON(&body); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	needRestart, err := a.clientService.DetachByEmailMany(&a.inboundService, email, body.InboundIds)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}

+ 46 - 322
web/controller/inbound.go

@@ -6,7 +6,6 @@ import (
 	"net"
 	"strconv"
 	"strings"
-	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
@@ -18,8 +17,9 @@ import (
 
 // InboundController handles HTTP requests related to Xray inbounds management.
 type InboundController struct {
-	inboundService service.InboundService
-	xrayService    service.XrayService
+	inboundService  service.InboundService
+	xrayService     service.XrayService
+	fallbackService service.FallbackService
 }
 
 // NewInboundController creates a new InboundController and sets up its routes.
@@ -61,38 +61,18 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) {
 func (a *InboundController) initRouter(g *gin.RouterGroup) {
 
 	g.GET("/list", a.getInbounds)
+	g.GET("/options", a.getInboundOptions)
 	g.GET("/get/:id", a.getInbound)
-	g.GET("/getClientTraffics/:email", a.getClientTraffics)
-	g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById)
-	g.GET("/getSubLinks/:subId", a.getSubLinks)
-	g.GET("/getClientLinks/:id/:email", a.getClientLinks)
+	g.GET("/:id/fallbacks", a.getFallbacks)
 
 	g.POST("/add", a.addInbound)
 	g.POST("/del/:id", a.delInbound)
 	g.POST("/update/:id", a.updateInbound)
 	g.POST("/setEnable/:id", a.setInboundEnable)
-	g.POST("/clientIps/:email", a.getClientIps)
-	g.POST("/clearClientIps/:email", a.clearClientIps)
-	g.POST("/addClient", a.addInboundClient)
-	g.POST("/:id/copyClients", a.copyInboundClients)
-	g.POST("/:id/delClient/:clientId", a.delInboundClient)
-	g.POST("/updateClient/:clientId", a.updateInboundClient)
 	g.POST("/:id/resetTraffic", a.resetInboundTraffic)
-	g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
 	g.POST("/resetAllTraffics", a.resetAllTraffics)
-	g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics)
-	g.POST("/delDepletedClients/:id", a.delDepletedClients)
 	g.POST("/import", a.importInbound)
-	g.POST("/onlines", a.onlines)
-	g.POST("/lastOnline", a.lastOnline)
-	g.POST("/updateClientTraffic/:email", a.updateClientTraffic)
-	g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
-}
-
-type CopyInboundClientsRequest struct {
-	SourceInboundID int      `form:"sourceInboundId" json:"sourceInboundId"`
-	ClientEmails    []string `form:"clientEmails" json:"clientEmails"`
-	Flow            string   `form:"flow" json:"flow"`
+	g.POST("/:id/fallbacks", a.setFallbacks)
 }
 
 // getInbounds retrieves the list of inbounds for the logged-in user.
@@ -106,6 +86,19 @@ func (a *InboundController) getInbounds(c *gin.Context) {
 	jsonObj(c, inbounds, nil)
 }
 
+// getInboundOptions returns a lightweight projection of the user's inbounds
+// (id, remark, protocol, port, tlsFlowCapable) for pickers in the clients UI.
+// Avoids shipping per-client settings and traffic stats just to fill a dropdown.
+func (a *InboundController) getInboundOptions(c *gin.Context) {
+	user := session.GetLoginUser(c)
+	options, err := a.inboundService.GetInboundOptions(user.Id)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, options, nil)
+}
+
 // getInbound retrieves a specific inbound by its ID.
 func (a *InboundController) getInbound(c *gin.Context) {
 	id, err := strconv.Atoi(c.Param("id"))
@@ -121,28 +114,6 @@ func (a *InboundController) getInbound(c *gin.Context) {
 	jsonObj(c, inbound, nil)
 }
 
-// getClientTraffics retrieves client traffic information by email.
-func (a *InboundController) getClientTraffics(c *gin.Context) {
-	email := c.Param("email")
-	clientTraffics, err := a.inboundService.GetClientTrafficByEmail(email)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
-		return
-	}
-	jsonObj(c, clientTraffics, nil)
-}
-
-// getClientTrafficsById retrieves client traffic information by inbound ID.
-func (a *InboundController) getClientTrafficsById(c *gin.Context) {
-	id := c.Param("id")
-	clientTraffics, err := a.inboundService.GetClientTrafficByID(id)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
-		return
-	}
-	jsonObj(c, clientTraffics, nil)
-}
-
 // addInbound creates a new inbound configuration.
 func (a *InboundController) addInbound(c *gin.Context) {
 	inbound := &model.Inbound{}
@@ -274,174 +245,6 @@ func (a *InboundController) setInboundEnable(c *gin.Context) {
 	websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
 }
 
-// getClientIps retrieves the IP addresses associated with a client by email.
-func (a *InboundController) getClientIps(c *gin.Context) {
-	email := c.Param("email")
-
-	ips, err := a.inboundService.GetInboundClientIps(email)
-	if err != nil || ips == "" {
-		jsonObj(c, "No IP Record", nil)
-		return
-	}
-
-	// Prefer returning a normalized string list for consistent UI rendering
-	type ipWithTimestamp struct {
-		IP        string `json:"ip"`
-		Timestamp int64  `json:"timestamp"`
-	}
-
-	var ipsWithTime []ipWithTimestamp
-	if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
-		formatted := make([]string, 0, len(ipsWithTime))
-		for _, item := range ipsWithTime {
-			if item.IP == "" {
-				continue
-			}
-			if item.Timestamp > 0 {
-				ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
-				formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
-				continue
-			}
-			formatted = append(formatted, item.IP)
-		}
-		jsonObj(c, formatted, nil)
-		return
-	}
-
-	var oldIps []string
-	if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
-		jsonObj(c, oldIps, nil)
-		return
-	}
-
-	// If parsing fails, return as string
-	jsonObj(c, ips, nil)
-}
-
-// clearClientIps clears the IP addresses for a client by email.
-func (a *InboundController) clearClientIps(c *gin.Context) {
-	email := c.Param("email")
-
-	err := a.inboundService.ClearClientIps(email)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
-		return
-	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
-}
-
-// addInboundClient adds a new client to an existing inbound.
-func (a *InboundController) addInboundClient(c *gin.Context) {
-	data := &model.Inbound{}
-	err := c.ShouldBind(data)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
-		return
-	}
-
-	needRestart, err := a.inboundService.AddInboundClient(data)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-}
-
-// copyInboundClients copies clients from source inbound to target inbound.
-func (a *InboundController) copyInboundClients(c *gin.Context) {
-	targetID, err := strconv.Atoi(c.Param("id"))
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-
-	req := &CopyInboundClientsRequest{}
-	err = c.ShouldBind(req)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	if req.SourceInboundID <= 0 {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("invalid source inbound id"))
-		return
-	}
-
-	result, needRestart, err := a.inboundService.CopyInboundClients(targetID, req.SourceInboundID, req.ClientEmails, req.Flow)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonObj(c, result, nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-}
-
-// delInboundClient deletes a client from an inbound by inbound ID and client ID.
-func (a *InboundController) delInboundClient(c *gin.Context) {
-	id, err := strconv.Atoi(c.Param("id"))
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
-		return
-	}
-	clientId := c.Param("clientId")
-
-	needRestart, err := a.inboundService.DelInboundClient(id, clientId)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-}
-
-// updateInboundClient updates a client's configuration in an inbound.
-func (a *InboundController) updateInboundClient(c *gin.Context) {
-	clientId := c.Param("clientId")
-
-	inbound := &model.Inbound{}
-	err := c.ShouldBind(inbound)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
-		return
-	}
-
-	needRestart, err := a.inboundService.UpdateInboundClient(inbound, clientId)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-}
-
-// resetClientTraffic resets the traffic counter for a specific client in an inbound.
-func (a *InboundController) resetClientTraffic(c *gin.Context) {
-	id, err := strconv.Atoi(c.Param("id"))
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
-		return
-	}
-	email := c.Param("email")
-
-	needRestart, err := a.inboundService.ResetClientTraffic(id, email)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-}
-
 // resetInboundTraffic resets traffic counters for a specific inbound.
 func (a *InboundController) resetInboundTraffic(c *gin.Context) {
 	id, err := strconv.Atoi(c.Param("id"))
@@ -472,24 +275,6 @@ func (a *InboundController) resetAllTraffics(c *gin.Context) {
 	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
 }
 
-// resetAllClientTraffics resets traffic counters for all clients in a specific inbound.
-func (a *InboundController) resetAllClientTraffics(c *gin.Context) {
-	id, err := strconv.Atoi(c.Param("id"))
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
-		return
-	}
-
-	err = a.inboundService.ResetAllClientTraffics(id)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	} else {
-		a.xrayService.SetToNeedRestart()
-	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
-}
-
 // importInbound imports an inbound configuration from provided data.
 func (a *InboundController) importInbound(c *gin.Context) {
 	inbound := &model.Inbound{}
@@ -522,79 +307,6 @@ func (a *InboundController) importInbound(c *gin.Context) {
 	}
 }
 
-// delDepletedClients deletes clients in an inbound who have exhausted their traffic limits.
-func (a *InboundController) delDepletedClients(c *gin.Context) {
-	id, err := strconv.Atoi(c.Param("id"))
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
-		return
-	}
-	err = a.inboundService.DelDepletedClients(id)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.delDepletedClientsSuccess"), nil)
-}
-
-// onlines retrieves the list of currently online clients.
-func (a *InboundController) onlines(c *gin.Context) {
-	jsonObj(c, a.inboundService.GetOnlineClients(), nil)
-}
-
-// lastOnline retrieves the last online timestamps for clients.
-func (a *InboundController) lastOnline(c *gin.Context) {
-	data, err := a.inboundService.GetClientsLastOnline()
-	jsonObj(c, data, err)
-}
-
-// updateClientTraffic updates the traffic statistics for a client by email.
-func (a *InboundController) updateClientTraffic(c *gin.Context) {
-	email := c.Param("email")
-
-	// Define the request structure for traffic update
-	type TrafficUpdateRequest struct {
-		Upload   int64 `json:"upload"`
-		Download int64 `json:"download"`
-	}
-
-	var request TrafficUpdateRequest
-	err := c.ShouldBindJSON(&request)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
-		return
-	}
-
-	err = a.inboundService.UpdateClientTrafficByEmail(email, request.Upload, request.Download)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
-}
-
-// delInboundClientByEmail deletes a client from an inbound by email address.
-func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
-	inboundId, err := strconv.Atoi(c.Param("id"))
-	if err != nil {
-		jsonMsg(c, "Invalid inbound ID", err)
-		return
-	}
-
-	email := c.Param("email")
-	needRestart, err := a.inboundService.DelInboundClientByEmail(inboundId, email)
-	if err != nil {
-		jsonMsg(c, "Failed to delete client by email", err)
-		return
-	}
-
-	jsonMsg(c, "Client deleted successfully", nil)
-	if needRestart {
-		a.xrayService.SetToNeedRestart()
-	}
-}
-
 // resolveHost mirrors what sub.SubService.ResolveRequest does for the host
 // field: prefers X-Forwarded-Host (first entry of any list, port stripped),
 // then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the
@@ -621,30 +333,42 @@ func resolveHost(c *gin.Context) string {
 	return c.Request.Host
 }
 
-// getSubLinks returns every protocol URL produced for the given subscription
-// ID — the JSON-array equivalent of /sub/<subId> (no base64 wrap).
-func (a *InboundController) getSubLinks(c *gin.Context) {
-	links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
+// getFallbacks returns the fallback rules attached to the master inbound.
+func (a *InboundController) getFallbacks(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
 	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
+	rows, err := a.fallbackService.GetByMaster(id)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
 		return
 	}
-	jsonObj(c, links, nil)
+	jsonObj(c, rows, nil)
 }
 
-// getClientLinks returns the URL(s) for one client on one inbound — the same
-// string the Copy URL button copies in the panel UI. Empty array when the
-// protocol has no URL form, or when the email isn't found on the inbound.
-func (a *InboundController) getClientLinks(c *gin.Context) {
+// setFallbacks atomically replaces the master inbound's fallback list
+// and triggers an Xray restart so the new settings.fallbacks take effect.
+func (a *InboundController) setFallbacks(c *gin.Context) {
 	id, err := strconv.Atoi(c.Param("id"))
 	if err != nil {
-		jsonMsg(c, I18nWeb(c, "get"), err)
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 	}
-	links, err := a.inboundService.GetClientLinks(resolveHost(c), id, c.Param("email"))
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+	type body struct {
+		Fallbacks []service.FallbackInput `json:"fallbacks"`
+	}
+	var b body
+	if err := c.ShouldBindJSON(&b); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	if err := a.fallbackService.SetByMaster(id, b.Fallbacks); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 	}
-	jsonObj(c, links, nil)
+	a.xrayService.SetToNeedRestart()
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
 }
+

+ 1 - 1
web/controller/node.go

@@ -178,7 +178,7 @@ func (a *NodeController) history(c *gin.Context) {
 		return
 	}
 	bucket, err := strconv.Atoi(c.Param("bucket"))
-	if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
+	if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
 		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
 		return
 	}

+ 44 - 136
web/controller/server.go

@@ -27,11 +27,6 @@ type ServerController struct {
 	settingService     service.SettingService
 	panelService       service.PanelService
 	xrayMetricsService service.XrayMetricsService
-
-	lastStatus *service.Status
-
-	lastVersions        []string
-	lastGetVersionsTime int64 // unix seconds
 }
 
 // NewServerController creates a new ServerController, initializes routes, and starts background tasks.
@@ -74,63 +69,43 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
 	g.POST("/getNewEchCert", a.getNewEchCert)
 }
 
-// refreshStatus updates the cached server status and collects time-series
-// metrics. CPU/Mem/Net/Online/Load are all written in one call so the
-// SystemHistoryModal's tabs share an identical x-axis.
-func (a *ServerController) refreshStatus() {
-	a.lastStatus = a.serverService.GetStatus(a.lastStatus)
-	if a.lastStatus != nil {
-		now := time.Now()
-		a.serverService.AppendStatusSample(now, a.lastStatus)
-		a.xrayMetricsService.Sample(now)
-		// Broadcast status update via WebSocket
-		websocket.BroadcastStatus(a.lastStatus)
-	}
-}
-
-// startTask initiates background tasks for continuous status monitoring.
+// startTask registers the @2s ticker that refreshes server status, samples
+// xray metrics, and pushes the new snapshot to all websocket subscribers.
+// State + sampling live in ServerService; the controller only orchestrates
+// the cross-service side effects (xrayMetrics sample + websocket broadcast).
 func (a *ServerController) startTask() {
-	webServer := global.GetWebServer()
-	c := webServer.GetCron()
+	c := global.GetWebServer().GetCron()
 	c.AddFunc("@every 2s", func() {
-		// Always refresh to keep CPU history collected continuously.
-		// Sampling is lightweight and capped to ~6 hours in memory.
-		a.refreshStatus()
+		status := a.serverService.RefreshStatus()
+		if status == nil {
+			return
+		}
+		a.xrayMetricsService.Sample(time.Now())
+		websocket.BroadcastStatus(status)
 	})
 }
 
 // status returns the current server status information.
-func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) }
-
-// allowedHistoryBuckets is the bucket-second whitelist shared by both
-// /cpuHistory/:bucket and /history/:metric/:bucket. Restricting it
-// prevents callers from triggering arbitrary aggregation work and keeps
-// the front-end's bucket selector self-documenting.
-var allowedHistoryBuckets = map[int]bool{
-	2:   true, // Real-time view
-	30:  true, // 30s intervals
-	60:  true, // 1m intervals
-	120: true, // 2m intervals
-	180: true, // 3m intervals
-	300: true, // 5m intervals
+func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.LastStatus(), nil) }
+
+func parseHistoryBucket(c *gin.Context) (int, bool) {
+	bucket, err := strconv.Atoi(c.Param("bucket"))
+	if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
+		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
+		return 0, false
+	}
+	return bucket, true
 }
 
 // getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
 // Kept for back-compat; new callers should use /history/cpu/:bucket which
 // returns {"t","v"} (uniform across all metrics) instead of {"t","cpu"}.
 func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
-	bucketStr := c.Param("bucket")
-	bucket, err := strconv.Atoi(bucketStr)
-	if err != nil || bucket <= 0 {
-		jsonMsg(c, "invalid bucket", fmt.Errorf("bad bucket"))
+	bucket, ok := parseHistoryBucket(c)
+	if !ok {
 		return
 	}
-	if !allowedHistoryBuckets[bucket] {
-		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
-		return
-	}
-	points := a.serverService.AggregateCpuHistory(bucket, 60)
-	jsonObj(c, points, nil)
+	jsonObj(c, a.serverService.AggregateCpuHistory(bucket, 60), nil)
 }
 
 // getMetricHistoryBucket returns up to 60 buckets of history for a single
@@ -142,9 +117,8 @@ func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
 		jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
 		return
 	}
-	bucket, err := strconv.Atoi(c.Param("bucket"))
-	if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
-		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
+	bucket, ok := parseHistoryBucket(c)
+	if !ok {
 		return
 	}
 	jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
@@ -160,9 +134,8 @@ func (a *ServerController) getXrayMetricsHistoryBucket(c *gin.Context) {
 		jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
 		return
 	}
-	bucket, err := strconv.Atoi(c.Param("bucket"))
-	if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
-		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
+	bucket, ok := parseHistoryBucket(c)
+	if !ok {
 		return
 	}
 	jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil)
@@ -178,37 +151,19 @@ func (a *ServerController) getXrayObservatoryHistoryBucket(c *gin.Context) {
 		jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag"))
 		return
 	}
-	bucket, err := strconv.Atoi(c.Param("bucket"))
-	if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
-		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
+	bucket, ok := parseHistoryBucket(c)
+	if !ok {
 		return
 	}
 	jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
 }
 
 func (a *ServerController) getXrayVersion(c *gin.Context) {
-	const cacheTTLSeconds = 15 * 60
-
-	now := time.Now().Unix()
-	if a.lastVersions != nil && now-a.lastGetVersionsTime <= cacheTTLSeconds {
-		jsonObj(c, a.lastVersions, nil)
-		return
-	}
-
-	versions, err := a.serverService.GetXrayVersions()
+	versions, err := a.serverService.GetXrayVersionsCached()
 	if err != nil {
-		if a.lastVersions != nil {
-			logger.Warning("getXrayVersion failed; serving cached list:", err)
-			jsonObj(c, a.lastVersions, nil)
-			return
-		}
 		jsonMsg(c, I18nWeb(c, "getVersion"), err)
 		return
 	}
-
-	a.lastVersions = versions
-	a.lastGetVersionsTime = now
-
 	jsonObj(c, versions, nil)
 }
 
@@ -240,7 +195,6 @@ func (a *ServerController) updatePanel(c *gin.Context) {
 func (a *ServerController) updateGeofile(c *gin.Context) {
 	fileName := c.Param("fileName")
 
-	// Validate the filename for security (prevent path traversal attacks)
 	if fileName != "" && !a.serverService.IsValidGeofileName(fileName) {
 		jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"),
 			fmt.Errorf("invalid filename: contains unsafe characters or path traversal patterns"))
@@ -287,55 +241,22 @@ func (a *ServerController) restartXrayService(c *gin.Context) {
 
 // getLogs retrieves the application logs based on count, level, and syslog filters.
 func (a *ServerController) getLogs(c *gin.Context) {
-	count := c.Param("count")
-	level := c.PostForm("level")
-	syslog := c.PostForm("syslog")
-	logs := a.serverService.GetLogs(count, level, syslog)
+	logs := a.serverService.GetLogs(c.Param("count"), c.PostForm("level"), c.PostForm("syslog"))
 	jsonObj(c, logs, nil)
 }
 
 // getXrayLogs retrieves Xray logs with filtering options for direct, blocked, and proxy traffic.
 func (a *ServerController) getXrayLogs(c *gin.Context) {
-	count := c.Param("count")
-	filter := c.PostForm("filter")
-	showDirect := c.PostForm("showDirect")
-	showBlocked := c.PostForm("showBlocked")
-	showProxy := c.PostForm("showProxy")
-
-	var freedoms []string
-	var blackholes []string
-
-	//getting tags for freedom and blackhole outbounds
-	config, err := a.settingService.GetDefaultXrayConfig()
-	if err == nil && config != nil {
-		if cfgMap, ok := config.(map[string]any); ok {
-			if outbounds, ok := cfgMap["outbounds"].([]any); ok {
-				for _, outbound := range outbounds {
-					if obMap, ok := outbound.(map[string]any); ok {
-						switch obMap["protocol"] {
-						case "freedom":
-							if tag, ok := obMap["tag"].(string); ok {
-								freedoms = append(freedoms, tag)
-							}
-						case "blackhole":
-							if tag, ok := obMap["tag"].(string); ok {
-								blackholes = append(blackholes, tag)
-							}
-						}
-					}
-				}
-			}
-		}
-	}
-
-	if len(freedoms) == 0 {
-		freedoms = []string{"direct"}
-	}
-	if len(blackholes) == 0 {
-		blackholes = []string{"blocked"}
-	}
-
-	logs := a.serverService.GetXrayLogs(count, filter, showDirect, showBlocked, showProxy, freedoms, blackholes)
+	freedoms, blackholes := a.serverService.GetDefaultLogOutboundTags()
+	logs := a.serverService.GetXrayLogs(
+		c.Param("count"),
+		c.PostForm("filter"),
+		c.PostForm("showDirect"),
+		c.PostForm("showBlocked"),
+		c.PostForm("showProxy"),
+		freedoms,
+		blackholes,
+	)
 	jsonObj(c, logs, nil)
 }
 
@@ -358,36 +279,25 @@ func (a *ServerController) getDb(c *gin.Context) {
 	}
 
 	filename := "x-ui.db"
-
-	if !isValidFilename(filename) {
+	if !filenameRegex.MatchString(filename) {
 		c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
 		return
 	}
 
-	// Set the headers for the response
 	c.Header("Content-Type", "application/octet-stream")
 	c.Header("Content-Disposition", "attachment; filename="+filename)
-
-	// Write the file contents to the response
 	c.Writer.Write(db)
 }
 
-func isValidFilename(filename string) bool {
-	// Validate that the filename only contains allowed characters
-	return filenameRegex.MatchString(filename)
-}
-
 // importDB imports a database file and restarts the Xray service.
 func (a *ServerController) importDB(c *gin.Context) {
-	// Get the file from the request body
 	file, _, err := c.Request.FormFile("db")
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
 		return
 	}
 	defer file.Close()
-	err = a.serverService.ImportDB(file)
-	if err != nil {
+	if err := a.serverService.ImportDB(file); err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
 		return
 	}
@@ -416,8 +326,7 @@ func (a *ServerController) getNewmldsa65(c *gin.Context) {
 
 // getNewEchCert generates a new ECH certificate for the given SNI.
 func (a *ServerController) getNewEchCert(c *gin.Context) {
-	sni := c.PostForm("sni")
-	cert, err := a.serverService.GetNewEchCert(sni)
+	cert, err := a.serverService.GetNewEchCert(c.PostForm("sni"))
 	if err != nil {
 		jsonMsg(c, "get ech certificate", err)
 		return
@@ -442,7 +351,6 @@ func (a *ServerController) getNewUUID(c *gin.Context) {
 		jsonMsg(c, "Failed to generate UUID", err)
 		return
 	}
-
 	jsonObj(c, uuidResp, nil)
 }
 

+ 2 - 2
web/controller/util.go

@@ -27,7 +27,7 @@ func getRemoteIp(c *gin.Context) string {
 		}
 
 		if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
-			for _, part := range strings.Split(xff, ",") {
+			for part := range strings.SplitSeq(xff, ",") {
 				if ip, ok := extractTrustedIP(part); ok {
 					return ip
 				}
@@ -50,7 +50,7 @@ func isTrustedProxy(ip string) bool {
 	}
 
 	trusted := trustedProxyCIDRs()
-	for _, value := range strings.Split(trusted, ",") {
+	for value := range strings.SplitSeq(trusted, ",") {
 		value = strings.TrimSpace(value)
 		if value == "" {
 			continue

+ 5 - 0
web/controller/xui.go

@@ -33,6 +33,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
 
 	g.GET("/", a.index)
 	g.GET("/inbounds", a.inbounds)
+	g.GET("/clients", a.clients)
 	g.GET("/nodes", a.nodes)
 	g.GET("/settings", a.settings)
 	g.GET("/xray", a.xraySettings)
@@ -62,6 +63,10 @@ func (a *XUIController) inbounds(c *gin.Context) {
 	serveDistPage(c, "inbounds.html")
 }
 
+func (a *XUIController) clients(c *gin.Context) {
+	serveDistPage(c, "clients.html")
+}
+
 // nodes renders the multi-panel nodes management page.
 func (a *XUIController) nodes(c *gin.Context) {
 	serveDistPage(c, "nodes.html")

+ 1 - 1
web/entity/entity.go

@@ -195,7 +195,7 @@ func (s *AllSetting) CheckValid() error {
 		s.SubClashPath += "/"
 	}
 
-	for _, cidr := range strings.Split(s.TrustedProxyCIDRs, ",") {
+	for cidr := range strings.SplitSeq(s.TrustedProxyCIDRs, ",") {
 		cidr = strings.TrimSpace(cidr)
 		if cidr == "" {
 			continue

+ 25 - 0
web/global/global.go

@@ -3,6 +3,7 @@ package global
 
 import (
 	"context"
+	"sync"
 	_ "unsafe"
 
 	"github.com/robfig/cron/v3"
@@ -11,6 +12,9 @@ import (
 var (
 	webServer WebServer
 	subServer SubServer
+
+	restartHookMu sync.RWMutex
+	restartHook   func()
 )
 
 // WebServer interface defines methods for accessing the web server instance.
@@ -44,3 +48,24 @@ func SetSubServer(s SubServer) {
 func GetSubServer() SubServer {
 	return subServer
 }
+
+// SetRestartHook registers a callback that triggers an in-process panel
+// restart. main.go sets this up to push SIGHUP into its own signal channel
+// so the restart path works on Windows (where p.Signal(SIGHUP) is unsupported).
+func SetRestartHook(fn func()) {
+	restartHookMu.Lock()
+	defer restartHookMu.Unlock()
+	restartHook = fn
+}
+
+// TriggerRestart fires the registered restart hook. Returns false if none is set.
+func TriggerRestart() bool {
+	restartHookMu.RLock()
+	fn := restartHook
+	restartHookMu.RUnlock()
+	if fn == nil {
+		return false
+	}
+	fn()
+	return true
+}

+ 46 - 102
web/job/ldap_sync_job.go

@@ -1,18 +1,15 @@
 package job
 
 import (
+	"strings"
 	"time"
 
-	"strings"
+	"github.com/google/uuid"
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/logger"
 	ldaputil "github.com/mhsanaei/3x-ui/v3/util/ldap"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
-
-	"strconv"
-
-	"github.com/google/uuid"
 )
 
 var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
@@ -20,6 +17,7 @@ var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
 type LdapSyncJob struct {
 	settingService service.SettingService
 	inboundService service.InboundService
+	clientService  service.ClientService
 	xrayService    service.XrayService
 }
 
@@ -135,18 +133,29 @@ func (j *LdapSyncJob) Run() {
 		}
 	}
 
-	// --- Execute batch create ---
 	for tag, newClients := range clientsToCreate {
 		if len(newClients) == 0 {
 			continue
 		}
-		payload := &model.Inbound{Id: inboundMap[tag].Id}
-		payload.Settings = j.clientsToJSON(newClients)
-		if _, err := j.inboundService.AddInboundClient(payload); err != nil {
-			logger.Warningf("Failed to add clients for tag %s: %v", tag, err)
-		} else {
-			logger.Infof("LDAP auto-create: %d clients for %s", len(newClients), tag)
-			j.xrayService.SetToNeedRestart()
+		ib := inboundMap[tag]
+		created := 0
+		restartNeeded := false
+		for _, c := range newClients {
+			nr, err := j.clientService.CreateOne(&j.inboundService, ib.Id, c)
+			if err != nil {
+				logger.Warningf("Failed to add client %s for tag %s: %v", c.Email, tag, err)
+				continue
+			}
+			created++
+			if nr {
+				restartNeeded = true
+			}
+		}
+		if created > 0 {
+			logger.Infof("LDAP auto-create: %d clients for %s", created, tag)
+			if restartNeeded {
+				j.xrayService.SetToNeedRestart()
+			}
 		}
 	}
 
@@ -206,34 +215,31 @@ func (j *LdapSyncJob) buildClient(ib *model.Inbound, email string, defGB, defExp
 	return c
 }
 
-// batchSetEnable enables/disables clients in batch through a single call
 func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
 	if len(emails) == 0 {
 		return
 	}
-
-	// Prepare JSON for mass update
-	clients := make([]model.Client, 0, len(emails))
+	restartNeeded := false
+	changed := 0
 	for _, email := range emails {
-		clients = append(clients, model.Client{
-			Email:  email,
-			Enable: enable,
-		})
+		ok, needRestart, err := j.clientService.SetClientEnableByEmail(&j.inboundService, email, enable)
+		if err != nil {
+			logger.Warningf("Batch set enable failed for %s in inbound %s: %v", email, ib.Tag, err)
+			continue
+		}
+		if ok {
+			changed++
+		}
+		if needRestart {
+			restartNeeded = true
+		}
 	}
-
-	payload := &model.Inbound{
-		Id:       ib.Id,
-		Settings: j.clientsToJSON(clients),
+	if changed > 0 {
+		logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, changed, ib.Tag)
 	}
-
-	// Use a single AddInboundClient call to update enable
-	if _, err := j.inboundService.AddInboundClient(payload); err != nil {
-		logger.Warningf("Batch set enable failed for inbound %s: %v", ib.Tag, err)
-		return
+	if restartNeeded {
+		j.xrayService.SetToNeedRestart()
 	}
-
-	logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, len(emails), ib.Tag)
-	j.xrayService.SetToNeedRestart()
 }
 
 // deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart
@@ -269,90 +275,28 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
 			continue
 		}
 
-		// Delete in batches
 		for i := 0; i < len(toDelete); i += batchSize {
 			end := min(i+batchSize, len(toDelete))
 			batch := toDelete[i:end]
 
 			for _, c := range batch {
-				var clientKey string
-				switch ib.Protocol {
-				case model.Trojan:
-					clientKey = c.Password
-				case model.Shadowsocks:
-					clientKey = c.Email
-				default: // vless/vmess
-					clientKey = c.ID
-				}
-
-				if _, err := j.inboundService.DelInboundClient(ib.Id, clientKey); err != nil {
+				nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email)
+				if err != nil {
 					logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
 						c.Email, ib.Id, ib.Tag, err)
-				} else {
-					logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
-						c.Email, ib.Id, ib.Tag)
-					// do not restart here
+					continue
+				}
+				logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
+					c.Email, ib.Id, ib.Tag)
+				if nr {
 					restartNeeded = true
 				}
 			}
 		}
 	}
 
-	// One time after all batches
 	if restartNeeded {
 		j.xrayService.SetToNeedRestart()
 		logger.Info("Xray restart scheduled after batch deletion")
 	}
 }
-
-// clientsToJSON serializes an array of clients to JSON
-func (j *LdapSyncJob) clientsToJSON(clients []model.Client) string {
-	b := strings.Builder{}
-	b.WriteString("{\"clients\":[")
-	for i, c := range clients {
-		if i > 0 {
-			b.WriteString(",")
-		}
-		b.WriteString(j.clientToJSON(c))
-	}
-	b.WriteString("]}")
-	return b.String()
-}
-
-// clientToJSON serializes minimal client fields to JSON object string without extra deps
-func (j *LdapSyncJob) clientToJSON(c model.Client) string {
-	// construct minimal JSON manually to avoid importing json for simple case
-	b := strings.Builder{}
-	b.WriteString("{")
-	if c.ID != "" {
-		b.WriteString("\"id\":\"")
-		b.WriteString(c.ID)
-		b.WriteString("\",")
-	}
-	if c.Password != "" {
-		b.WriteString("\"password\":\"")
-		b.WriteString(c.Password)
-		b.WriteString("\",")
-	}
-	b.WriteString("\"email\":\"")
-	b.WriteString(c.Email)
-	b.WriteString("\",")
-	b.WriteString("\"enable\":")
-	if c.Enable {
-		b.WriteString("true")
-	} else {
-		b.WriteString("false")
-	}
-	b.WriteString(",")
-	b.WriteString("\"limitIp\":")
-	b.WriteString(strconv.Itoa(c.LimitIP))
-	b.WriteString(",")
-	b.WriteString("\"totalGB\":")
-	b.WriteString(strconv.FormatInt(c.TotalGB, 10))
-	if c.ExpiryTime > 0 {
-		b.WriteString(",\"expiryTime\":")
-		b.WriteString(strconv.FormatInt(c.ExpiryTime, 10))
-	}
-	b.WriteString("}")
-	return b.String()
-}

+ 19 - 0
web/job/node_traffic_sync_job.go

@@ -20,6 +20,8 @@ const (
 type NodeTrafficSyncJob struct {
 	nodeService    service.NodeService
 	inboundService service.InboundService
+	settingService service.SettingService
+	xrayService    service.XrayService
 	running        sync.Mutex
 	structural     atomicBool
 }
@@ -83,6 +85,22 @@ func (j *NodeTrafficSyncJob) Run() {
 	}
 	wg.Wait()
 
+	_, clientsDisabled, err := j.inboundService.AddTraffic(nil, nil)
+	if err != nil {
+		logger.Warning("node traffic sync: depletion check failed:", err)
+	}
+	if clientsDisabled {
+		if restartOnDisable, settingErr := j.settingService.GetRestartXrayOnClientDisable(); settingErr == nil && restartOnDisable {
+			if err := j.xrayService.RestartXray(true); err != nil {
+				logger.Warning("node traffic sync: restart xray after disabling clients failed:", err)
+				j.xrayService.SetToNeedRestart()
+			}
+		} else if settingErr != nil {
+			logger.Warning("node traffic sync: get RestartXrayOnClientDisable failed:", settingErr)
+		}
+		j.structural.set()
+	}
+
 	if !websocket.HasClients() {
 		return
 	}
@@ -123,6 +141,7 @@ func (j *NodeTrafficSyncJob) Run() {
 
 	if j.structural.takeAndReset() {
 		websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
+		websocket.BroadcastInvalidate(websocket.MessageTypeClients)
 	}
 }
 

+ 69 - 0
web/job/node_traffic_sync_job_test.go

@@ -0,0 +1,69 @@
+package job
+
+import (
+	"sync"
+	"testing"
+)
+
+func TestAtomicBool_DefaultIsFalse(t *testing.T) {
+	var a atomicBool
+	if a.takeAndReset() {
+		t.Fatal("default atomicBool should report false")
+	}
+}
+
+func TestAtomicBool_SetThenTakeReturnsTrueOnce(t *testing.T) {
+	var a atomicBool
+	a.set()
+	if !a.takeAndReset() {
+		t.Fatal("takeAndReset after set should return true")
+	}
+	if a.takeAndReset() {
+		t.Fatal("second takeAndReset should return false (state was reset)")
+	}
+}
+
+func TestAtomicBool_SetIsIdempotent(t *testing.T) {
+	var a atomicBool
+	a.set()
+	a.set()
+	a.set()
+	if !a.takeAndReset() {
+		t.Fatal("repeated set should still leave the flag true")
+	}
+	if a.takeAndReset() {
+		t.Fatal("flag should be cleared after the first take")
+	}
+}
+
+func TestAtomicBool_ConcurrentSettersExactlyOneTakeWins(t *testing.T) {
+	var a atomicBool
+	const setters = 100
+	const readers = 20
+
+	var wg sync.WaitGroup
+	for range setters {
+		wg.Go(func() {
+			a.set()
+		})
+	}
+	wg.Wait()
+
+	trueCount := 0
+	var rwg sync.WaitGroup
+	var mu sync.Mutex
+	for range readers {
+		rwg.Go(func() {
+			if a.takeAndReset() {
+				mu.Lock()
+				trueCount++
+				mu.Unlock()
+			}
+		})
+	}
+	rwg.Wait()
+
+	if trueCount != 1 {
+		t.Fatalf("expected exactly one reader to observe true, got %d", trueCount)
+	}
+}

+ 2 - 1
web/job/periodic_traffic_reset_job.go

@@ -11,6 +11,7 @@ type Period string
 // PeriodicTrafficResetJob resets traffic statistics for inbounds based on their configured reset period.
 type PeriodicTrafficResetJob struct {
 	inboundService service.InboundService
+	clientService  service.ClientService
 	period         Period
 }
 
@@ -42,7 +43,7 @@ func (j *PeriodicTrafficResetJob) Run() {
 			logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
 		}
 
-		resetClientErr := j.inboundService.ResetAllClientTraffics(inbound.Id)
+		resetClientErr := j.clientService.ResetAllClientTraffics(&j.inboundService, inbound.Id)
 		if resetClientErr != nil {
 			logger.Warning("Failed to reset traffic for all users of inbound", inbound.Id, ":", resetClientErr)
 		}

+ 49 - 4
web/runtime/local.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
+	"strings"
 	"sync"
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
@@ -78,6 +79,54 @@ func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) e
 	})
 }
 
+func (l *Local) AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error {
+	if !client.Enable {
+		return nil
+	}
+	user := map[string]any{
+		"email":    client.Email,
+		"id":       client.ID,
+		"security": client.Security,
+		"flow":     client.Flow,
+		"auth":     client.Auth,
+		"password": client.Password,
+	}
+	return l.AddUser(ctx, ib, user)
+}
+
+func (l *Local) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
+	if email == "" {
+		return nil
+	}
+	if err := l.RemoveUser(ctx, ib, email); err != nil {
+		if strings.Contains(err.Error(), "not found") {
+			return nil
+		}
+		return err
+	}
+	return nil
+}
+
+func (l *Local) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
+	if oldEmail != "" {
+		if err := l.RemoveUser(ctx, ib, oldEmail); err != nil && !strings.Contains(err.Error(), "not found") {
+			return err
+		}
+	}
+	if !payload.Enable {
+		return nil
+	}
+	user := map[string]any{
+		"email":    payload.Email,
+		"id":       payload.ID,
+		"security": payload.Security,
+		"flow":     payload.Flow,
+		"auth":     payload.Auth,
+		"password": payload.Password,
+	}
+	return l.AddUser(ctx, ib, user)
+}
+
 func (l *Local) RestartXray(_ context.Context) error {
 	if l.deps.SetNeedRestart != nil {
 		l.deps.SetNeedRestart()
@@ -89,10 +138,6 @@ func (l *Local) ResetClientTraffic(_ context.Context, _ *model.Inbound, _ string
 	return nil
 }
 
-func (l *Local) ResetInboundClientTraffics(_ context.Context, _ *model.Inbound) error {
-	return nil
-}
-
 func (l *Local) ResetAllTraffics(_ context.Context) error {
 	return nil
 }

+ 46 - 19
web/runtime/remote.go

@@ -257,31 +257,58 @@ func (r *Remote) RemoveUser(ctx context.Context, ib *model.Inbound, _ string) er
 	return r.UpdateInbound(ctx, ib, ib)
 }
 
-func (r *Remote) RestartXray(ctx context.Context) error {
-	_, err := r.do(ctx, http.MethodPost, "panel/api/server/restartXrayService", nil)
-	return err
-}
-
-func (r *Remote) ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error {
+func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error {
 	id, err := r.resolveRemoteID(ctx, ib.Tag)
 	if err != nil {
-		logger.Warning("remote ResetClientTraffic: tag", ib.Tag, "not found on", r.node.Name)
+		return fmt.Errorf("remote AddClient: resolve tag %q: %w", ib.Tag, err)
+	}
+	payload := map[string]any{
+		"client":     client,
+		"inboundIds": []int{id},
+	}
+	if _, err := r.do(ctx, http.MethodPost, "panel/api/clients/add", payload); err != nil {
+		return err
+	}
+	return nil
+}
+
+// DeleteUser is idempotent: master's per-inbound Delete loop may call it
+// multiple times for the same node, and "not found" on the follow-ups is
+// the expected success path.
+func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string) error {
+	if email == "" {
+		return nil
+	}
+	_, err := r.do(ctx, http.MethodPost,
+		"panel/api/clients/del/"+url.PathEscape(email), nil)
+	if err == nil {
+		return nil
+	}
+	if strings.Contains(strings.ToLower(err.Error()), "not found") {
 		return nil
 	}
-	_, err = r.do(ctx, http.MethodPost,
-		fmt.Sprintf("panel/api/inbounds/%d/resetClientTraffic/%s", id, url.PathEscape(email)),
-		nil)
 	return err
 }
 
-func (r *Remote) ResetInboundClientTraffics(ctx context.Context, ib *model.Inbound) error {
-	id, err := r.resolveRemoteID(ctx, ib.Tag)
-	if err != nil {
-		logger.Warning("remote ResetInboundClientTraffics: tag", ib.Tag, "not found on", r.node.Name)
-		return nil
+func (r *Remote) UpdateUser(ctx context.Context, _ *model.Inbound, oldEmail string, payload model.Client) error {
+	if oldEmail == "" {
+		oldEmail = payload.Email
 	}
-	_, err = r.do(ctx, http.MethodPost,
-		fmt.Sprintf("panel/api/inbounds/resetAllClientTraffics/%d", id), nil)
+	if _, err := r.do(ctx, http.MethodPost,
+		"panel/api/clients/update/"+url.PathEscape(oldEmail), payload); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (r *Remote) RestartXray(ctx context.Context) error {
+	_, err := r.do(ctx, http.MethodPost, "panel/api/server/restartXrayService", nil)
+	return err
+}
+
+func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
+	_, err := r.do(ctx, http.MethodPost,
+		"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
 	return err
 }
 
@@ -307,14 +334,14 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er
 		return nil, fmt.Errorf("decode inbound list: %w", err)
 	}
 
-	envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/onlines", nil)
+	envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
 	if err != nil {
 		logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
 	} else if len(envOnlines.Obj) > 0 {
 		_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
 	}
 
-	envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/lastOnline", nil)
+	envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil)
 	if err != nil {
 		logger.Warning("remote", r.node.Name, "lastOnline fetch failed:", err)
 	} else if len(envLastOnline.Obj) > 0 {

+ 3 - 3
web/runtime/remote_test.go

@@ -7,8 +7,8 @@ import (
 
 func TestSanitizeStreamSettingsForRemote(t *testing.T) {
 	tests := []struct {
-		name     string
-		input    string
+		name  string
+		input string
 		// wantCertFile / wantKeyFile: expected presence after sanitize
 		wantCertFile bool
 		wantKeyFile  bool
@@ -55,7 +55,7 @@ func TestSanitizeStreamSettingsForRemote(t *testing.T) {
 			wantKeyFile:  false,
 		},
 		{
-			name: "empty stream settings",
+			name:  "empty stream settings",
 			input: "",
 			// empty input returns empty, nothing to check
 		},

+ 7 - 1
web/runtime/runtime.go

@@ -16,9 +16,15 @@ type Runtime interface {
 	AddUser(ctx context.Context, ib *model.Inbound, userMap map[string]any) error
 	RemoveUser(ctx context.Context, ib *model.Inbound, email string) error
 
+	// Per-client operations that route through the node's clients API on
+	// Remote (instead of pushing the whole inbound) so the node applies
+	// per-user xray API calls without a DelInbound+AddInbound cycle.
+	UpdateUser(ctx context.Context, ib *model.Inbound, email string, payload model.Client) error
+	DeleteUser(ctx context.Context, ib *model.Inbound, email string) error
+	AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error
+
 	RestartXray(ctx context.Context) error
 
 	ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error
-	ResetInboundClientTraffics(ctx context.Context, ib *model.Inbound) error
 	ResetAllTraffics(ctx context.Context) error
 }

+ 1959 - 0
web/service/client.go

@@ -0,0 +1,1959 @@
+package service
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/random"
+	"github.com/mhsanaei/3x-ui/v3/xray"
+
+	"gorm.io/gorm"
+)
+
+type ClientWithAttachments struct {
+	model.ClientRecord
+	InboundIds []int               `json:"inboundIds"`
+	Traffic    *xray.ClientTraffic `json:"traffic,omitempty"`
+}
+
+// MarshalJSON is required because model.ClientRecord defines its own
+// MarshalJSON. Go promotes the embedded method to the outer struct, so without
+// this the encoder would call ClientRecord.MarshalJSON for the whole value and
+// silently drop InboundIds and Traffic from the API response.
+func (c ClientWithAttachments) MarshalJSON() ([]byte, error) {
+	rec, err := json.Marshal(c.ClientRecord)
+	if err != nil {
+		return nil, err
+	}
+	extras := struct {
+		InboundIds []int               `json:"inboundIds"`
+		Traffic    *xray.ClientTraffic `json:"traffic,omitempty"`
+	}{InboundIds: c.InboundIds, Traffic: c.Traffic}
+	extra, err := json.Marshal(extras)
+	if err != nil {
+		return nil, err
+	}
+	if len(rec) < 2 || rec[len(rec)-1] != '}' || len(extra) <= 2 {
+		return rec, nil
+	}
+	out := make([]byte, 0, len(rec)+len(extra))
+	out = append(out, rec[:len(rec)-1]...)
+	if len(rec) > 2 {
+		out = append(out, ',')
+	}
+	out = append(out, extra[1:]...)
+	return out, nil
+}
+
+func clientKeyForProtocol(p model.Protocol, rec *model.ClientRecord) string {
+	if rec == nil {
+		return ""
+	}
+	switch p {
+	case model.Trojan:
+		return rec.Password
+	case model.Shadowsocks:
+		return rec.Email
+	case model.Hysteria, model.Hysteria2:
+		return rec.Auth
+	default:
+		return rec.UUID
+	}
+}
+
+type ClientService struct{}
+
+// Short-lived tombstone of just-deleted client emails so that a node snapshot
+// arriving between delete and node-side processing doesn't resurrect them.
+var (
+	recentlyDeletedMu sync.Mutex
+	recentlyDeleted   = map[string]time.Time{}
+)
+
+const deleteTombstoneTTL = 90 * time.Second
+
+var (
+	inboundMutationLocksMu sync.Mutex
+	inboundMutationLocks   = map[int]*sync.Mutex{}
+)
+
+func lockInbound(inboundId int) *sync.Mutex {
+	inboundMutationLocksMu.Lock()
+	defer inboundMutationLocksMu.Unlock()
+	m, ok := inboundMutationLocks[inboundId]
+	if !ok {
+		m = &sync.Mutex{}
+		inboundMutationLocks[inboundId] = m
+	}
+	m.Lock()
+	return m
+}
+
+func compactOrphans(db *gorm.DB, clients []any) []any {
+	if len(clients) == 0 {
+		return clients
+	}
+	emails := make([]string, 0, len(clients))
+	for _, c := range clients {
+		cm, ok := c.(map[string]any)
+		if !ok {
+			continue
+		}
+		if e, _ := cm["email"].(string); e != "" {
+			emails = append(emails, e)
+		}
+	}
+	if len(emails) == 0 {
+		return clients
+	}
+	var existingEmails []string
+	if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails).Pluck("email", &existingEmails).Error; err != nil {
+		logger.Warning("compactOrphans pluck:", err)
+		return clients
+	}
+	if len(existingEmails) == len(emails) {
+		return clients
+	}
+	existing := make(map[string]struct{}, len(existingEmails))
+	for _, e := range existingEmails {
+		existing[e] = struct{}{}
+	}
+	out := make([]any, 0, len(existingEmails))
+	for _, c := range clients {
+		cm, ok := c.(map[string]any)
+		if !ok {
+			out = append(out, c)
+			continue
+		}
+		e, _ := cm["email"].(string)
+		if e == "" {
+			out = append(out, c)
+			continue
+		}
+		if _, ok := existing[e]; ok {
+			out = append(out, c)
+		}
+	}
+	return out
+}
+
+func tombstoneClientEmail(email string) {
+	if email == "" {
+		return
+	}
+	recentlyDeletedMu.Lock()
+	defer recentlyDeletedMu.Unlock()
+	recentlyDeleted[email] = time.Now()
+	cutoff := time.Now().Add(-deleteTombstoneTTL)
+	for e, ts := range recentlyDeleted {
+		if ts.Before(cutoff) {
+			delete(recentlyDeleted, e)
+		}
+	}
+}
+
+func isClientEmailTombstoned(email string) bool {
+	if email == "" {
+		return false
+	}
+	recentlyDeletedMu.Lock()
+	defer recentlyDeletedMu.Unlock()
+	ts, ok := recentlyDeleted[email]
+	if !ok {
+		return false
+	}
+	if time.Since(ts) > deleteTombstoneTTL {
+		delete(recentlyDeleted, email)
+		return false
+	}
+	return true
+}
+
+func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+
+	if err := tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error; err != nil {
+		return err
+	}
+
+	for i := range clients {
+		c := clients[i]
+		email := strings.TrimSpace(c.Email)
+		if email == "" {
+			continue
+		}
+
+		incoming := c.ToRecord()
+		row := &model.ClientRecord{}
+		err := tx.Where("email = ?", email).First(row).Error
+		if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+			return err
+		}
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			if isClientEmailTombstoned(email) {
+				continue
+			}
+			if err := tx.Create(incoming).Error; err != nil {
+				return err
+			}
+			row = incoming
+		} else {
+			row.UUID = incoming.UUID
+			row.Password = incoming.Password
+			row.Auth = incoming.Auth
+			row.Flow = incoming.Flow
+			row.Security = incoming.Security
+			row.Reverse = incoming.Reverse
+			row.SubID = incoming.SubID
+			row.LimitIP = incoming.LimitIP
+			row.TotalGB = incoming.TotalGB
+			row.ExpiryTime = incoming.ExpiryTime
+			row.Enable = incoming.Enable
+			row.TgID = incoming.TgID
+			row.Comment = incoming.Comment
+			row.Reset = incoming.Reset
+			if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
+				row.CreatedAt = incoming.CreatedAt
+			}
+			if incoming.UpdatedAt > row.UpdatedAt {
+				row.UpdatedAt = incoming.UpdatedAt
+			}
+			if err := tx.Save(row).Error; err != nil {
+				return err
+			}
+		}
+
+		link := model.ClientInbound{
+			ClientId:     row.Id,
+			InboundId:    inboundId,
+			FlowOverride: c.Flow,
+		}
+		if err := tx.Create(&link).Error; err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (s *ClientService) DetachInbound(tx *gorm.DB, inboundId int) error {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	return tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error
+}
+
+func (s *ClientService) ListForInbound(tx *gorm.DB, inboundId int) ([]model.Client, error) {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	type joinedRow struct {
+		model.ClientRecord
+		FlowOverride string
+	}
+	var rows []joinedRow
+	err := tx.Table("clients").
+		Select("clients.*, client_inbounds.flow_override AS flow_override").
+		Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
+		Where("client_inbounds.inbound_id = ?", inboundId).
+		Order("clients.id ASC").
+		Find(&rows).Error
+	if err != nil {
+		return nil, err
+	}
+
+	out := make([]model.Client, 0, len(rows))
+	for i := range rows {
+		c := rows[i].ToClient()
+		if rows[i].FlowOverride != "" {
+			c.Flow = rows[i].FlowOverride
+		}
+		out = append(out, *c)
+	}
+	return out, nil
+}
+
+func (s *ClientService) GetRecordByEmail(tx *gorm.DB, email string) (*model.ClientRecord, error) {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	row := &model.ClientRecord{}
+	err := tx.Where("email = ?", email).First(row).Error
+	if err != nil {
+		return nil, err
+	}
+	return row, nil
+}
+
+func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, error) {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	var ids []int
+	err := tx.Table("client_inbounds").
+		Select("client_inbounds.inbound_id").
+		Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+		Where("clients.email = ?", email).
+		Scan(&ids).Error
+	if err != nil {
+		return nil, err
+	}
+	return ids, nil
+}
+
+func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
+	row := &model.ClientRecord{}
+	if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
+		return nil, err
+	}
+	return row, nil
+}
+
+func (s *ClientService) GetInboundIdsForRecord(id int) ([]int, error) {
+	var ids []int
+	err := database.GetDB().Table("client_inbounds").
+		Where("client_id = ?", id).
+		Order("inbound_id ASC").
+		Pluck("inbound_id", &ids).Error
+	if err != nil {
+		return nil, err
+	}
+	return ids, nil
+}
+
+func (s *ClientService) List() ([]ClientWithAttachments, error) {
+	db := database.GetDB()
+	var rows []model.ClientRecord
+	if err := db.Order("id ASC").Find(&rows).Error; err != nil {
+		return nil, err
+	}
+	if len(rows) == 0 {
+		return []ClientWithAttachments{}, nil
+	}
+
+	clientIds := make([]int, 0, len(rows))
+	emails := make([]string, 0, len(rows))
+	for i := range rows {
+		clientIds = append(clientIds, rows[i].Id)
+		if rows[i].Email != "" {
+			emails = append(emails, rows[i].Email)
+		}
+	}
+
+	var links []model.ClientInbound
+	if err := db.Where("client_id IN ?", clientIds).Find(&links).Error; err != nil {
+		return nil, err
+	}
+	attachments := make(map[int][]int, len(rows))
+	for _, l := range links {
+		attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
+	}
+
+	trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
+	if len(emails) > 0 {
+		var stats []xray.ClientTraffic
+		if err := db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
+			return nil, err
+		}
+		for i := range stats {
+			trafficByEmail[stats[i].Email] = &stats[i]
+		}
+	}
+
+	out := make([]ClientWithAttachments, 0, len(rows))
+	for i := range rows {
+		out = append(out, ClientWithAttachments{
+			ClientRecord: rows[i],
+			InboundIds:   attachments[rows[i].Id],
+			Traffic:      trafficByEmail[rows[i].Email],
+		})
+	}
+	return out, nil
+}
+
+type ClientCreatePayload struct {
+	Client     model.Client `json:"client"`
+	InboundIds []int        `json:"inboundIds"`
+}
+
+func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
+	if payload == nil {
+		return false, common.NewError("empty payload")
+	}
+	client := payload.Client
+	if strings.TrimSpace(client.Email) == "" {
+		return false, common.NewError("client email is required")
+	}
+	if len(payload.InboundIds) == 0 {
+		return false, common.NewError("at least one inbound is required")
+	}
+
+	if client.SubID == "" {
+		client.SubID = uuid.NewString()
+	}
+	if !client.Enable {
+		client.Enable = true
+	}
+	now := time.Now().UnixMilli()
+	if client.CreatedAt == 0 {
+		client.CreatedAt = now
+	}
+	client.UpdatedAt = now
+
+	existing := &model.ClientRecord{}
+	err := database.GetDB().Where("email = ?", client.Email).First(existing).Error
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+		return false, err
+	}
+	emailTaken := !errors.Is(err, gorm.ErrRecordNotFound)
+	if emailTaken {
+		if existing.SubID == "" || existing.SubID != client.SubID {
+			return false, common.NewError("email already in use:", client.Email)
+		}
+	}
+
+	needRestart := false
+	for _, ibId := range payload.InboundIds {
+		inbound, getErr := inboundSvc.GetInbound(ibId)
+		if getErr != nil {
+			return needRestart, getErr
+		}
+		if err := s.fillProtocolDefaults(&client, inbound); err != nil {
+			return needRestart, err
+		}
+		settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {client}})
+		if mErr != nil {
+			return needRestart, mErr
+		}
+		nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
+			Id:       ibId,
+			Settings: string(settingsPayload),
+		})
+		if addErr != nil {
+			return needRestart, addErr
+		}
+		if nr {
+			needRestart = true
+		}
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error {
+	switch ib.Protocol {
+	case model.VMESS, model.VLESS:
+		if c.ID == "" {
+			c.ID = uuid.NewString()
+		}
+	case model.Trojan:
+		if c.Password == "" {
+			c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
+		}
+	case model.Shadowsocks:
+		method := shadowsocksMethodFromSettings(ib.Settings)
+		if c.Password == "" || !validShadowsocksClientKey(method, c.Password) {
+			c.Password = randomShadowsocksClientKey(method)
+		}
+	case model.Hysteria, model.Hysteria2:
+		if c.Auth == "" {
+			c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "")
+		}
+	}
+	return nil
+}
+
+// shadowsocksMethodFromSettings pulls the "method" field out of the inbound's
+// settings JSON. Returns "" when the field is missing or settings is invalid.
+func shadowsocksMethodFromSettings(settings string) string {
+	if settings == "" {
+		return ""
+	}
+	var m map[string]any
+	if err := json.Unmarshal([]byte(settings), &m); err != nil {
+		return ""
+	}
+	method, _ := m["method"].(string)
+	return method
+}
+
+// randomShadowsocksClientKey returns a per-client key sized to the cipher.
+// The 2022-blake3 ciphers require a base64-encoded key of an exact byte
+// length (16 bytes for aes-128-gcm, 32 bytes for aes-256-gcm and
+// chacha20-poly1305) — anything else fails with "bad key" on xray start.
+// Older ciphers accept arbitrary passwords, so we keep the uuid-style.
+func randomShadowsocksClientKey(method string) string {
+	if n := shadowsocksKeyBytes(method); n > 0 {
+		return random.Base64Bytes(n)
+	}
+	return strings.ReplaceAll(uuid.NewString(), "-", "")
+}
+
+// validShadowsocksClientKey reports whether key is acceptable for the cipher.
+// For 2022-blake3 it must decode to the exact byte length the cipher needs;
+// any other method accepts any non-empty string.
+func validShadowsocksClientKey(method, key string) bool {
+	n := shadowsocksKeyBytes(method)
+	if n == 0 {
+		return key != ""
+	}
+	decoded, err := base64.StdEncoding.DecodeString(key)
+	if err != nil {
+		return false
+	}
+	return len(decoded) == n
+}
+
+func shadowsocksKeyBytes(method string) int {
+	switch method {
+	case "2022-blake3-aes-128-gcm":
+		return 16
+	case "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305":
+		return 32
+	}
+	return 0
+}
+
+// applyShadowsocksClientMethod ensures each client entry carries a "method"
+// field for legacy shadowsocks ciphers. xray's multi-user shadowsocks code
+// requires a per-client method; an empty/missing field fails with
+// "unsupported cipher method:". 2022-blake3 ciphers use the top-level
+// method only, so the per-client field must stay absent.
+func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
+	method, _ := settings["method"].(string)
+	if method == "" || strings.HasPrefix(method, "2022-blake3-") {
+		return
+	}
+	for i := range clients {
+		cm, ok := clients[i].(map[string]any)
+		if !ok {
+			continue
+		}
+		if existing, _ := cm["method"].(string); existing != "" {
+			continue
+		}
+		cm["method"] = method
+		clients[i] = cm
+	}
+}
+
+func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) {
+	existing, err := s.GetByID(id)
+	if err != nil {
+		return false, err
+	}
+	inboundIds, err := s.GetInboundIdsForRecord(id)
+	if err != nil {
+		return false, err
+	}
+
+	if strings.TrimSpace(updated.Email) == "" {
+		return false, common.NewError("client email is required")
+	}
+	if updated.SubID == "" {
+		updated.SubID = existing.SubID
+	}
+	if updated.SubID == "" {
+		updated.SubID = uuid.NewString()
+	}
+	updated.UpdatedAt = time.Now().UnixMilli()
+	if updated.CreatedAt == 0 {
+		updated.CreatedAt = existing.CreatedAt
+	}
+
+	needRestart := false
+	for _, ibId := range inboundIds {
+		inbound, getErr := inboundSvc.GetInbound(ibId)
+		if getErr != nil {
+			return needRestart, getErr
+		}
+		oldKey := clientKeyForProtocol(inbound.Protocol, existing)
+		if oldKey == "" {
+			continue
+		}
+		if err := s.fillProtocolDefaults(&updated, inbound); err != nil {
+			return needRestart, err
+		}
+		settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {updated}})
+		if mErr != nil {
+			return needRestart, mErr
+		}
+		nr, upErr := s.UpdateInboundClient(inboundSvc, &model.Inbound{
+			Id:       ibId,
+			Settings: string(settingsPayload),
+		}, oldKey)
+		if upErr != nil {
+			return needRestart, upErr
+		}
+		if nr {
+			needRestart = true
+		}
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic bool) (bool, error) {
+	existing, err := s.GetByID(id)
+	if err != nil {
+		return false, err
+	}
+	tombstoneClientEmail(existing.Email)
+
+	inboundIds, err := s.GetInboundIdsForRecord(id)
+	if err != nil {
+		return false, err
+	}
+
+	needRestart := false
+	for _, ibId := range inboundIds {
+		inbound, getErr := inboundSvc.GetInbound(ibId)
+		if getErr != nil {
+			return needRestart, getErr
+		}
+		key := clientKeyForProtocol(inbound.Protocol, existing)
+		if key == "" {
+			continue
+		}
+		nr, delErr := s.DelInboundClient(inboundSvc, ibId, key)
+		if delErr != nil {
+			return needRestart, delErr
+		}
+		if nr {
+			needRestart = true
+		}
+	}
+
+	db := database.GetDB()
+	if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
+		return needRestart, err
+	}
+	if !keepTraffic && existing.Email != "" {
+		if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
+			return needRestart, err
+		}
+		if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
+			return needRestart, err
+		}
+	}
+	if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil {
+		return needRestart, err
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
+	existing, err := s.GetByID(id)
+	if err != nil {
+		return false, err
+	}
+	currentIds, err := s.GetInboundIdsForRecord(id)
+	if err != nil {
+		return false, err
+	}
+	have := make(map[int]struct{}, len(currentIds))
+	for _, x := range currentIds {
+		have[x] = struct{}{}
+	}
+
+	clientWire := existing.ToClient()
+	clientWire.UpdatedAt = time.Now().UnixMilli()
+
+	needRestart := false
+	for _, ibId := range inboundIds {
+		if _, attached := have[ibId]; attached {
+			continue
+		}
+		inbound, getErr := inboundSvc.GetInbound(ibId)
+		if getErr != nil {
+			return needRestart, getErr
+		}
+		copyClient := *clientWire
+		if err := s.fillProtocolDefaults(&copyClient, inbound); err != nil {
+			return needRestart, err
+		}
+		settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {copyClient}})
+		if mErr != nil {
+			return needRestart, mErr
+		}
+		nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
+			Id:       ibId,
+			Settings: string(settingsPayload),
+		})
+		if addErr != nil {
+			return needRestart, addErr
+		}
+		if nr {
+			needRestart = true
+		}
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) CreateOne(inboundSvc *InboundService, inboundId int, client model.Client) (bool, error) {
+	return s.Create(inboundSvc, &ClientCreatePayload{
+		Client:     client,
+		InboundIds: []int{inboundId},
+	})
+}
+
+func (s *ClientService) DetachByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) {
+	if email == "" {
+		return false, common.NewError("client email is required")
+	}
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return false, err
+	}
+	return s.Detach(inboundSvc, rec.Id, []int{inboundId})
+}
+
+func (s *ClientService) AttachByEmail(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
+	if email == "" {
+		return false, common.NewError("client email is required")
+	}
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return false, err
+	}
+	return s.Attach(inboundSvc, rec.Id, inboundIds)
+}
+
+func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
+	if email == "" {
+		return false, common.NewError("client email is required")
+	}
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return false, err
+	}
+	return s.Detach(inboundSvc, rec.Id, inboundIds)
+}
+
+func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string, keepTraffic bool) (bool, error) {
+	if email == "" {
+		return false, common.NewError("client email is required")
+	}
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return false, err
+	}
+	return s.Delete(inboundSvc, rec.Id, keepTraffic)
+}
+
+func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client) (bool, error) {
+	if email == "" {
+		return false, common.NewError("client email is required")
+	}
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return false, err
+	}
+	return s.Update(inboundSvc, rec.Id, updated)
+}
+
+func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) {
+	if email == "" {
+		return false, common.NewError("client email is required")
+	}
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return false, err
+	}
+	inboundIds, err := s.GetInboundIdsForRecord(rec.Id)
+	if err != nil {
+		return false, err
+	}
+	if len(inboundIds) == 0 {
+		if rErr := inboundSvc.ResetClientTrafficByEmail(email); rErr != nil {
+			return false, rErr
+		}
+		return false, nil
+	}
+	needRestart := false
+	for _, ibId := range inboundIds {
+		nr, rErr := inboundSvc.ResetClientTraffic(ibId, email)
+		if rErr != nil {
+			return needRestart, rErr
+		}
+		if nr {
+			needRestart = true
+		}
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, error) {
+	db := database.GetDB()
+	now := time.Now().UnixMilli()
+	depletedClause := "reset = 0 and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))"
+
+	var rows []xray.ClientTraffic
+	if err := db.Where(depletedClause, now).Find(&rows).Error; err != nil {
+		return 0, false, err
+	}
+	if len(rows) == 0 {
+		return 0, false, nil
+	}
+
+	emails := make(map[string]struct{}, len(rows))
+	for _, r := range rows {
+		if r.Email != "" {
+			emails[r.Email] = struct{}{}
+		}
+	}
+
+	needRestart := false
+	deleted := 0
+	for email := range emails {
+		var rec model.ClientRecord
+		if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				continue
+			}
+			return deleted, needRestart, err
+		}
+		nr, err := s.Delete(inboundSvc, rec.Id, false)
+		if err != nil {
+			return deleted, needRestart, err
+		}
+		if nr {
+			needRestart = true
+		}
+		deleted++
+	}
+	return deleted, needRestart, nil
+}
+
+func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) error {
+	return submitTrafficWrite(func() error {
+		return s.resetAllClientTrafficsLocked(id)
+	})
+}
+
+func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
+	db := database.GetDB()
+	now := time.Now().Unix() * 1000
+
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		whereText := "inbound_id "
+		if id == -1 {
+			whereText += " > ?"
+		} else {
+			whereText += " = ?"
+		}
+
+		result := tx.Model(xray.ClientTraffic{}).
+			Where(whereText, id).
+			Updates(map[string]any{"enable": true, "up": 0, "down": 0})
+
+		if result.Error != nil {
+			return result.Error
+		}
+
+		inboundWhereText := "id "
+		if id == -1 {
+			inboundWhereText += " > ?"
+		} else {
+			inboundWhereText += " = ?"
+		}
+
+		result = tx.Model(model.Inbound{}).
+			Where(inboundWhereText, id).
+			Update("last_traffic_reset_time", now)
+
+		return result.Error
+	}); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (s *ClientService) ResetAllTraffics() (bool, error) {
+	res := database.GetDB().Model(&xray.ClientTraffic{}).
+		Where("1 = 1").
+		Updates(map[string]any{"up": 0, "down": 0})
+	if res.Error != nil {
+		return false, res.Error
+	}
+	return res.RowsAffected > 0, nil
+}
+
+func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
+	existing, err := s.GetByID(id)
+	if err != nil {
+		return false, err
+	}
+	currentIds, err := s.GetInboundIdsForRecord(id)
+	if err != nil {
+		return false, err
+	}
+	have := make(map[int]struct{}, len(currentIds))
+	for _, x := range currentIds {
+		have[x] = struct{}{}
+	}
+
+	needRestart := false
+	for _, ibId := range inboundIds {
+		if _, attached := have[ibId]; !attached {
+			continue
+		}
+		inbound, getErr := inboundSvc.GetInbound(ibId)
+		if getErr != nil {
+			return needRestart, getErr
+		}
+		key := clientKeyForProtocol(inbound.Protocol, existing)
+		if key == "" {
+			continue
+		}
+		nr, delErr := s.DelInboundClient(inboundSvc, ibId, key)
+		if delErr != nil {
+			return needRestart, delErr
+		}
+		if nr {
+			needRestart = true
+		}
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, clients []model.Client) (string, error) {
+	emailSubIDs, err := inboundSvc.getAllEmailSubIDs()
+	if err != nil {
+		return "", err
+	}
+	seen := make(map[string]string, len(clients))
+	for _, client := range clients {
+		if client.Email == "" {
+			continue
+		}
+		key := strings.ToLower(client.Email)
+		if prev, ok := seen[key]; ok {
+			if prev != client.SubID || client.SubID == "" {
+				return client.Email, nil
+			}
+			continue
+		}
+		seen[key] = client.SubID
+		if existingSub, ok := emailSubIDs[key]; ok {
+			if client.SubID == "" || existingSub == "" || existingSub != client.SubID {
+				return client.Email, nil
+			}
+		}
+	}
+	return "", nil
+}
+
+func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model.Inbound) (bool, error) {
+	defer lockInbound(data.Id).Unlock()
+
+	clients, err := inboundSvc.GetClients(data)
+	if err != nil {
+		return false, err
+	}
+
+	var settings map[string]any
+	err = json.Unmarshal([]byte(data.Settings), &settings)
+	if err != nil {
+		return false, err
+	}
+
+	interfaceClients := settings["clients"].([]any)
+	nowTs := time.Now().Unix() * 1000
+	for i := range interfaceClients {
+		if cm, ok := interfaceClients[i].(map[string]any); ok {
+			if _, ok2 := cm["created_at"]; !ok2 {
+				cm["created_at"] = nowTs
+			}
+			cm["updated_at"] = nowTs
+			interfaceClients[i] = cm
+		}
+	}
+	existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients)
+	if err != nil {
+		return false, err
+	}
+	if existEmail != "" {
+		return false, common.NewError("Duplicate email:", existEmail)
+	}
+
+	oldInbound, err := inboundSvc.GetInbound(data.Id)
+	if err != nil {
+		return false, err
+	}
+
+	for _, client := range clients {
+		if strings.TrimSpace(client.Email) == "" {
+			return false, common.NewError("client email is required")
+		}
+		switch oldInbound.Protocol {
+		case "trojan":
+			if client.Password == "" {
+				return false, common.NewError("empty client ID")
+			}
+		case "shadowsocks":
+			if client.Email == "" {
+				return false, common.NewError("empty client ID")
+			}
+		case "hysteria", "hysteria2":
+			if client.Auth == "" {
+				return false, common.NewError("empty client ID")
+			}
+		default:
+			if client.ID == "" {
+				return false, common.NewError("empty client ID")
+			}
+		}
+	}
+
+	var oldSettings map[string]any
+	err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
+	if err != nil {
+		return false, err
+	}
+
+	if oldInbound.Protocol == model.Shadowsocks {
+		applyShadowsocksClientMethod(interfaceClients, oldSettings)
+	}
+
+	oldClients := oldSettings["clients"].([]any)
+	oldClients = compactOrphans(database.GetDB(), oldClients)
+	oldClients = append(oldClients, interfaceClients...)
+
+	oldSettings["clients"] = oldClients
+
+	newSettings, err := json.MarshalIndent(oldSettings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+
+	oldInbound.Settings = string(newSettings)
+
+	db := database.GetDB()
+	tx := db.Begin()
+
+	defer func() {
+		if err != nil {
+			tx.Rollback()
+		} else {
+			tx.Commit()
+		}
+	}()
+
+	needRestart := false
+	rt, rterr := inboundSvc.runtimeFor(oldInbound)
+	if rterr != nil {
+		if oldInbound.NodeID != nil {
+			err = rterr
+			return false, err
+		}
+		needRestart = true
+	} else if oldInbound.NodeID == nil {
+		for _, client := range clients {
+			if len(client.Email) == 0 {
+				needRestart = true
+				continue
+			}
+			inboundSvc.AddClientStat(tx, data.Id, &client)
+			if !client.Enable {
+				continue
+			}
+			cipher := ""
+			if oldInbound.Protocol == "shadowsocks" {
+				cipher = oldSettings["method"].(string)
+			}
+			err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
+				"email":    client.Email,
+				"id":       client.ID,
+				"auth":     client.Auth,
+				"security": client.Security,
+				"flow":     client.Flow,
+				"password": client.Password,
+				"cipher":   cipher,
+			})
+			if err1 == nil {
+				logger.Debug("Client added on", rt.Name(), ":", client.Email)
+			} else {
+				logger.Debug("Error in adding client on", rt.Name(), ":", err1)
+				needRestart = true
+			}
+		}
+	} else {
+		for _, client := range clients {
+			if len(client.Email) > 0 {
+				inboundSvc.AddClientStat(tx, data.Id, &client)
+			}
+			if err1 := rt.AddClient(context.Background(), oldInbound, client); err1 != nil {
+				err = err1
+				return false, err
+			}
+		}
+	}
+
+	if err = tx.Save(oldInbound).Error; err != nil {
+		return false, err
+	}
+	finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+	if gcErr != nil {
+		err = gcErr
+		return false, err
+	}
+	if err = s.SyncInbound(tx, oldInbound.Id, finalClients); err != nil {
+		return false, err
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *model.Inbound, clientId string) (bool, error) {
+	defer lockInbound(data.Id).Unlock()
+
+	clients, err := inboundSvc.GetClients(data)
+	if err != nil {
+		return false, err
+	}
+
+	var settings map[string]any
+	err = json.Unmarshal([]byte(data.Settings), &settings)
+	if err != nil {
+		return false, err
+	}
+
+	interfaceClients := settings["clients"].([]any)
+
+	oldInbound, err := inboundSvc.GetInbound(data.Id)
+	if err != nil {
+		return false, err
+	}
+
+	oldClients, err := inboundSvc.GetClients(oldInbound)
+	if err != nil {
+		return false, err
+	}
+
+	oldEmail := ""
+	newClientId := ""
+	clientIndex := -1
+	for index, oldClient := range oldClients {
+		oldClientId := ""
+		switch oldInbound.Protocol {
+		case "trojan":
+			oldClientId = oldClient.Password
+			newClientId = clients[0].Password
+		case "shadowsocks":
+			oldClientId = oldClient.Email
+			newClientId = clients[0].Email
+		case "hysteria", "hysteria2":
+			oldClientId = oldClient.Auth
+			newClientId = clients[0].Auth
+		default:
+			oldClientId = oldClient.ID
+			newClientId = clients[0].ID
+		}
+		if clientId == oldClientId {
+			oldEmail = oldClient.Email
+			clientIndex = index
+			break
+		}
+	}
+
+	if newClientId == "" || clientIndex == -1 {
+		return false, common.NewError("empty client ID")
+	}
+	if strings.TrimSpace(clients[0].Email) == "" {
+		return false, common.NewError("client email is required")
+	}
+
+	if clients[0].Email != oldEmail {
+		existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients)
+		if err != nil {
+			return false, err
+		}
+		if existEmail != "" {
+			return false, common.NewError("Duplicate email:", existEmail)
+		}
+	}
+
+	var oldSettings map[string]any
+	err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
+	if err != nil {
+		return false, err
+	}
+	settingsClients := oldSettings["clients"].([]any)
+	var preservedCreated any
+	if clientIndex >= 0 && clientIndex < len(settingsClients) {
+		if oldMap, ok := settingsClients[clientIndex].(map[string]any); ok {
+			if v, ok2 := oldMap["created_at"]; ok2 {
+				preservedCreated = v
+			}
+		}
+	}
+	if len(interfaceClients) > 0 {
+		if newMap, ok := interfaceClients[0].(map[string]any); ok {
+			if preservedCreated == nil {
+				preservedCreated = time.Now().Unix() * 1000
+			}
+			newMap["created_at"] = preservedCreated
+			newMap["updated_at"] = time.Now().Unix() * 1000
+			interfaceClients[0] = newMap
+		}
+	}
+	if oldInbound.Protocol == model.Shadowsocks {
+		applyShadowsocksClientMethod(interfaceClients, oldSettings)
+	}
+	settingsClients[clientIndex] = interfaceClients[0]
+	oldSettings["clients"] = settingsClients
+
+	if oldInbound.Protocol == model.VLESS {
+		hasVisionFlow := false
+		for _, c := range settingsClients {
+			cm, ok := c.(map[string]any)
+			if !ok {
+				continue
+			}
+			if flow, _ := cm["flow"].(string); flow == "xtls-rprx-vision" {
+				hasVisionFlow = true
+				break
+			}
+		}
+		if !hasVisionFlow {
+			delete(oldSettings, "testseed")
+		}
+	}
+
+	newSettings, err := json.MarshalIndent(oldSettings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+
+	oldInbound.Settings = string(newSettings)
+	db := database.GetDB()
+	tx := db.Begin()
+
+	defer func() {
+		if err != nil {
+			tx.Rollback()
+		} else {
+			tx.Commit()
+		}
+	}()
+
+	if len(clients[0].Email) > 0 {
+		if len(oldEmail) > 0 {
+			emailUnchanged := strings.EqualFold(oldEmail, clients[0].Email)
+			targetExists := int64(0)
+			if !emailUnchanged {
+				if err = tx.Model(xray.ClientTraffic{}).Where("email = ?", clients[0].Email).Count(&targetExists).Error; err != nil {
+					return false, err
+				}
+			}
+			if emailUnchanged || targetExists == 0 {
+				err = inboundSvc.UpdateClientStat(tx, oldEmail, &clients[0])
+				if err != nil {
+					return false, err
+				}
+				err = inboundSvc.UpdateClientIPs(tx, oldEmail, clients[0].Email)
+				if err != nil {
+					return false, err
+				}
+			} else {
+				stillUsed, sErr := inboundSvc.emailUsedByOtherInbounds(oldEmail, data.Id)
+				if sErr != nil {
+					return false, sErr
+				}
+				if !stillUsed {
+					if err = inboundSvc.DelClientStat(tx, oldEmail); err != nil {
+						return false, err
+					}
+					if err = inboundSvc.DelClientIPs(tx, oldEmail); err != nil {
+						return false, err
+					}
+				}
+				if err = inboundSvc.UpdateClientStat(tx, clients[0].Email, &clients[0]); err != nil {
+					return false, err
+				}
+			}
+		} else {
+			inboundSvc.AddClientStat(tx, data.Id, &clients[0])
+		}
+	} else {
+		stillUsed, err := inboundSvc.emailUsedByOtherInbounds(oldEmail, data.Id)
+		if err != nil {
+			return false, err
+		}
+		if !stillUsed {
+			err = inboundSvc.DelClientStat(tx, oldEmail)
+			if err != nil {
+				return false, err
+			}
+			err = inboundSvc.DelClientIPs(tx, oldEmail)
+			if err != nil {
+				return false, err
+			}
+		}
+	}
+	needRestart := false
+	if len(oldEmail) > 0 {
+		rt, rterr := inboundSvc.runtimeFor(oldInbound)
+		if rterr != nil {
+			if oldInbound.NodeID != nil {
+				err = rterr
+				return false, err
+			}
+			needRestart = true
+		} else if oldInbound.NodeID == nil {
+			if oldClients[clientIndex].Enable {
+				err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
+				if err1 == nil {
+					logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail)
+				} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) {
+					logger.Debug("User is already deleted. Nothing to do more...")
+				} else {
+					logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
+					needRestart = true
+				}
+			}
+			if clients[0].Enable {
+				cipher := ""
+				if oldInbound.Protocol == "shadowsocks" {
+					cipher = oldSettings["method"].(string)
+				}
+				err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
+					"email":    clients[0].Email,
+					"id":       clients[0].ID,
+					"security": clients[0].Security,
+					"flow":     clients[0].Flow,
+					"auth":     clients[0].Auth,
+					"password": clients[0].Password,
+					"cipher":   cipher,
+				})
+				if err1 == nil {
+					logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
+				} else {
+					logger.Debug("Error in adding client on", rt.Name(), ":", err1)
+					needRestart = true
+				}
+			}
+		} else {
+			if err1 := rt.UpdateUser(context.Background(), oldInbound, oldEmail, clients[0]); err1 != nil {
+				err = err1
+				return false, err
+			}
+		}
+	} else {
+		logger.Debug("Client old email not found")
+		needRestart = true
+	}
+	if err = tx.Save(oldInbound).Error; err != nil {
+		return false, err
+	}
+	finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+	if gcErr != nil {
+		err = gcErr
+		return false, err
+	}
+	if err = s.SyncInbound(tx, oldInbound.Id, finalClients); err != nil {
+		return false, err
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId int, clientId string) (bool, error) {
+	defer lockInbound(inboundId).Unlock()
+
+	oldInbound, err := inboundSvc.GetInbound(inboundId)
+	if err != nil {
+		logger.Error("Load Old Data Error")
+		return false, err
+	}
+	var settings map[string]any
+	err = json.Unmarshal([]byte(oldInbound.Settings), &settings)
+	if err != nil {
+		return false, err
+	}
+
+	email := ""
+	client_key := "id"
+	switch oldInbound.Protocol {
+	case "trojan":
+		client_key = "password"
+	case "shadowsocks":
+		client_key = "email"
+	case "hysteria", "hysteria2":
+		client_key = "auth"
+	}
+
+	interfaceClients := settings["clients"].([]any)
+	var newClients []any
+	needApiDel := false
+	clientFound := false
+	for _, client := range interfaceClients {
+		c := client.(map[string]any)
+		c_id := c[client_key].(string)
+		if c_id == clientId {
+			clientFound = true
+			email, _ = c["email"].(string)
+			needApiDel, _ = c["enable"].(bool)
+		} else {
+			newClients = append(newClients, client)
+		}
+	}
+
+	if !clientFound {
+		return false, common.NewError("Client Not Found In Inbound For ID:", clientId)
+	}
+
+	db := database.GetDB()
+	newClients = compactOrphans(db, newClients)
+	if newClients == nil {
+		newClients = []any{}
+	}
+	settings["clients"] = newClients
+	newSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+
+	oldInbound.Settings = string(newSettings)
+
+	emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
+	if err != nil {
+		return false, err
+	}
+
+	if !emailShared {
+		err = inboundSvc.DelClientIPs(db, email)
+		if err != nil {
+			logger.Error("Error in delete client IPs")
+			return false, err
+		}
+	}
+	needRestart := false
+
+	if len(email) > 0 {
+		var enables []bool
+		err = db.Model(xray.ClientTraffic{}).Where("email = ?", email).Limit(1).Pluck("enable", &enables).Error
+		if err != nil {
+			logger.Error("Get stats error")
+			return false, err
+		}
+		notDepleted := len(enables) > 0 && enables[0]
+		if !emailShared {
+			err = inboundSvc.DelClientStat(db, email)
+			if err != nil {
+				logger.Error("Delete stats Data Error")
+				return false, err
+			}
+		}
+		if needApiDel && notDepleted && oldInbound.NodeID == nil {
+			rt, rterr := inboundSvc.runtimeFor(oldInbound)
+			if rterr != nil {
+				needRestart = true
+			} else {
+				err1 := rt.RemoveUser(context.Background(), oldInbound, email)
+				if err1 == nil {
+					logger.Debug("Client deleted on", rt.Name(), ":", email)
+					needRestart = false
+				} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
+					logger.Debug("User is already deleted. Nothing to do more...")
+				} else {
+					logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
+					needRestart = true
+				}
+			}
+		}
+	}
+	if oldInbound.NodeID != nil && len(email) > 0 {
+		rt, rterr := inboundSvc.runtimeFor(oldInbound)
+		if rterr != nil {
+			return false, rterr
+		}
+		if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
+			return false, err1
+		}
+	}
+	if err := db.Save(oldInbound).Error; err != nil {
+		return false, err
+	}
+	finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+	if gcErr != nil {
+		return false, gcErr
+	}
+	if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
+		return false, err
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) {
+	defer lockInbound(inboundId).Unlock()
+
+	oldInbound, err := inboundSvc.GetInbound(inboundId)
+	if err != nil {
+		logger.Error("Load Old Data Error")
+		return false, err
+	}
+
+	var settings map[string]any
+	if err := json.Unmarshal([]byte(oldInbound.Settings), &settings); err != nil {
+		return false, err
+	}
+
+	interfaceClients, ok := settings["clients"].([]any)
+	if !ok {
+		return false, common.NewError("invalid clients format in inbound settings")
+	}
+
+	var newClients []any
+	needApiDel := false
+	found := false
+
+	for _, client := range interfaceClients {
+		c, ok := client.(map[string]any)
+		if !ok {
+			continue
+		}
+		if cEmail, ok := c["email"].(string); ok && cEmail == email {
+			found = true
+			needApiDel, _ = c["enable"].(bool)
+		} else {
+			newClients = append(newClients, client)
+		}
+	}
+
+	if !found {
+		return false, common.NewError(fmt.Sprintf("client with email %s not found", email))
+	}
+	db := database.GetDB()
+	newClients = compactOrphans(db, newClients)
+	if newClients == nil {
+		newClients = []any{}
+	}
+	settings["clients"] = newClients
+	newSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+
+	oldInbound.Settings = string(newSettings)
+
+	emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
+	if err != nil {
+		return false, err
+	}
+
+	if !emailShared {
+		if err := inboundSvc.DelClientIPs(db, email); err != nil {
+			logger.Error("Error in delete client IPs")
+			return false, err
+		}
+	}
+
+	needRestart := false
+
+	if len(email) > 0 && !emailShared {
+		traffic, err := inboundSvc.GetClientTrafficByEmail(email)
+		if err != nil {
+			return false, err
+		}
+		if traffic != nil {
+			if err := inboundSvc.DelClientStat(db, email); err != nil {
+				logger.Error("Delete stats Data Error")
+				return false, err
+			}
+		}
+
+		if needApiDel {
+			rt, rterr := inboundSvc.runtimeFor(oldInbound)
+			if rterr != nil {
+				if oldInbound.NodeID != nil {
+					return false, rterr
+				}
+				needRestart = true
+			} else if oldInbound.NodeID == nil {
+				if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
+					logger.Debug("Client deleted on", rt.Name(), ":", email)
+					needRestart = false
+				} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
+					logger.Debug("User is already deleted. Nothing to do more...")
+				} else {
+					logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
+					needRestart = true
+				}
+			} else {
+				if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
+					return false, err1
+				}
+			}
+		}
+	}
+
+	if err := db.Save(oldInbound).Error; err != nil {
+		return false, err
+	}
+	finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+	if gcErr != nil {
+		return false, gcErr
+	}
+	if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
+		return false, err
+	}
+	return needRestart, nil
+}
+
+func (s *ClientService) SetClientTelegramUserID(inboundSvc *InboundService, trafficId int, tgId int64) (bool, error) {
+	traffic, inbound, err := inboundSvc.GetClientInboundByTrafficID(trafficId)
+	if err != nil {
+		return false, err
+	}
+	if inbound == nil {
+		return false, common.NewError("Inbound Not Found For Traffic ID:", trafficId)
+	}
+
+	clientEmail := traffic.Email
+
+	oldClients, err := inboundSvc.GetClients(inbound)
+	if err != nil {
+		return false, err
+	}
+
+	clientId := ""
+
+	for _, oldClient := range oldClients {
+		if oldClient.Email == clientEmail {
+			switch inbound.Protocol {
+			case "trojan":
+				clientId = oldClient.Password
+			case "shadowsocks":
+				clientId = oldClient.Email
+			default:
+				clientId = oldClient.ID
+			}
+			break
+		}
+	}
+
+	if len(clientId) == 0 {
+		return false, common.NewError("Client Not Found For Email:", clientEmail)
+	}
+
+	var settings map[string]any
+	err = json.Unmarshal([]byte(inbound.Settings), &settings)
+	if err != nil {
+		return false, err
+	}
+	clients := settings["clients"].([]any)
+	var newClients []any
+	for client_index := range clients {
+		c := clients[client_index].(map[string]any)
+		if c["email"] == clientEmail {
+			c["tgId"] = tgId
+			c["updated_at"] = time.Now().Unix() * 1000
+			newClients = append(newClients, any(c))
+		}
+	}
+	settings["clients"] = newClients
+	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+	inbound.Settings = string(modifiedSettings)
+	needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId)
+	return needRestart, err
+}
+
+func (s *ClientService) checkIsEnabledByEmail(inboundSvc *InboundService, clientEmail string) (bool, error) {
+	_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
+	if err != nil {
+		return false, err
+	}
+	if inbound == nil {
+		return false, common.NewError("Inbound Not Found For Email:", clientEmail)
+	}
+
+	clients, err := inboundSvc.GetClients(inbound)
+	if err != nil {
+		return false, err
+	}
+
+	isEnable := false
+
+	for _, client := range clients {
+		if client.Email == clientEmail {
+			isEnable = client.Enable
+			break
+		}
+	}
+
+	return isEnable, err
+}
+
+func (s *ClientService) ToggleClientEnableByEmail(inboundSvc *InboundService, clientEmail string) (bool, bool, error) {
+	_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
+	if err != nil {
+		return false, false, err
+	}
+	if inbound == nil {
+		return false, false, common.NewError("Inbound Not Found For Email:", clientEmail)
+	}
+
+	oldClients, err := inboundSvc.GetClients(inbound)
+	if err != nil {
+		return false, false, err
+	}
+
+	clientId := ""
+	clientOldEnabled := false
+
+	for _, oldClient := range oldClients {
+		if oldClient.Email == clientEmail {
+			switch inbound.Protocol {
+			case "trojan":
+				clientId = oldClient.Password
+			case "shadowsocks":
+				clientId = oldClient.Email
+			default:
+				clientId = oldClient.ID
+			}
+			clientOldEnabled = oldClient.Enable
+			break
+		}
+	}
+
+	if len(clientId) == 0 {
+		return false, false, common.NewError("Client Not Found For Email:", clientEmail)
+	}
+
+	var settings map[string]any
+	err = json.Unmarshal([]byte(inbound.Settings), &settings)
+	if err != nil {
+		return false, false, err
+	}
+	clients := settings["clients"].([]any)
+	var newClients []any
+	for client_index := range clients {
+		c := clients[client_index].(map[string]any)
+		if c["email"] == clientEmail {
+			c["enable"] = !clientOldEnabled
+			c["updated_at"] = time.Now().Unix() * 1000
+			newClients = append(newClients, any(c))
+		}
+	}
+	settings["clients"] = newClients
+	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		return false, false, err
+	}
+	inbound.Settings = string(modifiedSettings)
+
+	needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId)
+	if err != nil {
+		return false, needRestart, err
+	}
+
+	return !clientOldEnabled, needRestart, nil
+}
+
+func (s *ClientService) SetClientEnableByEmail(inboundSvc *InboundService, clientEmail string, enable bool) (bool, bool, error) {
+	current, err := s.checkIsEnabledByEmail(inboundSvc, clientEmail)
+	if err != nil {
+		return false, false, err
+	}
+	if current == enable {
+		return false, false, nil
+	}
+	newEnabled, needRestart, err := s.ToggleClientEnableByEmail(inboundSvc, clientEmail)
+	if err != nil {
+		return false, needRestart, err
+	}
+	return newEnabled == enable, needRestart, nil
+}
+
+func (s *ClientService) ResetClientIpLimitByEmail(inboundSvc *InboundService, clientEmail string, count int) (bool, error) {
+	_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
+	if err != nil {
+		return false, err
+	}
+	if inbound == nil {
+		return false, common.NewError("Inbound Not Found For Email:", clientEmail)
+	}
+
+	oldClients, err := inboundSvc.GetClients(inbound)
+	if err != nil {
+		return false, err
+	}
+
+	clientId := ""
+
+	for _, oldClient := range oldClients {
+		if oldClient.Email == clientEmail {
+			switch inbound.Protocol {
+			case "trojan":
+				clientId = oldClient.Password
+			case "shadowsocks":
+				clientId = oldClient.Email
+			default:
+				clientId = oldClient.ID
+			}
+			break
+		}
+	}
+
+	if len(clientId) == 0 {
+		return false, common.NewError("Client Not Found For Email:", clientEmail)
+	}
+
+	var settings map[string]any
+	err = json.Unmarshal([]byte(inbound.Settings), &settings)
+	if err != nil {
+		return false, err
+	}
+	clients := settings["clients"].([]any)
+	var newClients []any
+	for client_index := range clients {
+		c := clients[client_index].(map[string]any)
+		if c["email"] == clientEmail {
+			c["limitIp"] = count
+			c["updated_at"] = time.Now().Unix() * 1000
+			newClients = append(newClients, any(c))
+		}
+	}
+	settings["clients"] = newClients
+	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+	inbound.Settings = string(modifiedSettings)
+	needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId)
+	return needRestart, err
+}
+
+func (s *ClientService) ResetClientExpiryTimeByEmail(inboundSvc *InboundService, clientEmail string, expiry_time int64) (bool, error) {
+	_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
+	if err != nil {
+		return false, err
+	}
+	if inbound == nil {
+		return false, common.NewError("Inbound Not Found For Email:", clientEmail)
+	}
+
+	oldClients, err := inboundSvc.GetClients(inbound)
+	if err != nil {
+		return false, err
+	}
+
+	clientId := ""
+
+	for _, oldClient := range oldClients {
+		if oldClient.Email == clientEmail {
+			switch inbound.Protocol {
+			case "trojan":
+				clientId = oldClient.Password
+			case "shadowsocks":
+				clientId = oldClient.Email
+			default:
+				clientId = oldClient.ID
+			}
+			break
+		}
+	}
+
+	if len(clientId) == 0 {
+		return false, common.NewError("Client Not Found For Email:", clientEmail)
+	}
+
+	var settings map[string]any
+	err = json.Unmarshal([]byte(inbound.Settings), &settings)
+	if err != nil {
+		return false, err
+	}
+	clients := settings["clients"].([]any)
+	var newClients []any
+	for client_index := range clients {
+		c := clients[client_index].(map[string]any)
+		if c["email"] == clientEmail {
+			c["expiryTime"] = expiry_time
+			c["updated_at"] = time.Now().Unix() * 1000
+			newClients = append(newClients, any(c))
+		}
+	}
+	settings["clients"] = newClients
+	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+	inbound.Settings = string(modifiedSettings)
+	needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId)
+	return needRestart, err
+}
+
+func (s *ClientService) ResetClientTrafficLimitByEmail(inboundSvc *InboundService, clientEmail string, totalGB int) (bool, error) {
+	if totalGB < 0 {
+		return false, common.NewError("totalGB must be >= 0")
+	}
+	_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
+	if err != nil {
+		return false, err
+	}
+	if inbound == nil {
+		return false, common.NewError("Inbound Not Found For Email:", clientEmail)
+	}
+
+	oldClients, err := inboundSvc.GetClients(inbound)
+	if err != nil {
+		return false, err
+	}
+
+	clientId := ""
+
+	for _, oldClient := range oldClients {
+		if oldClient.Email == clientEmail {
+			switch inbound.Protocol {
+			case "trojan":
+				clientId = oldClient.Password
+			case "shadowsocks":
+				clientId = oldClient.Email
+			default:
+				clientId = oldClient.ID
+			}
+			break
+		}
+	}
+
+	if len(clientId) == 0 {
+		return false, common.NewError("Client Not Found For Email:", clientEmail)
+	}
+
+	var settings map[string]any
+	err = json.Unmarshal([]byte(inbound.Settings), &settings)
+	if err != nil {
+		return false, err
+	}
+	clients := settings["clients"].([]any)
+	var newClients []any
+	for client_index := range clients {
+		c := clients[client_index].(map[string]any)
+		if c["email"] == clientEmail {
+			c["totalGB"] = totalGB * 1024 * 1024 * 1024
+			c["updated_at"] = time.Now().Unix() * 1000
+			newClients = append(newClients, any(c))
+		}
+	}
+	settings["clients"] = newClients
+	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		return false, err
+	}
+	inbound.Settings = string(modifiedSettings)
+	needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId)
+	return needRestart, err
+}

+ 59 - 0
web/service/client_test.go

@@ -0,0 +1,59 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/xray"
+)
+
+func TestClientWithAttachmentsMarshalJSONIncludesExtras(t *testing.T) {
+	c := ClientWithAttachments{
+		ClientRecord: model.ClientRecord{Id: 1, Email: "[email protected]"},
+		InboundIds:   []int{3, 5},
+		Traffic:      &xray.ClientTraffic{Email: "[email protected]", Up: 1024, Down: 4096, Enable: true},
+	}
+	out, err := json.Marshal(c)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatalf("output is not valid JSON: %v", err)
+	}
+	if parsed["email"] != "[email protected]" {
+		t.Errorf("expected ClientRecord fields to survive, got %v", parsed)
+	}
+	ids, ok := parsed["inboundIds"].([]any)
+	if !ok {
+		t.Fatalf("expected inboundIds to be present as an array, got %T (%s)", parsed["inboundIds"], out)
+	}
+	if len(ids) != 2 {
+		t.Errorf("expected 2 inbound ids, got %d", len(ids))
+	}
+	if _, ok := parsed["traffic"].(map[string]any); !ok {
+		t.Errorf("expected traffic to be present as an object, got %T", parsed["traffic"])
+	}
+}
+
+func TestClientWithAttachmentsMarshalJSONOmitsAbsentTraffic(t *testing.T) {
+	c := ClientWithAttachments{
+		ClientRecord: model.ClientRecord{Id: 1, Email: "[email protected]"},
+		InboundIds:   nil,
+	}
+	out, err := json.Marshal(c)
+	if err != nil {
+		t.Fatalf("Marshal failed: %v", err)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatalf("output is not valid JSON: %v", err)
+	}
+	if _, present := parsed["traffic"]; present {
+		t.Errorf("expected traffic to be omitted when nil, got %v", parsed["traffic"])
+	}
+	if _, present := parsed["inboundIds"]; !present {
+		t.Errorf("expected inboundIds key to always be present, got %s", out)
+	}
+}

+ 147 - 0
web/service/fallback.go

@@ -0,0 +1,147 @@
+package service
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+
+	"gorm.io/gorm"
+)
+
+type FallbackService struct{}
+
+// FallbackInput is the payload shape POSTed by the inbound form.
+type FallbackInput struct {
+	ChildId   int    `json:"childId"`
+	Name      string `json:"name"`
+	Alpn      string `json:"alpn"`
+	Path      string `json:"path"`
+	Xver      int    `json:"xver"`
+	SortOrder int    `json:"sortOrder"`
+}
+
+// GetByMaster returns every fallback rule attached to the master inbound.
+func (s *FallbackService) GetByMaster(masterId int) ([]model.InboundFallback, error) {
+	var rows []model.InboundFallback
+	err := database.GetDB().
+		Where("master_id = ?", masterId).
+		Order("sort_order ASC, id ASC").
+		Find(&rows).Error
+	if err != nil {
+		return nil, err
+	}
+	return rows, nil
+}
+
+// GetParentForChild finds the first fallback rule that points at childId.
+// Used by client-link generation: when a child inbound is attached as a
+// fallback, its client links should advertise the master's address+port
+// and TLS instead of the child's loopback listen.
+func (s *FallbackService) GetParentForChild(childId int) (*model.InboundFallback, error) {
+	var row model.InboundFallback
+	err := database.GetDB().
+		Where("child_id = ?", childId).
+		Order("sort_order ASC, id ASC").
+		First(&row).Error
+	if err == gorm.ErrRecordNotFound {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &row, nil
+}
+
+// SetByMaster replaces the master's entire fallback list atomically.
+func (s *FallbackService) SetByMaster(masterId int, items []FallbackInput) error {
+	db := database.GetDB()
+	return db.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Where("master_id = ?", masterId).Delete(&model.InboundFallback{}).Error; err != nil {
+			return err
+		}
+		for i, c := range items {
+			if c.ChildId <= 0 || c.ChildId == masterId {
+				continue
+			}
+			row := model.InboundFallback{
+				MasterId:  masterId,
+				ChildId:   c.ChildId,
+				Name:      c.Name,
+				Alpn:      c.Alpn,
+				Path:      c.Path,
+				Xver:      c.Xver,
+				SortOrder: c.SortOrder,
+			}
+			if row.SortOrder == 0 {
+				row.SortOrder = i
+			}
+			if err := tx.Create(&row).Error; err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
+
+// BuildFallbacksJSON resolves the master's fallback rows into Xray's
+// expected settings.fallbacks shape, looking up each child's listen+port
+// to fill the dest field. Returns nil when the master has no rules.
+func (s *FallbackService) BuildFallbacksJSON(tx *gorm.DB, masterId int) ([]map[string]any, error) {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	var rows []model.InboundFallback
+	err := tx.Where("master_id = ?", masterId).
+		Order("sort_order ASC, id ASC").
+		Find(&rows).Error
+	if err != nil {
+		return nil, err
+	}
+	if len(rows) == 0 {
+		return nil, nil
+	}
+
+	childIds := make([]int, 0, len(rows))
+	for i := range rows {
+		childIds = append(childIds, rows[i].ChildId)
+	}
+	var children []model.Inbound
+	if err := tx.Where("id IN ?", childIds).Find(&children).Error; err != nil {
+		return nil, err
+	}
+	byId := make(map[int]*model.Inbound, len(children))
+	for i := range children {
+		byId[children[i].Id] = &children[i]
+	}
+
+	out := make([]map[string]any, 0, len(rows))
+	for _, r := range rows {
+		child, ok := byId[r.ChildId]
+		if !ok {
+			continue
+		}
+		listen := strings.TrimSpace(child.Listen)
+		if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
+			listen = "127.0.0.1"
+		}
+		entry := map[string]any{
+			"dest": fmt.Sprintf("%s:%d", listen, child.Port),
+		}
+		if r.Name != "" {
+			entry["name"] = r.Name
+		}
+		if r.Alpn != "" {
+			entry["alpn"] = r.Alpn
+		}
+		if r.Path != "" {
+			entry["path"] = r.Path
+		}
+		if r.Xver > 0 {
+			entry["xver"] = r.Xver
+		}
+		out = append(out, entry)
+	}
+	return out, nil
+}

File diff suppressed because it is too large
+ 260 - 646
web/service/inbound.go


+ 1 - 1
web/service/metric_history.go

@@ -124,7 +124,7 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
 }
 
 // systemMetrics holds whole-host time series (cpu, mem, netUp, etc.)
-// fed by ServerController.refreshStatus every 2s. nodeMetrics holds
+// fed by ServerService.RefreshStatus every 2s. nodeMetrics holds
 // per-node CPU/Mem fed by NodeHeartbeatJob every 10s. Both are
 // process-local — survival across panel restart is not required.
 var (

+ 119 - 15
web/service/node.go

@@ -24,6 +24,7 @@ type HeartbeatPatch struct {
 	LastHeartbeat int64
 	LatencyMs     int
 	XrayVersion   string
+	PanelVersion  string
 	CpuPct        float64
 	MemPct        float64
 	UptimeSecs    uint64
@@ -45,7 +46,105 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
 	db := database.GetDB()
 	var nodes []*model.Node
 	err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error
-	return nodes, err
+	if err != nil || len(nodes) == 0 {
+		return nodes, err
+	}
+
+	type inboundRow struct {
+		Id     int
+		NodeID int `gorm:"column:node_id"`
+	}
+	var inboundRows []inboundRow
+	if err := db.Table("inbounds").
+		Select("id, node_id").
+		Where("node_id IS NOT NULL").
+		Scan(&inboundRows).Error; err != nil {
+		return nodes, nil
+	}
+	if len(inboundRows) == 0 {
+		return nodes, nil
+	}
+	inboundsByNode := make(map[int][]int, len(nodes))
+	nodeByInbound := make(map[int]int, len(inboundRows))
+	for _, row := range inboundRows {
+		inboundsByNode[row.NodeID] = append(inboundsByNode[row.NodeID], row.Id)
+		nodeByInbound[row.Id] = row.NodeID
+	}
+
+	type clientCountRow struct {
+		NodeID int `gorm:"column:node_id"`
+		Count  int `gorm:"column:count"`
+	}
+	var clientCounts []clientCountRow
+	if err := db.Raw(`
+		SELECT inbounds.node_id AS node_id, COUNT(DISTINCT client_inbounds.client_id) AS count
+		FROM inbounds
+		JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
+		WHERE inbounds.node_id IS NOT NULL
+		GROUP BY inbounds.node_id
+	`).Scan(&clientCounts).Error; err == nil {
+		for _, row := range clientCounts {
+			for _, n := range nodes {
+				if n.Id == row.NodeID {
+					n.ClientCount = row.Count
+					break
+				}
+			}
+		}
+	}
+
+	now := time.Now().UnixMilli()
+	type trafficRow struct {
+		InboundID  int `gorm:"column:inbound_id"`
+		Email      string
+		Enable     bool
+		Total      int64
+		Up         int64
+		Down       int64
+		ExpiryTime int64 `gorm:"column:expiry_time"`
+	}
+	var trafficRows []trafficRow
+	inboundIDs := make([]int, 0, len(nodeByInbound))
+	for id := range nodeByInbound {
+		inboundIDs = append(inboundIDs, id)
+	}
+	if err := db.Table("client_traffics").
+		Select("inbound_id, email, enable, total, up, down, expiry_time").
+		Where("inbound_id IN ?", inboundIDs).
+		Scan(&trafficRows).Error; err == nil {
+		online := make(map[string]struct{})
+		for _, email := range s.onlineEmails() {
+			online[email] = struct{}{}
+		}
+		depletedByNode := make(map[int]int)
+		onlineByNode := make(map[int]int)
+		for _, row := range trafficRows {
+			nodeID, ok := nodeByInbound[row.InboundID]
+			if !ok {
+				continue
+			}
+			expired := row.ExpiryTime > 0 && row.ExpiryTime <= now
+			exhausted := row.Total > 0 && row.Up+row.Down >= row.Total
+			if expired || exhausted || !row.Enable {
+				depletedByNode[nodeID]++
+			}
+			if _, ok := online[row.Email]; ok {
+				onlineByNode[nodeID]++
+			}
+		}
+		for _, n := range nodes {
+			n.InboundCount = len(inboundsByNode[n.Id])
+			n.DepletedCount = depletedByNode[n.Id]
+			n.OnlineCount = onlineByNode[n.Id]
+		}
+	}
+
+	return nodes, nil
+}
+
+func (s *NodeService) onlineEmails() []string {
+	svc := InboundService{}
+	return svc.GetOnlineClients()
 }
 
 func (s *NodeService) GetById(id int) (*model.Node, error) {
@@ -154,6 +253,7 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
 		"last_heartbeat": p.LastHeartbeat,
 		"latency_ms":     p.LatencyMs,
 		"xray_version":   p.XrayVersion,
+		"panel_version":  p.PanelVersion,
 		"cpu_pct":        p.CpuPct,
 		"mem_pct":        p.MemPct,
 		"uptime_secs":    p.UptimeSecs,
@@ -238,7 +338,8 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 			Xray struct {
 				Version string `json:"version"`
 			} `json:"xray"`
-			Uptime uint64 `json:"uptime"`
+			PanelVersion string `json:"panelVersion"`
+			Uptime       uint64 `json:"uptime"`
 		} `json:"obj"`
 	}
 	if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
@@ -255,28 +356,31 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 		patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
 	}
 	patch.XrayVersion = o.Xray.Version
+	patch.PanelVersion = o.PanelVersion
 	patch.UptimeSecs = o.Uptime
 	return patch, nil
 }
 
 type ProbeResultUI struct {
-	Status      string  `json:"status"`
-	LatencyMs   int     `json:"latencyMs"`
-	XrayVersion string  `json:"xrayVersion"`
-	CpuPct      float64 `json:"cpuPct"`
-	MemPct      float64 `json:"memPct"`
-	UptimeSecs  uint64  `json:"uptimeSecs"`
-	Error       string  `json:"error"`
+	Status       string  `json:"status"`
+	LatencyMs    int     `json:"latencyMs"`
+	XrayVersion  string  `json:"xrayVersion"`
+	PanelVersion string  `json:"panelVersion"`
+	CpuPct       float64 `json:"cpuPct"`
+	MemPct       float64 `json:"memPct"`
+	UptimeSecs   uint64  `json:"uptimeSecs"`
+	Error        string  `json:"error"`
 }
 
 func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
 	r := ProbeResultUI{
-		LatencyMs:   p.LatencyMs,
-		XrayVersion: p.XrayVersion,
-		CpuPct:      p.CpuPct,
-		MemPct:      p.MemPct,
-		UptimeSecs:  p.UptimeSecs,
-		Error:       p.LastError,
+		LatencyMs:    p.LatencyMs,
+		XrayVersion:  p.XrayVersion,
+		PanelVersion: p.PanelVersion,
+		CpuPct:       p.CpuPct,
+		MemPct:       p.MemPct,
+		UptimeSecs:   p.UptimeSecs,
+		Error:        p.LastError,
 	}
 	if ok {
 		r.Status = "online"

+ 162 - 0
web/service/node_test.go

@@ -0,0 +1,162 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+)
+
+func TestNormalizeBasePath(t *testing.T) {
+	cases := []struct {
+		in   string
+		want string
+	}{
+		{"", "/"},
+		{"   ", "/"},
+		{"/", "/"},
+		{"/panel", "/panel/"},
+		{"panel", "/panel/"},
+		{"panel/", "/panel/"},
+		{"/panel/", "/panel/"},
+		{"  /panel  ", "/panel/"},
+		{"/a/b/c", "/a/b/c/"},
+	}
+	for _, c := range cases {
+		t.Run(c.in, func(t *testing.T) {
+			got := normalizeBasePath(c.in)
+			if got != c.want {
+				t.Fatalf("normalizeBasePath(%q) = %q, want %q", c.in, got, c.want)
+			}
+		})
+	}
+}
+
+func TestNodeMetricKey(t *testing.T) {
+	cases := []struct {
+		id     int
+		metric string
+		want   string
+	}{
+		{1, "cpu", "node:1:cpu"},
+		{42, "mem", "node:42:mem"},
+		{0, "anything", "node:0:anything"},
+	}
+	for _, c := range cases {
+		got := nodeMetricKey(c.id, c.metric)
+		if got != c.want {
+			t.Fatalf("nodeMetricKey(%d, %q) = %q, want %q", c.id, c.metric, got, c.want)
+		}
+	}
+}
+
+func TestHeartbeatPatch_ToUI_OnlineCopiesFields(t *testing.T) {
+	p := HeartbeatPatch{
+		Status:       "ignored-source",
+		LatencyMs:    42,
+		XrayVersion:  "1.8.4",
+		PanelVersion: "3.0.0",
+		CpuPct:       12.5,
+		MemPct:       33.3,
+		UptimeSecs:   12345,
+		LastError:    "",
+	}
+	ui := p.ToUI(true)
+	if ui.Status != "online" {
+		t.Fatalf("Status = %q, want online", ui.Status)
+	}
+	if ui.LatencyMs != 42 || ui.XrayVersion != "1.8.4" || ui.PanelVersion != "3.0.0" {
+		t.Fatalf("scalar copy mismatch: %+v", ui)
+	}
+	if ui.CpuPct != 12.5 || ui.MemPct != 33.3 || ui.UptimeSecs != 12345 {
+		t.Fatalf("metric copy mismatch: %+v", ui)
+	}
+	if ui.Error != "" {
+		t.Fatalf("Error = %q, want empty", ui.Error)
+	}
+}
+
+func TestHeartbeatPatch_ToUI_OfflinePreservesError(t *testing.T) {
+	p := HeartbeatPatch{LastError: "connection refused"}
+	ui := p.ToUI(false)
+	if ui.Status != "offline" {
+		t.Fatalf("Status = %q, want offline", ui.Status)
+	}
+	if ui.Error != "connection refused" {
+		t.Fatalf("Error = %q, want %q", ui.Error, "connection refused")
+	}
+}
+
+func TestNodeService_Normalize_Valid(t *testing.T) {
+	s := &NodeService{}
+	n := &model.Node{
+		Name:     "  primary  ",
+		ApiToken: "  abc  ",
+		Address:  "example.com",
+		Port:     8443,
+		Scheme:   "",
+		BasePath: "panel",
+	}
+	if err := s.normalize(n); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if n.Name != "primary" {
+		t.Fatalf("Name not trimmed: %q", n.Name)
+	}
+	if n.ApiToken != "abc" {
+		t.Fatalf("ApiToken not trimmed: %q", n.ApiToken)
+	}
+	if n.Scheme != "https" {
+		t.Fatalf("empty Scheme should default to https, got %q", n.Scheme)
+	}
+	if n.BasePath != "/panel/" {
+		t.Fatalf("BasePath = %q, want /panel/", n.BasePath)
+	}
+}
+
+func TestNodeService_Normalize_KeepsValidScheme(t *testing.T) {
+	s := &NodeService{}
+	n := &model.Node{Name: "n", Address: "example.com", Port: 80, Scheme: "http"}
+	if err := s.normalize(n); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if n.Scheme != "http" {
+		t.Fatalf("Scheme = %q, want http", n.Scheme)
+	}
+}
+
+func TestNodeService_Normalize_RejectsEmptyName(t *testing.T) {
+	s := &NodeService{}
+	n := &model.Node{Name: "   ", Address: "example.com", Port: 443}
+	if err := s.normalize(n); err == nil {
+		t.Fatal("expected error for empty name")
+	}
+}
+
+func TestNodeService_Normalize_RejectsBadHost(t *testing.T) {
+	s := &NodeService{}
+	n := &model.Node{Name: "n", Address: "bad host name with spaces", Port: 443}
+	if err := s.normalize(n); err == nil {
+		t.Fatal("expected error for invalid host")
+	}
+}
+
+func TestNodeService_Normalize_RejectsOutOfRangePort(t *testing.T) {
+	s := &NodeService{}
+	for _, port := range []int{0, -1, 65536, 100000} {
+		n := &model.Node{Name: "n", Address: "example.com", Port: port}
+		if err := s.normalize(n); err == nil {
+			t.Fatalf("expected error for port %d", port)
+		}
+	}
+}
+
+func TestNodeService_Normalize_OverridesUnknownScheme(t *testing.T) {
+	s := &NodeService{}
+	n := &model.Node{Name: "n", Address: "example.com", Port: 443, Scheme: "ftp"}
+	if err := s.normalize(n); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if n.Scheme != "https" {
+		t.Fatalf("Scheme = %q, want https", n.Scheme)
+	}
+}

+ 14 - 6
web/service/panel.go

@@ -16,6 +16,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/config"
 	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/global"
 )
 
 // PanelService provides business logic for panel management operations.
@@ -35,14 +36,21 @@ const (
 )
 
 func (s *PanelService) RestartPanel(delay time.Duration) error {
-	p, err := os.FindProcess(syscall.Getpid())
-	if err != nil {
-		return err
-	}
 	go func() {
 		time.Sleep(delay)
-		err := p.Signal(syscall.SIGHUP)
+		if global.TriggerRestart() {
+			return
+		}
+		if runtime.GOOS == "windows" {
+			logger.Error("panel restart: no restart hook registered (SIGHUP unsupported on Windows)")
+			return
+		}
+		p, err := os.FindProcess(syscall.Getpid())
 		if err != nil {
+			logger.Error("panel restart: FindProcess failed:", err)
+			return
+		}
+		if err := p.Signal(syscall.SIGHUP); err != nil {
 			logger.Error("failed to send SIGHUP signal:", err)
 		}
 	}()
@@ -213,7 +221,7 @@ func compareVersionStrings(a string, b string) (int, bool) {
 	if !okA || !okB {
 		return 0, false
 	}
-	for i := 0; i < len(aParts); i++ {
+	for i := range len(aParts) {
 		if aParts[i] > bParts[i] {
 			return 1, true
 		}

+ 1 - 1
web/service/port_conflict.go

@@ -72,7 +72,7 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string)
 				// "udp", or "tcp,udp". if it's set, it wins outright.
 				if n, ok := st["network"].(string); ok && n != "" {
 					bits = 0
-					for _, part := range strings.Split(n, ",") {
+					for part := range strings.SplitSeq(n, ",") {
 						switch strings.TrimSpace(part) {
 						case "tcp":
 							bits |= transportTCP

+ 5 - 4
web/service/port_conflict_test.go

@@ -56,7 +56,8 @@ func seedInboundConflictNode(t *testing.T, tag, listen string, port int, protoco
 	}
 }
 
-func intPtr(v int) *int { return &v }
+//go:fix inline
+func intPtr(v int) *int { return new(v) }
 
 func TestInboundTransports(t *testing.T) {
 	cases := []struct {
@@ -360,7 +361,7 @@ func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) {
 func TestCheckPortConflict_NodeScope(t *testing.T) {
 	setupConflictDB(t)
 	seedInboundConflictNode(t, "local-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
-	seedInboundConflictNode(t, "node1-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, intPtr(1))
+	seedInboundConflictNode(t, "node1-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, new(1))
 
 	svc := &InboundService{}
 
@@ -370,8 +371,8 @@ func TestCheckPortConflict_NodeScope(t *testing.T) {
 		want   bool
 	}{
 		{"new local same port + tcp clashes with local", nil, true},
-		{"new remote on different node from local is fine", intPtr(2), false},
-		{"new remote on existing node 1 clashes", intPtr(1), true},
+		{"new remote on different node from local is fine", new(2), false},
+		{"new remote on existing node 1 clashes", new(1), true},
 	}
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {

+ 132 - 7
web/service/server.go

@@ -71,11 +71,12 @@ type Status struct {
 		ErrorMsg string       `json:"errorMsg"`
 		Version  string       `json:"version"`
 	} `json:"xray"`
-	Uptime   uint64    `json:"uptime"`
-	Loads    []float64 `json:"loads"`
-	TcpCount int       `json:"tcpCount"`
-	UdpCount int       `json:"udpCount"`
-	NetIO    struct {
+	PanelVersion string    `json:"panelVersion"`
+	Uptime       uint64    `json:"uptime"`
+	Loads        []float64 `json:"loads"`
+	TcpCount     int       `json:"tcpCount"`
+	UdpCount     int       `json:"udpCount"`
+	NetIO        struct {
 		Up   uint64 `json:"up"`
 		Down uint64 `json:"down"`
 	} `json:"netIO"`
@@ -104,6 +105,7 @@ type Release struct {
 type ServerService struct {
 	xrayService        XrayService
 	inboundService     InboundService
+	settingService     SettingService
 	cachedIPv4         string
 	cachedIPv6         string
 	noIPv6             bool
@@ -114,6 +116,128 @@ type ServerService struct {
 	emaCPU             float64
 	cachedCpuSpeedMhz  float64
 	lastCpuInfoAttempt time.Time
+
+	lastStatusMu sync.RWMutex
+	lastStatus   *Status
+
+	versionsCacheMu sync.Mutex
+	versionsCache   *cachedXrayVersions
+}
+
+type cachedXrayVersions struct {
+	versions  []string
+	fetchedAt time.Time
+}
+
+// xrayVersionsCacheTTL bounds how often /getXrayVersion hits GitHub. The list
+// is purely informational (rendered in the "switch Xray version" picker) so a
+// quarter-hour staleness window is fine and saves the API budget.
+const xrayVersionsCacheTTL = 15 * time.Minute
+
+// allowedHistoryBuckets is the bucket-second whitelist for time-series
+// aggregation endpoints (server + node metrics). Restricting it prevents
+// callers from triggering arbitrary aggregation work and keeps the
+// frontend's bucket selector self-documenting.
+var allowedHistoryBuckets = map[int]bool{
+	2:   true, // Real-time view
+	30:  true, // 30s intervals
+	60:  true, // 1m intervals
+	120: true, // 2m intervals
+	180: true, // 3m intervals
+	300: true, // 5m intervals
+}
+
+// IsAllowedHistoryBucket reports whether a bucket-seconds value is in the
+// whitelist used by /server/history, /server/cpuHistory, /server/xrayMetricsHistory,
+// /server/xrayObservatoryHistory, and /nodes/history.
+func IsAllowedHistoryBucket(bucketSeconds int) bool {
+	return allowedHistoryBuckets[bucketSeconds]
+}
+
+// LastStatus returns the most recent Status snapshot collected by
+// RefreshStatus. Safe for concurrent readers.
+func (s *ServerService) LastStatus() *Status {
+	s.lastStatusMu.RLock()
+	defer s.lastStatusMu.RUnlock()
+	return s.lastStatus
+}
+
+// RefreshStatus collects a new system snapshot, stores it as LastStatus, and
+// appends it to the system-metrics time series. Returns the new snapshot (may
+// be nil if collection failed). Called by the background ticker; the caller is
+// responsible for any side effects (websocket broadcast, xray metrics sample).
+func (s *ServerService) RefreshStatus() *Status {
+	next := s.GetStatus(s.LastStatus())
+	if next == nil {
+		return nil
+	}
+	s.lastStatusMu.Lock()
+	s.lastStatus = next
+	s.lastStatusMu.Unlock()
+	s.AppendStatusSample(time.Now(), next)
+	return next
+}
+
+// GetXrayVersionsCached wraps GetXrayVersions with a TTL cache. On fetch
+// failure we serve the last successful list (if any) so the UI doesn't go
+// blank during a GitHub API hiccup; if there's no cache at all the underlying
+// error is surfaced.
+func (s *ServerService) GetXrayVersionsCached() ([]string, error) {
+	s.versionsCacheMu.Lock()
+	cache := s.versionsCache
+	s.versionsCacheMu.Unlock()
+	if cache != nil && time.Since(cache.fetchedAt) <= xrayVersionsCacheTTL {
+		return cache.versions, nil
+	}
+	versions, err := s.GetXrayVersions()
+	if err != nil {
+		if cache != nil {
+			logger.Warning("GetXrayVersionsCached: serving stale list:", err)
+			return cache.versions, nil
+		}
+		return nil, err
+	}
+	s.versionsCacheMu.Lock()
+	s.versionsCache = &cachedXrayVersions{versions: versions, fetchedAt: time.Now()}
+	s.versionsCacheMu.Unlock()
+	return versions, nil
+}
+
+// GetDefaultLogOutboundTags scans the default Xray config for freedom and
+// blackhole outbound tags so /getXrayLogs can colour-code log lines without
+// the controller re-doing the JSON walk. Falls back to the historical
+// "direct"/"blocked" defaults when the config can't be read.
+func (s *ServerService) GetDefaultLogOutboundTags() (freedoms, blackholes []string) {
+	config, err := s.settingService.GetDefaultXrayConfig()
+	if err == nil && config != nil {
+		if cfgMap, ok := config.(map[string]any); ok {
+			if outbounds, ok := cfgMap["outbounds"].([]any); ok {
+				for _, outbound := range outbounds {
+					obMap, ok := outbound.(map[string]any)
+					if !ok {
+						continue
+					}
+					tag, _ := obMap["tag"].(string)
+					if tag == "" {
+						continue
+					}
+					switch obMap["protocol"] {
+					case "freedom":
+						freedoms = append(freedoms, tag)
+					case "blackhole":
+						blackholes = append(blackholes, tag)
+					}
+				}
+			}
+		}
+	}
+	if len(freedoms) == 0 {
+		freedoms = []string{"direct"}
+	}
+	if len(blackholes) == 0 {
+		blackholes = []string{"blocked"}
+	}
+	return freedoms, blackholes
 }
 
 // AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds.
@@ -360,6 +484,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 		status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
 	}
 	status.Xray.Version = s.xrayService.GetXrayVersion()
+	status.PanelVersion = config.GetVersion()
 
 	// Application stats
 	var rtm runtime.MemStats
@@ -383,8 +508,8 @@ func (s *ServerService) AppendCpuSample(t time.Time, v float64) {
 
 // AppendStatusSample writes one tick of every metric we keep — CPU, memory
 // percent, network throughput (bytes/s), online client count, and the three
-// load averages. Called by ServerController.refreshStatus on the same @2s
-// cadence as AppendCpuSample, so all series stay aligned.
+// load averages. Called by RefreshStatus on the same @2s cadence as
+// AppendCpuSample, so all series stay aligned.
 func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
 	if status == nil {
 		return

File diff suppressed because it is too large
+ 284 - 407
web/service/tgbot.go


+ 1 - 1
web/service/tgbot_test.go

@@ -6,7 +6,7 @@ import (
 )
 
 func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
-	typ := reflect.TypeOf(LoginAttempt{})
+	typ := reflect.TypeFor[LoginAttempt]()
 	if _, ok := typ.FieldByName("Password"); ok {
 		t.Fatal("LoginAttempt must not carry attempted passwords")
 	}

+ 132 - 36
web/service/xray.go

@@ -4,8 +4,10 @@ import (
 	"encoding/json"
 	"errors"
 	"runtime"
+	"strings"
 	"sync"
 
+	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/logger"
 	"github.com/mhsanaei/3x-ui/v3/xray"
 
@@ -116,57 +118,101 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 		if inbound.NodeID != nil {
 			continue
 		}
-		// get settings clients
 		settings := map[string]any{}
 		json.Unmarshal([]byte(inbound.Settings), &settings)
-		clients, ok := settings["clients"].([]any)
-		if ok {
-			// Fast O(N) lookup map for client traffic enablement
-			clientStats := inbound.ClientStats
-			enableMap := make(map[string]bool, len(clientStats))
-			for _, clientTraffic := range clientStats {
-				enableMap[clientTraffic.Email] = clientTraffic.Enable
-			}
 
-			// filter and clean clients
-			var final_clients []any
-			for _, client := range clients {
-				c, ok := client.(map[string]any)
-				if !ok {
-					continue
-				}
+		dbClients, listErr := s.inboundService.clientService.ListForInbound(nil, inbound.Id)
+		if listErr != nil {
+			return nil, listErr
+		}
 
-				email, _ := c["email"].(string)
+		clientStats := inbound.ClientStats
+		enableMap := make(map[string]bool, len(clientStats))
+		for _, clientTraffic := range clientStats {
+			enableMap[clientTraffic.Email] = clientTraffic.Enable
+		}
 
-				// check users active or not via stats
-				if enable, exists := enableMap[email]; exists && !enable {
-					logger.Infof("Remove Inbound User %s due to expiration or traffic limit", email)
-					continue
+		var finalClients []any
+		for i := range dbClients {
+			c := dbClients[i]
+			if enable, exists := enableMap[c.Email]; exists && !enable {
+				logger.Infof("Remove Inbound User %s due to expiration or traffic limit", c.Email)
+				continue
+			}
+			if !c.Enable {
+				continue
+			}
+			flow := c.Flow
+			if flow == "xtls-rprx-vision-udp443" {
+				flow = "xtls-rprx-vision"
+			}
+			entry := map[string]any{"email": c.Email}
+			switch inbound.Protocol {
+			case model.VLESS:
+				if c.ID != "" {
+					entry["id"] = c.ID
 				}
-
-				// check manual disabled flag
-				if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
-					continue
+				if flow != "" {
+					entry["flow"] = flow
+				}
+				if c.Reverse != nil {
+					entry["reverse"] = c.Reverse
+				}
+			case model.VMESS:
+				if c.ID != "" {
+					entry["id"] = c.ID
 				}
+				if c.Security != "" {
+					entry["security"] = c.Security
+				}
+			case model.Trojan:
+				if c.Password != "" {
+					entry["password"] = c.Password
+				}
+				if flow != "" {
+					entry["flow"] = flow
+				}
+			case model.Shadowsocks:
+				if c.Password != "" {
+					entry["password"] = c.Password
+				}
+				if c.Security != "" {
+					entry["method"] = c.Security
+				}
+			case model.Hysteria, model.Hysteria2:
+				if c.Auth != "" {
+					entry["auth"] = c.Auth
+				}
+			}
+			finalClients = append(finalClients, entry)
+		}
+
+		_, hadClients := settings["clients"]
+		mutated := hadClients || len(finalClients) > 0
+		if mutated {
+			settings["clients"] = finalClients
+		}
 
-				// clear client config for additional parameters
-				for key := range c {
-					if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" && key != "auth" && key != "reverse" {
-						delete(c, key)
-					}
-					if flow, ok := c["flow"].(string); ok && flow == "xtls-rprx-vision-udp443" {
-						c["flow"] = "xtls-rprx-vision"
-					}
+		if inboundCanHostFallbacks(inbound) {
+			fallbacks, fbErr := s.inboundService.fallbackService.BuildFallbacksJSON(nil, inbound.Id)
+			if fbErr != nil {
+				return nil, fbErr
+			}
+			if len(fallbacks) > 0 {
+				generic := make([]any, 0, len(fallbacks))
+				for _, f := range fallbacks {
+					generic = append(generic, f)
 				}
-				final_clients = append(final_clients, any(c))
+				settings["fallbacks"] = generic
+				mutated = true
 			}
+		}
 
-			settings["clients"] = final_clients
+		if mutated {
 			modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
 			if err != nil {
 				return nil, err
 			}
-
 			inbound.Settings = string(modifiedSettings)
 		}
 
@@ -195,12 +241,62 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 			inbound.StreamSettings = string(newStream)
 		}
 
+		if inbound.Protocol == model.Shadowsocks {
+			if healed, ok := healShadowsocksClientMethods(inbound.Settings); ok {
+				inbound.Settings = healed
+			}
+		}
+
 		inboundConfig := inbound.GenXrayInboundConfig()
 		xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
 	}
 	return xrayConfig, nil
 }
 
+// healShadowsocksClientMethods is the same idea as applyShadowsocksClientMethod
+// (see client.go) but applied at xray-config-build time, to backfill the
+// per-client method field for legacy shadowsocks inbounds whose clients were
+// stored before applyShadowsocksClientMethod existed. Returns the rewritten
+// settings string and true when anything actually changed.
+func healShadowsocksClientMethods(settings string) (string, bool) {
+	if settings == "" {
+		return settings, false
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return settings, false
+	}
+	method, _ := parsed["method"].(string)
+	if method == "" || strings.HasPrefix(method, "2022-blake3-") {
+		return settings, false
+	}
+	clients, ok := parsed["clients"].([]any)
+	if !ok {
+		return settings, false
+	}
+	changed := false
+	for i := range clients {
+		cm, ok := clients[i].(map[string]any)
+		if !ok {
+			continue
+		}
+		if existing, _ := cm["method"].(string); existing != "" {
+			continue
+		}
+		cm["method"] = method
+		clients[i] = cm
+		changed = true
+	}
+	if !changed {
+		return settings, false
+	}
+	out, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return settings, false
+	}
+	return string(out), true
+}
+
 // GetXrayTraffic fetches the current traffic statistics from the running Xray process.
 func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
 	if !s.IsXrayRunning() {

Some files were not shown because too many files changed in this diff