Commit Graph
100 Commits
Author SHA1 Message Date
MHSanaei dd14e9b3b0 feat(inbounds): attach existing clients to an inbound in one click
Adds an 'Attach Existing Clients' row action on multi-user inbounds (shown even when the inbound is empty). It opens a modal listing the whole client pool with search and group filter, all attachable clients pre-selected, and attaches the selection to that inbound via the existing bulkAttach endpoint. Clients already on the inbound are shown disabled and skipped. Translations added for all 13 locales.
2026-06-01 07:26:30 +02:00
MHSanaei 971843f669 feat(nodes): bulk panel self-update with live online indicator
Adds the ability to update node panels to the latest release from the Nodes
page: select online, enabled nodes (checkboxes) and trigger their official
self-updater, or use the per-row Update action. A node whose reported panel
version trails the latest GitHub release is flagged with an 'update available'
tag (compared via lib/panel-version, mirroring the Go isNewerVersion).

Backend: Remote.UpdatePanel calls the node's existing
POST /panel/api/server/updatePanel; NodeService.UpdatePanels fans out over the
selected ids, skipping disabled/offline nodes with a per-node reason; exposed
as POST /panel/api/nodes/updatePanel (documented in endpoints.ts + openapi.json).

The bulk request sends a JSON body, so it sets Content-Type: application/json
explicitly — axios defaults POST to form-urlencoded, which made ShouldBindJSON
fail with 'invalid character i'.

Also reuses the clients-page online cue on the Nodes page: a pulsing green dot
plus green label for an online node. The .online-dot style moved to the shared
styles/utils.css so both pages load it.

Translations for all new node keys added across every language file.
2026-06-01 07:03:06 +02:00
MHSanaei c8df1b19ff feat(clients): live online dot + last-online tooltip on offline
Two small UX cues on the clients table online column:

- a pulsing green dot next to the Online tag so an active client reads as
  live at a glance (honors prefers-reduced-motion).
- hovering the Offline tag shows the client's last-online timestamp from
  record.traffic.lastOnline, formatted with the panel's calendar setting
  (or "-" when the client has never connected).
2026-06-01 06:17:30 +02:00
MHSanaei b67c4c2f81 fix(clients): keep the summary card live without a page refresh
The clients page summary counters (Online / Depleted / Depleting / Disabled
/ Active) came only from the paged-list response (staleTime: Infinity), so
they stayed frozen until a manual refresh or a mutation-triggered refetch —
the per-row columns updated over WebSocket but the summary card did not.

The client_stats WS event already broadcasts every client's traffic
(enable/up/down/total/expiryTime) every few seconds, so recompute the summary
client-side from it: computeClientsSummary mirrors the server's
buildClientsSummary, the latest event is stored in allClientStats, and the
summary is a useMemo over that plus the live onlines set. Falls back to the
server summary until the first event lands and keeps the server's
authoritative total. No extra polling, consistent with the existing
no-REST-fallback traffic design.
2026-06-01 06:10:25 +02:00
MHSanaei fb311afa6f fix(sub): keep listen/bind IP out of subscription page URLs
The subscription page leaked an inbound's server-side Listen IP into the
client-facing URLs when a bind address was set:

- Per-config links: resolveInboundAddress returned the bind Listen IP
  (loopback/private/public alike) instead of the host the subscriber
  reached the panel on. It now returns the node address for node-managed
  inbounds, otherwise the subscriber host; the bind Listen is ignored
  (External Proxy remains the way to advertise a specific endpoint).

- Subscription Copy URL (SUB/JSON/CLASH): BuildURLs composed the base
  differently from the panel's Client Information page and never
  normalized the request host, so a loopback/bind request leaked the raw
  IP. The composition is extracted into the shared
  SettingService.BuildSubURIBase, used by both the panel and the sub page
  so they render identically, and fed the already-normalized subscriber
  host.
2026-06-01 05:47:18 +02:00
MHSanaei eb78b8666f fix(inbound): re-derive auto tags on edit and keep node tags consistent
Auto-generated inbound tags (in-<port>-<l4>, n<id>- prefixed for node inbounds) now re-derive when port/listen/transport change on update instead of keeping the stale round-tripped value. The resolved tag is mirrored onto the API response, and NodeID is pinned to the stored row so a node inbound never loses its n<id>- prefix on edit. The edit form recomputes the tag live via a Go-parity helper so the JSON preview matches what gets saved.

Make node/central tag matching prefix-agnostic in all three places (traffic attribution, remote-id resolution, and the orphan sweep) so an n<id>- prefix present on only one side can no longer spawn duplicate inbounds or drop traffic on sync.

Force LF on shell scripts via .gitattributes (CRLF broke the Docker build shebang when the repo is checked out on Windows) and add a .dockerignore to keep node_modules/.git out of the build context.

Adds Go and frontend tests covering tag re-derivation, prefix-agnostic matching, and node-snapshot prefix mismatch.
2026-06-01 05:08:29 +02:00
MHSanaei 4a11375f36 fix(tgbot): send login notification asynchronously
UserLoginNotify ran SendMsgToTgbotAdmins synchronously on the login request goroutine. When Telegram was unreachable, the send retried up to 3x with a 30s timeout each, blocking the login handler for ~90s+ and effectively locking users out (issue #4585).

Dispatch the send in a goroutine after the cheap bot-running/login-notify-enabled guards so login always returns promptly; the existing per-send 30s context timeout and bounded retries keep the background goroutine from leaking.
2026-06-01 02:38:06 +02:00
MHSanaei 8db9729913 fix(model): accept tun protocol in inbound validation
Adding a TUN inbound failed with "request body failed validation" because the Inbound.Protocol oneof allowlist omitted "tun". Add it so the validator matches the protocol the frontend already offers.

Closes #4736
2026-06-01 02:23:57 +02:00
MHSanaei 4e4e30d8c1 fix(ci): raise issue-bot max-turns so full triage completes
The handle-issue job capped at 25 turns, which only covered the
early-exit spam/duplicate paths. Real bug reports went through the full
flow (categorize + Read/Grep the code + post an answer) and hit the cap
mid-step 5, leaving the issue labeled but with no reply. Raise to 45 to
match the heavier path; the mention job already uses 40.
@
2026-06-01 02:06:11 +02:00
MHSanaei 3f5e37b038 fix(postgres): record client traffic when inbound_id is stale
When an inbound is deleted and recreated it gets a new id, but the shared-by-email client_traffics row keeps the old (now deleted) inbound_id because AddClientStat's OnConflict-DoNothing never refreshes it. The traffic updater matched rows with inbound_id IN (local inbounds), so those orphaned rows were dropped: client traffic and online status stopped updating and auto-renew skipped them, while inbound-level traffic (matched by tag) kept working and the client count still showed (matched by email).

Match by email and exclude only rows owned by a node inbound (inbound_id NOT IN (node inbounds)) in addClientTraffic and autoRenewClients. The local Xray only reports local-client emails, so a stale local pointer no longer hides the row, while genuine node-owned rows stay protected. Verified against a real affected dump: visible rows went from 4/668 to 668/668.
2026-06-01 01:39:21 +02:00
MHSanaei 49c30d6baf fix(frontend): add missing react-hooks/exhaustive-deps
ESLint failed the frontend build on four react-hooks/exhaustive-deps errors. Add the missing dependencies: the hysteria streamSettings effect now lists form, and the inbounds page prompt/import/general-action callbacks now list t. Both form (Form.useForm) and t (useTranslation) are stable references, so no extra re-renders or loops.
2026-06-01 00:49:44 +02:00
MHSanaei 61ba5754ca fix(postgres): commit client traffic backfill in migration
MigrationRequirements backfills missing client_traffics rows from each inbound's settings.clients, but the later MultiDomain->ExternalProxy detection query used SQLite-only json_extract and executed via .Scan. On PostgreSQL it errored, rolling back the whole transaction including the backfill, so clients had no traffic rows: client traffic was never recorded, clients showed offline, and the inbound list showed 0 clients until each inbound was edited and saved.

Make the detection query dialect-aware (NULLIF(stream_settings,'')::jsonb #>> / #>) so the function runs to completion and commits on both dialects.
2026-06-01 00:43:42 +02:00
MHSanaei c6855d4752 fix(ci): let issue bot run for non-collaborator issue authors
The handle-issue job uses claude-code-action, which by default refuses
to run unless the triggering user has write access. Public issue authors
never do, so the job failed on essentially every real issue. Set
allowed_non_write_users: "*" on the triage job (mention job left gated).
2026-05-31 23:57:27 +02:00
MHSanaei e8c6c30982 fix(postgres): resync id sequences so adding clients no longer collides
resetPostgresSequences hardcoded table names that did not match the models: it used "client_records" (real table is "clients") and "inbound_fallback_children" (real table is "inbound_fallbacks"). For both, pg_get_serial_sequence returned NULL, so the guarded setval was a silent no-op and those id sequences were never advanced past MAX(id) after a SQLite->Postgres migration. The first client added afterward reused an existing id and failed with duplicate key value violates unique constraint "clients_pkey".

Resolve table names from the models via GORM instead of hardcoding, and run the resync on every Postgres startup (initModels) so databases already broken by the previous migration repair themselves on boot.
2026-05-31 23:44:57 +02:00
MHSanaei 575355e4f1 fix(inbounds): only reset id sequence when all inbounds are deleted
Commit 80110f9 realigned sqlite_sequence to MAX(id) after every delete,
which recycled freed ids and let a newly added inbound take an old
inbound id. Now the sequence row is cleared only when the table is empty,
so the counter keeps climbing while any inbound remains and existing ids
are never reused. Still guarded behind !IsPostgres().
@
2026-05-31 23:04:15 +02:00
MHSanaei 76dbbfc1f8 feat(inbounds): clearer client validation errors on save
When an inbound save fails Zod validation, the toast previously showed a
raw path like `settings.clients.494.tgId: Invalid input`, which gave no
hint which of hundreds of clients was at fault. Resolve the client array
index back to the client email, name the field, and append a "(+N more)"
count when several fields fail. console.error now logs a readable list of
every issue instead of dumping the whole form.

Adds the invalidClientField/invalidField/moreIssues toast strings across
all 13 translations.
2026-05-31 22:41:58 +02:00
MHSanaei 61e8bed3e0 refactor(inbounds): remove column sorter from inbound list
Drop the table header sorter on the inbounds page: the sortKey/sortOrder
state, the sortedInbounds memo and onChange handler, the per-column
sorterFor spreads, the SORT_FNS comparator map, and the now-unused
SortKey/SortOrder types. The list renders in DB order.
2026-05-31 22:01:10 +02:00
MHSanaei 998fa0dfe1 fix(postgres): stop FK constraint from blocking inbound delete
The schema was written for SQLite, which never enforces foreign keys, so
relationships are managed in application code and deleting an inbound keeps
its client_traffics by design. On Postgres GORM auto-created the
fk_inbounds_client_stats constraint, which rejected those deletes with
SQLSTATE 23503.

Set DisableForeignKeyConstraintWhenMigrating so neither backend creates the
constraint, and drop the already-created one on existing Postgres DBs via
dropLegacyForeignKeys. Also revert the client_traffics deletion that
c20ee00f added to DelInbound so traffic is preserved.
2026-05-31 21:45:41 +02:00
MHSanaei f02018cfb7 fix(outbounds): prevent freedom save crash, complete its fields (#4686)
freedomToWire called Object.entries(s.fragment), but getFieldsValue(true)
returns freedom settings without a fragment object when the Fragment switch
is off (its sub-fields never register). That threw 'Cannot convert undefined
or null to object' and silently killed the save. Guard fragment with a
fallback so an unset value is treated as empty.

While verifying against xray-core's freedom config, also:
- add the missing userLevel field (schema, form schema, adapter, UI)
- fix noise applyTo enum to ip/ipv4/ipv6 (xray rejects the old host/all)

Closes #4686
2026-05-31 19:50:50 +02:00
MHSanaei c20ee00fa3 fix(postgres): clear client_traffics before deleting inbound
DelInbound removed the client_inbounds join rows but never deleted the
inbound's client_traffics, so Postgres rejected the inbound delete with
fk_inbounds_client_stats (SQLSTATE 23503). SQLite never enforced the FK
so this went unnoticed. Delete client_traffics first, matching the order
already used in the sync path.
2026-05-31 19:48:19 +02:00
MHSanaei b1c141a515 fix(settings): sync generated schemas
- entity.go: tighten SessionMaxAge validate tag gte=0 -> gte=1 to match the panel UI (min 60) and the hand-written setting.ts schema

- GeneralTab.tsx: add max bounds to sessionMaxAge (525600) and pageSize (1000), raise pageSize min to 1

- regenerate zod.ts/types.ts, picking up pending drift: panelProxy field, client group field, InboundFallback.dest, and dropping the stale hysteria2 protocol enum value
2026-05-31 19:00:26 +02:00
MHSanaei 982a78ecdd ci(issue-bot): focus @claude mention on answering, raise turn limit 2026-05-31 18:28:56 +02:00
MHSanaei 9f67ba56c9 ci(issue-bot): auto-close clearly spam/invalid issues 2026-05-31 18:16:13 +02:00
MHSanaei cc34dc381c feat(postgres): in-panel backup/restore and consistent CLI backend
Two PostgreSQL gaps on the panel:

1. x-ui setting and other CLI subcommands read XUI_DB_TYPE/XUI_DB_DSN from
   the process environment, which systemd injects via EnvironmentFile but a
   plain shell invocation does not. On a PostgreSQL install the CLI silently
   fell back to SQLite, so changes made from the management menu never
   reached the panel's database. Load the systemd EnvironmentFile
   (/etc/default/x-ui and distro equivalents) at startup; godotenv.Load does
   not override existing vars, so it stays a no-op for the managed service.

2. DB backup/restore (panel endpoints and the Telegram bot) only handled the
   SQLite file, so on PostgreSQL Back Up returned a stale/absent x-ui.db and
   Restore silently did nothing. Add pg_dump/pg_restore based backup/restore:
   - GetDb/ImportDB run pg_dump (custom format) / pg_restore, passing
     credentials via the PG* environment instead of argv.
   - getDb downloads x-ui.dump on Postgres, x-ui.db on SQLite.
   - Telegram backup sends the matching file via GetDb.
   - BackupModal shows a Postgres note and accepts .dump; the dist page
     injects window.X_UI_DB_TYPE; new strings translated for all locales.
   - install.sh installs postgresql-client for the external-DSN path and
     points the user to in-panel Backup & Restore.

Closes #4658
2026-05-31 17:53:34 +02:00
Sanaei a2f20f85f3 Claude Issue Bot 2026-05-31 17:35:47 +02:00
MHSanaei 7028c15e8c i18n(nodes): translate basePath and apiToken labels
Localize the node form 'basePath' (zh-CN, zh-TW, tr-TR) and 'apiToken'
(zh-CN, zh-TW, uk-UA) labels that were still showing the English defaults.
2026-05-31 16:17:06 +02:00
MHSanaei 9d99428cce fix(inbounds): auto-increment WireGuard peer IP
New peers were always seeded with allowedIPs 10.0.0.2/32, so each "Add
peer" reused the same address. Derive the next address from the highest
IPv4 already present across existing peers (max + 1, keeping its prefix),
falling back to 10.0.0.2/32 when there are no peers yet.

Closes #4682
2026-05-31 15:46:57 +02:00
MHSanaei 24d0e4ec7c fix(clients): persist group for node-inbound clients
The client create/edit form left `group` out of the request payload, so choosing a group in the form was silently dropped (bulkAdd from the Groups page still worked because it writes the column directly). Add `group` to the payload next to `comment`.

SyncInbound also overwrote group_name unconditionally; a group set via bulkAdd is never pushed to the node, so the next node snapshot — which lacks it — wiped the column. Keep group sticky (only overwrite when the incoming value is non-empty); group is only ever set/cleared via the Groups page. Preserve comment for node clients during snapshot sync the same way. Add tests.
2026-05-31 15:25:21 +02:00
MHSanaei b94e859e73 test: name temp sqlite db x-ui.db to match the real db filename 2026-05-31 15:25:05 +02:00
MHSanaei 3f6fe1167d fix(sub): don't leak loopback bind IP into link host
When the sub server is reached on a loopback/unspecified host (e.g. 127.0.0.2 from its Listen IP bind), the request host was used as the link address. Substitute the configured Subscription/Web Domain, or normalize loopback to localhost, so the sub link address matches the panel's Client Information.
2026-05-31 03:34:17 +02:00
MHSanaei 234cce408b @
ci: replace legacy frontend path filters with frontend/** glob

The CI, CodeQL, and release workflows still gated on a per-extension
list (**.js, **.html, **.css, **.cjs, ...) left over from the old
Vue/JS UI. That list missed .tsx entirely, so React component edits
never triggered the workflows, and carried dead entries like **.cjs.
Replace the whole enumeration with a single frontend/** glob in all
three so any change under frontend/ triggers build/test/analysis,
while keeping **.go, go.mod, go.sum, **.sh, and the service-file paths.
@
2026-05-31 01:18:59 +02:00
MHSanaei a7d763a542 fix(clients): persist sort selection across navigation
The clients page saved searchKey and filters to localStorage but not
the sort selection, so leaving the page and returning reset sort to the
default (Oldest). Persist the chosen sort alongside the existing filter
state and restore it on mount, matching the filter-persistence pattern.
2026-05-31 01:00:00 +02:00
MHSanaei 80110f9404 fix(inbounds): reset id sequence on delete so old ids are reused
SQLite AUTOINCREMENT keeps a high-water mark in sqlite_sequence that
deleting rows never lowers, so after removing inbounds the next add kept
climbing instead of reusing freed ids. DelInbound now realigns the
counter to MAX(id) after each delete, clearing the sqlite_sequence row
entirely when the table is empty so the next inbound starts at id 1.
Guarded behind !IsPostgres(); Postgres sequences are left untouched.
2026-05-31 00:43:26 +02:00
MHSanaei cf50952921 feat(inbounds): add multi-select and bulk delete
Mirror the clients page: checkbox selection on the desktop table and on
mobile cards, with a danger Delete button in the toolbar that removes all
selected inbounds in one call.

Backend adds POST /panel/api/inbounds/bulkDel, which loops the existing
DelInbound per id (xray restarts at most once) and returns {deleted,
skipped}. Frontend shows a confirm modal plus a result toast, clears the
selection on success, adds bulk-delete i18n keys across all 13 languages,
and documents the endpoint in the in-panel API docs.
2026-05-31 00:29:24 +02:00
MHSanaei 6bb5a3b56b fix(inbounds): preserve client data on delete and show traffic in detail
Deleting an inbound now only detaches its clients (removes the
client_inbounds rows). It no longer deletes client_traffics or client IP
logs: those are keyed centrally by email (one row per client) and must
survive, since a client may stay attached to other inbounds and is
managed from the Clients page.

Separately, /get/:id now uses a new GetInboundDetail that preloads and
enriches ClientStats, so hydrated records (info / QR / export) carry
per-client traffic instead of null. DBInbound.toJSON drops the internal
_clientStatsMap cache so it no longer leaks into the exported JSON.
2026-05-30 23:53:28 +02:00
MHSanaei a08bb91f58 fix(settings): reject spaces, '\' and control chars in URI path settings
webBasePath, subPath, subJsonPath and subClashPath are URL paths, so '/'
stays allowed, but spaces, backslashes and control characters break
routing. Strip them as you type (shared sanitizePath helper, now also
applied to the panel base path) and reject them on save in
AllSetting.CheckValid so direct API callers are covered too.
2026-05-30 23:29:08 +02:00
MHSanaei 2fa7be86dc fix(clients): reject spaces, '/', '\' and control chars in subscription ID
Like the client email, the subId is embedded directly in subscription
URLs, so the same characters break it. Validate it on the backend
(Create + Update) and the frontend (Zod), with a localized message
across all 13 locales. An empty subId stays allowed (it is then
auto-generated).
2026-05-30 23:28:58 +02:00
MHSanaei a0865a67fd fix(clients): reject spaces, '/', '\' and control chars in client email
Client emails containing a slash broke the path-param routes
(edit/delete/view returned 404 / "client not found"), leaving stale
records that could only be cleared with manual SQLite edits. Validate
the email on both the backend (Create + Update, which also covers the
bulk paths) and the frontend (Zod) so these characters are rejected at
save time with a clear, localized message across all 13 locales.

Closes #4695
2026-05-30 22:40:48 +02:00
Sanaei d1882c7f29 refactor(frontend): reorganize source tree & break down oversized modals/tabs (#4698)
* refactor(frontend): reorganize components & pages into feature folders

No behavior change; pure file relocation + import path updates.

* refactor(frontend): move shared protocol enums to schemas/protocols/shared

Decouple Outbound from Inbound schemas: SSMethodSchema and VmessSecuritySchema (shared between inbound & outbound) now live in a neutral schemas/protocols/shared/ module. Outbound no longer reaches into schemas/protocols/inbound/*. Pure relocation + import rewiring; schema values identical, snapshots & golden tests unchanged.

* refactor(frontend): break InboundList into helpers/types/RowActions/columns hook/stats modal

InboundList.tsx 781 -> 203 lines. Extracted pure helpers (network labels, sort fns, isInboundMultiUser), shared types, the row-actions menu/cell, the table columns hook, and the mobile stats modal into the list/ folder. Code moved verbatim; no behavior change. typecheck/lint/test/build green, 337 tests pass.

* refactor(frontend): extract InboundInfoModal helpers, types & buildInboundInfo

InboundInfoModal.tsx 1081 -> 836 lines. Moved the pure data helpers (network host/path readers, link-protocol check, copy/download/statsColor/IP formatting) plus all shared types and the buildInboundInfo data builder into info/helpers.ts and info/types.ts. The state-coupled render body is left intact (no React render tests to guard a deeper split). Code moved verbatim; no behavior change. All gates green, 337 tests pass.

* test(frontend): add React Testing Library + jsdom render-test harness

- vitest projects: node unit tests stay lean; new jsdom 'components' project runs *.test.tsx
- component setup: matchMedia/ResizeObserver/localStorage polyfills, react-i18next init, persian-calendar-suite stub (only used under jalali locale)
- smoke + field-label structure snapshots for Inbound & Outbound form modals
- establishes the regression net required before decomposing the oversized form modals
- 341 tests pass (337 unit + 4 component); typecheck/lint/build green

* test(frontend): per-protocol field-structure coverage for both form modals

- drive the protocol Select in jsdom and snapshot rendered Form.Item labels for every protocol
- 10 outbound + 10 inbound protocol states captured as the regression net for protocol-core extraction
- add robust select-driving helpers (test-utils) + post-test body cleanup (setup.components)
- 341 tests pass; typecheck/lint green

* refactor(frontend): extract OutboundFormModal constants & stream helpers

OutboundFormModal.tsx 2238 -> 2080. Moved the pure option arrays/sets and the stream-slice helpers (newStreamSlice, hysteriaStreamSlice, isMuxAllowed, buildAddModeValues) into outbound-form-constants.ts and outbound-form-helpers.ts. Per-protocol render snapshots unchanged -> verified no behavior change. typecheck/lint/build green.

* refactor(frontend): extract InboundFormModal advanced JSON editors

InboundFormModal.tsx 3129 -> 2863. Moved AdvancedSliceEditor and AdvancedAllEditor (the in-modal JSON slice/all editors) into advanced-editors.tsx along with their adapter-helper imports. Per-protocol render snapshots unchanged -> verified no behavior change. typecheck/lint/build green.

* refactor(frontend): extract OutboundFormModal loopback/blackhole/dns field blocks

Moved the outbound-only protocol field blocks (loopback, blackhole, dns) out of the modal render body into outbound-only-fields.tsx. First render-body extraction behind the per-protocol snapshot net: loopback/blackhole/dns snapshots unchanged -> verified no behavior change. typecheck/lint/build green.

* refactor(frontend): extract OutboundFormModal freedom field block

OutboundFormModal.tsx 2063 -> 1753. Moved the freedom protocol field block (domainStrategy, fragment, noises, finalRules) into outbound-freedom-fields.tsx. Verbatim relocation; freedom per-protocol snapshot unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): extract OutboundFormModal wireguard field block

OutboundFormModal.tsx 1753 -> 1622. Moved the wireguard protocol field block (address, keypair gen, domainStrategy, peers + allowedIPs) into outbound-wireguard-fields.tsx; dropped now-unused icon/InputAddon/WireguardDomainStrategy imports. Verbatim relocation; wireguard snapshot unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): extract OutboundFormModal core protocol fields

OutboundFormModal.tsx 1622 -> 1538. Moved the shared protocol core field blocks (vmess/vless ID, vmess security, vless encryption/reverseTag, trojan/ss password, ss method/uot, socks/http user/pass) into outbound-core-fields.tsx; dropped now-unused schema/option imports. Per-protocol snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): fold OutboundFormModal server address/port block into core fields

OutboundFormModal.tsx 1538 -> 1516. Moved the shared connect-target (address/port) block into OutboundCoreProtocolFields at the same render position; dropped the now-unused SERVER_PROTOCOLS import. Snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): split outbound-only protocol forms into per-protocol files

Replace the grouped outbound-only-fields.tsx + outbound-freedom-fields.tsx with one file per protocol under outbounds/protocols/: freedom.tsx, blackhole.tsx, dns.tsx, loopback.tsx (+ barrel). Matches the prompt's 1-file-per-protocol structure. Outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): split outbound protocol forms into per-protocol files

Replace the grouped outbound-core-fields / outbound-wireguard-fields with one file per protocol under outbounds/protocols/: vmess, vless, trojan, shadowsocks, http, socks, wireguard, freedom, blackhole, dns, loopback (+ shared server-target). Matches the prompt's 1-file-per-protocol structure (per-modal). Outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): split outbound transport forms into per-transport files

Extract the tcp(raw)/kcp/ws/grpc/httpupgrade transport blocks into outbounds/transport/ per-file components (RawForm, KcpForm, WsForm, GrpcForm, HttpUpgradeForm). xhttp + hysteria transport remain inline for a follow-up. Verbatim relocation; outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): extract OutboundFormModal xhttp transport form

Move the xhttp transport block into transport/xhttp.tsx (takes form + onXmuxToggle prop); drop now-unused HeaderMapEditor and MODE_OPTIONS imports from the modal. OutboundFormModal.tsx down to ~1001 lines (from 2238 originally). Verbatim relocation; outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): extract OutboundFormModal tls/reality security forms

Move the TLS and Reality field blocks into outbounds/security/{tls,reality}.tsx; the none/TLS/Reality Radio.Group selector stays in the modal. Drop now-unused ALPN_OPTIONS/UTLS_OPTIONS imports. OutboundFormModal.tsx down to ~918 lines (from 2238 originally). Verbatim relocation; outbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): split inbound-only protocol forms (tun, tunnel) into per-file

Extract the tun and tunnel protocol blocks from InboundFormModal into inbounds/form/protocols/{tun,tunnel}.tsx (presentational, declarative). First inbound-side per-protocol split. Verbatim relocation; inbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): split inbound wireguard & shadowsocks protocol forms

Extract the wireguard and shadowsocks protocol blocks from InboundFormModal into inbounds/form/protocols/{wireguard,shadowsocks}.tsx (presentational; form + regen handlers / isSSWith2022 passed as props). Drop now-unused Divider + SSMethodSchema imports. Verbatim relocation; inbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): split inbound vless/http/mixed/hysteria protocol forms

Extract the remaining inbound protocol blocks into inbounds/form/protocols/: vless (auth handlers/state as props), http + mixed (shared accounts-list), hysteria. Drop now-unused HysteriaMasqueradeForm/Typography/Text imports from the modal. InboundFormModal.tsx 2841 -> 2478. Inbound snapshots unchanged -> no behavior change. typecheck/lint/build green.

* refactor(frontend): move HysteriaMasqueradeForm to lib/xray/forms/transport

The hysteria masquerade form edits streamSettings.hysteriaSettings.masquerade (a transport/stream concept) and is rendered identically by both modals, so it belongs next to FinalMaskForm in lib/xray/forms/transport/ rather than protocols/shared/. Moved the file, updated the transport barrel + both consumers (inbound hysteria protocol form, outbound modal), and removed the now-empty protocols/shared/ folder. Pure relocation; snapshots unchanged, typecheck/lint/build green.

* refactor(frontend): extract inbound transport forms into transport/ folder

Move the six inbound stream-transport blocks (tcp/raw, ws, grpc, xhttp,
httpupgrade, kcp) out of InboundFormModal into presentational components
under inbounds/form/transport/. XhttpForm takes the form instance and
re-derives its mode/obfs/placement watches internally; the rest are
declarative. InboundFormModal drops from 2566 to 2105 lines. No behavior
change — per-protocol field-label snapshots unchanged.

* refactor(frontend): extract inbound security forms into security/ folder

Move the inbound TLS and Reality stream-security blocks out of
InboundFormModal into presentational components under
inbounds/form/security/. The Radio.Group security selector stays in the
modal; TlsForm and RealityForm receive their cert/key/ECH generation
handlers and the saving flag as props. InboundFormModal drops from 2105
to 1708 lines.

Add inbound-form-blocks.test.tsx: render-snapshot coverage for each
extracted transport (raw/ws/grpc/kcp/httpupgrade/xhttp) and security
(tls/reality) component in isolation inside a minimal Form. The full
modal cannot exercise the stream/security tabs in jsdom because they are
gated behind Form.useWatch values that do not propagate in the test
harness, so component-level snapshots are the regression net for these
blocks. No behavior change.

* refactor(frontend): extract outbound sockopt/mux/hysteria transport blocks

Move the last three oversized inline stream blocks out of
OutboundFormModal into presentational components under
xray/outbounds/transport/: SockoptForm (~260 lines, the worst offender),
MuxForm, and HysteriaForm. Each takes the form instance; MuxForm also
takes protocol/network and keeps its isMuxAllowed gate. OutboundFormModal
drops from 962 to 621 lines and no inline section now exceeds the
250-line guideline. Existing outbound-form-modal snapshots already cover
sockopt/mux and stay byte-identical, confirming no behavior change.

* refactor(frontend): extract inbound sockopt + external-proxy blocks

Move the inbound Sockopt (~250 lines) and External Proxy stream blocks
out of InboundFormModal into presentational components under
inbounds/form/transport/, mirroring the outbound extraction. Each takes
its toggle handler (toggleSockopt / toggleExternalProxy) as a prop and
keeps its render-prop getFieldValue gate. InboundFormModal drops from
1708 to 1332 lines.

Extend inbound-form-blocks.test.tsx with isolated render-snapshot
coverage for both (SockoptForm seeded enabled + happyEyeballs;
ExternalProxyForm seeded with one TLS entry). No behavior change.

* refactor(frontend): break down RoutingTab into sections

Extract RoutingTab's presentational pieces into the routing/ folder:
helpers.ts (arrJoin/csv/chipPreview/ruleCriteriaChips), types.ts
(RuleRow), CriterionRow.tsx, RuleCardList.tsx (mobile card view), and
useRoutingColumns.tsx (desktop table columns hook). RoutingTab stays the
orchestrator holding rule state, mutate, tag-option memos and the
pointer-drag reorder logic, and drops from 550 to 291 lines. No behavior
change.

* refactor(frontend): extract BasicsTab constants and rule helpers

Move BasicsTab's geo option arrays + freedom/ipv4 outbound presets into
basics/constants.ts and the routing-rule get/set/sync helpers into
basics/helpers.ts. BasicsTab drops from 550 to 447 lines and keeps its
Collapse-of-settings panels (which stay coupled to mutate + derived
state, so splitting them into components would only add prop-drilling).
No behavior change.

* refactor(frontend): break down DnsTab columns/helpers/types

Extract DnsTab's pure pieces into the dns/ folder: helpers.ts
(STRATEGIES/DEFAULT_FAKEDNS + addr/domains/expectedIPs accessors),
types.ts (DnsConfig/HostRow/FakednsRow), and useDnsColumns.tsx
(useDnsServerColumns + useFakednsColumns table-column hooks taking their
row handlers as params). DnsTab stays the orchestrator for dns state,
mutate, hosts sync and the Collapse panels, and drops from 539 to 424
lines. No behavior change.

* refactor(frontend): break down OutboundsTab into sections

Extract OutboundsTab's pieces: outbounds-tab-types.ts (OutboundRow),
outbounds-tab-helpers.ts (address/untestable/security/breakdown +
traffic/testing/result accessors), useOutboundColumns.tsx (desktop table
columns hook) and OutboundCardList.tsx (mobile card view). OutboundsTab
stays the orchestrator for outbound state, mutate, reorder and the
toolbar, and drops from 516 to 238 lines. No behavior change.

This completes plan section 2.4.5 — all four oversized Xray tabs
(Basics/Routing/Dns/Outbounds) are now broken into sections + hooks.

* refactor(frontend): fold HysteriaMasqueradeForm into the hysteria forms

Inline the masquerade fields directly into both hysteria transport forms
(inbounds/form/protocols/hysteria + xray/outbounds/transport/hysteria)
and delete the shared lib/xray/forms/transport/HysteriaMasqueradeForm so
each hysteria form is self-contained. The masquerade JSX is unchanged;
form is typed as the untyped FormInstance (as the shared component was)
so the masquerade name paths still resolve. No behavior change.

* refactor(frontend): slim InboundFormModal by extracting hooks + sections

Pull the modal's non-layout logic into focused files at the form root:
- useSecurityActions.ts: TLS/Reality key + cert generation handlers and
  onSecurityChange (consumed by the security tab)
- useInboundFallbacks.ts: fallback row state + load/save/derive/add/
  update/remove/move handlers + eligible-child options
- FallbacksCard.tsx: the fallbacks card UI (presentational)
- SniffingTab.tsx: the sniffing tab UI (presentational)

Also drop the stale "Pattern A rewrite / sibling file" header comment and
the imports the extractions made unused. InboundFormModal goes from 1332
to 868 lines with no behavior change (351 tests green, snapshots
unchanged).
2026-05-30 21:51:33 +02:00
MHSanaei eee26e4788 fix(outbounds): lock hysteria to its QUIC transport + TLS, add version/masquerade
The hysteria protocol now offers only the Hysteria transport (other transports removed) and security is always TLS. This prevents the broken hysteria-over-tcp / security:none outbounds that made xray-core fail to start with 'Failed to build Hysteria config. > version != 2'.

Show the fixed version field directly under Transmission, and expose the full masquerade sub-form on the outbound too. The masquerade UI was extracted into a shared HysteriaMasqueradeForm component used by both the inbound and outbound forms.

Closes #4665
2026-05-29 23:56:27 +02:00
MHSanaei 987a6dd1e5 feat(clients/inbounds): IP log popups, clearer titles, tag-based inbound labels
Add an IP Log popup (view list + refresh + clear) to the client edit form and the Client Information modal, with IPs stacked vertically.

Identify inbounds by their xray tag (not remark/protocol:port) across every picker and chip: attach/detach modals, the attached-inbounds column and field, the filter drawer, and bulk-add. Add the tag field to the InboundOption schema (the backend already returned it).

Clarify modal titles/labels: Client Information (was More Information) and Inbound Information (was Inbound's Data); Client Information / QR Code titles now include the client email.

i18n: rename keys moreInformation->clientInfo and inboundData->inboundInfo with proper translations in all languages; addTitle->addClient, editTitle->editClient, addToGroupPlaceholder->groupName.
2026-05-29 23:22:49 +02:00
MHSanaei 12afb862ff fix(outbounds): parse wireguard:// links and fix ss:// query-string port
Add parseWireguardLink to the outbound import dispatcher: maps the secretKey userinfo, peer publicKey/endpoint, address, mtu, reserved, preSharedKey and keepAlive (probing common client aliases). Previously any wireguard:// link fell through to null and showed "Wrong Link!".

Also fix parseShadowsocksLink so a trailing query string (e.g. ?type=tcp) no longer leaks into the host:port slice, which made Number(port) NaN and silently fell back to 443. Strip the query before parsing in both the modern and legacy ss forms.
2026-05-29 21:27:50 +02:00
MHSanaei cb7af04cd3 fix(xray): test UDP outbounds via xray probe (#4657) + Vision testseed & Flow form fixes
Outbound connection tester (#4657): UDP-based outbounds (wireguard,
hysteria, kcp/quic transports) were probed with a raw UDP dial that
treated the inevitable read timeout as success, so every one reported a
fake ~5s 'alive'. Route them through the authoritative xray
burstObservatory probe and drop the broken raw-UDP path. Test All now
runs a parallel TCP lane and a serial HTTP lane so xray-probe outbounds
don't collide on the test semaphore.

Vision testseed: the [900, 500, 900, 256] default repeats 900, and a
tags Select keys each tag by value -> 'two children with the same key,
900'. Render it as four InputNumbers (inbound + outbound forms); the
field is a fixed 4-tuple where repeats are valid.

Inbound form: drop the null-valued 'Local Panel' Select option (AntD
rejects null option values; placeholder + allowClear already cover it).

Outbound form: add an explicit 'None' option to the Flow selector.
2026-05-29 21:07:01 +02:00
MHSanaei 8c30ddbfd9 fix(outbounds): persist optional blocks and fix stale edit reopen
- derive XMUX toggle from saved xmux on load, seed defaults on enable,
  and drop xmux when disabled (#4654)
- save the JSON tab straight from parsed text so sockopt, finalmask (TCP
  masks), mux, and reverse excludes round-trip instead of being dropped
  by the form-store bounce
- remove the redundant Host/Path fields from HTTP obfuscation that fought
  the request.headers editor over the same form path
- rebuild the outbounds table columns on row content change (rows, not
  rows.length) so a re-opened edited outbound shows fresh values
- add adapter round-trip regression tests

Closes #4654
2026-05-29 19:10:31 +02:00
MHSanaei 62c293e034 fix(outbounds): support proxyProtocol on freedom outbound
Xray's freedom outbound accepts a numeric proxyProtocol (0 disabled,
1 or 2 for the PROXY protocol version), but the panel had no field for
it and the typed form adapter dropped the key on save — so a value set
via the JSON editor disappeared the moment the outbound was saved.

Model proxyProtocol through the freedom wire schema, the form schema,
and both adapter directions (clamped to 0/1/2, omitted from the wire
when 0), and add a Select (none / v1 / v2) to the freedom section of
the outbound form. Add round-trip test coverage and the proxyProtocol
label across all locales.

Closes #4486
2026-05-29 17:18:21 +02:00
MHSanaei 5d0081a3b9 fix(qr): hide QR for post-quantum links on client QR page
Opening the client sublinks/QR modal crashed when a link used
post-quantum keys (ML-DSA-65 / ML-KEM-768): the encoded URL exceeds
the antd QRCode capacity and the component throws. The client QR modal
rendered the QRCode unconditionally, so it took down the page.

The names don't appear verbatim in a share link — mldsa65Verify rides
inside pqv=<base64> and ML-KEM-768 inside encryption=mlkem768x25519plus.
The QR modal and inbound QR modal used a literal-substring guard that
missed those encoded forms, leaving the QR (and the crash) in place.

Consolidate detection into a single isPostQuantumLink() helper in
inbound-link.ts and reuse it across the client QR, inbound QR, client
info, and sub surfaces. The copy/download link still works; only the
QR image is suppressed for oversized post-quantum links.

Closes #4656
2026-05-29 17:04:30 +02:00
MHSanaei 90a64a1b22 fix(ssl): prompt before setting IP cert path for panel
The IP certificate flow auto-set the panel cert path silently, unlike the
domain and Cloudflare flows which ask first. Add the same
"Would you like to set this certificate for the panel? (y/n)" prompt so
the IP flow is consistent and only configures the panel on confirmation.
2026-05-29 02:52:57 +02:00
MHSanaei 7ea88e3e37 fix(clients): store flow per-inbound for shared clients
A client shared across inbounds (e.g. VLESS+TCP+Reality and VLESS+WS+TLS)
had its `flow` applied globally, so enabling xtls-rprx-vision for Reality
broke the WS+TLS inbound for the same client (#4628).

Gate flow per inbound at every fan-out site via clientWithInboundFlow,
reusing inboundCanEnableTlsFlow (VLESS+TCP+TLS/Reality only), and make
ListForInbound treat flow_override as authoritative so an empty override
means "no flow on this inbound" instead of inheriting the record's global
flow. Also tighten buildTargetClientFromSource (copy-clients) to gate on
transport, not just protocol.
2026-05-29 02:35:53 +02:00
MHSanaei 8e301dbca9 fix(clients): preserve UUID when toggling enable from clients page
The clients list returns slim rows without secrets (uuid/password/auth)
or flow/security/tgId/reset/group. setEnable built its update payload
straight from the slim row, sending an empty id, so the backend treated
it as a new client and regenerated the UUID (and dropped the omitted
fields). Hydrate the full record first and send a complete payload that
changes only the enable flag.
2026-05-29 02:22:27 +02:00
MHSanaei 8a28373a01 fix(nodes): use GREATEST for last_online merge on PostgreSQL
setRemoteTrafficLocked merged last_online with MAX(last_online, ?), which
is SQLite's two-argument scalar max. PostgreSQL's MAX() is aggregate-only,
so node traffic sync failed every cycle with "function max(bigint, unknown)
does not exist (SQLSTATE 42883)", flooding the logs.

Add a dialect-aware database.GreatestExpr helper (GREATEST on Postgres,
MAX on SQLite) and use it for the last_online merge. last_online is a
non-null int64, so the two functions are semantically identical here.

Closes #4633
2026-05-29 02:04:02 +02:00
MHSanaei df777c12d3 fix(outbounds): preserve TLS/Reality security on save
OutboundFormModal.onOk built the save payload from form.validateFields(),
which only returns REGISTERED Form.Item values. The security selector is a
Radio.Group that writes streamSettings.security via setFieldValue with no
bound Form.Item, so validateFields() dropped it — network, tlsSettings and
realitySettings (all registered) survived, but the security discriminator
vanished and xray-core fell back to security="none". This hit both new
outbounds and re-saved ones.

Read the full form store with getFieldsValue(true) for the payload (still
validating first), matching how the inbound modal already does it.

Closes #4634
2026-05-29 01:58:36 +02:00
MHSanaei 169068d8fb fix(nodes): clean up orphaned client_inbounds on node inbound removal
When a remote node disconnects or one of its inbounds vanishes from the
traffic snapshot, setRemoteTrafficLocked deleted the central inbound row
but left the client_inbounds join rows behind. Affected clients ended up
linked to hundreds of phantom inbounds, and editing one then failed with
"record not found" / "Load Old Data Error" because Update aborted on the
first GetInbound miss.

- Detach client_inbounds rows when deleting a vanished node inbound
- Prune stale links during client Update instead of aborting the save
- Drop orphaned client_inbounds rows on startup to heal existing DBs

Closes #4636
2026-05-29 01:41:52 +02:00
MHSanaei b395a1b951 fix(inbounds): restore xHTTP Headers editor in form
The xHTTP transport schema and share-link emitter already supported a
headers map, but the inbound form lost its editor row, so operators had
no way to set custom headers on xHTTP inbounds. Add the HeaderMapEditor
row in the same position the outbound form uses.
2026-05-28 21:54:45 +02:00
MHSanaei cda7f2ac17 fix(sub): stop external-proxy dest from clobbering TLS SNI
externalProxySNI fell back to ep["dest"] when an external-proxy row had
no SNI of its own, silently overwriting the upstream tlsSettings
serverName already written into the share-link params. Operators using
forceTls=same with a CDN edge IP got SNI=<edge-ip> in the link instead
of the real cert hostname, breaking TLS handshakes.

The fallback is dropped: an explicit ep["sni"] still overrides, but a
blank entry now leaves the upstream SNI in place. Tests updated.
2026-05-28 21:26:24 +02:00
MHSanaei 798e18b6ee feat(fallbacks): add per-rule dest override
Operators can now type an explicit dest (e.g. "8443", "127.0.0.1:8443",
"/dev/shm/x.sock") on each fallback row to override the auto-resolved
child listen+port. Empty keeps the existing auto behavior.

Adds the column to inbound_fallbacks (GORM AutoMigrate), threads it
through the panel form, API docs, and translations.
2026-05-28 21:17:49 +02:00
MHSanaei 1fd2c1333c v3.2.0 2026-05-28 20:27:39 +02:00
MHSanaei ffe661d212 fix(groups): fetch full client list for Add/Remove/SubLinks modals
GroupsPage was sourcing modal candidates from useClients(), which is server-paginated at 25 rows — so "Add clients to group" only ever offered the first page, "Remove" missed members past page 1, and SubLinks silently skipped emails whose record wasn't in the cached page. Pull the unpaginated list via /panel/api/clients/list when any of the three modals open.
2026-05-28 20:25:37 +02:00
MHSanaei 3f0b7fbe97 feat(tls): surface pinnedPeerCertSha256 in panel, share links, and subs
Adds a panel-only `pinnedPeerCertSha256` field on TLS settings with a tags input and a random-hash generator. The hashes ride share links as `pcs` (v2rayN-compatible), Clash sub as `pin-sha256`, and JSON sub as `pinnedPeerCertSha256`, while remaining stripped from the run-config sent to xray-core.
2026-05-28 19:32:10 +02:00
MHSanaei c5b5606bf5 i18n(panel): translate Copy/Cancel buttons, Stream/Sniffing tabs, and All-Inbounds filenames
- TextModal: route the Copy button label and the post-copy toast
  through t('copy')/t('copied') instead of hardcoded English.
- PromptModal: route cancelText through t('cancel') and default okText
  through t('confirm') so the import-inbound prompt stops showing
  "Cancel" in non-English UI.
- InboundsPage: pass the All-Inbounds and All-Inbounds-Subs download
  filenames through t(...) so each locale can localize them.
- en-US.json: add pages.inbounds.exportAllLinksFileName and
  pages.inbounds.exportAllSubsFileName.
- All 12 non-English locales: translate streamTab and sniffingTab
  (previously left as literal English) and add the two new filename
  keys with appropriate translations.

All 13 locale files now have 1541 lines.
2026-05-28 18:45:59 +02:00
MHSanaei bee8288d41 fix(clients): bump auto-generated email length to 10 chars
The "create" form opened with a 9-char random email default, the bulk
modal's random portion was only 6 chars, and the inbound-defaults seed
used 8 — all below the 10-char minimum we want for new clients. Bring
each generator to 10 so an unedited auto-generated email meets the
threshold without the user having to extend it.
2026-05-28 18:27:35 +02:00
MHSanaei 99df5d70a8 fix(clients): backfill missing subId on startup and guard create/update
Legacy clients (and any API consumer that POSTs to AddInboundClient
without a subId) ended up with an empty SubID, which breaks the panel's
sub-link generation. Backfill them once at startup and stop the gap at
the write path so new clients can't reintroduce it.

- util/random: add NumLower(n) — 16-char [0-9a-z] generator that matches
  the frontend's RandomUtil.randomLowerAndNum convention.
- database/db.go: new InboundClientSubIdFix seeder, modeled on
  InboundClientTgIdFix. Loops every inbound, parses settings.clients,
  fills empty/missing subId with random.NumLower(16), persists via the
  same transaction-wrapped Update("settings", …) path, then records in
  HistoryOfSeeders so it runs at most once.
- web/service/client.go: defense-in-depth in AddInboundClient and
  UpdateInboundClient — fill subId on the persisted settings map when
  the payload omits it (Update prefers the previous value before
  generating a fresh one).
- database/db_seed_test: cover empty subId, missing-key subId, and
  preserved-existing subId; assert exactly one HistoryOfSeeders row.
2026-05-28 18:20:34 +02:00
MHSanaei 72b97efa8a i18n(panel): migrate hardcoded panel strings to en-US and translate all locales
Surface ~400 hardcoded English labels, tooltips, placeholders, dt/divider
text, modal okText/cancelText, and Spin loading from the panel pages
(clients/groups/inbounds/nodes/settings/xray/sub/index) into
web/translation/en-US.json under existing pages.<page>.* namespaces, with
JSX swapped to t(...). Brand and protocol identifiers (TLS, MTU, SNI,
NordVPN, Cloudflare WARP, etc.) stay literal.

Sync all 12 non-English locales (ar-EG, es-ES, fa-IR, id-ID, ja-JP,
pt-BR, ru-RU, tr-TR, uk-UA, vi-VN, zh-CN, zh-TW) to match en-US's
structure and translate the 521 new key paths per locale. Every locale
file now has 1539 lines, mirroring en-US ordering.

Also remove a dead duplicate "info": "Info" key under pages.inbounds
that collided with the new pages.inbounds.info.* object.

Backend: bulk attach/detach errors in web/service/client.go now route
through logger.Warningf (so they appear under /panel/api/server/logs/)
instead of only living on the response payload.
2026-05-28 18:03:07 +02:00
Sanaei 058c030e81 Random PostgreSQL role + post-install credentials display (#4608)
* feat(install): random PostgreSQL role + post-install credentials display

The local-Postgres installer used to bake in a static role name (`xui`)
and only printed `PostgreSQL (xui@127.0.0.1:5432/xui)` at the end of
install, leaving operators without the random password or any hint of
how to connect from the shell.

Two changes:

- install_postgres_local now generates an 8-char random role name
  alongside the random password, and double-quotes identifiers in the
  CREATE/ALTER statements (a random alphanumeric may start with a digit,
  which Postgres rejects for unquoted identifiers).

- After a successful local install, a dedicated "PostgreSQL Credentials"
  block is rendered in the summary — DB / user / pass / host / port /
  DSN / env-file path, plus ready-to-paste psql commands for both the
  postgres superuser and the new role. Credentials cross the subshell
  boundary via a 0600 tmpfile (PG_CRED_FILE) that the parent shell
  sources and unlinks; the PG_* vars are unset after display.

Only fires for the local-install flow; the external-DSN path is
unchanged.

* fix(install): address Copilot review on Postgres install flow

- Use mktemp (unguessable, 0600) instead of /tmp/x-ui-pg-creds.$$ and
  cleanup in both success and failure paths to close the symlink/race
  attack on the predictable filename.
- In install_postgres_local, capture the prior umask and restore it
  after writing PG_CRED_FILE; return 1 if the write fails so the
  caller does not source nothing and label the install with empty
  PG_* vars.
- On reinstall, reuse the existing xui DB owner instead of generating
  a fresh role each run, so existing tables stay accessible after a
  re-run; only the password is rotated. Falls back to a fresh random
  role when the DB does not exist or is owned by postgres.
2026-05-28 17:20:16 +02:00
MHSanaei c5dc84d314 refactor(inbound-tag): drop protocol segment from canonical shape
The canonical tag is now "[n<id>-]in-[<listen>:]<port>-<transport>"
instead of "[n<id>-]in-[<listen>:]<port>-<protocol>-<transport>".
Two TCP inbounds on the same port are already blocked by
checkPortConflict, so only the transport segment is needed to
disambiguate the legitimate tcp/udp coexistence case.

Existing DB rows keep their current tags via resolveInboundTag's
"reuse if free" branch — no migration needed (protocol-segment
form was never released).
2026-05-28 15:50:00 +02:00
MHSanaei aefee2c15f fix(clients): log bulk attach/detach failures to console
The backend returns descriptive error strings (email/inbound + reason)
but the UI only surfaced a count. Forward result.errors to console.error
so the actual failure cause is recoverable from DevTools.
2026-05-28 15:18:33 +02:00
MHSanaei b42a4d93fc fix(inbounds): heal legacy client data and TLS cert form hydration
- Detach preserves client traffic stats. DelInboundClient,
  DelInboundClientByEmail, and bulkDelInboundClients now take a
  keepTraffic flag; Detach passes true, delete-paths keep prior
  behavior. Runtime user removal still runs so xray drops the session.
- Two startup seeders normalize legacy inbound settings JSON:
  clients:null -> [] and any non-numeric tgId -> 0 (string, bool,
  NaN, Inf, non-integer floats). Each records itself once in
  history_of_seeders.
- MigrationRequirements no longer rewrites empty clients arrays back
  to null: newClients is initialized as a non-nil slice and incoming
  clients:null is coerced before the type assertion.
- TLS cert form: rawInboundToFormValues synthesizes a useFile
  discriminator per cert from whichever side carries data, so the
  edit modal can show file-mode paths again. formValuesToWirePayload
  strips useFile so saved JSON stays in wire shape.
2026-05-28 15:11:53 +02:00
MHSanaei 8046d1519d fix(links): include TCP HTTP host header in share links
The inbound form intentionally only exposes the response side of the
TCP HTTP header object (xray-core's inbound listener reads the
response object, not request — see the existing comment in
InboundFormModal). But the share-link generators were still reading
the Host header from request.headers, so the configured value ended
up in tcpSettings.header.response.headers while the link query
emitted host= (empty).

Fix the host lookup in both code paths:
- sub/subService.go: applyShareNetworkParams (VLESS / Trojan /
  Shadowsocks share URLs) and applyVmessNetworkParams (the VMess
  base64 JSON link) now try header.response.headers first and fall
  back to request.headers for legacy / hand-edited configs.
- frontend/src/lib/xray/inbound-link.ts mirrors the same fallback in
  the three TCP HTTP branches (VMess obj, VLESS params, the shared
  Trojan+Shadowsocks writer) so the JS-side generator used by the
  API docs preview stays in sync with the Go output.

Also restore the request-side inputs (version / method / path /
headers) under the TCP HTTP toggle in InboundFormModal. They were
previously removed because xray-core ignores them on the inbound
side, but they're still useful when copying the same config out to
an outbound or hand-tuning the share link, and they no longer
mislead users about Host — the link now derives Host from
response.headers.host where the response-only form writes it.
2026-05-28 13:54:04 +02:00
MHSanaei 2fea71387b fix(ui): polish across routing, groups, inbounds, mobile sidebar
A bundle of small UI fixes that surfaced together while reviewing the
panel.

Routing rules — stale Edit after drag:
- Dragging a rule and then clicking its Edit button used to open the
  modal with the *previous* rule's content. Root cause: desktopColumns
  was memoized with [t, isMobile, rows.length] (rows.length doesn't
  change on reorder), so the cached render function kept handing AntD
  the openEdit closure that captured the pre-drag rules array. Fix is
  a rulesRef updated each render and read inside openEdit, so even the
  cached closure sees the live array.
- Mobile rule cards on the same page were hard to tell apart: bumped
  the inter-card gap, slightly stronger border, soft shadow, and a
  small centered divider line between adjacent cards.

Mobile drawer (dark / ultra):
- The AntD Menu inside the mobile drawer was rendering with its own
  darkItemBg (#15161a / #050507) while the drawer body used the
  lighter colorBgElevated, producing visible two-tone seams. Force
  the drawer-content / drawer-body to the same dark color that the
  desktop sider uses, and make the menus transparent so they inherit.

Row menus — visual grouping:
- Groups page row menu: moved Rename above the divider so the
  ordering reads safe → divider → destructive (Remove from group,
  Delete clients, Delete group only) instead of mixing the two
  groups.
- Inbounds page row menu: inserted a divider before delAllClients /
  delete so the destructive items sit visually separated from the
  earlier safe actions.

Dropdown affordances:
- Non-danger dropdown items had no perceivable hover state (default
  colorBgTextHover is too subtle, especially under the light theme).
  Apply the same primary-tint pattern the sider/drawer menu uses: 14%
  primary background and primary color on label + icon.
- ant-dropdown-menu-item-divider now uses var(--ant-color-border)
  (and an explicit rgba in dark) so the separator is actually visible
  in the light theme.

Clients toolbar — narrow-desktop wrap:
- Between 769px and 920px, the bulk-action bar (Attach / Detach /
  Add to group / Ungroup / more + Delete) wrapped to two rows with
  Delete stranded alone on the right. In that range, switch the
  toolbar buttons to icon-only, tighten gap to 6px and inline padding
  to 8px so everything stays on one line.
2026-05-28 13:25:43 +02:00
MHSanaei 530e338c66 refactor(clients): coherent group management — rename, split, extract
This bundles a set of group-related improvements that built up across
one session and only make sense together.

Terminology / API surface:
- Rename "assign group" → "add to group" everywhere: i18n keys,
  callback names (bulkAddToGroup), component + file names
  (BulkAddToGroupModal, AddClientsToGroupModal), Go controller/struct
  names (bulkAddToGroup, AddToGroup), OpenAPI summaries. Nothing keeps
  the word "assign" anymore.
- Move group routes under /panel/api/clients/groups/* (was
  /bulkAssignGroup at the clients root).
- Split add and remove into two endpoints: /groups/bulkAdd now rejects
  empty group; new /groups/bulkRemove clears the label for the given
  emails. The old "submit empty to clear" UX is gone — Ungroup is its
  own action.

UI affordances on Clients page:
- Promote Group + Ungroup to visible bar buttons next to Attach +
  Detach. Group reuses BulkAddToGroupModal; Ungroup pops a danger
  confirm and calls bulkRemoveFromGroup.
- Custom UngroupIcon (TagsOutlined with a diagonal strike) for the
  Ungroup button so the pairing reads at a glance.
- Hide the Group column when no clients have a group label yet —
  removes a column of em-dashes on fresh installs.

UI on Groups page:
- New per-row Add clients… / Remove clients… actions backed by
  GroupAddClientsModal and GroupRemoveClientsModal: rich client picker
  (email / comment / current group / enable) with search and
  preserveSelectedRowKeys, mirroring the inbounds Attach modal UX.

Controller split:
- Move all /groups/* routes, handlers, and request bodies out of
  web/controller/client.go into a dedicated web/controller/group.go
  (GroupController with leaner clientService + xrayService
  dependencies). URLs are byte-identical because the new controller
  registers on the same parent gin.RouterGroup; api_docs_test.go gets
  a group.go → /panel/api/clients basePath entry so its route
  extraction keeps working.

Invalidation dedup:
- Removing a client from a group on the Groups page used to refetch
  /clients/groups and /clients/onlines three times: once from the
  mutation's onSuccess, once from a redundant invalidate() in the
  page's onSubmit, once from the WebSocket invalidate broadcast that
  the backend fires after every mutation. The manual invalidate() is
  gone, and a small invalidationTracker module lets websocketBridge
  skip WS-driven invalidates that arrive within 1.5s of a local
  invalidate — bringing the refetch count down to one. The WS path
  still works for changes made by another tab or user.
2026-05-28 12:59:20 +02:00
MHSanaei bf1b488a63 feat(clients): tidier bulk action toolbar
When at least one client is selected, the toolbar now collapses to a
small selection indicator plus the three most-used actions instead of
spreading six count-suffixed buttons across the row:

- Replaces every per-button "(N)" with a single closable "{N} selected"
  tag on the left — one click on its × clears the selection.
- Hides "+ Add Clients" while a selection is active (focus mode).
- Keeps Attach, Detach, and Delete as visible buttons; Delete is pushed
  to the right with auto margin so it doesn't sit flush against the
  non-destructive actions.
- Folds Adjust, Group, and Sub links into the existing "more"
  dropdown, which is now context-aware: selection-scoped overflow when
  rows are picked, global actions (Add Bulk / Reset all / Del depleted)
  otherwise.

On mobile the new buttons collapse to icon-only the same way as the
rest of the toolbar.
2026-05-28 11:24:21 +02:00
MHSanaei 8d6d845262 feat(settings): include email in default remarkModel pattern
Change the out-of-the-box remarkModel from "-io" (Inbound, Other) to
"-ieo" so newly provisioned panels include the client's email between
the inbound name and the other slot — much easier to identify which
client a generated remark belongs to. Existing installs that have
already written a remarkModel value are unaffected; only first-run /
unset defaults inherit the new pattern.
2026-05-28 11:08:59 +02:00
MHSanaei 72b68cce22 feat(clients): selective bulk attach + new bulk detach
Inbounds page:
- AttachClientsModal now shows a per-client selection table (email,
  comment, enabled tag) with search and a live "selected of total"
  counter; all clients are pre-selected so the old "attach all"
  workflow stays a single OK click.
- New DetachClientsModal on the inbound row menu lets you pick which
  clients to remove from that inbound (records are kept so they can be
  re-attached later; for full removal use Delete).

Clients page:
- New "Attach (N)" bulk-action button + BulkAttachInboundsModal that
  attaches selected clients to one or more multi-user inbounds.
- New "Detach (N)" bulk-action button + BulkDetachInboundsModal that
  removes selected clients from chosen inbounds; (email, inbound) pairs
  where the client isn't attached are silently skipped.

Backend adds POST /panel/api/clients/bulkDetach, wrapping the existing
Detach service for each email and reporting per-email
detached/skipped/errors. ClientRecord rows are kept on detach to match
the single-client endpoint; bulkDel remains the path for full removal.
2026-05-28 11:08:52 +02:00
MHSanaei a07b68894c docs(api): document clients bulkAttach endpoint 2026-05-28 02:47:48 +02:00
MHSanaei 9e005ffcf9 feat(inbounds): restore "Set Cert from Panel" / Clear buttons in TLS certs
Bring back the per-certificate buttons in the inbound TLS section (File Path
mode): "Set Cert from Panel" fetches the panel's own webCertFile/webKeyFile via
/panel/setting/all and fills the cert's certificateFile/keyFile, warning when no
panel cert is configured; "Clear" empties both paths.

Reuses the existing pages.inbounds.setDefaultCert label and adds a
setDefaultCertEmpty warning string.
2026-05-28 02:41:39 +02:00
MHSanaei 486ac9c28d feat(inbounds): expose Vision testseed field with sensible default
Add a "Vision testseed" form item to the inbound modal for TCP + TLS/reality
inbounds, normalized to positive integers and defaulting to [900,500,900,256].
Apply the same default in the outbound form adapter when no valid saved seed
is present.

Replace the http/mixed snapshot assertions in inbound-defaults with explicit
field checks so generated credentials don't break the snapshots.
2026-05-28 02:33:13 +02:00
MHSanaei 1a096d72f1 feat(inbounds): bulk-attach & assign-group client actions + form defaults
- Bulk-attach an inbound's clients onto other inbounds (same identity, shared traffic): new ClientService.BulkAttach + POST /clients/bulkAttach, an inbound row action, and AttachClientsModal.
- Assign all of an inbound's clients to a group from the inbound page, reusing /clients/bulkAssignGroup and the existing BulkAssignGroupModal.
- Default a random user/pass account for new Mixed and HTTP inbounds instead of an empty accounts list.
- Capitalize the inbound Security toggle labels (None/TLS/Reality).
2026-05-28 01:54:32 +02:00
MHSanaei 9d9737f470 feat(settings): panel network proxy for the panel's own outbound requests
Add a panelProxy setting that routes the panel's self-initiated HTTP requests (geo updates, Xray version/core download, panel update check) through an admin-configured socks5/http(s) proxy, to bypass server-side filtering of GitHub/Telegram. The Telegram bot falls back to it when tgBotProxy is empty (socks5 only). New util/netproxy.NewHTTPClient builds the proxied client.

Also fix the Mixed-inbound SOCKS/HTTP share URLs that had host:port and user:pass in the wrong order, and consolidate the Telegram settings tab (move API server into the general tab, drop the empty Proxy & Server tab).
2026-05-28 00:45:32 +02:00
Sanaei 272854df91 Client/inbound resilience + Postgres pool tuning + schema fixes (#4607)
* fix(clients): fall back to inbound scan when ClientRecord is missing

DeleteByEmail looked up the email in client_records and returned the
raw "record not found" gorm error when nothing matched, even though
the client could still live inside an inbound's settings.clients JSON
(legacy entries that SyncInbound never picked up, or rows deleted out
from under a stale inbound). The user-visible delete then fails
mysteriously while xray happily keeps serving the client.

When GetRecordByEmail returns ErrRecordNotFound, walk inbounds whose
settings JSON references the email and run DelInboundClientByEmail on
each. The traffic / IP rows are cleaned up at the end unless keepTraffic
is set. If no inbound carries the email either, surface a clear
"client %q not found in any inbound or client record" error instead.

* chore(logging): include request + caller context in jsonMsgObj warnings

The generic "X-UI: Something went wrong. Error: record not found" log
gave no clue about which endpoint, client, or controller line emitted
it. Prepend a context block:

  [POST /panel/api/clients/del/ADMIN ip=109.124.234.127
   handler=controller.(*ClientController).delete client.go:146]

Handler frame is located by scanning the stack for the first caller
outside util.go, so it points at the right controller method whether
the path went through jsonMsg, jsonObj, or jsonMsgObj directly.

* fix(clients): tolerate orphan client_inbounds rows in Delete

DeleteByEmail's previous fix only covered the case where GetRecordByEmail
returned ErrRecordNotFound. When the ClientRecord exists but a client_inbounds
row points to an inbound that has been removed out-of-band (failed mid-delete,
manual SQL, pre-SyncInbound migration), Delete bubbled the raw gorm
"record not found" from inboundSvc.GetInbound and aborted before any cleanup
ran — leaving the client un-deletable through the UI/API.

Match the tolerance bulkDelInboundClients already has: when GetInbound
returns gorm.ErrRecordNotFound for a join row, log a warning and continue.
The unconditional Delete(&model.ClientInbound{}) later in the function then
removes the stale row, and the ClientRecord delete succeeds.

* fix(schemas): accept empty-string fingerprint on externalProxy

The External Proxy form offers a "Default" option with value '' for the
uTLS fingerprint dropdown, but UtlsFingerprintSchema.optional() rejects
empty strings (only undefined or a valid enum member). Saving an inbound
with externalProxy rows failed with `expected one of "360"|"chrome"|...`.

Preprocess '' to undefined before the optional enum, matching the existing
pattern used for VmessSecuritySchema.

* chore(logging): drop noisy orphan client_inbounds warning

Per-row WARNINGs spammed logs whenever a client referenced multiple
already-deleted inbounds. The continue keeps the orphan-tolerant
behavior; just no longer announces each skipped row.

* feat(clients): per-client VMess security in client form

Restores the VMess `security` selector on the client form (auto, aes-128-gcm,
chacha20-poly1305, none, zero) and surfaces it only when at least one attached
inbound is VMess. The value rides into the share link via the existing
`scy=` field in genVmessLink; the panel persists it on ClientRecord and in
the inbound's settings.clients so the link generator can read it back.

Adds the pages.clients.vmessSecurity i18n key in en-US and fa-IR.

* fix(xray-config): strip panel-only fields from inbound config

Two fields the panel stores but Xray doesn't accept on the inbound side:

- VMess clients[].security — panel persists it so the share-link generator
  can write `scy=...`, but xray's vmess inbound spec has no per-client
  security. The field was leaking into the inbound JSON pushed to xray-core.
- VLESS settings.encryption — per the xray spec the inbound only takes
  `decryption`; `encryption` is for the matching client outbound. The panel
  keeps it for operator reference, but it must not appear in the inbound
  payload.

Add two strip helpers next to HealShadowsocksClientMethods and wire them
into GenXrayInboundConfig via a per-protocol switch, so both local and
remote runtime paths get the cleaned config.

* chore(db): backend-aware pool sizes with env overrides

Per-backend defaults:
- Postgres: 25 max open / 25 max idle. Matching idle to open removes
  pool churn under bursts (Postgres handles concurrency at the server,
  idle connections are cheap).
- SQLite: 1 max open / 1 max idle. Single-writer model means a wider
  cap just queues behind busy_timeout; tight cap is honest.

Both back ends share ConnMaxLifetime=1h and ConnMaxIdleTime=30m so
stale connections (vault rotation, pgbouncer drops, load-balancer
idle eviction) rotate out without operator intervention.

Operators can override either default at boot via:
  XUI_DB_MAX_OPEN_CONNS=...
  XUI_DB_MAX_IDLE_CONNS=...

envInt parses these; missing/empty/non-positive values fall back to
the per-backend default.

* fix(schemas): accept boolean acceptProxyProtocol on TCP stream

TcpStreamSettingsSchema declared `acceptProxyProtocol: z.literal(true).optional()`,
so saving an inbound where the AntD Switch sat in the off state failed
validation with `Invalid input` because the Switch always emits a plain
boolean.

Switch to `z.boolean().default(false)` — same shape ws/sockopt/httpupgrade
already use, and matches the actual wire payload (golden fixtures and
other settings blocks all store `acceptProxyProtocol: false`).

Snapshots for stream.test and inbound-full.test pick up the new defaulted
field on TCP fixtures.
2026-05-27 22:51:37 +02:00
MHSanaei 3046d96145 refactor(inbound-tag): add short protocol segment, rename tcpudp suffix
Tag shape becomes "[n<id>-]inbound-[<listen>:]<port>-<proto>-<net>"
where <proto> is a 2-char alias (vmess→vm, vless→vl, trojan→tr,
shadowsocks→ss, mixed→mx, wireguard→wg, hysteria→hy, tunnel→tn;
http stays as "http"), and <net> uses "tcpudp" for the TCP+UDP combo
instead of the previous "mixed" (which clashed visually with the
mixed protocol name).

Examples:
  local VLESS TCP 443        → inbound-443-vl-tcp
  local Hysteria UDP 443     → inbound-443-hy-udp
  local Mixed protocol dual  → inbound-22912-mx-tcpudp
  local Tunnel allow=tcp,udp → inbound-51542-tn-tcpudp
  node 1 VLESS TCP 443       → n1-inbound-443-vl-tcp

protocolShortName returns the raw protocol identifier for anything not
in the table, so future protocols still get a tag without a code edit.
Existing inbound tags are left alone — only newly generated tags adopt
the shape.
2026-05-27 19:47:02 +02:00
MHSanaei 7ade9d9a1f refactor(inbound-tag): node-prefixed + transport-suffixed canonical shape
Tag scheme moves to "[n<nodeID>-]inbound-[<listen>:]<port>-<transport>"
so two long-standing collision classes go away on the create path:
  - tcp/443 and udp/443 on the same listener (independent sockets)
  - same listen+port living on the central panel and on a remote node

Examples:
  local TCP 443    → inbound-443-tcp
  local UDP 443    → inbound-443-udp
  node 1 TCP 443   → n1-inbound-443-tcp

Refactor:
  - composeInboundTag is the single source of truth, called from
    generateInboundTag. Transport segment is now always present
    (used to appear only on collision); n<id>- prefix is added when
    Inbound.NodeID != nil.
  - addInbound / importInbound drop their inline "inbound-<port>"
    fallback; an empty Tag now flows through resolveInboundTag, which
    keeps caller-supplied tags verbatim when free and otherwise
    delegates to generateInboundTag.
  - setRemoteTrafficLocked indexes tagToCentral under both the stored
    tag and the prefix-stripped form, so a node sending its bare tag
    still resolves to a row we may have rewritten at materialization.
    The create branch now picks between snap.Tag and the n<id>-
    prefixed form before falling back to the warn-once skip.
  - Tests updated for the always-on transport suffix, and two new
    cases cover the node-prefix behaviour.

Existing inbounds keep their tags — only newly generated tags adopt
the new shape, so user routing rules pointing at "inbound-443" still
match the row they always did until the row is recreated.
2026-05-27 19:14:22 +02:00
MHSanaei d347605233 fix(remote-traffic): handle tag collisions + readable warning format
setRemoteTrafficLocked attempted to INSERT a new central inbound for
every snap whose tag was not in tagToCentral (which is scoped by
node_id). When a different owner — the local panel or another node —
already held the tag, the INSERT tripped the UNIQUE constraint on
inbounds.tag and re-fired on every periodic snap.

Pre-check for tag ownership before the INSERT. If a different owner
holds it, log once per (nodeID, tag) via a sync.Map dedupe and skip
silently from then on. Real DB errors still surface.

Also switch the six setRemoteTraffic warnings from logger.Warning(...)
to logger.Warningf("%q ... %v", ...) — fmt.Sprint only inserts spaces
between adjacent non-string operands, so the all-string call sites
produced runs like "taginbound-443failed:UNIQUE...". Format strings
also let us quote the tag with %q so it stands out from the prose.
2026-05-27 18:34:18 +02:00
MHSanaei 76043fe306 docs(api): document POST /panel/api/inbounds/:id/delAllClients
Adds the OpenAPI entry for the new "delete all clients of an inbound"
endpoint and regenerates openapi.json (116 paths, 117 operations).
2026-05-27 18:20:02 +02:00
MHSanaei be5425cbed refactor(sparkline): move min/max readout to a corner badge
On-chart extrema labels were colliding with the Y-axis ticks at the
top, the X-axis timestamps at the bottom, and the chart line itself
when min/max sat near a chart edge. Replace the floating labels with
a single rounded pill in the chart's top-right corner that lists
"▲ max  ▼ min", outside the drawing area. Dots still mark the points
on the line. Also nudge Y tick text 4px left, push X timestamps down
with tickMargin=14, and widen YAxis to 56px so values like "234 KB/s"
don't crowd the chart.
2026-05-27 18:18:08 +02:00
MHSanaei e23599cb18 feat(inbounds): row action to delete all clients of an inbound
Adds POST /panel/api/inbounds/:id/delAllClients that collects every
client email from settings.clients[] and runs ClientService.BulkDelete
in one pass. Row action lives in the More menu as a danger item, only
shown for multi-user inbounds that currently have at least one client;
confirmation modal displays the live client count.
2026-05-27 18:17:44 +02:00
MHSanaei 93eda06878 feat(clients,groups): client groups + sub-links export + dedicated groups page
Persistent client groups
- New ClientGroup model + client_groups table that holds empty
  (placeholder) groups so a user can define a label before any client
  references it. ListGroups merges these with the distinct group_name
  values already stored on clients and reports {name, clientCount}.
- ClientRecord gains group_name column; the model.Client wire shape
  gains a matching `group` JSON field that survives the
  inbound.settings → SyncInbound round-trip.
- Rename/Delete on a group mutates client_groups (rename row / delete
  row) AND propagates to all matching clients in ClientRecord and in
  every owning inbound's settings JSON, all in one transaction.

Bulk operations
- AssignGroup(emails, group) updates clients.group_name + patches each
  affected inbound's settings JSON in one read-modify-write per inbound.
  Empty group clears the label. Auto-creates the client_groups row when
  the user assigns to a brand-new name.
- BulkResetTraffic(emails) loops the existing single-reset path so the
  caller can zero traffic across a whole selection or a whole group.
- EmailsByGroup(name) returns just the email list (used by the groups
  page to fan a single bulk action over every member).

Endpoints (all under /panel/api/clients)
- GET  /groups                         — summaries with counts
- GET  /groups/:name/emails            — emails in a group
- POST /groups/create                  — empty placeholder group
- POST /groups/rename                  — rename (table + clients + JSON)
- POST /groups/delete                  — drop label everywhere (clients survive)
- POST /bulkAssignGroup                — assign N selected clients
- POST /bulkResetTraffic               — reset traffic on a list

Clients page UX
- New Group column (Actions → Client → Group → Inbounds → …) with a
  click-to-filter chip.
- FilterDrawer gains a multi-select Group filter whose options come
  from the new ClientPageResponse.groups field (sourced from ListGroups
  so empty/placeholder groups are pickable too).
- Single-client and bulk-add forms gain a Group AutoComplete pre-loaded
  with all known group names.
- New toolbar buttons when selection > 0: "Group ({n})" opens
  BulkAssignGroupModal, "Sub links ({n})" opens SubLinksModal.

Sub-links export modal (new SubLinksModal.tsx)
- Table of selected clients with their subscription URL (and JSON URL
  when subJsonEnable is on), per-row copy, Copy all, and Download as
  sub-links-<timestamp>.txt. Warns when subscription is disabled or
  none of the selected clients have a subId.

Dedicated Groups page (new pages/groups/GroupsPage.tsx)
- /groups route + sidebar entry (TagsOutlined icon) + page title key.
- Card-based layout matching Clients/Inbounds/Nodes — summary card with
  Total/Grouped/Empty stats, main card with Add Group button + table.
- Per-row More dropdown (icon-first column on the left): Sub links,
  Adjust (days+traffic), Reset traffic, Rename, Delete clients in
  group, Delete group (keep clients). Empty groups disable the
  client-targeted actions.
- Reuses SubLinksModal and ClientBulkAdjustModal — emails for the
  group are fetched on demand from GET /groups/:name/emails.

Other polish
- /groups + groups-page selectors added to page-shell.css and
  page-cards.css so the new page inherits the same background, padding,
  card borders, hover shadow, and summary-card padding.
- .card-toolbar gains a small vertical padding so the larger toolbar
  buttons (now default size, matching Inbounds) don't crowd the top of
  the card-head on Clients and Groups pages.
2026-05-27 17:30:55 +02:00
MHSanaei 7680e27d1d feat(clients): toolbar sort selector + preserve updated_at on unchanged rows
Frontend
- New Sort dropdown in the clients toolbar covering oldest/newest,
  recently updated, recently online, email A↔Z, most traffic, highest
  remaining, expiring soonest. Default is Oldest first.
- Strip per-column sorter arrows from the Table — all sorting now flows
  through the single dropdown, so the column headers stop competing
  with it.
- Empty state: TeamOutlined icon, t('noData'), text-secondary color
  (matching the inbound/node polish).

Backend
- sortClients: add createdAt, updatedAt and lastOnline cases (with id
  tie-break for stable ordering when timestamps collide).
- Fix Recently updated: SyncInbound was calling tx.Save on every client
  in the inbound, and GORM's autoUpdateTime tag stamped updated_at to
  time.Now() each time — so editing one client bumped ALL of them.
  After the Save, restore each row's preserved updated_at via
  UpdateColumn (skips hooks). The actually-edited client gets its
  fresh stamp from the explicit UpdateColumn at the end of Update().
- Fix periodic updated_at churn: adjustTraffics unconditionally set
  c["updated_at"] = now() for every client in any inbound that had a
  delayed-start expiry, every traffic-stats pass. Turn that into a
  backfill (only when the key is missing), matching the created_at
  treatment one line above.
2026-05-27 15:07:17 +02:00
MHSanaei 6286bb8676 chore(ui): polish empty states + sidebar icon + i18n page titles
- AppSidebar: switch the inbounds icon from UserOutlined (a single
  person — wrong semantic) to ImportOutlined, matching the empty-state
  icon and reflecting the actual concept of an incoming entry point.
- usePageTitle: stop hardcoding English titles; resolve them through
  i18n (menu.* keys are already translated), so the browser tab now
  follows the active language.
- InboundList / NodeList: replace the bare "—" empty cell with a
  centered icon + t('noData') message (ImportOutlined for inbounds,
  ClusterOutlined for nodes), and swap opacity:0.4 for
  var(--ant-color-text-secondary) so the text stays readable on the
  light theme's tinted card background.
2026-05-27 15:06:57 +02:00
MHSanaei 2bba1d21d2 refactor(metrics-modal): mark min/max on chart + improve grid contrast
Drop the Current/Min/Avg/Max stats row and Live auto-refresh toggle —
clutter that didn't earn its space. Min/max are now rendered as colored
dots on the chart itself (green ▼ for min, orange ▲ for max), which
exposes both the value AND the time-axis position of each extremum at a
glance. Tooltip now formats the timestamp fully (with date prefix when
the sample crosses a day boundary).

Switch CartesianGrid stroke from var(--ant-color-border-secondary) to
rgba(128,128,140,0.35) so the gridlines stay readable in light theme
against the chart-wrap's faint primary tint — the AntD variable
resolved to near-zero alpha and the gridlines disappeared.

XrayMetricsModal keeps its implicit 2s observatory polling.
2026-05-27 15:06:43 +02:00
MHSanaei f1e433e839 feat(clients,inbound): Auto Renew in Bulk Add + cleaner inbound wire payload
Bulk Add now exposes the same Auto Renew (`reset`, days) input as the
single-client form, applied to every client the batch produces. The
field was already on ClientBulkAddFormSchema's siblings; just wire it
into the schema, the empty-form defaults, the UI, and the bulkCreate
payload. Also relabel "Subscription info" to "Subscription ID" by
switching to the canonical pages.clients.subId key and modernise the
SyncOutlined-in-label random affordance on the same row.

On the inbound submit path, two payload-shape cleanups in
dropLegacyOptionalEmpties:
- streamSettings.hysteriaSettings.auth is a holdover slot whose
  real per-client value lives in settings.clients[*].auth; drop the
  field entirely when empty instead of shipping `"auth": ""`.
- finalmask's `tcp` / `udp` arrays were already dropped together when
  both were empty, but a UDP-only setup still emitted a stray
  `"tcp": []`. Drop each sub-array on its own when empty so a
  Hysteria-style "salamander on udp only" config no longer carries
  the empty tcp sibling.
2026-05-27 13:43:52 +02:00
MHSanaei 43288e6686 refactor(forms): modernize random buttons in client + outbound modals
Replace the last holdouts of the old random-affordance patterns:
- ClientFormModal's five "↻" text buttons (email / subId / auth /
  password / uuid) now use <Button icon={<ReloadOutlined />} /> so
  they match the icon-based actions elsewhere in the form.
- OutboundFormModal's WireGuard private-key SyncOutlined-in-label
  becomes a real button inside a Space.Compact next to the key
  field — same pattern the inbound side already uses.

The shared .random-icon CSS class has no remaining consumers after
this and the previous inbound-form pass, so drop it from utils.css.
2026-05-27 13:43:35 +02:00
MHSanaei 9d2a4f217e feat(inbound-form): salamander auto-seed for Hysteria + modernize random buttons
Picking Hysteria from the protocol select used to leave finalmask.udp
empty, so the listener went out without obfs unless the admin added
the salamander wrapper by hand. Hook into onValuesChange so switching
to Hysteria seeds finalmask.udp with
{type: 'salamander', settings: {password: <random>}} alongside the
hysteriaSettings / tlsSettings reset already happening there.

Also modernise the SyncOutlined-in-label "random" affordances on
Shadowsocks password, WireGuard secret key (server + per-peer), and
Reality target / SNI / shortIds into proper icon buttons inside a
Space.Compact next to the field. The old pattern dropped a tiny
clickable icon into the form-item label, which was easy to miss and
inconsistent with the other action buttons in the modal.
2026-05-27 13:43:21 +02:00
MHSanaei 222e000b3b feat(inbound-form): seed FinalMask with mkcp-original when KCP is selected
Picking mKCP from the Transmission select used to leave finalmask.udp
empty, so the inbound went out as unobfuscated mKCP unless the admin
remembered to add the wrapper by hand in the FinalMask section. Hook
into onNetworkChange so switching the network to kcp appends
{type: 'mkcp-original', settings: {}} to finalmask.udp (only when no
mkcp-original entry exists yet, so re-selecting kcp or editing other
udp masks doesn't pile up duplicates).
2026-05-27 13:11:32 +02:00
MHSanaei 0296b2abd0 docs(port-conflict): refresh stale comments after the refactor
A few of the doc blocks in this file had drifted from what the code
actually does:

- inboundTransports' rule list claimed tunnel reads settings.network
  the same as shadowsocks. It doesn't — tunnel uses
  settings.allowedNetwork (3x-ui's wrapper around dokodemo-door).
- The "merge" hint above the settings-parsing block undersold what
  happens: SS / Tunnel override the streamSettings-derived bit while
  Mixed adds to it.
- portConflictDetail's example message and the checkPortConflict
  intro still mentioned "the old port-only check", a historical
  comparison that no longer says anything useful now that the
  transport-aware check is the only one.
- transportTagSuffix was documented as a generateInboundTag helper,
  but it's now also called by the conflict-error formatter.

Tighten the wording so future readers don't have to cross-check the
code against the doc.
2026-05-27 12:57:37 +02:00
MHSanaei 980511bcad feat(port-conflict): include offending inbound + L4 in the error, cover quic and tunnel.allowedNetwork
checkPortConflict used to return a bare bool, which the API layer
translated into "Port already exists: 443" with no hint about which
existing inbound owned the port, what listen address it used, or
which L4 transport actually clashed. On a panel with dozens of
inbounds the admin had to scan the list by hand to figure out the
collision.

Return a portConflictDetail{InboundID, Remark, Tag, Listen, Port,
Transports} instead; a String() method formats it as
"port 443 (tcp) already used by inbound 'my-vless' (#7) on *" so the
existing common.NewError wrapping carries the full context up to the
UI without a second round-trip.

Two predicate gaps fixed at the same time:
- streamSettings.network="quic" rides on UDP the same way "kcp" does,
  so it now joins KCP in the UDP branch instead of falling through to
  the TCP default (a QUIC inbound used to silently allow a UDP
  neighbour on the same port).
- Tunnel reads settings.allowedNetwork ("tcp" / "udp" / "tcp,udp"),
  not settings.network — 3x-ui's dokodemo-door wrapper renames the
  field, and treating it as Shadowsocks-shaped left every Tunnel
  inbound looking like plain TCP regardless of what the admin
  configured.

Tests: TCP/UDP coexist + same-transport collision matrix already
covered the happy path; added QUICTreatedAsUDP, TunnelAllowedNetwork,
and DetailMessage to lock in the new behaviour. Dropped the unused
transportBits.conflicts() helper now that the call site composes the
mask itself to populate the detail.
2026-05-27 12:56:15 +02:00
MHSanaei 96a5c73e02 refactor(inbounds): cleaner network tags and cover Mixed/Tunnel + client form select polish
The InboundList protocol column had a few rough edges: raw transports
rendered with mixed casing (TCP vs ws vs grpc), WireGuard never got a
network tag at all, and Mixed/Tunnel rows had no L4 indication even
though they listen on tcp/udp combinations through their own settings
keys (settings.udp for Mixed, settings.allowedNetwork for Tunnel).

Normalise the column: a small networkLabel helper upper-cases every
known transport (so TCP / UDP / KCP / QUIC / WS / GRPC / HTTP all
share the same visual weight, with HTTPUpgrade / SplitHTTP / XHTTP
keeping a touch of casing for readability). Add an extra UDP tag
beside KCP / QUIC so the user sees the underlying L4 without having
to know each transport's wire shape. Add isTunnel to the dbinbound
model and per-protocol branches for Mixed (TCP / TCP,UDP) and Tunnel
(reads settings.allowedNetwork the same shape Shadowsocks uses for
settings.network).

Also polish the attached-inbounds Select in the client form: open
upwards (placement="topLeft") with a 220px listHeight and
maxTagCount="responsive" so a long selection doesn't push the modal's
Save button below the viewport.
2026-05-27 12:54:26 +02:00
MHSanaei 3675f88caf feat(clients): advanced filter drawer with multi-select state/protocol/inbound + expiry/usage ranges + auto-renew/tg/comment
The old toolbar exposed a single-value Search box, a single bucket
radio, and one Protocol + Inbound dropdown. Real panels with hundreds
of clients across mixed protocols need to slice by combinations
(active + expiring, two specific inbounds, expiring within a window,
high-usage subset, etc.), which the old shape couldn't express.

Backend ClientPageParams now accepts comma-separated multi values for
Filter / Protocol / Inbound and three new structured fields each:
expiry/usage ranges (ms / bytes), and three trinary toggles
(AutoRenew / HasTgID / HasComment with on/off, yes/no). The free-text
search predicate also picks up UUID / Password / Auth, which were
previously invisible to search.

Frontend introduces a dedicated FilterDrawer (multi-select for
state/protocol/inbound, DatePicker.RangePicker for expiry, paired
InputNumbers for usage, radio buttons for the trinary toggles) opened
from a single Filter button with a badge for the active count. Active
filters render as closable chips above the table so the user can drop
them one at a time, with a Clear-all next to the Filter button. The
search box stays inline and always visible.
2026-05-27 12:54:06 +02:00
MHSanaei 5eb80eca8e fix(clients): avoid duplicate ClientRecord when email is changed on edit
SyncInbound (invoked from UpdateInboundClient during a client edit)
looks up the ClientRecord row by the email present in the inbound's
settings. After an email change the lookup misses the original row,
hits the gorm.ErrRecordNotFound branch, and inserts a fresh
ClientRecord with the new email. The original row stays in place with
its inbound link cleared, so the clients list shows both — the
original as an orphan and the new one as if it had just been created.

Rename the existing ClientRecord row to the new email up front, before
the inbound loop runs. SyncInbound then finds and updates the same
row instead of creating a duplicate. A pre-check rejects renames that
would collide with another client's email so the unique index keeps
its meaning.
2026-05-27 11:23:33 +02:00
MHSanaei 313d041db3 feat(clients): restore Auto Renew field in client form
The vue→react rewrite dropped the per-client `reset` (Auto Renew)
input. The backend's autoRenewClients job has always honoured it, but
the form had no way to set or change the value, so existing
auto-renew settings were also invisible during edits.

Reinstate the field as an InputNumber with a tooltip explaining
"0 = disable (unit: day)", placed on the same row as the Reverse tag
field so the form doesn't grow taller for the common cases. Wired
through FormState defaults, edit-mode hydration, the submit payload,
and ClientFormSchema validation.
2026-05-27 11:22:49 +02:00
MHSanaei 3c5e9fa774 fix(sub): preserve userinfo encoding in trojan/shadowsocks/hysteria links
The link builders ran the assembled share link through url.Parse +
parsedURL.String(), which decodes the userinfo and re-emits it via
Go's lenient encoder — sub-delim chars (=, +, ;) are left literal even
when the caller had pre-encoded them via encodeUserinfo. Result: copy
URL from the panel UI worked (FE never round-trips), but the same
inbound in the subscription body became "trojan://abc%2Fdef=ghi+@..."
and was rejected by Trojan/Hysteria clients.

Replace url.Parse + .String() with a direct string-builder that
appends ?query and #fragment without touching the userinfo, and apply
it to genHysteriaLink's inline copies too. Also switch the
shadowsocks userinfo from base64.StdEncoding (with =/+/ /padding) to
base64.RawURLEncoding to match the frontend's Base64.encode(s, true).
2026-05-27 10:51:41 +02:00
MHSanaei 31d7ed5103 refactor(outbound): probe via xray burstObservatory instead of SOCKS round-trip
Replace the HTTP-mode outbound test that spun up a SOCKS inbound and
ran an httptrace'd request from the Go client with a probe-only xray
config: burstObservatory probes the target outbound directly and the
result is read from xray's /debug/vars metrics endpoint.

The probe lives inside xray, so the measured delay and failure
reasons reflect what xray itself sees over the real proxy chain.
Drops the DNS/Connect/TLS/TTFB breakdown (and statusCode) since the
observatory snapshot only exposes total delay; the frontend popover
is updated accordingly.
2026-05-27 04:53:13 +02:00