Compare commits

..

611 Commits

Author SHA1 Message Date
MHSanaei 8f3202f431 fix(traffic-writer): replace sync.Once with Start/Stop cycle so SIGHUP restart works
After a SIGHUP-driven panel restart (which is exactly what the frontend
triggers after a successful DB import via /panel/setting/restartPanel),
the previous implementation deadlocked:

1. server.Stop() called StopTrafficWriter — cancels the context and waits
   for the consumer goroutine to exit. The goroutine dies.
2. server.Start() called StartTrafficWriter, but sync.Once had already
   fired, so it was a no-op. twQueue still pointed to the old channel
   with no consumer.
3. startTask() → RestartXray(true) → GetXrayConfig() →
   InboundService.AddTraffic(nil, nil) → submitTrafficWrite. The send
   to twQueue succeeded (buffer space) but <-req.done blocked forever
   because no goroutine was draining the channel.
4. RestartXray held the global xray lock for the entire hang, so every
   subsequent restart attempt from the panel UI also blocked on
   lock.Lock(). User-visible symptom: xray stopped silently after DB
   import and no panel action could revive it.

Replace sync.Once with a mutex-guarded Start that spawns a fresh
goroutine on each cycle, and a Stop that resets the package state so
the next Start works. runTrafficWriter now takes its channels as
parameters instead of reading package vars, so the old goroutine can't
interfere with a new one if their lifetimes briefly overlap.
2026-05-11 16:01:04 +02:00
MHSanaei 0cb6568fd5 v3.0.1 2026-05-11 15:05:23 +02:00
MHSanaei 6a90f98412 feat(inbounds): add sub/client link endpoints; hide panel version on login
- New GET /panel/api/inbounds/getSubLinks/:subId and /getClientLinks/:id/:email
  return the same protocol URLs the panel UI's Copy button emits, honouring
  X-Forwarded-Host / X-Forwarded-Proto. Documented in the API docs page.
- Refactor: sub package no longer imports web. The embedded dist FS is
  injected via sub.SetDistFS, and the link generator is registered with the
  service layer via service.RegisterSubLinkProvider, avoiding the circular
  import the new endpoints would otherwise introduce.
- Security: stop emitting window.X_UI_CUR_VER on login.html and drop the
  visible version chip from the login page, so the panel version is no
  longer pre-auth info disclosure. Authenticated pages still receive it.
- Bump config/version.
2026-05-11 15:03:47 +02:00
Farhad H. P. Shirvan 9318c2105f fix(xray): implement graceful shutdown for xray process and add tests (#4259) 2026-05-11 14:11:40 +02:00
MHSanaei e642f7324e feat(panel): in-panel API documentation page
New /panel/api-docs route with a one-page reference covering every
/panel/api/* endpoint (Auth, Inbounds, Server, Nodes, Custom Geo,
Backup) plus a Bearer-token primer that reads the current token and
exposes Show/Copy/Regenerate inline. Sidebar gets an API Docs entry
right after Xray; the menu label is shared via menu.apiDocs across all
13 locales.
2026-05-11 13:57:42 +02:00
MHSanaei 7214ffafc5 fix(inbounds): scope port check to node and preserve caller tag
Different nodes are different machines, so same port + transport across
NodeIDs shouldn't conflict. resolveInboundTag now keeps a caller-supplied
unique tag verbatim so central and node panels stay in agreement instead
of regenerating into a UNIQUE constraint failure on sync.
2026-05-11 12:51:45 +02:00
MHSanaei 88061bac10 fix(theme): default to dark, polish theme cycle visibility and hover
New installs land on plain dark instead of ultra-dark. The cycle button
icon now has an explicit colour so it stays visible inside the mobile
drawer (the previous color:inherit didn't cascade through the teleported
node), and hover/focus matches the menu's blue across sidebar, login,
and sub pages.
2026-05-11 12:51:37 +02:00
MHSanaei b5479f3f30 feat(sidebar): pin Logout above trigger, inline 3-state theme cycle
The desktop sider stretched to match the page height, so below lg
(992px) where dashboard cards stack into one column the collapse
trigger plus Logout slid off-screen. Pin the sider with
`position: sticky; height: 100vh; align-self: flex-start` so the chrome
stays viewport-tall. Split the menu into `.sider-nav` (flex: 1,
scrollable) and `.sider-utility` so Logout sits directly above the
48px trigger reserved by padding-bottom.

Replace the `<ThemeSwitch>` a-sub-menu with a single inline icon
button next to the '3X-UI' brand (sun / moon / moon+star SVG). One
click cycles Light -> Dark -> Ultra Dark -> Light. ThemeSwitch.vue
removed since it is now inlined.

Override AD-Vue dark Menu selected + hover/active state on the
sider-nav, sider-utility, and drawer menus to use the same light-blue
tint AD-Vue's light theme uses (rgba(64,150,255,0.2) / #4096ff). The
default dark variant was too subtle against #252526, so the current
page and Logout-on-hover barely distinguished themselves.
2026-05-11 12:05:45 +02:00
MHSanaei d8aedcdde4 fix(inbounds): bulk-delete keeps last client to satisfy backend constraint
DelClient rejects the removal that would leave an inbound with zero
clients (the constraint exists because Xray protocols need at least
one client to keep the inbound functional). The bulk-delete flow
fired one DelClient call per picked client in a loop, so picking
every client meant the final iteration always errored out with
"no client remained in Inbound" and surfaced as a red toast even
though N-1 deletions had already gone through.

Now confirmBulkDelete detects the "all selected" case up front,
drops the last client from the request, and surfaces the partial
operation in the confirm dialog ("N-1 / N — last selected will
remain. Delete the inbound to remove all."). The pre-existing
single-row delete path and partial-selection bulk delete paths are
untouched. If the only client in the inbound is selected, a
Modal.warning explains the constraint instead of asking for confirm.
2026-05-11 10:22:52 +02:00
MHSanaei 5f3e9ed0ea feat(xray/nord): searchable server list + colored load tag, surface API errors
Frontend (NordModal.vue):
- Server selector gets show-search with the option label set to
  `${cityName} ${name} ${hostname}` so admins can find a specific
  server inside a 100+ entry country list by typing.
- Each option renders the load as a colored a-tag (green <30%,
  orange 30-70%, red >70%) instead of plain text — quicker visual
  scan when sorting through servers in the dropdown.

Backend (nord.go):
- GetCountries / GetServers now check resp.StatusCode and return
  "NordVPN API error: <status>" on non-200, matching the pattern
  GetCredentials already used. Previously a 4xx/5xx body was
  returned as a "success" string and the frontend silently failed
  to parse it, surfacing only as an empty "No servers found".
- GetCredentials drops its own ad-hoc 10s http.Client and reuses
  the shared nordHTTPClient (15s) — one client, one timeout.
2026-05-11 10:06:01 +02:00
MHSanaei 3e8a0eb93e fix(inbounds): paginate expanded client list, restore ID column, hide empty Remark
- ClientRowTable now applies the General-Settings pageSize to its
  expanded client list. The 3.0 rewrite dropped pagination, so users
  with thousands of clients per inbound hit a 30-60s browser hang on
  expand (#4233).
- ID column was marked responsive: ['xs'] so it was hidden on desktop;
  removed the restriction so it shows as the first column everywhere.
- Remark column is now omitted entirely when no inbound has a non-empty
  remark, matching the existing Node-column pattern.
2026-05-11 09:05:47 +02:00
MHSanaei 4c2915586c fix(alpine): restart_xray uses rc-service; OpenRC reload reads pidfile contents
`14. Restart Xray` failed on Alpine with `systemctl: command not found` —
restart_xray was the only service action missing an Alpine branch. While
fixing it, the OpenRC reload action was passing the pidfile path to `kill`
instead of the PID inside it, so `rc-service x-ui reload` would have
failed too.
2026-05-11 09:05:36 +02:00
Harry NG 9f06bffbea chore: fix remarks shadowrocket subscription (#4247) 2026-05-11 08:24:22 +02:00
Amirmohammad Sadat Shokouhi e20d73ba7e add loopback and dns servers tag to inbound lists in RuleFormModal (#4244)
* add loopback and dns servers tag to inbound lists in RuleFormModal

* fix: remove clientIp from dns section when its empty
2026-05-11 08:23:30 +02:00
MHSanaei 8834e5fbbe feat(xray/outbounds): TCP probe mode + Test All + timing breakdown
- service.TestOutbound now dispatches on `mode`:
  - "tcp": parallel net.DialTimeout to every server/peer endpoint
    (vmess/vless/trojan/ss/socks/http/wireguard). No xray spin-up,
    no semaphore — safe to run concurrently across outbounds.
  - "http" (default): existing temp-xray + SOCKS path, now with an
    httptrace.ClientTrace breakdown (DNS / Connect / TLS / TTFB)
    alongside the total delay and status code.
- testSemaphore renamed to httpTestSemaphore — only HTTP probes
  serialise, TCP runs free.
- TestOutboundResult carries the per-mode extras: timing fields for
  HTTP, per-endpoint dial list for TCP, plus a `mode` echo.
- Controller reads `mode` from the form and passes it through.
- useXraySetting: testOutbound accepts mode (default "tcp"); new
  testAllOutbounds(mode) runs a worker pool (concurrency 8 for TCP,
  1 for HTTP) and skips blackhole / loopback / blocked outbounds —
  also skips freedom / dns under TCP since they have no endpoint.
- OutboundsTab: TCP/HTTP radio toggle and a Test All button land in
  the toolbar; the per-row  now uses the selected mode. Results
  surface in a popover with the full timing breakdown plus the
  endpoint list for TCP probes. Latency header replaces the duplicate
  "check" column title.

Practical effect: testing ten outbounds in TCP mode drops from ~50–100s
(serial HTTP) to ~1–2s (parallel dial × 8). HTTP mode stays as the
authoritative probe and now shows where the latency actually lives.
2026-05-11 04:17:23 +02:00
MHSanaei 6d732d8d32 feat(inbounds): bulk-select clients + UX polish
- ClientBulkModal: add `comment` and VLESS `reverseTag` fields so the
  bulk-add modal can set them on every generated client (matching the
  single-client form)
- ClientRowTable: add multi-select checkboxes (desktop + mobile) with a
  tri-state select-all and a sticky bulk-action bar; emits a new
  `delete-clients` event so the parent can wipe the picked clients in
  one go. Hidden entirely when the inbound has only one client (the
  last one must stay)
- ClientRowTable: new "Remained" column shows live remaining quota
  per client (∞ for unlimited, red when depleted)
- InboundInfoModal: Remained cell now shows the ∞ tag when the client
  has no totalGB limit, matching how Total Usage already renders it
- InboundsPage: add Online tag (+ per-bucket popovers listing client
  emails) to the summary card so it mirrors the per-inbound row, and
  wire an `onDeleteClients` handler that loops the existing single-
  delete endpoint then refreshes once
- InboundList: forward the `delete-clients` event; hide empty remarks
  on both the desktop table (custom #bodyCell) and the mobile card
- useInbounds: aggregate an `online` email list across all inbounds
  so the summary popover has data to render
2026-05-11 03:50:28 +02:00
MHSanaei e4900f1bd4 feat(install): add skip-SSL option for reverse-proxy / SSH-tunnel setups
Adds a 4th choice to the install-time SSL prompt for users who terminate
TLS elsewhere (nginx, Caddy, Traefik) or only reach the panel through an
SSH tunnel — closes #3802.

- Option 4 prints a clear warning, then optionally binds the panel to
  127.0.0.1 via `x-ui setting -listenIP` so it's unreachable from the
  public internet
- When the user binds to 127.0.0.1, print the same SSH port-forwarding
  command set that x-ui.sh's SSH_port_forwarding() already shows, so
  remote access is one ssh -L away
- Track SSL_SCHEME so the final "Access URL:" line shows http:// when
  SSL is skipped, instead of misleadingly advertising https://
- Soften the section header from "(MANDATORY)" to "(RECOMMENDED)" and
  print "SSL Certificate: Skipped" when option 4 is chosen
- Rework the SSL menu copy to a parallel "verb — what (constraint)"
  shape with a single Tip line focused on option 4's risks
2026-05-11 02:46:47 +02:00
MHSanaei 04828246fc feat(frontend): swap QRious for ant-design-vue's a-qrcode
- Migrate SubPage, QrPanel and TwoFactorModal from a QRious canvas to
  <a-qrcode type="svg">, which renders the QR matrix as crispEdges
  SVG rectangles — pixel-perfect at any display size or DPR, no more
  white scan-line artifacts from non-integer canvas scaling
- Drop the now-unused qrious dependency and its manualChunks entry
- Default the panel to ultra-dark on first load (existing user
  preferences in localStorage are preserved)
- Let the sub controller read subpage.html from web/dist/ first and
  fall back to the embedded copy, so Vite rebuilds in dev no longer
  require a Go recompile to refresh the asset hashes
2026-05-11 02:07:47 +02:00
MHSanaei c1efc48694 feat(frontend): refresh dark theme + redesign login page
- Swap navy dark palette for VS Code Dark+ neutrals (#1e1e1e/#252526/
  #2d2d30) across theme tokens, page backgrounds and DateTimePicker
- Add brand header to the mobile drawer and desktop sider, and recolor
  the drawer body so it reads as one panel with the menu
- Redesign login page with a centered card, cycling Hello/Welcome
  headline and per-theme animated gradient-blob backgrounds
2026-05-11 01:10:05 +02:00
MHSanaei f1760b0a28 feat(xray/balancer): restore observatory editor + auto-sync selectors
The Vue3 migration dropped the Observatory / Burst Observatory section
that used to sit under the balancer table. Without it, leastPing /
leastLoad strategies had nowhere to populate Xray's required
subjectSelector, so balancers that depended on probe data silently
ran with an empty observer config.

- Auto-seed and sync `observatory` for leastPing balancers and
  `burstObservatory` for leastLoad balancers (subjectSelector
  recomputed from every matching balancer's selector list). Drops
  the observatory when no matching strategy remains.
- Defaults (probeURL, interval, connectivity, sampling) match the
  values the legacy panel shipped, themselves taken from the Xray
  docs at xtls.github.io/config/{observatory,burstobservatory}.html.
- Surface both observatories under the table as a radio-switched
  JSON textarea so admins can tune probe settings inline without
  dropping into the full xray template tab.
2026-05-11 00:11:09 +02:00
MHSanaei 745e394c74 refactor(panel): rename injected globals + collapse QR modal entries
Rename the SPA globals injected by Go to drop the ad-hoc dunder shape
and free up the bare `webBasePath` name (still the DB setting key)
from colliding with the JS global it used to share:
  window.__X_UI_BASE_PATH__ -> window.X_UI_BASE_PATH
  window.__X_UI_CUR_VER__   -> window.X_UI_CUR_VER

Also rework the QR-Code modal to fold every QR (subscription + JSON
sub URL, share links, WireGuard config/peer links) into a single
a-collapse with one panel per QR. Subscription panels are listed
first and open by default; everything else stays collapsed so a
multi-link inbound no longer scrolls forever.
2026-05-10 23:40:39 +02:00
MHSanaei 737300b14b fix(outbound): default VLESS encryption to "none"
A blank encryption field caused Xray to reject the outbound config with
'VLESS users: please add/set "encryption":"none"'. Default the
constructor parameter, coerce empty values, and final-guard toJson so
every code path emits a valid encryption value.
2026-05-10 23:06:28 +02:00
GRCR13 30469fcd10 fix: backup path with webbasepath (#4223)
* Update BackupModal.vue

add base path to importDB API call

* fix

add base path to importDB API call
2026-05-10 22:48:35 +02:00
MHSanaei 887fca86ec fix(fail2ban): escape % in 3x-ipl action date format (#4218)
Fail2ban parses % as variable interpolation in action.d configs, so the
unescaped %Y/%m/%d %H:%M:%S in the date command crashed fail2ban on
startup. Double the %s in the heredoc so the rendered action file
contains %% and fail2ban collapses it back to a literal % when invoking
the shell command.
2026-05-10 19:26:21 +02:00
MHSanaei 6efc4b0665 Revert "perf(frontend): code-split heavy components to improve LCP"
This reverts commit 444b05cac9.
2026-05-10 17:45:05 +02:00
MHSanaei 94a7dbfe3c fix(docker): pin frontend stage to BUILDPLATFORM and drop removed buildx input
node:22-alpine has no manifest for linux/arm/v6, breaking multi-arch
builds. Frontend output is static JS/CSS that doesn't need to be
built per target arch — pin the stage to $BUILDPLATFORM so Vite
always runs on the host. Also drop `install: true` from
setup-buildx-action@v4 (input was removed).
2026-05-10 17:22:15 +02:00
qwardo e2649f98df fix(arch): correct x-ui service path (#4213) 2026-05-10 17:17:33 +02:00
MHSanaei 3d839e0ee1 v3.0.0 2026-05-10 17:15:48 +02:00
MHSanaei a96612f595 feat(xray/dns): align DNS settings with Xray docs + UI polish
- DNS server modal: rename expectIPs -> expectedIPs (per docs); add
  per-server tag, clientIP, serveStale, serveExpiredTTL, timeoutMs;
  flip skipFallback default to false; hydration still accepts legacy
  expectIPs for back-compat.
- DNS tab: add hosts editor (domain -> IP/array), serveStale +
  serveExpiredTTL controls, "Use Preset" button bringing back the
  legacy preset gallery (Google / Cloudflare / AdGuard + Family
  variants — fixed AdGuard Family IPs that were wrong in legacy),
  and a "Delete All" button to wipe the server list at once.
- i18n: add 15 new dns.* keys across all 13 locales.
- Frontend-wide formatter pass on Vue components (whitespace and
  attribute layout only, no behavior changes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 17:03:11 +02:00
MHSanaei 8e7d215b4a feat(nodes): traffic-writer queue, full-mirror sync, WS event fixes
- Traffic-writer single-consumer queue (web/service/traffic_writer.go)
  serialises every DB write that touches up/down/all_time/last_online
  (AddTraffic, SetRemoteTraffic, Reset*, UpdateClientTrafficByEmail) so
  overlapping goroutines can no longer clobber each other's column-scoped
  Updates with a stale tx.Save.

- DB pool: WAL + busy_timeout=10s + synchronous=NORMAL + _txlock=
  immediate, MaxOpenConns=8 / MaxIdleConns=4. The immediate-tx PRAGMA
  fixes residual "database is locked [0ms]" cases where deferred-tx
  writer-upgrade conflicts bypass busy_timeout.

- SetRemoteTraffic full-mirrors node-authoritative state into central:
  settings JSON, remark, listen, port, total, expiry, all_time, enable,
  plus per-client total/expiry/reset/all_time. Inbounds and
  client_traffics rows present on node but missing from central are
  created; rows missing from snap are deleted (with cascading
  client_traffics removal).

- NodeTrafficSyncJob detects structural changes from the mirror and
  broadcasts invalidate(inbounds) so open central UIs re-fetch via REST
  on node-side add/del/edit without manual refresh.

- XrayTrafficJob broadcasts invalidate(inbounds) when auto-disable flips
  client_traffics.enable so the per-client toggle reflects depletion
  without manual refresh.

- Frontend: inbounds page now subscribes to the BroadcastInbounds 'inbounds'
  WS event (full-list pushes from add/del/update controllers were silently
  dropped). Fixes invalidate payload field (dataType -> type). Restart-
  panel modal switched from Promise-wrap to onOk-only so Cancel actually
  cancels.

- Node files trimmed of stale prose-comments; cron cadence dropped
  10s -> 5s to match the inbounds page UX.

- README badges and Go module path bumped v2 -> v3 to match module rename.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 16:25:23 +02:00
Qiaochu Hu 24cd271486 Fix overly permissive file permissions (os.ModePerm) (#4207)
Several file operations used os.ModePerm (0777) which makes files
world-writable and world-readable, violating the principle of least
privilege:

- database/db.go: InitDB directory creation → 0755
- xray/process.go: Xray config write → 0644
- xray/process.go: Crash report write → 0644
- web/service/server.go: Binary extraction → 0755

Also removes unused "io/fs" imports from the affected files.
2026-05-10 14:47:28 +02:00
Qiaochu Hu dee2525d5f Fix silently ignored errors in password migration seeder (#4206)
The runSeeders function in database/db.go had three database operations
whose errors were silently ignored:

1. Pluck("seeder_name", &seedersHistory) - if this fails, the seeder
   might re-run and double-hash already bcrypt'd passwords, corrupting
   them
2. Find(&users) - if this fails, no users get migrated but the seeder
   still marks itself as complete
3. Update("password", hashedPassword) - if this fails for a user, their
   password silently remains in the old format

All three now properly check and return errors with descriptive messages.
2026-05-10 14:46:42 +02:00
Qiaochu Hu 81b4ae5661 Fix silently ignored error when saving outbound test URL setting (#4209)
In the Xray settings update handler, the error from
SetXrayOutboundTestUrl was silently discarded. If the database write
failed, the user received a success toast ("Settings updated
successfully") but the outbound test URL was not actually saved.

Now properly checks the error and returns a failure response to the
user, consistent with how the preceding SaveXraySetting call is
handled.
2026-05-10 14:45:53 +02:00
Ali Fotouhi 9cbba130ab fix(xray): clear outbound test state on delete to prevent result bleed (#4205) 2026-05-10 12:03:00 +02:00
MHSanaei cf5767acd1 i18n: localize sidebar theme toggle, xray-status badge, and nodes menu
The sidebar theme submenu (Theme / Dark / Ultra dark) and the dashboard's
Xray status badge ("Xray is running" etc.) were hardcoded English strings.
Wire them through vue-i18n: ThemeSwitch.vue uses menu.theme/dark/ultraDark,
and XrayStatusCard.vue derives the badge text from the existing
pages.index.xrayStatus{Running,Stop,Error,Unknown} keys (status.js no
longer carries an English stateMsg field).

The "Nodes" menu item was already keyed as menu.nodes but only en-US and
fa-IR had a translation; add it to the other 11 languages, matching the
wording each file already uses for pages.nodes.title.
#4201
2026-05-10 11:56:30 +02:00
MHSanaei 444b05cac9 perf(frontend): code-split heavy components to improve LCP
Switch the inbounds-page modals, login page's theme switch, and the
Persian date picker to defineAsyncComponent. They're not needed on
first paint, so deferring them shrinks the initial bundle and lets
the LCP element render sooner.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 11:33:46 +02:00
MHSanaei f70e131dfe fix(nodes): bind form-encoded posts and skip node inbounds in central xray
The Node model only carried `json:` tags, so when the panel's axios
posted form-encoded bodies to /panel/api/nodes/add and /test, Gin's
form binder produced a zero-valued Node — empty Name, empty Address,
Port=0 — surfacing as "node name is required" and a probe URL of
"https://:0/...". Add `form:` tags so add/test bind correctly.

Also skip inbounds with NodeID set when building the central xray
config; otherwise the central panel tried to listen on ports owned by
node-managed inbounds and xray-core failed to start with a bind
collision.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 11:32:06 +02:00
Amirmohammad Sadat Shokouhi 14165fc54d avoid reset in QueryStatsRequest (#4202) 2026-05-10 10:59:42 +02:00
MHSanaei 7cd26a0583 v3 2026-05-10 02:13:42 +02:00
MHSanaei 267fb1c866 refactor(inbounds): reorder Inbound's Data tabs (client first, sub inline)
Show the per-client pane as the default tab and fold the subscription
URLs into its bottom (under a divider) so the modal has two tabs
instead of three. Inbound details move to the second tab and remain
the default fallback for protocol-only entries (HTTP/Mixed/Tunnel/
WireGuard) that have no per-client view.
2026-05-10 01:59:02 +02:00
MHSanaei 5ac88271af feat(inbounds): mobile card layout for inbounds and clients
Replace the cramped <a-table> on <768px with a stacked card list for
both inbounds and the per-client expanded rows. Each card surfaces
protocol, port, node, traffic, all-time traffic, client count and
expiry inline as labeled rows instead of hiding them behind popovers,
fixes the 0px gutter that made cards visually merge, and softens the
in-quota green from #52c41a to #389e0a (Ant green-7) so traffic tags
are no longer blinding on dark themes.
2026-05-10 01:46:48 +02:00
MHSanaei b776b33497 fix(ui): correct responsive breakpoints for add client form and bulk 2026-05-10 00:52:22 +02:00
MHSanaei 1478124712 fix(ui): correct responsive breakpoints for inbound form and settings
InboundFormModal forms specified label/wrapper cols only at md
(>=768), leaving 576-767 unset and breaking the grid in that range.
Move the breakpoint down to sm so the desktop 8/14 split applies
from 576 upward.

SettingListItem had its breakpoints inverted: at <992 no span was
set so the meta and control cols squeezed side-by-side, and at lg
(992-1199) they stacked. Switch to xs/lg so input stacks below the
text under 992 and sits beside it from 992 upward.
2026-05-10 00:43:25 +02:00
MHSanaei 9735d26b3d perf(xray): bound Xray-version request and extend cache
Replace the unbounded http.Get used by GetXrayVersions with a 10s-
timeout client so a slow or unreachable GitHub can't hang the Xray
Updates modal. Bump the controller cache from 60s to 15 minutes,
and on a request error fall back to the last successful list when
one is available.
2026-05-10 00:24:25 +02:00
MHSanaei 113a29733e feat(logs): mobile-friendly log modals with theme-aware colors
Both index-page log modals (panel logs and xray access logs) now
adapt to narrow viewports and dark / ultra-dark themes:

- Render through Vue templates instead of v-html — drops the manual
  escapeHtml helper and the regex-based string formatting; each line
  is parsed once into structured fields (date, time, level, body for
  panel logs; from / to / inbound / outbound / email for xray logs).
- Mobile: stacked cards per entry. Panel-log cards show time + a
  level badge above the wrapped message; xray-log cards show time
  and event tag above the From → To pair, with inbound / outbound /
  email as small meta pairs below. Long IPv6 / hostnames wrap
  instead of overflowing.
- Modal goes full-bleed on mobile (100vw, no rounded corners,
  pinned to viewport height) so cards get full width.
- Toolbar wraps cleanly when the row-count, level, syslog checkbox,
  and download button can't fit on one line.
- Theme-aware colour palette via CSS variables on .log-container —
  brighter shades on body.dark and [data-theme="ultra-dark"] so
  level text and blocked / proxy rows keep AA contrast against the
  navy and near-black surfaces.
- Cards render flush on the container surface (no separate card bg)
  so the colour story is identical to the desktop view.
2026-05-10 00:13:20 +02:00
MHSanaei 3505430e57 fix(docker): include web/translation in frontend and final stages
The Vite SPA reads locale JSON via a glob that resolves to
<repo>/web/translation/*.json, but the frontend build stage only
copied frontend/, so the production bundle shipped with no messages
and the Docker panel rendered untranslated keys. Copy the directory
into the frontend stage at the path the glob expects, and into the
final image so the Go disk fallback in locale.loadTranslationsFromDisk
also has somewhere to read from.
2026-05-09 23:30:54 +02:00
MHSanaei f68a14a3ca fix(xray): align DNS outbound to spec and repair item-list rules UI
DNS outbound now mirrors xray-core's documented shape: rewriteNetwork
/ rewriteAddress / rewritePort / userLevel replace the legacy network
/ address / port keys, and unset values are dropped on the wire. Old
configs are still accepted on read so saved configs migrate cleanly.

While there, fix two latent bugs in repeat-item editors (DNS rules,
Freedom noise, WireGuard peers):
- The "+" buttons pushed plain objects into arrays of class instances,
  so toJson() crashed on the next read and the JSON tab silently went
  blank. Push proper class instances instead.
- Each item heading lived outside any a-form-item, so the delete icon
  ignored the form's column grid and slumped left. Wrap the heading
  in a form-item with the standard offset wrapper-col and switch the
  flex to space-between so the icon sits at the right of the input
  column, in line with the fields below it.
2026-05-09 23:17:31 +02:00
MHSanaei 60e2af088d feat(xray): add loopback outbound protocol
#4185
Surface xray-core's loopback outbound in the Outbounds form so users
can re-route already-processed traffic back into a named inbound for
secondary routing (e.g. splitting TCP/UDP from one ingress). The
inboundTag field is an autocomplete over existing inbound tags, with
free-text fallback for inbounds defined outside the panel. Loopback
outbounds are excluded from the connectivity test since they have no
network endpoint.
2026-05-09 22:49:49 +02:00
MHSanaei 917f9b307e fix(xray): surface reverse tags in routing and balancer dropdowns
Outbound reverse tags now appear as inbound options in routing rules
(#4199), and inbound-client reverse tags appear as outbounds in the
balancer selector (#4187). Both represent virtual endpoints created by
xray-core that the dropdowns previously missed.
2026-05-09 22:03:01 +02:00
MHSanaei 61c84e8223 fix(panel): make webBasePath work end-to-end in dev and prod
- Vite dev server reads webBasePath from x-ui.db via node:sqlite and
  injects __X_UI_BASE_PATH__ on every HTML serve, mirroring dist.go.
  Single broad proxy regex catches backend routes whether the URL is
  prefixed or not, and the bypass serves login.html for the bare
  basePath URL so post-logout navigation lands on Vite's own page
  instead of the production dist HTML's hashed asset URLs.
- axios.defaults.baseURL is set from __X_UI_BASE_PATH__ at startup so
  HttpUtil calls reach the backend's basePath group instead of 404ing
  on every prefixed install. fetch() for the public CSRF endpoint
  prepends the prefix manually since it doesn't honor axios defaults.
- Logout/redirect responses set Cache-Control: no-store and the index
  handler's logged-in redirect uses an absolute base_path+panel/ URL,
  preventing browsers from replaying a stale cached 307 that bounced
  the user back to /panel/ after logout.
- ClearSession also issues a Path=/ deletion cookie when basePath is
  not "/", so a legacy cookie from an earlier basePath setting can't
  keep IsLogin returning true after logout.
- getPanelUpdateInfo no longer returns a translated error message on
  GitHub fetch failures, so HttpUtil's auto-popup stays quiet on
  offline / blocked environments.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:47:37 +02:00
MHSanaei 72d8ebd269 fix(x-ui.sh): pass silent flag to stop/start during IP SSL setup 2026-05-09 19:59:01 +02:00
MHSanaei b885a1f8a6 fix(index): improve mobile dashboard layout
- Move System History action from the 3X-UI card into the System Load
  card's #extra slot so the chart opener sits next to live load values.
- Fix card widths on mobile by switching :sm="24" to :xs="24"; the sm
  breakpoint only kicks in at >=576px, so phones in portrait had no
  span set and cards shrank to content width.
- Restore vertical spacing between cards (vertical gutter was 0 on
  mobile) and reduce content padding on small screens, reserving 64px
  top so the sidebar drawer handle no longer overlaps the StatusCard.
- Wrap the 3X-UI link tags in a flex container so version/Telegram/docs
  chips wrap with consistent spacing on narrow widths.
- Make Sparkline's viewBox track its actual rendered pixel width via
  ResizeObserver so X-axis time labels stop being squashed horizontally
  by preserveAspectRatio="none" on narrow containers.
- Make the SystemHistory modal width responsive (95vw on mobile, was a
  fixed 900px that overflowed phone viewports).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:03:09 +02:00
MHSanaei 439f4cf1e8 Build frontend for CodeQL; remove release analyze job
In the CodeQL workflow, add Node.js setup and a frontend build step for the Go matrix so vite emits web/dist before CodeQL's Go autobuild (the Go binary uses //go:embed all:dist and web/dist is .gitignored). In the release workflow, remove the separate Go analyze job (gofmt, go vet, staticcheck, tests) and drop its dependency from build jobs to simplify the release pipeline.
2026-05-09 18:01:41 +02:00
Sanaei bc00d37ad8 Vue3 migration (#4198)
* docs(migration): Phase 1 inventory — Vue 2 / AD-Vue 1 surface area

Captures the breakage surface for the Vue 3 + Ant Design Vue 4 + Vite
migration: 17,650 lines across 69 templates, 3,145 a-* component
instances across 63 files, with per-pattern counts and file lists.

Key findings:
- No Vue filters anywhere — dodges a major Vue 3 breaking change
- 358 v-model uses; AD-Vue 4 absorbs most, custom components don't
- 233 <template slot="X"> usages must become <template #X>
- 49 scopedSlots: { ... } column defs need new slots: { ... } shape
- a-icon is removed in AD-Vue 4 — every icon must be imported

Establishes the 8-phase order; Phase 2 (Vite toolchain) is next.

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

* build(frontend): Phase 2 — scaffold Vite + Vue 3 + AD-Vue 4

Adds a frontend/ directory that lives alongside the legacy web/html/
Vue 2 templates during the migration. Vite builds into ../web/dist/
so the Go binary will be able to embed the result via embed.FS once
Phase 4 starts moving real pages over.

- package.json pins Vue 3.5, Ant Design Vue 4.2, Vite 6, vue-i18n 10
- vite.config.js: dev server on :5173 with API proxy to the Go panel
  on :2053; build output to ../web/dist/
- src/App.vue is currently a smoke-test placeholder — delete once the
  first real page (login) lands in Phase 4
- node_modules and dist are already ignored at repo root

To verify locally:
  cd frontend && npm install && npm run dev

Pages will be migrated one at a time on the vue3-migration branch.

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

* refactor(frontend): Phase 3 — port utils, models, axios, websocket as ES modules

Ports the framework-agnostic JS from web/assets/js/ into frontend/src/
so Vue 3 pages can import what they need without relying on script-tag
globals.

- web/assets/js/util/index.js (927 lines, 21 classes) →
  frontend/src/utils/legacy.js + a barrel at utils/index.js. All
  classes are now named exports.
- Vue.prototype.$message in HttpUtil → direct import of `message`
  from ant-design-vue (Vue 3 has no Vue.prototype).
- RandomUtil.randomShadowsocksPassword previously defaulted to
  SSMethods.BLAKE3_AES_256_GCM from inbound.js, creating a circular
  import. Replaced with the literal string default.
- MediaQueryMixin (Vue 2 mixin) removed. Replaced by
  composables/useMediaQuery.js — Vue 3 composable returning reactive
  `isMobile`.
- axios-init.js wrapped as setupAxios(); Qs global → npm `qs`.
- websocket.js exported as WebSocketClient class; the implicit
  window.wsClient global is gone — pages instantiate it themselves.
- model/{inbound,outbound,dbinbound,setting,reality_targets}.js
  copied with `export` added on every top-level declaration. Imports
  between models and utils are wired up explicitly.
- subscription.js deferred to Phase 5 (it's a Vue 2 mount, not a util).
- App.vue smoke test exercises SizeFormatter / RandomUtil / Wireguard /
  useMediaQuery so the user can verify Phase 3 with `npm run dev`.

Run `cd frontend && npm install && npm run dev` — qs was added so a
fresh install is required.

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

* feat(frontend): Phase 4 — port login.html to Vue 3 + AD-Vue 4 + Vite 8

First real page in the new toolchain. Multi-page Vite: each migrated
page is its own entry. login.html now lives at frontend/login.html with
a thin entrypoint at frontend/src/login.js mounting LoginPage.vue.

Vite 6 → Vite 8.0.11 (per user request). Requires Node 20.19+ or 22.12+.
@vitejs/plugin-vue bumped to ^6.0.6 (peers vite ^8). Ant Design Vue
stays on 4.2.6 — there is no AD-Vue 6.

Vue 2 → Vue 3 / AD-Vue 1 → AD-Vue 4 syntax changes hit on this page:
- new Vue({ el, delimiters, data, methods }) → createApp + <script setup>
- mounted() → onMounted()
- <template slot="X"> → <template #X>
- <a-icon slot="prefix" type="user"> → <template #prefix><UserOutlined />
  </template> with explicit @ant-design/icons-vue imports
- v-model.trim → v-model:value (AD-Vue 4 uses named v-model on inputs)

Three legacy features deferred so Phase 4 stays small:
- i18n (Phase 7 wires up vue-i18n)
- theme switcher (custom component pending Phase 5)
- headline word-cycle animation (purely aesthetic)

Run `cd frontend && npm install && npm run dev`, open
http://localhost:5173/login.html. With Go panel running on :2053 the
form submits real credentials via the configured proxy.

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

* feat(frontend): Phase 5a — theme system + Vite 8 + vue-i18n 11

Bumps Vite to 8.0.11 (npm install picked up 6.4.2 from the stale
lockfile; clean install resolves the new constraint). Bumps vue-i18n
to 11.1.4 since v10 was just EOL'd.

Migrates aThemeSwitch.html — the two-flavor theme picker + global
themeSwitcher object — into:

- composables/useTheme.js: single reactive `theme` state with
  toggleTheme / toggleUltra. Boot side-effect applies the stored theme
  to <body>/<html> before Vue renders; watchEffect persists changes
  back to localStorage.
- components/ThemeSwitch.vue: full menu version for the main panel.
- components/ThemeSwitchLogin.vue: login-popover version.

AD-Vue 1 → 4 changes hit on this component:
- <a-icon type="bulb" :theme="filled|outlined"> dropped — replaced by
  explicit BulbFilled / BulbOutlined imports from
  @ant-design/icons-vue, swapped via <component :is="BulbIcon">
- Vue.component('a-theme-switch', { ... }) global registration → SFC
  + per-page import
- this.$message.config(...) (Vue 2 instance method) → message.config(...)
  imported from ant-design-vue, called once in login.js at boot

Login page now surfaces a settings button → popover → theme picker.

Known gap: web/assets/css/custom.min.css isn't yet imported into the
new bundle, so toggling dark mode currently only re-themes AD-Vue's
own components, not the panel chrome. The body class is still toggled
so behavior is correct; visual fidelity returns when custom.css is
ported or directly imported.

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

* feat(frontend): Phase 5b — port four shared components to Vue 3

CustomStatistic.vue and SettingListItem.vue are mechanical
Vue.component → SFC ports.

AppSidebar.vue: AD-Vue 4 dropped <a-icon :type="dynamic">, so the
five sidebar icons (dashboard/user/setting/tool/logout) live in a
name→component map and render via <component :is>. The legacy
<a-drawer slot="handle"> hack is replaced with a sibling fixed-
position toggle button. Tab paths take basePath/requestUri as
props instead of pulling them from Go template scope.

TableSortable.vue: the biggest Vue 3 rewrite of this phase.

  - $listeners is gone — replaced by inheritAttrs: false +
    explicit attrs forwarding
  - scopedSlots: this.$scopedSlots collapsed into Vue 3's unified
    slots object — just iterate Object.keys(this.slots) and forward
  - Vue 2 h(tag, { props, on, scopedSlots }, children) →
    Vue 3 h(tag, { ...props, ...on }, slotsObject)
  - 'a-table' string → resolveComponent('a-table') so app.use(Antd)
    registration is honored
  - inject: ['sortable'] (Options API) → inject('sortable', null)
    (Composition API) inside the trigger child
  - beforeDestroy → beforeUnmount
  - customRow's return shape flattened (no nested props/on/class)

Two intentional skips, documented in the migration doc:

  - aClientTable.html — slot fragments, not a component. Migrates
    inline with inbounds.html (new Phase 5f).
  - aPersianDatepicker.html — wraps a Persian-only third-party
    lib; defer until settings.html lands.

Build verified with vite 8.0.11.

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

* fix(frontend): anchor Vite dev proxy so /login.html isn't forwarded

The /login proxy entry was matching any path starting with /login —
including /login.html, which Vite is supposed to serve itself. Without
the Go backend running, this caused ECONNREFUSED noise on every page
load.

Switched to regex patterns anchored with ^...$ so only the bare backend
paths (/login, /logout, /getTwoFactorEnable) and explicit sub-routes
(/panel/*, /server/*) get proxied. Static .html files Vite serves
directly are no longer matched.

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

* fix(frontend): real dark mode + silence dev proxy ECONNREFUSED noise

Two issues from running login.html against no Go backend:

1. Dark mode toggled the body class but didn't actually re-theme any
   AD-Vue components. The legacy panel relied on custom.min.css which
   we haven't ported. AD-Vue 4 ships its own dark algorithm — wrap
   LoginPage in <a-config-provider :theme="{ algorithm }"> driven by
   our useTheme state, and AD-Vue restyles every component for free.
   Page chrome (background, card, title) gets explicit .is-dark CSS
   since the algorithm only covers AD-Vue components.

2. Vite logged every failed proxy attempt loudly. When the Go panel
   isn't running locally that's pure noise. Added a configure()
   callback that swallows ECONNREFUSED specifically; real errors
   (timeouts, 5xx, anything else) still surface.

Both fixes are dev-experience only — production build is unchanged.

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

* fix(frontend): use legacy panel palette for login page dark mode

Earlier dark mode used invented colors (#141a26 page bg, #1f2937 card)
that didn't match the rest of the panel. Replaced with the actual
values from web/assets/css/custom.min.css:

  light          dark             ultra-dark
  bg #c7ebe2     bg #222d42       bg #0f2d32
  card #fff      card #151f31     card #0c0e12
  title #008771  title #fff/.92   title #fff/.92

Drove everything off CSS custom properties on .login-app so the
.is-dark / .is-ultra class swap is a few var overrides instead of
duplicating selectors. Also restored the legacy card metrics
(2rem radius, 4rem 3rem padding, 2rem title) so the new page
matches the old panel's geometry, not just its colors.

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

* fix(frontend): match legacy wave layout + recolor for dark mode

The wave SVG had inline fill="#c7ebe2" (mint) on the bottom wave, so
in dark/ultra-dark mode it rendered as a pale-white blob against the
dark page. Stripped the inline fills, drove them off CSS variables
that swap with .is-dark / .is-ultra:

  light:      green tints + #c7ebe2 (mint) on the bottom wave
  dark:       #222d42 across all four waves
  ultra-dark: #0f2d32

The wave was also positioned wrong — anchored to the top 200px of
the viewport with absolute positioning. Restored the legacy layout:
  - .waves-header is fixed to the top of the viewport with z-index -1
    so the form floats over it
  - .waves-inner-header pushes the wave SVG down to ~50vh with a
    50vh-tall solid block of the page color
  - .waves SVG itself is 15vh tall, sitting at the bottom of that block

Net effect: top half is solid-colored, then a wavy edge transitions
into the rest of the page, with the form centered on top — matching
the legacy panel exactly.

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

* fix(frontend): bring wave-header to front so the wave actually shows

Two layering bugs were hiding the wave entirely:

1. .ant-layout-content had background: var(--bg-page) which painted an
   opaque rectangle covering the full content area — including the
   fixed wave-header behind it. Made the layout/content transparent
   and moved the bg paint up to .login-app (the outer ant-layout).

2. .waves-header had z-index: -1 which on its own was fine, but with
   .ant-layout-content opaque on top it was doubly buried. Promoted
   the wave-header to z-index: 0 and gave the form .login-row
   z-index: 1, so the form sits above the wave and the wave sits
   above the page-bg.

Also set --bg-page to the legacy mint (#c7ebe2) for light mode so the
bottom half of the page below the wave matches the legacy panel
(was white). Dark mode stays at the surface-100/login-wave palette.

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

* fix(frontend): match legacy wave animation timings + dark page bg

Two reasons the bottom wave looked static in dark/ultra-dark:

1. Animation durations were 7s/10s/13s/20s. Legacy uses 4s/7s/10s/13s.
   The 20s on the bottom wave was so slow that against the low dark-
   mode contrast it read as motionless. Restored the legacy timings.

2. --bg-page in dark mode was #151f31 (card color / surface-100), but
   the legacy .under uses surface-200 (#222d42) — that's the color of
   the bottom half of the page, the same as the wave fill, so the
   wave appears to flow into the page rather than meeting a hard edge.
   Now it does.

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

* feat(frontend): restore Hello/Welcome headline cycle on login

Earlier I deferred the legacy headline word-cycling animation as
"purely aesthetic". Restored it: the title now alternates between
'Hello' and 'Welcome' every 2 seconds, matching the legacy panel.

The legacy implementation toggled .is-visible / .is-hidden classes on
two <b> elements via setTimeout chains and DOM querying. Replaced
with a reactive ref + Vue 3 <Transition mode="out-in"> so the fade
between words is declarative — no manual DOM manipulation, and the
interval is properly cleaned up in onBeforeUnmount.

The earlier "Welcome to 3x-ui" string was wrong on two counts: it
should be just "Welcome", and it should be one of two cycling words
with "Hello" preceding it.

Ultra-dark palette already matched legacy after the prior wave timing
fix; no additional changes needed there beyond the animation speeds
that now also apply to ultra-dark via the shared CSS rules.

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

* fix(frontend): correct dark login bg + give ultra-dark wave real contrast

Two related fixes:

1. Default-dark wave-header bg was wrong. I had #0a2227, but that's
   the *ultra-dark* override; default dark uses --dark-color-background
   = #0a1222. Now the dark-mode top half is the legacy purple-blue
   instead of teal.

2. Ultra-dark wave fill is intentionally near-identical to its bg in
   the legacy palette (#0f2d32 vs #0a2227, ~5/11/11 RGB delta), which
   makes the wave look static even though the animation is running.
   Bumped --wave-fill / --wave-fill-bottom to #1f4d52 in ultra-dark
   only — far enough above the bg that the motion reads, while
   staying within the same teal hue family.

Also corrected ultra-dark --bg-page back to #0f2d32 (was briefly
#0c0e12, which is the card color, not the page color).

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

* fix(frontend): drop ultra-dark bottom-wave seam line

Last fix made the wave fill #1f4d52 in ultra-dark for both top-three
waves and the bottom wave, which gave visible motion but exposed a
hard horizontal line where the bottom wave's flat lower edge met the
page bg (#0f2d32). The user noticed it as "the wave at the bottom
not moving its like a line" — they were seeing the SVG's clipped
bottom edge, not the wave itself.

Solution: only the top three waves get the brighter fill (those carry
the visible motion). The bottom wave reverts to #0f2d32 = --bg-page,
so its flat bottom edge merges seamlessly into the page below. Net
effect: motion is still visible (from waves 2 and 3), and there's no
seam line at the bottom of the SVG.

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

* feat(frontend): Phase 5c-i — index.html dashboard shell

Replaces the smoke-test App.vue with a real IndexPage shell so the
/index.html route now boots the actual dashboard layout in Vue 3:

- a-config-provider drives AD-Vue 4's dark algorithm from useTheme
  (same pattern as LoginPage)
- AppSidebar (Phase 5b component) is wired in with basePath +
  requestUri props
- a-spin loading state with placeholder card while we build out the
  rest of the page
- Page palette mirrors the legacy: light #f0f2f5, dark #0a1222
  (--dark-color-background), ultra-dark #21242a

The 1,805-line legacy index.html is too big for one commit. Split
into five sub-phases on the todo list: ii) status cards + /server/status
polling, iii) xray status card, iv) logs/backup/panel-update modals,
v) custom-geo section.

frontend/src/App.vue and frontend/src/main.js (smoke-test scaffold)
are removed — both purposes now served by IndexPage and index.js.

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

* feat(frontend): Phase 5c-ii — live status cards on the dashboard

Adds the CPU / memory / swap / disk dashboard cards to IndexPage,
backed by a useStatus() composable that polls /panel/api/server/status
every 2 s and a Status / CurTotal model ported from the legacy inline
classes in index.html.

- models/status.js — Status & CurTotal classes (CurTotal exposes
  reactive .percent and .color computed-style getters; Status maps
  the API payload + xray state to color/message strings)
- composables/useStatus.js — 2s polling with shallowRef so each fetch
  swaps the whole Status object atomically. WebSocket integration
  intentionally deferred — the legacy panel falls back to this same
  2s polling when its websocket drops, so we ship the proven path
  first and add WS on top in a later sub-phase.
- pages/index/StatusCard.vue — four a-progress dashboard widgets in
  a 2x2 grid (mobile collapses to a 1x4). CPU widget exposes a
  history button; the modal it opens is part of 5c-iv.
- IndexPage now consumes both, plus useMediaQuery so the layout
  responds to viewport changes.

AD-Vue 4 changes: <a-icon type="area-chart"|"history"> dropped in
favor of explicit AreaChartOutlined / HistoryOutlined imports.
<a-tooltip slot="title"> → <template #title>.

i18n strings still hardcoded English (Phase 7 wires up vue-i18n).

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

* feat(frontend): Phase 5c-iii — xray status card + stop/restart controls

XrayStatusCard.vue renders the right-hand card on the dashboard:

- Title with mobile-only version tag (matches the legacy collapse)
- Animated badge for the running/stop/error states. The pulsing dot
  comes from xray-pulse keyframes (renamed from runningAnimation in
  legacy custom.min.css). Color rings on the badge use the legacy's
  per-state border-color overrides on .ant-badge-status-processing.
- Error state replaces the badge with a popover that surfaces the
  multi-line errorMsg + a logs shortcut.
- Action row at the bottom: optional logs (when ipLimitEnable),
  stop, restart, and version switch.

IndexPage now wires:
- POST /panel/api/server/stopXrayService and /restartXrayService,
  followed by a refresh() so the status card reflects the new state
  without waiting for the next poll tick
- POST /panel/setting/defaultSettings to read ipLimitEnable
- Stub handlers for the panel-logs / xray-logs / version-switch /
  cpu-history modals — those land in 5c-iv

AD-Vue 4 changes hit on this card:
- <a-icon type="bars|poweroff|reload|tool"> → explicit
  BarsOutlined / PoweroffOutlined / ReloadOutlined / ToolOutlined
- <span slot="title|content"> → <template #title|#content>
- The .xray-*-animation classes ship as global <style> (not scoped)
  so they pierce AD-Vue's internal .ant-badge-status-* DOM.

i18n still hardcoded English; Phase 7 wires vue-i18n.

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

* feat(frontend): Phase 5c-iv (a) — panel update / logs / backup modals

Adds three of the six dashboard modals plus a Quick Actions card
that surfaces them. The remaining three (xray logs, version picker,
CPU history sparkline) ship in 5c-iv-b.

- PanelUpdateModal.vue — current vs latest version, "update now"
  button. Confirm dialog → POST /panel/api/server/updatePanel,
  then poll /server/status for up to 90s until the new panel
  answers, then reload.
- LogModal.vue — panel logs viewer. Filters: rows (10-500), level
  (debug/info/notice/warning/error), syslog toggle. Auto-fetches
  on open and on every filter change. Color-coded timestamps and
  levels via inline span styles. Download button writes the raw
  log to x-ui.log via FileManager.downloadTextFile.
- BackupModal.vue — db export (window.location to /getDb) and
  import (FormData upload to /importDB, then panel restart + reload).
- Quick Actions card surfaces Logs / Backup / Update buttons and
  shows an orange update badge (extra slot) when an update is
  available.

Modal-busy pattern: long-running operations (update, import) emit
a `busy` event with a tip; IndexPage flips its a-spin overlay so the
user sees a loading message while the panel is restarting.

AD-Vue 4 changes:
- v-model on <a-modal> renamed to v-model:open
- v-model on <a-input>/<a-select>/<a-checkbox> uses the named
  v-model:value / v-model:checked pattern
- <a-icon type="..."> dropped — explicit Ant icon imports
  (BarsOutlined, CloudServerOutlined, CloudDownloadOutlined,
  DownloadOutlined, UploadOutlined, SyncOutlined)
- Modal.confirm() replaces this.$confirm() since setup() has no `this`

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

* feat(frontend): Phase 5c-iv (b) — cpu-history / xray-logs / xray-version modals

Wires up the three remaining dashboard buttons that were stubbed in
5c-iv (a): the CPU history button on StatusCard, the xray-logs button
in XrayStatusCard's error popover and ipLimitEnable action, and the
"Switch xray" button in XrayStatusCard's action footer.

- Sparkline.vue: shared SVG line chart (composition-API port of the
  inline Vue 2 component). Per-instance gradient id avoids defs
  collisions between sparklines on the same page.
- CpuHistoryModal.vue: bucket dropdown (2m/30m/1h/2h/3h/5h) drives
  GET /panel/api/server/cpuHistory/{bucket}; renders via Sparkline.
- XrayLogModal.vue: rows + filter + direct/blocked/proxy checkboxes;
  POST /panel/api/server/xraylogs/{rows} returns access-log entries
  rendered as a colored HTML table; download button serializes to text.
- VersionModal.vue: collapse with Xray panel (radio list of versions
  from getXrayVersion, install via installXray/{version}) and Geofiles
  panel (per-file reload + Update all). CustomGeo collapse panel is
  Phase 5c-v.

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

* feat(frontend): Phase 5c-v — custom-geo section in VersionModal

Adds the third collapse panel ("Custom geo") that lets users register
external geosite/geoip files referenced by routing rules via
ext:<filename>:tag. Backend endpoints are unchanged.

- CustomGeoSection.vue: bordered table over /panel/api/custom-geo/list
  with per-row edit, download (refetch), and delete actions, plus an
  Add button and Update-all. Lazy-loads the list when the parent
  collapse opens this panel — closed panels don't fetch.
- CustomGeoFormModal.vue: shared add/edit form with the same alias
  regex (^[a-z0-9_-]+$) and URL validation as legacy. Type and alias
  are immutable when editing — backend rejects changes anyway.
- ext:<filename>:tag value is click-to-copy via ClipboardManager.
- Relative time is computed inline (no moment dep); tooltip shows the
  absolute timestamp.

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

* feat(frontend): Phase 5d-i — settings page shell + dirty tracking

Adds the settings entry as a new Vite multi-page input. Lays down the
shared page chrome (sidebar, save bar, restart, security alert) and the
AllSetting fetch/dirty-poll lifecycle so 5d-ii through 5d-vi can drop
in tab partials without re-implementing it.

- settings.html + src/settings.js: third Vite entry; mounts SettingsPage.
- SettingsPage.vue: page chrome with the legacy two-button save/restart
  bar, conf-alerts banner, and 5 a-tabs (4 always-visible + the formats
  tab gated on subJsonEnable || subClashEnable). Each tab body is an
  a-empty placeholder until 5d-ii…vi fill them in.
- useAllSetting.js composable: POST /panel/setting/all on mount, mirrors
  the legacy 1s busy-loop dirty check via setInterval, and exposes
  fetchAll/saveAll. saveDisabled flips off as soon as the user diverges
  from the server snapshot.
- restartPanel rebuilds the URL (host/port/scheme/base path) from the
  saved settings so users land on the new endpoint after a port or
  cert change.
- models/setting.js: adopts the @/utils alias and a leading file-level
  doc — semantics unchanged.

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

* feat(frontend): Phase 5d-ii — settings General tab

Ports the panel/general partial (the largest single tab) — six
collapse panels: General, Notifications, Certificates, External
traffic webhook, Date and time, LDAP.

- GeneralTab.vue receives the reactive AllSetting via props and binds
  fields directly with v-model:value; SettingsPage stays the sole
  fetch/save owner.
- remarkModel/remarkSeparator surfaced as computed v-models that
  read+write the underlying single-string field (legacy stores them
  packed as <separator><orderedKeys>, e.g. "-ieo").
- LDAP inbound-tags select binds to a CSV ↔ array computed; inbound
  options come from /panel/api/inbounds/list on mount.
- Language select stays cookie-based via LanguageManager and reloads
  on change — same UX as legacy.

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

* feat(frontend): Phase 5d-iii — settings Security tab + 2FA modal

Ports the panel/security partial: change-credentials form and 2FA
toggle. The 2FA modal is a new shared component since enabling 2FA,
disabling 2FA, and changing credentials all funnel through it with
slightly different copy.

- TwoFactorModal.vue: 'set' flow renders a QR code + manual key + a
  6-digit verifier; 'confirm' flow renders just the verifier. The
  parent passes a confirm(success) callback that fires only when the
  entered code matches the live TOTP value (otpauth lib).
- SecurityTab.vue: holds the local user form (oldUsername/oldPassword/
  new*), POSTs /panel/setting/updateUser, and on success force-redirects
  to logout. When 2FA is on, the credentials change goes through the
  confirm-modal first.
- toggleTwoFactor leaves the switch read-only (the v-bound :checked
  matches AllSetting) and only flips after the modal succeeds, so
  cancelling out leaves state unchanged.
- Adds otpauth ^9.5.1 dep (qrious was already present).

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

* feat(frontend): Phase 5d-iv — settings Telegram tab

Ports the panel/telegram partial: bot enable/token/chatId/lang in the
General panel, schedule/backup/login/CPU-threshold in Notifications,
and proxy/API-server overrides in the third panel. All bindings live
on the shared AllSetting reactive — no fetch/save logic in this tab.

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

* feat(frontend): Phase 5d-v — settings Subscription general tab

Ports the subscription/general partial — four collapse panels covering
the master enable switches, presentation/template fields, certs, and
update interval.

- Sub path goes through a strip-on-input + normalize-on-blur computed:
  legacy stripped `:` and `*` and ensured the value starts and ends
  with a single `/` — same here.
- Both `subEnableRouting` and the announce/profile/title/support URLs
  are bound directly on AllSetting.
- The "Subscription URI override" placeholder mirrors the legacy
  pattern for the manual full-URL form.

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

* feat(frontend): Phase 5d-vi — settings Subscription formats tab

Ports the subscription/json partial — paths/URIs for the JSON and
Clash formats plus the four packed-JSON sub-fields: fragment, noises,
mux, and direct routing rules.

- subJsonFragment / subJsonMux / subJsonNoises / subJsonRules are each
  a JSON string on the wire; the tab exposes their fields as computed
  v-models that read+write the underlying JSON. Toggling a top-level
  switch off resets the field to "" (matches legacy semantics).
- Direct routing rules surface the IP and domain entries of the seed
  rule array as multi-select tag inputs; setting/removing tags
  edits the rules array in place rather than rebuilding it from
  scratch, so manually-added rules are preserved.
- Tab is gated on subJsonEnable || subClashEnable in the parent (only
  rendered when the user actually opted into one of those formats).

This closes Phase 5d — full settings page parity with the legacy panel
across all five tabs.

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

* fix(frontend): route /panel/<route> to migrated pages in dev

The sidebar links to production-style URLs like /panel/settings, but
in dev that gets proxied to the legacy Go template — which fails
because we haven't loaded the legacy asset chain. Add a proxy bypass
so /panel and /panel/settings are served from index.html / settings.html
on the Vite dev server itself. Unmigrated routes (inbounds, xray)
still proxy to Go.

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

* fix(csrf): expose token endpoint for SPA pages and fetch it from axios

The legacy panel pages got their CSRF token from a <meta name="csrf-token">
tag rendered by Go. SPA pages built by Vite don't have that, so every
unsafe (POST/PUT/DELETE) request from them was hitting CSRFMiddleware
with no token and getting 403 — visible as the settings page being
stuck on "Loading…" because POST /panel/setting/all failed.

- web/controller/xui.go: GET /panel/csrf-token returns the session
  token. Lives under the xui group so checkLogin still gates it; the
  CSRFMiddleware on the same group is a no-op for GET.
- frontend/src/api/axios-init.js: cache the token at module scope and
  lazy-fetch it when a non-safe request needs one. Seed from the meta
  tag first when present (legacy compat). On a 403 response, drop the
  cache and retry once — handles the case where a server restart
  rotated the token after the SPA loaded.

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

* fix(frontend): keep sidebar links absolute when basePath is empty

The dashboard sidebar built tab keys as basePath + 'panel/...'. In dev
the window-injected basePath is '' so the resulting key was a relative
path like 'panel/settings'. When the browser resolved that against the
current /panel/settings URL it produced /panel/panel/settings — visible
as broken navigation between Dashboard and Settings.

Force a leading slash so the keys are always absolute regardless of
whether the host injected a basePath.

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

* feat(frontend): Phase 5f-i — inbounds page shell + list fetch

Adds the inbounds entry as a fourth Vite multi-page input and wires
/panel/inbounds through the dev proxy bypass. Lays down the page
chrome (sidebar, summary statistics card, refresh button) and the
fetch lifecycle composable so 5f-ii onward can drop in the table
columns and the modals without re-implementing it.

- inbounds.html + src/inbounds.js: fourth Vite entry; mounts InboundsPage.
- InboundsPage.vue: sidebar + summary card (totals over up/down,
  all-time, inbound count, client tags) + a basic table with enable/
  remark/port/protocol/traffic/expiry columns. Row actions, popovers,
  search/filter, auto-refresh, and the WebSocket delta path are all
  deferred to subsequent 5f subphases.
- useInbounds.js composable: GET /panel/api/inbounds/list +
  POST /panel/api/inbounds/onlines + POST /panel/api/inbounds/lastOnline +
  POST /panel/setting/defaultSettings, then computes the
  per-inbound clientCount roll-ups (active/deactive/depleted/expiring/
  online/comments) the table popovers consume.
- models/dbinbound.js + models/inbound.js: switched the legacy-utils
  import to the @/utils alias for consistency with the rest of the
  app. Semantics unchanged.

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

* feat(frontend): Phase 5f-ii — inbound list table + search/filter + auto-refresh

Fleshes out the inbound list with the full column set, search & filter
toolbar, row enable toggle wired to /panel/api/inbounds/setEnable/:id,
and a per-row action dropdown that emits events the parent will route
to modals as those land in 5f-iii through 5f-vii.

- InboundList.vue (new): toolbar (Add inbound + General actions
  dropdown + Refresh + auto-refresh popover), search-or-filter switch
  with the legacy radio buttons (Active/Disabled/Depleted/Depleting/
  Online), and a a-table with desktop and mobile column variants.
  Cells use AD-Vue 4's #bodyCell slot — protocol/clients/traffic/
  allTime/expiry/info cells render the same popovers and tags as
  legacy. Row enable switch is optimistic with rollback on POST
  failure.
- visibleInbounds computed mirrors the legacy search and filter
  projection: deep search through dbInbound + clients, or filter
  reduces inbound.settings.clients to the selected bucket so the
  table only shows matching client rows.
- Auto-refresh interval is read/written to localStorage with the
  same keys (`isRefreshEnabled`, `refreshInterval`) as the legacy
  panel. WebSocket delta updates are still deferred.
- Action menu emits event payloads {key, dbInbound}; the parent
  currently shows a "coming in later 5f subphase" toast for each.
  Modals (edit/qr/clone/delete/reset/info/clients) land in
  5f-iii through 5f-vii.

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

* fix(inbounds): wrap popover-table rows in <tbody>

Vue's template compiler warned that <tr> can't be a direct child of
<table> per the HTML spec; the browser silently inserts a <tbody>
wrapper but Vue's SSR/hydration path doesn't, which can cause
hydration mismatches. Add explicit <tbody> in both popover tables
(traffic + mobile-info).

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

* feat(frontend): Phase 5f-iii — inbound add/edit modal + delete/clone/reset

Wires up the inbound CRUD flows. The protocol-specific and transport-
specific forms are still ahead in 5f-iii-b — for now the modal exposes
those as JSON textareas so users can both edit existing inbounds without
losing settings and create new ones from default templates.

- InboundFormModal.vue: tabbed modal with a full Basics tab (enable,
  remark, protocol, listen, port, total GB, traffic reset, expiry
  date) and three JSON-edit tabs (Settings, Stream, Sniffing). Add
  mode stamps a fresh template per protocol via
  Inbound.Settings.getSettings(protocol); changing the protocol in
  add mode restamps the JSON. Edit mode pretty-prints the existing
  JSON so the user sees the same fields they save back.
- POST /panel/api/inbounds/add or /panel/api/inbounds/update/:id on
  submit; on success the parent refreshes the list and the modal
  closes. Malformed JSON in any of the three textareas surfaces a
  message.error and aborts the save without losing user input.
- InboundsPage.vue: wires the row action menu to real handlers —
  edit (opens the modal in edit mode), delete, reset-traffic,
  clone, reset-clients, del-depleted-clients all go through
  Modal.confirm and refresh on success. General actions menu wires
  reset-inbounds / reset-clients / del-depleted-clients the same way.
  Remaining actions (qrcode/info/import/export/copyClients) still
  toast as "coming soon" — those land in 5f-iv and 5f-v.
- Adds dayjs ^1.11.20 dep for the a-date-picker v-model interop.

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

* feat(frontend): Phase 5f-iv — client add/edit + bulk-add modals

Wires per-inbound client management. Both flows go through the same
addClient/updateClient endpoints as legacy; the modals just funnel
the form state into the right shape (`{id, settings: '{"clients": [...]}'}`).

- ClientFormModal.vue: protocol-aware single-client editor — email/
  password/id/auth/security/flow/subId/tgId/comment/ipLimit/totalGB/
  expiry/renewal fields are shown/hidden per protocol like legacy.
  Edit mode displays the per-client traffic stats with a reset
  button; IP-limit log is read on click and clearable. Random
  helpers (sync icon next to each label) regenerate UUID/email/
  password/sub-id values.
- ClientBulkModal.vue: 1–500 clients in one POST, with the legacy
  five email-generation modes (Random / +Prefix / +Num / +Postfix /
  Pure-Prefix-Num-Postfix). Builds clients via the protocol-aware
  factory and concatenates their toString() output into a single
  settings.clients JSON array.
- InboundsPage.vue: opens both modals from the row action menu
  (`addClient` / `addBulkClient`). They both refresh the inbound list
  on success.
- Outstanding row actions still toast as "coming soon": qrcode,
  showInfo, copyClients, clipboard. Those land in 5f-v / 5f-vi.

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

* feat(frontend): Phase 5f-v — inbound info + QR-code modals

Wires the row "info" and "qrcode" actions and ports the legacy
inbound_info_modal end-to-end. The info modal handles every protocol
the legacy panel did:
  • multi-user (VMess/VLess/Trojan/SS-multi/Hysteria) — per-client
    table + share links + per-link QR;
  • SS single-user — share link + QR;
  • WireGuard — full peer table with downloadable peer-N.conf and a
    wg:// share link per peer;
  • Mixed/HTTP/Tunnel — connection-detail tables.

- QrPanel.vue: shared link card (header tag, copy button, optional
  download button, optional QR canvas, monospace footer with the
  raw value). Per-instance QRious instances are repainted on
  value/size change.
- InboundInfoModal.vue: full info modal. Subscription URL block keys
  off subSettings.subURI/subJsonURI; IP-log lazy-loads on open and
  surfaces refresh + clear; tg-id, last-online, depleted/enabled tags
  all match legacy.
- QrCodeModal.vue: lighter modal used for the row "qrcode" action on
  SS-single and WireGuard inbounds (just the QRs, no info table).
- InboundsPage.vue: wires both flows. checkFallback() reproduces the
  legacy logic — when an inbound listens on a unix-socket fallback
  (`@<name>`), the link generator is pointed at the root inbound that
  owns the listen address so QRs/links carry the public host:port +
  the right TLS state. Multi-client navigation (focusing a specific
  client's links) is deferred to 5f-vi where the per-inbound expand-
  row table will pass the email through.

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

* feat(frontend): Phase 5f-vi — per-inbound client expand-row table

Each multi-user inbound row in the list now expands to show its
client roster, mirroring the legacy aClientTable component.

- ClientRowTable.vue: inner a-table with full desktop column set
  (action icons / enable / online / client-with-status-dot / traffic
  with progress bar / all-time / expiry with reset cycle) and a
  collapsed mobile variant (single dropdown menu + popover info).
  Self-contained: stats are looked up via a per-inbound email->stats
  Map; per-client confirms (reset/delete) live on the row.
- The component emits typed events (edit/qrcode/info/reset-traffic/
  delete/toggle-enable) — InboundsPage routes them back to the
  existing client and info modals (with `findClientIndex` so the
  modal opens focused on the right client).
- InboundList.vue: hooks ClientRowTable into the a-table's
  expandedRowRender slot; row-class-name `hide-expand-icon` and a
  scoped CSS rule hide the chevron for non-multi-user inbounds
  (HTTP/Mixed/Tunnel/WireGuard/SS-single) so they keep looking flat.
- toggle-enable-client routes through updateClient with the same
  `{id, settings: '{"clients": [...]}'}` shape as the other modals,
  so backend parsing stays single-pathed.

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

* feat(frontend): Phase 5f-iii-b — replace inbound modal JSON textareas with structured forms

Rewrites InboundFormModal to look like the legacy panel: structured
forms for the common case, with a compact "Advanced (JSON)" fallback
for the rare bits we don't yet have UI for.

Tabs:
  • Basics — enable/remark/protocol/listen/port/total/trafficReset/expiry
  • Protocol — protocol-aware:
      VMess/VLess/Trojan/SS-multi/Hysteria in add mode embed an inline
        first-client form (email + ID/password/auth, security, flow,
        subId, comment, total GB, expiry);
      edit mode shows a clients-count summary table;
      VLess: decryption/encryption inputs;
      SS: method dropdown that re-randomizes password and propagates
        method change to the multi-user array (matches legacy
        SSMethodChange);
      HTTP/Mixed: accounts table with add/remove rows + Mixed
        auth/udp/ip toggles;
      Tunnel: address/port/network/followRedirect;
      WireGuard: secretKey/pubKey (regen via Wireguard.generateKeypair)
        + per-peer fields with PSK regen + allowedIPs add/remove +
        keepAlive.
  • Stream — only when canEnableStream(); transport selector with
      structured forms for TCP (proxy-protocol, http camouflage),
      WS (host/path/heartbeat/headers), gRPC (serviceName, multiMode),
      HTTPUpgrade (host/path). KCP/XHTTP fall back to the Advanced tab
      with an alert banner. Security selector with TLS (sni/alpn/
      fingerprint) and Reality (target/serverNames/keypair-gen via
      /panel/api/server/getNewX25519Cert / shortIds / fingerprint).
  • Sniffing — enabled/destOverride/metadataOnly/routeOnly/
      ipsExcluded/domainsExcluded as structured fields.
  • Advanced (JSON) — raw streamSettings + sniffing JSON for users
      reaching KCP/XHTTP/sockopt/finalmask/full TLS cert arrays. The
      stream JSON is auto-synced from the live model whenever the
      structured fields change.

State source of truth is a deeply-reactive Inbound + DBInbound pair
cloned on open; submit serializes via inbound.settings.toString() +
inbound.stream.toString() so the wire shape matches the legacy panel
byte-for-byte. streamNetworkChange semantics (clear flow when
TLS/Reality unavailable, reset finalmask.udp when not KCP) are
preserved.

Vision Seed for VLess + finer-grained TCP HTTP camouflage + the full
TLS cert/ECH editor will land in 5f-iii-c.

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

* feat(frontend): Phase 5f-vii — shared text/prompt modals + remaining export/import wiring

Wires up the last batch of inbound row + general actions that were
toasting "coming soon": export-inbound-links, export-subs (per-inbound
and global), export-all-links, import-inbound, and the clipboard JSON
peek. Two small shared components back them — both can be reused by
the xray page later.

- TextModal.vue (shared): read-only multi-line viewer with a copy
  button and an optional download button when fileName is set.
  Replaces the legacy txtModal which the inbounds page used for every
  link export.
- PromptModal.vue (shared): generic title + input/textarea + confirm
  callback, with the legacy keybindings (Enter submits in single-line
  mode; Ctrl+S submits in textarea mode). Used here for import-inbound
  but also by xray-config edits in Phase 6.
- InboundsPage.vue: drops the toast stubs for `import`/`export`/`subs`
  on the general-actions menu and `export`/`subs`/`clipboard` on the
  per-row menu, routing each through openText / openPrompt + the
  appropriate model helper (genInboundLinks, etc.). The copyClients
  cross-inbound modal stays toast-stubbed — that's its own dedicated
  legacy modal worth its own commit.

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

* feat(frontend): Phase 6-i — xray page scaffold + Advanced JSON tab

The fifth and last legacy page comes online. Tabs are scaffolded with
a-empty placeholders for the structured editors (Basics / Routing /
Outbounds / Balancers / DNS) so navigation is stable; the
Advanced (JSON) tab is fully functional and lets power users edit
the raw xraySetting tree exactly like the legacy CodeMirror pane.

- xray.html + src/xray.js: fifth Vite multi-page entry, mounted as
  XrayPage; vite.config.js routes /panel/xray and /panel/xray/ to it
  through the dev proxy bypass alongside the other pages.
- XrayPage.vue: page chrome with the Save / Restart-xray bar, restart-
  output popover (surfaces /panel/xray/getXrayResult content when
  startup fails), 6 a-tabs, and a textarea-backed Advanced JSON editor.
  CodeMirror is intentionally not pulled in — the textarea works for
  every modern browser and keeps the bundle slim while structured
  editors land in 6-ii through 6-v.
- useXraySetting.js composable: POST /panel/xray/ on mount, mirrors
  the settings-page 1s busy-loop dirty check for both xraySetting
  and outboundTestUrl, and exposes saveAll + restartXray. The dirty
  flag relies on string equality of the pretty-printed JSON, so
  reformat-only edits don't enable Save.

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

* feat(frontend): Phase 6-ii — xray Basics tab structured editor

Replaces the placeholder on the Basics tab with a structured form for
the most-touched fields of the xray template — outbound + routing
strategy, log levels, traffic stat counters, and the "basic routing"
shortcuts (block torrent / IPs / domains, direct IPs / domains, IPv4
forced, WARP / NordVPN routing).

- useXraySetting.js: hoists a parsed `templateSettings` reactive
  alongside the JSON string, with two cooperating watches that keep
  them in sync. Editing structured fields stringifies into xraySetting
  for the dirty-poll + Advanced JSON tab; editing the JSON re-parses
  into templateSettings only when valid, so structured tabs stay
  readable mid-edit.
- BasicsTab.vue: collapse panels mirror the legacy partial — General,
  Statistics, Logs, Basic routing. Every input is a computed v-model
  reading/writing into templateSettings; the routing-rule shortcuts
  funnel through ruleGetter/ruleSetter which match the legacy
  templateRuleGetter/templateRuleSetter behavior (replace-first,
  drop-duplicates, pop-the-rule-when-empty). Direct/IPv4 setters
  also call syncOutbound() to provision/prune the matching outbound.
- XrayPage.vue: imports BasicsTab + derives `warpExist`/`nordExist`
  from the parsed templateSettings. WARP/NordVPN provisioning modals
  are still placeholders that toast — those land in 6-v with the
  routing/outbound editors.

Default tab flips back to Basics so users land on the structured
editor.

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

* feat(frontend): Phase 6-iii — xray Routing tab + rule modal

Replaces the Routing tab placeholder with a full editor for
templateSettings.routing.rules:

- RoutingTab.vue: a-table over the parsed rules with the legacy six-
  column layout (action / source / network / destination / inbound /
  outbound) and the same "lead value + N more" pill renderer for
  multi-value criteria. Mobile drops source/network/destination for
  readability. Per-row dropdown handles edit / move-up / move-down /
  delete; the array-mutation reordering replaces the legacy jQuery
  Sortable drag handle without pulling in a sortable lib.
- RuleFormModal.vue: full form mirroring xray_rule_modal.html —
  CSV inputs for sourceIP/sourcePort/vlessRoute/ip/domain/user/port,
  Network select, Protocol multi-select, Attrs key/value pairs,
  inbound-tag multi-select sourced from
  templateSettings.inbounds + parent inboundTags + dnsTag,
  outbound-tag single-select sourced from templateSettings.outbounds
  + clientReverseTags, and balancerTag from
  templateSettings.routing.balancers. Submit serializes via the
  same shape the legacy `getResult` produces (CSV → array, drop
  empty fields).
- XrayPage.vue: imports RoutingTab and exposes inboundTags +
  clientReverseTags from useXraySetting so the modal can populate
  its tag pools.

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

* feat(frontend): Phase 6-iv — xray Outbounds tab + outbound modal

Replaces the Outbounds tab placeholder with a full table + add/edit
flow. The 1.3k-line legacy outbound modal is condensed to a tabbed
modal with structured Basics fields (tag/protocol/sendThrough/domain
strategy) and JSON tabs for the protocol-specific settings + stream
trees — same approach the Inbound modal uses, and a power user can
still edit the same trees via the page-level Advanced (JSON) tab.

- useXraySetting.js: adds fetchOutboundsTraffic +
  resetOutboundsTraffic + testOutbound. Test states are tracked per
  outbound index so the row's Test button can show loading + the
  Test-result column can render the response delay / status / error.
- OutboundsTab.vue: full table (action / identity / address / traffic
  / test result / test) plus a card-list mobile variant with the
  same row dropdown (set-first / edit / move up/down / reset traffic
  / delete). outboundAddresses() reproduces the legacy
  findOutboundAddress logic so each protocol's host:port list is
  rendered consistently. Add/edit go through OutboundFormModal,
  delete goes through Modal.confirm, reset traffic posts to
  /panel/xray/resetOutboundsTraffic with the row's tag (or
  "-alltags-" from the toolbar).
- OutboundFormModal.vue: tag/protocol/sendThrough/domainStrategy on
  the Basics tab; settings + streamSettings as raw JSON on their
  respective tabs. Tag-collision check happens client-side before
  emitting; malformed JSON aborts the save with a message.error.
- XrayPage.vue: imports OutboundsTab and wires the test action to
  the composable's testOutbound helper.

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

* feat(frontend): Phase 6-v — xray Balancers tab + DNS placeholder

Brings Balancers to full parity with the legacy panel and adds a
DNS tab placeholder that exposes the full dns/fakedns trees as JSON
so users can edit them without falling through to Advanced.

- BalancerFormModal.vue: tag (with duplicate-tag warning across
  other balancers), strategy (random/roundRobin/leastLoad/leastPing),
  selector tag-mode multi-select sourced from existing outbound
  tags + free-form additions, fallback. Disable-on-invalid is
  driven by the duplicateTag + emptySelector computed flags.
- BalancersTab.vue: empty state with a single "Add balancer" CTA;
  populated state shows the legacy 4-column table (action / tag /
  strategy / selector / fallback) with per-row edit + delete in a
  dropdown. On submit the wire shape preserves the
  `strategy: { type }` nesting only when the strategy is non-default,
  matching the legacy emit. Tag renames also chase across
  routing.rules.balancerTag references so existing rules don't dangle.
- DnsTab.vue: master enable switch + raw JSON for `dns` and
  `fakedns`. Legacy had a dedicated server-by-server editor + a
  fakedns row editor; both are big enough to deserve their own
  commits, and the JSON path supports every field today.

WARP / NordVPN provisioning modals still toast as "coming soon" —
those are third-party API integrations worth their own commits.
The xray page now has structured editors for Basics / Routing /
Outbounds / Balancers and JSON editors for DNS / Advanced — every
xray tab the legacy panel offered is functional.

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

* feat(server): Phase 8 — cut HTML routes over to web/dist/

Production cutover. Every user-facing HTML route now serves the
Vue-3-built bundle from web/dist/ instead of rendering the legacy
Go template; the long-hashed Vite assets are served at /assets/ from
the same embedded filesystem. The legacy templates in web/html/ and
the legacy static tree in web/assets/ are kept on disk for now in
case a quick revert is needed, but nothing the binary serves
references them.

What changed:
- web.go: a new //go:embed dist/* feeds the controller package via
  a SetDistFS hand-off before controller construction. The static
  /assets/ route is rebound: in dev to web/dist/assets/ on disk so
  Vite's incremental rebuilds show up live; in prod to the embedded
  dist via wrapDistFS (rooted one level deeper than wrapAssetsFS).
- controller/dist.go: serveDistPage helper used by every HTML
  handler. Reads dist/<name> from the embedded FS and applies two
  transforms before sending:
    1. injects <script>window.__X_UI_BASE_PATH__="..."</script>
       just before </head> so AppSidebar links resolve under the
       panel's basePath.
    2. when basePath != "/", rewrites Vite's absolute /assets/ URLs
       to <basePath>assets/ so installs running under a custom URL
       prefix load the bundle where the static handler lives.
  HTML responses go out with no-cache so panel upgrades reach
  users on the next refresh; hashed JS/CSS stays cacheable.
- controller/index.go: IndexController.index now serves
  dist/login.html for logged-out callers (the redirect for logged-in
  users is unchanged).
- controller/xui.go: XUIController.{index,inbounds,settings,xraySettings}
  each become a one-line wrapper around serveDistPage.

Smoke checklist for the maintainer:
- run `cd frontend && npm run build` to refresh web/dist/ before
  building the Go binary (the embed snapshot is taken at compile
  time);
- visit /panel/, /panel/inbounds, /panel/settings, /panel/xray and
  confirm each loads its Vue page;
- log out and log back in to verify the login flow;
- confirm the sidebar links navigate correctly under your install's
  basePath;
- POST flows (e.g. saving settings) still need the CSRF token —
  that endpoint (/panel/csrf-token, added earlier) is unchanged.

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

* feat(frontend): Phase 6-vi — WARP + NordVPN provisioning modals

Replaces the toast stubs on the Basics tab and Outbounds toolbar
with the legacy WARP + NordVPN provisioning flows. Both modals now
stage their wireguard outbounds back into templateSettings.outbounds
through the same event channels OutboundsTab uses, so the existing
add / reset / delete / refresh-traffic surface keeps working.

- WarpModal.vue: empty state shows a single Create button that
  generates a wireguard keypair locally (Wireguard.generateKeypair)
  and posts it to /panel/xray/warp/reg; populated state surfaces
  the access_token / device_id / license_key / private_key, lets
  the user upgrade to WARP+ via /panel/xray/warp/license, refreshes
  the account info from /panel/xray/warp/config (plan / quota /
  usage in human-readable bytes), and stages a wireguard outbound
  with the WARP-specific reserved-byte encoding pulled from
  client_id. Add / Reset / Delete go through events the parent
  routes back to templateSettings.outbounds.
- NordModal.vue: dual-tab login (NordVPN access token →
  /panel/xray/nord/reg, or paste a NordLynx private key →
  /panel/xray/nord/setKey). Once authenticated, country / city /
  server selectors fetch from /panel/xray/nord/{countries,servers},
  servers sort by load ascending, the lowest-load server in the
  current city auto-selects. Reset emits oldTag/newTag so the
  parent renames matching routing rules in place; logout emits a
  remove-routing-rules event with prefix `nord-` to purge any
  dangling references.
- XrayPage.vue: holds warpOpen / nordOpen flags, ensures the
  outbounds array exists before mutating it, and wires the modal
  events (add-outbound / reset-outbound / remove-outbound /
  remove-routing-rules) to in-place edits of templateSettings.

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

* feat(frontend): Phase 7 — vue-i18n wired up + login page translated

Sets up vue-i18n on top of the panel's existing TOML translation
files. The Go side stays the source of truth — translators continue
to edit web/translation/*.toml; a sync script snapshots those files
into per-locale JSON the Vue bundle imports. The login page is
translated end-to-end as a worked example; remaining pages can be
converted incrementally without infrastructure churn.

What's in the box:
- scripts/sync-locales.mjs: small TOML→JSON converter that walks
  web/translation/*.toml and writes frontend/src/locales/<code>.json.
  Handles the narrow subset of TOML the panel uses (flat key/value
  pairs + dotted [section.subsection] heads). Wired as a `prebuild`
  + `predev` script so production builds always include the latest
  strings without a manual step.
- src/i18n/index.js: createI18n() in composition mode with all 13
  locales emitted as their own Vite chunks. The active locale (read
  from the same `lang` cookie LanguageManager has always managed)
  plus the en-US fallback are eagerly loaded; the rest are
  dynamically importable via a loadLocale(code) helper. This keeps
  the per-page bundle the user actually downloads small — only ~30
  KB of strings end up in the initial payload, vs ~220 KB if all
  13 were eager.
- All five page entries (index/login/settings/inbounds/xray) wire
  the i18n plugin into createApp via .use(i18n).
- LoginPage.vue: t(...) replaces hardcoded English on the username
  / password / 2FA placeholders, the submit button label, and the
  Settings popover title. The Hello/Welcome headline cycle stays
  hardcoded — those are stylistic, not labels.

The 'Hello'/'Welcome' cycle stays in English deliberately; the rest
of the migration's components still ship hardcoded English and will
be converted page by page in follow-up commits.

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

* i18n(frontend): translate page chrome — sidebar, save bars, tabs, summary cards

Replaces hardcoded English with t() calls in the components every
user sees on every page load. The translations themselves come from
the existing TOML files via the sync script — no new strings, no
new locale keys.

Per component:
- AppSidebar.vue: 5 menu titles (dashboard / inbounds / settings /
  xray / logout). Computed so the sidebar re-renders when the
  cookie-driven locale flips on reload.
- IndexPage.vue: Quick actions card title + Logs / Backup / Up-to-
  date / Update buttons.
- StatusCard.vue: CPU / Memory / Swap / Storage labels +
  logical-processors / frequency tooltips.
- XrayStatusCard.vue: card title + error popover header + Stop /
  Restart / Switch xray action labels (kept the v-prefix version
  string as-is — it's content, not a label).
- SettingsPage.vue: 5 tab titles + Save / Restart-panel buttons +
  unsaved-changes warning.
- XrayPage.vue: 6 tab titles + Save / Restart-xray buttons +
  unsaved-changes warning.
- InboundsPage.vue: 5 summary-stat card titles.
- InboundList.vue: 10 column titles (computed for live locale),
  Add inbound / General actions buttons + every dropdown menu item,
  search placeholder, filter radio labels, popover titles
  (disabled / depleted / depleting / online), traffic + info
  popover row labels.

Total: ~75 strings localised across 8 files. The remaining English
labels live in the per-tab settings forms, the form modals
(Inbound / Client / Outbound / Rule / Balancer / WARP / Nord), and
the per-row table cell helpers — all incremental work that doesn't
touch infrastructure.

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

* i18n(frontend): translate every remaining English string on the index page

Closes the index page's i18n coverage. Combined with the page-chrome
commit, every label users see on the dashboard is now sourced from
the TOML translation files.

Per file:
- IndexPage.vue: loading-spinner tip (initial + dynamic).
- BackupModal.vue: modal title, both list-item titles + descriptions
  ("Back up" / "Restore"), in-flight busy tips ("Importing database…"
  / "Restarting panel…").
- PanelUpdateModal.vue: modal title, update-available alert,
  current/latest version row labels, "Up to date" tag + label,
  primary action button. Modal.confirm now uses the translated
  panelUpdateDialog / panelUpdateDialogDesc with #version#
  substitution; success toast uses panelUpdateStartedPopover.
- LogModal.vue: title slot ("Logs"). The Debug/Info/Notice/Warning/
  Error log-level options stay literal — they're xray's wire values,
  not user-facing labels (matches the existing settings-page choice).
- XrayLogModal.vue: title + Filter label. Direct/Blocked/Proxy stay
  literal for the same reason.
- VersionModal.vue: modal title + xray-switch alert + per-file
  tooltip + "Update all" button + custom-geo collapse header. The
  Modal.confirm flows for switchXrayVersion + updateGeofile use
  translated dialog/desc with #version# / #filename# substitution.
- CpuHistoryModal.vue: title slot.
- CustomGeoSection.vue: routing-hint alert, Add / Update-all buttons,
  every column title (computed for live locale), copy/edit/download/
  delete tooltips, copy toast, delete-confirm modal, empty-state
  text.
- CustomGeoFormModal.vue: add/edit titles, OK/cancel labels, Type/
  Alias/URL field labels, alias placeholder, all three validation
  toasts.

Total: ~50 strings localised across 8 index-page files. The Hello /
Welcome login headline cycle and a handful of literal xray wire
values (Direct/Blocked/Proxy/log levels) are intentionally kept
hardcoded.

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

* i18n(frontend): Phase 7-c — translate settings, inbounds modals, xray tabs

Continues the page-by-page translation pass started in cb37dd55 — runs
every user-visible string on settings (General/Security/Telegram/Sub),
inbounds (Client/QR/Info modals), and xray (Routing/Balancer/Rule/Warp/
Nord/Basics/Outbounds tabs) through useI18n. Updates the TOML→JSON sync
script to escape `@` (vue-i18n parses it as a linked-format prefix) and
refreshes all 13 locale files.

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

* fix(frontend): Phase 9 — restore index dashboard, fix login/CSRF, port legacy styles

- Index dashboard regains the 8 cards that were lost in the SPA port
  (3X-UI panel info, Operation Hours, System Load, Usage, Overall Speed,
  Total Data, IP Addresses, Connection Stats), plus a Config button that
  shows the live xray config.json. Version display falls back through
  panelUpdateInfo → window.__X_UI_CUR_VER__ → '?' so dev mode isn't blank.
- Xray config no longer hangs on load: useXraySetting surfaces failures
  instead of leaving a perpetual spinner, and the Vite dev proxy stops
  hijacking POST requests to migrated routes (only GETs get bypassed).
- Inbound page no longer throws __asyncLoader/emitsOptions errors —
  inbound.js was missing imports (NumberFormatter, SizeFormatter,
  Wireguard) and InboundList kept emitting after unmount.
- Login round-trip works after logout: a public /csrf-token endpoint
  bootstraps the SPA before authentication, axios caches the token
  module-level, and the dev 401 handler navigates to /login.html
  instead of reloading the dashboard into a redirect loop.
- legacy.css mirrors the legacy panel's surface/text variables so dark
  and ultra-dark themes match main; every SPA entry imports it.

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

* feat(frontend): rebuild xray DNS section to match main branch

DnsTab now exposes every field the legacy panel did — top-level toggles
(tag, hosts, queryStrategy, disableCache/queryConcurrency, fallback
strategy, client subnet), the servers table with per-row strategy and
domain/expectIP/unexpectedIP overrides, and the Fake DNS pool. The new
DnsServerModal covers the full add/edit flow and collapses to a bare
string when the user only sets an address — matching the wire shape
the legacy form emits for plain DNS entries like "8.8.8.8".

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

* feat(frontend): rebuild xray outbound modal with structured per-protocol forms

Replaces the JSON textareas with the same shape the legacy panel uses:
all 11 outbound protocols (vmess/vless/trojan/shadowsocks/socks/http/
mixed/wireguard/tun/dns/loopback/blackhole/freedom) get dedicated
fields, every transport (TCP/KCP/WS/gRPC/HTTPUpgrade/XHTTP) gets its
own panel, and TLS/Reality/sockopt/Mux are configured through the same
controls as the inbound side. Brings the SPA outbound editor to parity
with main so users no longer have to drop into raw JSON.

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

* feat(frontend): bring inbound modal to full parity with main branch

Switches the default protocol on add to VLESS, fixes a crash when adding
a Mixed account (the constructor is SocksAccount, not MixedAccount),
and fills in the fields the SPA was previously delegating to the
Advanced JSON tab:
- TLS: cipher suites, min/max version, reject SNI / disable system root /
  session resumption switches, the certificate array with per-row
  Path-or-Content toggle (Set Default pulls from /panel/setting/
  defaultSettings), One Time Loading, Usage / Build Chain, plus ECH
  key/config with a Get New ECH Cert button.
- Reality: xver, target/SNI sync icons (uses getRandomRealityTarget),
  max time diff, min/max client version, short IDs randomizer, SpiderX,
  mldsa65 seed/verify with Get New Seed.
- Stream: full structured forms for every transport — TCP HTTP
  camouflage gets its request/response editor, mKCP gets MTU/TTI/uplink/
  downlink/CWND/maxSendingWindow, WebSocket / gRPC (now with Authority) /
  HTTPUpgrade get headers + proxy-protocol toggles, XHTTP gets the
  full SplitHTTPConfig surface (mode-aware fields, padding obfs,
  session/sequence placement, uplink data, no-SSE).
- New External Proxy section and a structured Sockopt block (mark,
  TCP keepalive/timeout/clamp, fast open, MPTCP, penetrate, V6Only,
  domain strategy, congestion, TProxy, dialer/interface, trusted XFF).
- VLESS gets the legacy X25519 / ML-KEM-768 buttons that fetch fresh
  decryption/encryption blocks via /panel/api/server/getNewVlessEnc.

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

* feat(frontend): add FinalMask UI (TCP/UDP masks + QUIC params) to inbound and outbound

Mirrors web/html/form/stream/stream_finalmask.html as a shared
FinalMaskForm component used by both modals — they share the same
StreamSettings shape (addTcpMask/addUdpMask/finalmask/enableQuicParams)
so a single template handles both. Surfaces:
- TCP masks for raw/tcp/httpupgrade/ws/grpc/xhttp networks: fragment,
  sudoku, and header-custom (with the 2D clients/servers groups, each
  row supporting array/str/hex/base64 packets and a randomize button
  for base64).
- UDP masks for hysteria protocol or kcp network: hysteria gets just
  salamander; kcp gets the full type list (mkcp variants, header-*,
  xdns/xicmp, header-custom with flat client/server lists, and noise).
  Switching to xdns shrinks the kcp MTU to 900 to match the legacy
  panel's behavior.
- QUIC Params for hysteria or xhttp: congestion (incl. brutal up/down
  fields), debug, UDP hop ports/interval, idle/keepalive timeouts,
  path-MTU discovery toggle, and the four receive-window tunables.

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

* fix(frontend): remove duplicate Outbound test URL from xray Advanced tab

The Basics tab already exposes this field through BasicsTab —
duplicating it on the Advanced tab let two inputs race the same
ref and only added clutter.

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

* feat(frontend): unify theming on vanilla AD-Vue light/dark/ultra-dark

The legacy panel CSS (custom.min.css ported as legacy.css) tinted every
non-primary button teal-green via .dark .ant-btn:not(.ant-btn-primary)
overrides while AD-Vue 4's darkAlgorithm kept primary buttons blue —
producing the mixed blue/green button look on dark mode. Drop legacy.css
entirely and let AD-Vue 4's algorithms own the palette.

Centralize antdThemeConfig in useTheme.js so every page resolves to the
same source of truth (light = defaultAlgorithm, dark = darkAlgorithm,
ultra-dark = darkAlgorithm + deeper colorBgBase/Layout/Container/
Elevated tokens). Each page's <a-config-provider> now imports the
shared computed instead of defining its own copy.

Drops the 67 KB legacy CSS chunk; per-page CSS bundles fall to ≤5.9 KB.

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

* fix(frontend): restore computed import in Settings + Xray pages

When 5f1aba28 dropped the local antdThemeConfig computed (now shared
from useTheme), it also stripped `computed` from the import list — but
both pages still call computed() elsewhere (confAlerts, advanced-tab
helpers). Re-adds it.

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

* fix(frontend): retheme dashboard gauges to AD-Vue blue and shrink them

- StatusCard's CPU/RAM/Swap/Storage dashboards rendered at AD-Vue's
  default 120px width which made the percent text balloon to ~36px.
  Drop to 90px (70px on mobile) so the gauge fits the rest of the card.
- The CurTotal.color thresholds still hardcoded the legacy teal/orange
  palette (#008771 / #f37b24 / #cf3c3c). Switch to AD-Vue's primary /
  warning / danger tokens (#1677ff / #faad14 / #ff4d4f) so the gauges
  match the rest of the panel under both light and dark themes.
- XrayStatusCard's running-animation badge ring also still pointed at
  the deleted --color-primary-100 var; hardcode the new primary blue.

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

* i18n: shorten backupTitle to "Backup & Restore" across all 13 locales

The backup modal header was the second-longest title in the dashboard
on every locale ("Database Backup & Restore" / "Резервне копіювання
та відновлення бази даних" / etc). Drop the "Database / Veritabanı /
数据库" qualifier — the modal already lives under the "Database"
column, so the shorter form reads cleaner on narrow viewports.

Updated both the .toml source-of-truth files and the synced .json
locales (re-running scripts/sync-locales.mjs).

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

* i18n: collapse two translation databases into a single web/translation/<lang>.json set

The Vue SPA had been reading from frontend/src/locales/*.json while the
Go binary still loaded web/translation/translate.*.toml — and a
sync-locales.mjs pre-build step kept the two in lockstep, with TOML as
the source of truth. Now that go-i18n v2.6.1 already flattens nested
JSON via recGetMessages/addChildMessages, both runtimes can share one
file per locale.

- Move the 13 nested-JSON locale files to web/translation/<lang>.json
  so they live alongside the Go //go:embed translation/* directive.
- Switch web/locale/locale.go from toml.Unmarshal to json.Unmarshal
  (and drop the pelletier/go-toml import — it's now indirect-only).
  Confirmed via a smoke test that pages.index.cpu, subscription.title,
  tgbot.commands.help, and menu.settings all resolve in en-US, fa-IR,
  ru-RU, and zh-CN.
- Repoint Vue's i18n loader at the new path (../../../web/translation/
  *.json glob) and drop the moved-here pathDelimiter comment that no
  longer applies.
- Delete the 13 legacy translate.*.toml files and the sync-locales.mjs
  script + its npm pre-script hooks (predev/prebuild/i18n:sync). The
  Telegram bot and subscription page still get their messages because
  they were reading the same MessageIDs the JSON files now produce.
- Update copilot-instructions.md so the next contributor knows where
  the canonical translation files live.

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

* fix(frontend): redesign expand-row + retheme client visuals

When you expanded an inbound row, the nested <a-table> inside
ClientRowTable burst out of the parent's scroll-x box — its
.ant-spin-container ended up wider than the parent's narrow
.ant-table-cell, so the child looked oversized while the parent looked
squeezed. Replace the nested table with a CSS-grid layout that owns
its sizing, sits flush inside the expanded cell, and collapses to a
3-column layout on mobile (action menu, client identity, info popover).

While in there, fix three other client-row visuals:
- The Unicode infinity glyph (U+221E) renders as an "m"-shaped
  character in some system fonts (Windows Segoe UI in particular).
  Add a shared <InfinityIcon /> SVG component (legacy panel's path)
  and use it in ClientRowTable, InboundList, and InboundInfoModal —
  desktop and mobile cells.
- The "unlimited quota" traffic bar passed :percent="100" with no
  stroke-color, so AD-Vue auto-coloured it success-green. Pin it to
  the AD-Vue purple token (#722ed1) so it reads as the no-limit
  sentinel rather than another usage state.
- ColorUtils + the in-row statsExpColor still hardcoded the legacy
  teal/orange/red/purple palette (#008771 / #f37b24 / #cf3c3c /
  #7a316f). Map them onto AD-Vue 4's success/warning/danger/purple
  tokens (#52c41a / #faad14 / #ff4d4f / #722ed1) so badges, tags,
  and progress bars all match the rest of the panel.

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

* fix(frontend): darken light-theme page bg so cards stand out

The light-theme --bg-page was #f0f2f5 — close enough to AD-Vue's #fff
card background that the cards faded into the page. Bump it to #e6e8ec
(a more visibly distinct gray) so cards lift cleanly off the surface.
Dark and ultra-dark stay where they were.

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

* fix(frontend): shrink dashboard percent text and surface the unfinished arc

Two follow-up tweaks to the dashboard gauges:
- AD-Vue scales the percent text from the SVG, not from :width, so
  the 90px gauges still rendered the number at ~27px. Pin
  .ant-progress-text to 14px via :deep() and trim the gauge to 70px
  (60px on mobile) so the whole card stays compact.
- The default trail (rgba(0,0,0,0.06) / rgba(255,255,255,0.08)) was
  invisible on the light-theme card. Pass an explicit
  rgba(128,128,128,0.25) trail-color so the unfinished portion is
  visible under both light and dark themes.

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

* feat(frontend): migrate subpage.html to Vue 3 SPA

The subscription info page was the last page still rendered by Go
templates. Move it to the Vite multi-page setup so the whole panel
loads through one toolchain.

Frontend: SubPage.vue mounts at /sub/<id>?html=1 and reads window.__SUB_PAGE_DATA__
for the parsed view-model (traffic / quota / expiry + rendered share
links). Fix descriptions borders against the light-theme card by
painting the row divider on each cell's bottom edge — AD-Vue's <tr>
border doesn't render reliably under border-collapse:collapse.

Backend: serveSubPage reads dist/subpage.html, injects
window.__X_UI_BASE_PATH__ + window.__SUB_PAGE_DATA__ before </head>,
and rewrites Vite's absolute /assets/ URLs when the panel runs under
a URL prefix. Drop the legacy template-FuncMap wiring and switch the
sub server's static mount from web/assets to web/dist/assets.

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

* feat(frontend): inbound modal QR + tabs + restored TLS fallbacks

Per-client QR action: the qr icon on the expand-row table opened the
big info modal instead of the QR modal. Route it to QrCodeModal and
extend that modal with a `client` prop so genAllLinks() produces the
per-client share URLs (and per-peer remarks for WireGuard).

Inbound's Data redesign: split the dense single-page view into three
tabs — Inbound, Client, Subscription. Drop every QR rendering from
this modal (QrCodeModal is the QR home now). Each row in the Inbound
tab is one label/value pair instead of the legacy 2-column grid, and
long values like the VLESS encryption blob render as a wrapping code
block with a copy button so they can't blow out the dialog. The
Subscription tab renders sub URL + JSON URL as clickable anchors that
open in a new tab.

Restored TLS fallbacks UI: the model already exposed
VLESSSettings.Fallback / TrojanSettings.Fallback with addFallback /
delFallback / fallbackToJson, but the form modal never surfaced them
during the Vue 3 migration. Re-add the legacy form (SNI, ALPN, Path,
Destination, PROXY) on the protocol tab, gated on TCP transport plus
(for VLESS) encryption=none — same conditions as main.

Column widths: Protocol 70→130 and All-time Traffic 60→95 in the
inbound list; All-time Traffic 90→130 in the client expand-row, so
the header text fits and tags don't get squeezed.

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

* feat(frontend): navy dark theme + rounded inbound/client corners

Dark theme picks up a refined navy palette (page #0a1426, cards
#142340, sider #0d1d33) so the sidebar blends with the rest of the
surface; ultra-dark stays neutral black. Resolves the previous mismatch
where AD-Vue 4 hardcoded #001529 / #002140 for the sider, trigger and
dark Menu items via Layout.colorBgHeader / colorBgTrigger and Menu's
colorItemBg — overrides go through the component-token map now.

Round the inbound table's outer corners (header start/end + last row
end) and wrap the client expand-row grid in a 1px / 8px-radius border
so the list reads as a contained block instead of a flush rectangle.

Linter-driven whitespace cleanup across inbounds/*.vue rolled into the
same commit since it can't be split out cleanly.

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

* feat(frontend): xray tab fixes — modal close, tag validation, full XHTTP, reset to default

Modal close: BalancersTab / OutboundsTab / RoutingTab confirmDelete used
arrow expressions that returned splice's removed-items array. AD-Vue 4
treats truthy non-thenables from onOk as "still pending" and never closes
the dialog (see ActionButton.js:103-106), so the confirm modal stayed
open. Wrap the body so onOk returns undefined and AD-Vue auto-closes.

Tag validation: outbound + balancer modals only flipped between
warning/success on duplicate, leaving the empty case as a green ✓.
Split into a 3-state computed — error (empty) / warning (duplicate) /
success — and wire a help message so the input clearly explains why
the OK button is disabled.

Reset to default: re-add the legacy "Reset to Default" panel at the
bottom of BasicsTab. Calls /panel/setting/getDefaultJsonConfig and
overwrites templateSettings; the existing watch re-stringifies so the
JSON tab + dirty-poll see the new state.

Restored Basics option lists from main: IPs (4→10, +Vietnam/Spain/
Indonesia/Ukraine/Türkiye/Brazil), DomainsOptions (4→10, +regex
entries), BlockDomainsOptions (5→17, +Malware/Phishing/Adult/regex),
ServicesOptions (Reddit/Speedtest in, off-template Microsoft out).

Outbound form parity with main:
  • Reverse Sniffing UI for VLESS — toggle + destOverride checkboxes
    (HTTP/TLS/QUIC/FAKEDNS) + Metadata/Route Only + IPs/Domains
    excluded multi-selects, gated on reverseTag being set.
  • Full XHTTP transport — request headers list, Max Upload Size /
    Min Upload Interval (packet-up), Padding Obfs Mode + sub-fields,
    Uplink HTTP Method, Session/Sequence/UplinkData placement +
    keys, No gRPC Header (stream-up/stream-one), expanded XMUX with
    Max Concurrency/Connections/Reuse/Request/Reusable/Keep-alive.

Strip a-divider from the outbound form per request — replaced with
plain section/item heading divs so the labels and per-row delete
icons stay but the horizontal rule is gone.

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

* fix(frontend): xray Advanced tab parity + finalmask gating

Advanced tab was a single textarea bound to the full xraySetting blob.
Restore the legacy 4-way view: a radio group toggles between All /
Inbounds / Outbounds / Routing Rules, and the textarea reads/writes
the matching slice through templateSettings. Added the legacy header
("Advanced Xray Configuration Template" + description) so the page
introduces itself like main.

Outbound finalmask leaked into protocols that don't have a stream
(Freedom / Blackhole / DNS / Socks / HTTP / Wireguard) because the
v-if only checked outbound.stream. Gate the whole FinalMaskForm on
outbound.canEnableStream() to match main.

Drop the leading divider inside FinalMaskForm — its parent already
provides separation, so the rule above "TCP Masks" was redundant.

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

* fix(frontend): inbound Advanced tab live mirror + QR exact-fit sizing

Advanced tab in the inbound modal showed stale state. The watch only
refreshed advancedJson.stream, so toggling the Sniffing switch in the
Sniffing tab left the Advanced JSON showing the prior value. And
encryption — stored on inbound.settings.encryption, not on stream —
never appeared at all because Advanced only exposed stream + sniffing.

Split the watch into three (stream / sniffing / settings) and add a
settings textarea so encryption / clients / fallbacks live alongside
the existing two views. The submit() path now reads settings from
the JSON tab too (falling back to inbound.settings.toString()) so
power-user edits in Advanced override the structured form on save.

QR canvas: when a longer share-URL bumps the QR matrix size, QRious
falls back to floor(canvasSize / matrixWidth) and centers the pattern,
leaving a white margin (e.g. matrix=41, size=180 → 8px gap). Pre-pick
the QR version from the URL byte length and set canvas size to a
multiple of matrixWidth × pixelSize so the pattern always fills it
edge-to-edge — no white margin even after toggling encryption on.

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

* fix(frontend): inbound stream tidy-up + QR sizing + dev proxy

Stream tab clean-up: drop the seven a-divider rules in the inbound
form's Stream tab — replace the labelled ones (Request / Response /
Security) with a section-heading div that matches the outbound modal,
delete the empty rules above TLS sub-blocks / External Proxy /
Sockopt. Empty header-list form-items also leaked margin space below
each "Add header" button across TCP / WS / HTTPUpgrade / XHTTP — gate
each on headers.length > 0 so they vanish until the user adds one.

QR panel: drop the link text under the canvas (the user already has
a copy button on the header). Pin the canvas display size to a fixed
240px square via :style + image-rendering: pixelated/crisp-edges so
a dense WireGuard config QR and its sparser link share the same
on-screen footprint without blurring.

Dev proxy: Node's AggregateError wraps connection failures whenever
DNS returns more than one address (::1 + 127.0.0.1) and the code
lands on the inner errors, not the outer. The existing handler only
checked err.code so the ECONNREFUSED stack still spammed the log
when the Go backend was down. Walk err.errors too, print one
friendly line ("backend not reachable — start the Go server"), then
stay quiet for the rest of the session.

Vendor splitting + chunk-size warning: split node_modules into
stable vendor-* chunks so each page only ships the deps it uses and
the browser caches them across versions. ant-design-vue stays as a
single chunk because its components share internals; raise the
chunk-size warning to 1500kB so the build stays quiet (its 1.4MB
minified gzips to ~410kB on the wire).

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

* fix(frontend): info-modal cleanup + 2FA QR + outbound link import

- 2FA QR: matrix-snap canvas + opaque background to drop white margin
- Inbound info modal: stack Mixed/HTTP/Tunnel as info-rows, hide tab
  strip when only the Inbound tab applies
- Add inline VLESS Reverse tag input on first-client form
- Hide Protocol tab for TUN (no form yet)
- Outbound link converter: route through Outbound.fromLink so
  vless/trojan/ss/hysteria(2) imports work alongside vmess; fix stray
  implicit global in fromLink

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

* feat(frontend): jalali calendar + drop legacy moment-jalali

- Wire Calendar Type setting to a real Jalali datepicker via
  vue3-persian-datetime-picker, gated by useDatepicker composable
- DateTimePicker wrapper swaps between AD-Vue and Persian picker; keeps
  dayjs v-model contract so existing forms/setters work unchanged
- Theme picker popup explicitly per body.dark / data-theme=ultra-dark
  (AD-Vue 4 doesn't expose CSS vars, so var() fallbacks defaulted to
  white); fix invisible disabled days, SVG arrow fills, popup clipping
  via append-to="body"
- Replace stray moment() calls in dbinbound/inbound models with dayjs;
  the legacy global was undefined under ESM and broke the inbounds list
  whenever any inbound had expiryTime > 0
- Remove legacy moment-jalali / persian-datepicker / aPersianDatepicker
  assets — replaced by the Vue 3 picker

Note: dark/ultra background of the date popup still renders white in
some cases — pending follow-up.

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

* fix(frontend): jalali popup theming + full-month layout

- Re-prefix popup selectors with .vpd-wrapper (popup root that travels
  with appendTo='body'), not .vpd-main (which stays at the input);
  paints the popup's dark/ultra background again
- Drop the 1px border on .vpd-content — with box-sizing: border-box
  it ate 2px from the day-row width, wrapping the 7th cell of every
  row and hiding days 18-31 of months that needed a 5th week

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

* feat: render dates in Jalali when Calendar Type is jalalian

- IntlUtil.formatDate accepts an optional calendar arg; appends the
  BCP-47 -u-ca-persian extension so Intl renders Jalali across all UI
  languages, not just fa-IR
- Plumb the panel's datepicker setting into the SubPage via the Go
  injection (window.__SUB_PAGE_DATA__.datepicker)
- Panel pages (inbound list/info, client row, xray log) read the same
  setting through the useDatepicker composable so the whole panel
  stays consistent

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

* feat(frontend): ultra-dark page tint + mobile-friendly inbound view

- Drop --bg-page from #21242a (lighter than the cards) to #050505 in
  ultra-dark across index/sub/settings/inbounds/xray, so cards
  consistently elevate over the page
- Hide the inline sider's children + collapse-trigger and zero its
  width below 768px; the floating drawer-handle remains the menu
  trigger
- Inbounds page mobile pass: tighten content-area + card padding;
  flex-wrap the filter bar instead of stacking; shrink table cell
  padding so all 4 mobile columns fit; bump expand / action / info
  icon hit targets
- Per-client expand row on mobile: soft-tinted rounded cards instead
  of hairline borders, larger action / info touch targets, more
  legible email typography, bigger status badge dot

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

* chore: remove legacy template + asset trees and dead Go template engine

- Delete web/html/ entirely (page templates, form/, modals/, component/,
  common/, settings/) — every route is served from web/dist/ now via
  serveDistPage; nothing in the binary referenced these
- Delete web/assets/ entirely (jQuery-era ant-design-vue, axios, moment,
  codemirror, qrcode/qs/uri/vue/otpauth, custom CSS, Vazirmatn font);
  Vite bundles all of this into web/dist/assets
- Drop the Gin HTML template wiring: remove //go:embed assets +
  //go:embed html/*, the assetsFS/htmlFS vars, the wrapAssetsFS adapter,
  EmbeddedHTML / EmbeddedAssets exports, getHtmlFiles / getHtmlTemplate,
  the i18nWebFunc/funcMap and SetFuncMap call, and the dev/prod
  template-engine branch — only StaticFS for /assets/ is needed now
- Remove dead html()/getContext() helpers and unused imports from
  web/controller/util.go (no c.HTML(...) callers remain)

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

* fix(frontend): inbound expand chevron position + cpu history layout

- Push the inbound table's expand chevron away from the left edge with
  margin-inline + cell padding so it isn't flush against the corner
- Move "Timeframe: …" caption above the chart (was below); restore
  the line that the previous edit removed
- Fix x-axis time labels being clipped at the bottom of the cpu chart
  — the offset (paddingTop+drawHeight+22 = 222) exceeded the SVG
  viewBox height (220); dropped to +14 so labels sit at y=214 with
  room for descenders
- Move the SVG axis text colors out of <style scoped> into a global
  block — Vue's scoped CSS doesn't always hash-attribute SVG <text>
  descendants, so the dark-mode overrides via :global() weren't
  matching; bumped opacity 0.55 → 0.85 for legibility on navy/black

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

* feat(login): language picker in settings popover + fluid card sizing

- Add language select alongside the theme switch (mirrors SubPage)
- Bind headline to pages.login.hello / pages.login.title so the
  "Hello / Welcome" cycle re-translates with the active locale
- Replace AD-Vue 5-breakpoint grid with clamp() sizing so the card
  scales smoothly instead of jumping ~33% at each breakpoint
- Pin horizontal padding so input width stays stable on large viewports

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

* refactor(frontend): organize entry HTML + bootstrap JS into folders

- Move entry HTML files: frontend/*.html -> frontend/html/*.html
- Move per-page bootstrap modules: src/{index,login,settings,inbounds,xray,subpage}.js -> src/entries/
- Update vite.config rollup inputs and dev-mode MIGRATED_ROUTES to /html/<page>.html
- Build output now lands at web/dist/html/<page>.html
- serveDistPage and subController updated to read from dist/html/

Cleans up the flat frontend/ root which previously interleaved 6 HTML
files with package.json, README, src/, etc. The src/ root similarly
gets rid of 6 entry .js files mixed in alongside api/, components/,
models/, etc.

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

* chore: remove obsolete vue3 phase1 inventory doc

The migration is well past phase 1 — the inventory doc has rotted
and the live state lives in the codebase plus the plan files.

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

* refactor(frontend): merge utils/legacy.js into utils/index.js

The barrel was a placeholder for an eventual split that hasn't
happened. Collapsing the two files removes one layer of indirection
and the misleading "legacy" name (the contents are still actively
used by the migrated SPA).

- Move all 930 lines from utils/legacy.js into utils/index.js
- Delete utils/legacy.js
- Update direct import in models/outbound.js to '@/utils'
- Drop a stale legacy.js reference in InboundFormModal comment

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

* revert(frontend): keep entry HTML files at frontend/ root

The earlier move to frontend/html/ made dev-mode URLs ugly
(http://localhost:5173/html/index.html instead of plain /). The folder
didn't add real value — it just hid 6 files behind a non-conventional
layout. Reverting that piece while keeping src/entries/ (which is a
genuine separation between page bootstrap and the rest of src/).

- HTML files back at frontend/<page>.html
- Vite rollupOptions.input + MIGRATED_ROUTES restored to flat paths
- Build output is web/dist/<page>.html again
- web/controller/dist.go and sub/subController.go read from dist/<name>

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

* build(frontend): bump eslint to 10 + add flat config + clean lint warnings

- Upgrade eslint 9.39 -> 10.3 and eslint-plugin-vue 9.33 -> 10.9
- Add eslint.config.js (flat config required by ESLint 10) with
  vue3-recommended rules, sensible defaults, and exemptions for the
  project's existing formatting style
- Drop --ext from the lint script (removed in ESLint 10)
- vue/no-mutating-props is left off because the form-modal pattern
  ports straight from Vue 2 (parent passes a reactive object, child
  mutates it); a real fix is an architectural rewire, separate task

Lint warning cleanup:
- utils/index.js: var -> let/const in the X25519 routines, replace
  obj.hasOwnProperty(...) with Object.prototype.hasOwnProperty.call(...)
- Remove unused imports (reactive, ref, Inbound) in ClientFormModal,
  InboundInfoModal, QrCodeModal, DnsServerModal, OutboundFormModal,
  SubPage; remove unused locals (isClientOnline, ONLINE_GRACE_MS,
  fetchAll, isSocks, isHTTP, _antdAlgorithm)
- XrayStatusCard: declare 'open-logs' on defineEmits (was emitted but
  not declared)
- RuleFormModal: rename v-for var t -> tag (shadowed useI18n's t)
- Drop stale eslint-disable directives (no-new, no-unused-vars)
- OutboundsTab/InboundList: drop redundant initial null assigns
- InboundInfoModal/OutboundFormModal: explicit eslint-disable for the
  intentional local-ref-shadows-prop pattern in modal drafts

`npm run lint` now passes with 0 errors and 0 warnings.

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

* feat(inbounds): one client identity across multiple inbounds via subId

Lets the operator add the same email under the same subId to several
inbounds. Xray reports traffic per email, so a single client_traffics
row acts as the shared accumulator — no aggregation overhead, quota and
expiry stay consistent.

- Email validation allows duplicates only when subId matches
- AddClientStat upserts via OnConflict DoNothing (idempotent on rerun)
- Stat/IP rows survive client deletion when a sibling inbound still
  references the email
- enrichClientStats tops up GORM-preloaded stats with rows whose
  inbound_id points at a sibling, so every panel view sees usage
- disableInvalidClients cascades enable=false and syncs the row's
  total/expiry into every sibling JSON when the shared identity expires
- DelDepletedClients removes the depleted client from all referencing
  inbounds, batched
- Subscription services dedupe traffic by email so shared quota is
  counted once

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

* docs(frontend): rewrite README for multi-page Vue 3 layout

Reflects the current state — embedded build, per-route HTML entries,
ESLint 10 flat config, src/ layout, and the steps to add a new page.

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

* build(frontend): drop deprecated rimraf/glob/inflight transitive deps

vue3-persian-datetime-picker pinned moment-jalaali to ^0.9.4, which
pulled rimraf@3 → glob@7 → inflight@1. inflight in particular leaks
memory and is unmaintained. Override moment-jalaali to ^0.10.4 (same
runtime API, dropped the legacy build deps) so npm install no longer
warns and the dep tree is 12 packages lighter.

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

* feat(nodes): multi-node panel orchestration (CRUD, deployment, traffic sync, sub per-node)

- Node model + service + controller (/panel/api/nodes/*) with bearer-token apiToken auth
- Heartbeat job @every 10s; status/latency/xrayVersion surfaced in Nodes UI
- Runtime abstraction (Local + Remote) so inbound/client mutations target the
  inbound's owning node instead of always hitting the local xray
- Inbounds gain optional NodeID; tag-based correlation with remote panel (no
  RemoteInboundID column needed)
- NodeTrafficSyncJob @every 10s pulls absolute counters + online/lastOnline
  from each enabled+online node and writes them into central DB; 30s reset
  grace window prevents post-reset overwrite
- Reset propagation to nodes (best-effort) on client/inbound/all reset paths
- Subscription server uses node.Address for inbounds with NodeID, falling back
  to existing host resolution for local inbounds
- Frontend: Nodes page, "Deploy to" select in inbound form, Node column on
  inbound list, hostOverride threaded through genAllLinks/QR/Info modals

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

* feat(stats): system history modal + per-node CPU/Mem trends across all locales

Backend
- web/service/metric_history.go: generic in-memory ring buffer with two
  singletons — system-wide (cpu/mem/netUp/netDown/online/load1/5/15)
  and per-node (cpu/mem) keyed by node id
- ServerService.AppendStatusSample writes all 8 metrics every 2s on the
  same tick; AppendCpuSample/AggregateCpuHistory kept for back-compat
- NodeService.UpdateHeartbeat appends cpu/mem only on online ticks so
  offline gaps render as missing data, not phantom dips
- New routes: GET /panel/api/server/history/:metric/:bucket and
  GET /panel/api/nodes/history/:id/:metric/:bucket, both whitelisted

Frontend
- Sparkline component generalized: arbitrary value range (auto-scale
  when valueMax=null), pluggable yFormatter/tooltipFormatter for B/s,
  client counts, load averages
- SystemHistoryModal replaces CpuHistoryModal with tabs for every
  metric; opened from a tag on the 3X-UI card next to Documentation
- NodeHistoryPanel: expandable row on the Nodes table showing per-node
  CPU and Mem trends, refreshed every 15s

Localization
- Backfill systemHistoryTitle / trendLast2Min / pages.inbounds.{node,
  deployTo, localPanel} and the entire pages.nodes block (51 keys
  including statusValues + toasts) into all 11 non-en/fa locales:
  ar-EG, es-ES, id-ID, ja-JP, pt-BR, ru-RU, tr-TR, uk-UA, vi-VN,
  zh-CN, zh-TW

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

* fix(embed): include underscore-prefixed Vite chunks in dist FS

go:embed silently excludes files whose names start with `_` or `.`,
so the `_plugin-vue_export-helper-<hash>.js` chunk that Vite/rolldown
emits for @vitejs/plugin-vue was missing from the production binary.
First import at runtime hit a 404 and the SPA failed to mount — blank
page on every page load, no error in the server logs because the
asset 404 was just a static-handler miss.

Switched the directive to `//go:embed all:dist` which keeps the same
root layout but disables the underscore/dot exclusion rule. Dev mode
was unaffected (it serves dist/assets/ from disk, not the embedded FS).

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

* ci: build frontend bundle before Go compile in release.yml + Dockerfile

Phase 8 cut all panel HTML routes over to web/dist/ and embedded the
Vite bundle into the Go binary via //go:embed all:dist. web/dist/ is
.gitignored, so on a fresh CI checkout it doesn't exist — every Go
build since Phase 8 has been failing with "pattern dist: no matching
files found" or producing a binary that 404s on first asset request.

release.yml: add a setup-node@v4 + npm ci + npm run build trio before
the existing go build step in both the Linux matrix job (7 arches)
and the Windows job. npm cache is keyed on frontend/package-lock.json.

Dockerfile: add a node:22-alpine frontend stage that runs npm ci +
npm run build and emits to /src/web/dist (via vite.config.js's outDir).
The golang builder stage then COPY --from=frontend /src/web/dist into
./web/dist before the go build, so embed.FS sees the bundle.

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

* feat(ws): live updates on inbounds/xray/nodes pages, drop polling + manual refresh

Replaces the legacy polling + manual-refresh model with WebSocket pushes
across the three live-data pages. The hub already broadcast traffic /
client_stats / outbounds; this wires the frontend to consume them and
adds a new `nodes` channel for the heartbeat job's snapshot.

Frontend
- new useWebSocket composable: page-scoped singleton WebSocketClient,
  lifecycle-managed on/off, leaves disconnect to page-unload
- inbounds: useInbounds gains applyTrafficEvent / applyClientStatsEvent
  / applyInvalidate that merge counters and online/lastOnline in place;
  InboundsPage subscribes; InboundList drops the auto-refresh popover,
  the refresh button, and the now-unused refreshing prop
- xray outbounds: useXraySetting gains applyOutboundsEvent; XrayPage
  subscribes; OutboundsTab drops the refresh button + emit
- nodes: useNodes gains applyNodesEvent and stops the 5s
  setInterval/visibilitychange polling; NodesPage subscribes;
  NodeList drops the refresh button and ReloadOutlined import

Backend
- web/websocket: new MessageTypeNodes + BroadcastNodes notifier
- node_heartbeat_job: after wg.Wait(), reload the table once and
  BroadcastNodes(updated). Gated on websocket.HasClients() so a panel
  with no open browser doesn't spend the DB read

Bug fixes spotted in this pass
- websocket.js #buildUrl defaulted basePath to '' when the global was
  missing (dev mode), producing `ws://host:portws` and a SyntaxError
  on the WebSocket constructor. Fall back to '/' and ensure leading
  slash.
- vite.config.js: forward /ws to ws://localhost:2053 with ws:true so
  dev (5173) reaches the Go backend's WebSocket
- NodeFormModal: a-input-password's visibilityToggle is Boolean in
  AntD Vue 4; the v3-era object form (`{ visible, 'onUpdate:visible' }`)
  triggered a Vue prop-type warning. Drop the override (default true
  shows the eye icon and toggles internally) and remove the orphaned
  tokenVisible ref

Translations
- pages.inbounds.autoRefresh / autoRefreshInterval: removed from all
  13 locales (UI gone)
- pages.nodes.refresh: removed from all 13 locales (UI gone)

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

* feat(inbounds): hide Node column when no nodes are defined

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

---------

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 17:47:35 +02:00
MHSanaei 12c10dbd98 feat(custom-geo): refresh index UI
Split the single ext-snippet column into Alias / URL / Routing /
Last-updated, with the alias surfaced next to a colored type tag,
the URL ellipsized with a tooltip + open-in-new-tab, and the
ext:file.dat:tag snippet click-to-copy via ClipboardManager.

Switch Last-updated to a relative time ("2 hours ago") with the
absolute timestamp on hover, add a friendly empty state, and show
a result toast when "Update All" finishes with partial failures.

customGeoEmpty translated for all 13 locales.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:09:33 +02:00
MHSanaei 2fd2cd0af1 fix(panel): silence update-check WARN spam when offline
The panel polls api.github.com on every page load. When the host has no
internet (DNS fails, GitHub blocked, etc.) jsonMsg's auto-WARN logging
floods the log with the same error every poll.

Bypass jsonMsg for getPanelUpdateInfo: log the error at Debug level and
return Success:false with the existing localized message so the frontend
popover behavior is unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 09:51:05 +02:00
MHSanaei 37fb48ffff Axios v1.16.0 2026-05-08 09:41:56 +02:00
MHSanaei d8198f543b fix(warp): harden API client and frontend, bump to v0a4005
Backend:
- check HTTP status on every Cloudflare API call so error bodies don't
  get parsed as success
- replace unchecked type assertions with comma-ok form (no more panics
  when Cloudflare returns an error response)
- return real errors when license/id/token fields are missing instead
  of swallowing the failure
- guard SetWarpLicense against an empty errors array
- 15s timeout on the shared http.Client
- build all request bodies and persisted state with json.Marshal
- bump API path to v0a4005 and CF-Client-Version to a-6.30-3596 to
  match the current Cloudflare WARP client

Frontend (warp_modal.html):
- remove stray </a-form-item> closing tag
- declare config/peer with const and null-check before dereferencing
- guard addOutbound/resetOutbound against missing warpOutbound
- rename getResolved -> getReserved (the array it builds is "reserved")

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 09:29:42 +02:00
MHSanaei f2bc4938b7 Reality: remove tesla.com because of blocking
#4175
2026-05-08 00:59:09 +02:00
MHSanaei 7f703f927e fix(scripts): harden server-IP detection with multi-provider + manual fallback
Try six IPv4 providers in turn, accept only HTTP 200 + IPv4-shaped body,
and prompt the user to enter their IP if every provider fails.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 00:51:28 +02:00
MHSanaei f2c79b57fa Bump Go to 1.26.3
Raise module Go version to 1.26.3 and upgrade dependencies including github.com/valyala/fasthttp to v1.71.0 and google.golang.org/genproto/googleapis/rpc to a newer revision. go.sum was updated by module tooling to reflect these changes.
2026-05-08 00:19:05 +02:00
MHSanaei c394938f01 refactor(websocket): split controller into service + thin controller
Move per-connection lifecycle out of the controller and into a new
service.WebSocketService. The controller is now HTTP-layer only:
authenticate, validate origin, upgrade, and hand the connection off.

- web/service/websocket.go (new): owns the read/write pumps, hub
  registration, and connection lifetime. Pump constants are prefixed
  (wsWriteWait, wsPongWait, wsPingPeriod, wsClientReadLimit) to avoid
  collisions in the larger service package namespace.
- web/controller/websocket.go: trimmed to the upgrader, same-origin
  check, auth gate, and hand-off to the service.
- web/web.go: wires controller.NewWebSocketController(service.NewWebSocketService(hub)).

The hub package (web/websocket) stays as low-level fan-out
infrastructure. Behavior is unchanged — this is a structural cleanup
to align with the rest of the codebase's controller/service split.

Also includes a small range-int modernization in login_limiter_test.go
that gopls flagged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 00:00:44 +02:00
MHSanaei b84b58ef21 fix(websocket): guard stale events and disconnect race in JS client
Two subtle race conditions in the browser WebSocket client:

1. Stale-event clobber. When connect() is called while the old socket is
   in CLOSING state, the readyState guard falls through and a new socket
   is assigned to this.ws. The old socket's queued close event then
   nulls out this.ws, silently breaking send() until the next reconnect.
   Same risk for delayed open/error/message handlers.

2. Reconnect-after-disconnect. clearTimeout() does not cancel a callback
   that has already fired but whose macrotask has not yet run. If
   disconnect() lands in that window, the queued reconnect callback
   still calls #openSocket() and resurrects the connection.

Every event handler now bails out if this.ws no longer points at the
socket that fired the event, and the reconnect timer callback re-checks
shouldReconnect before opening a new socket.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 00:00:10 +02:00
Farhad H. P. Shirvan 10ebc6cbdc Implement CSRF protection and security hardening across the application (#4179)
* Implement CSRF protection and security hardening across the application

- Added CSRF token handling in axios requests and HTML templates.
- Introduced CSRF middleware to validate tokens for unsafe HTTP methods.
- Implemented login limiter to prevent brute-force attacks.
- Enhanced security headers in middleware for improved response security.
- Updated login notification to include safe metadata without passwords.
- Added tests for CSRF middleware and login limiter functionality.

* fix
2026-05-07 23:36:11 +02:00
Harry NG a1b2382877 chore: fix shadowrocketUrl client (#4183) 2026-05-07 20:59:10 +02:00
MHSanaei 59c55dfc92 fix(panel-update): poll for restart, fix dark-mode version label
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 20:55:22 +02:00
MHSanaei 28a3dddb60 refactor(fallbacks): share template, tighter UX, cleaner JSON
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 20:27:34 +02:00
MHSanaei 39bf31bd56 fix(tun): use single mtu number per Xray spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 19:50:47 +02:00
MHSanaei 42b2ebc00b refactor(xhttp): split fields by direction, expand outbound coverage
Audit panel xhttp config against xray-core's runtime paths and split
fields per direction so each side carries only what it actually uses:

- Bidirectional (must match): host, path, mode, all xPadding*,
  session*/seq*, uplinkData*/Key, scMaxEachPostBytes
- Server-only (inbound): noSSEHeader, scMaxBufferedPosts,
  scStreamUpServerSecs, serverMaxHeaderBytes
- Client-only (outbound): uplinkHTTPMethod, uplinkChunkSize,
  noGRPCHeader, scMinPostsIntervalMs, xmux

The inbound previously held client-only fields and the outbound was
missing every must-match field beyond host/path/mode — meaning a
panel-built outbound couldn't connect to an inbound with a custom
xPaddingKey/sessionKey/etc.

Headers stay on the inbound for URL-share purposes only; xray's
listener ignores them at runtime, but they travel through the share
link's `extra` blob so the client picks them up.

Renames the URL helpers (applyXhttpPadding* -> applyXhttpExtra*) since
the blob now carries more than padding, and folds path/host/mode into
the helper so each link generator's xhttp branch is one line.

Adds two enforcement points for xray's "uplinkHTTPMethod=GET only in
packet-up" rule: the GET option is disabled when mode != packet-up,
and a watcher on the outbound modal auto-clears GET when the user
switches modes.

Hides the XMUX block behind an `enableXmux` switch on the outbound
form (mirrors the QUIC Params toggle) so the section doesn't clutter
the form by default; fromJson auto-flips it on for outbounds with
saved xmux config.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 19:26:40 +02:00
MHSanaei 3b64a62137 refactor(vless): drop selectedAuth, expose two explicit auth buttons
selectedAuth was UI-only metadata (Xray never reads it) and entirely
redundant with the encryption string itself — the dropdown only
controlled which block from `xray vlessenc` to apply. Replace it with
two explicit buttons ("X25519" and "ML-KEM-768") so the user picks
the auth mode in one click instead of dropdown + Get-New-Keys.

- VLESSSettings drops the field from constructor, fromJson, and toJson;
  legacy `selectedAuth` values still in DB will be silently shed on the
  next save.
- getNewVlessEnc(authLabel) now takes the label as a parameter; clear
  resets only decryption/encryption.
- Fallbacks visibility now keys on encryption === "none" (the same
  thing the dropdown was effectively gating on).
- Info modal drops the redundant Authentication tag and colours the
  encryption tag red when it's "none", green otherwise.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:08:06 +02:00
MHSanaei 79a7e7a5b5 fix(vless): scope testseed to xtls-rprx-vision flow
testseed is only meaningful for the exact xtls-rprx-vision flow, but the
panel was emitting it for any non-empty flow (including the UDP variant)
and keeping it on the inbound after the flow was cleared via the client
modal. Tighten the gate end-to-end:

- VLESSSettings.toJson (inbound + outbound) now only emits testseed when
  the flow is exactly xtls-rprx-vision and the array is 4 positive ints;
  default state is empty so unmodified inbounds omit the field entirely.
- canEnableVisionSeed drops the udp443 variant per spec.
- Form adds a tooltip + theme-aware help text and an inline error when
  the user partially fills the four inputs; submit is blocked in that
  state. Reset clears to empty (= use server defaults).
- UpdateInboundClient strips a now-orphaned testseed when the spliced
  client no longer leaves any XRV flow in the inbound.
- MigrationRequirements cleans up legacy rows where testseed lingered
  after flow changes or was saved for non-XRV flows by older versions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 14:44:33 +02:00
MHSanaei 3349dcbc13 fix(fail2ban): fix banning regression and Docker zero-jail issue
- DockerEntrypoint.sh: create jail.d/filter.d/action.d config files
  before starting fail2ban so Docker containers no longer start with
  0 active jails (fixes #4134)

- x-ui.sh create_iplimit_jails: lower maxretry from 2 to 1 so
  fail2ban bans on the first log entry; with maxretry=2 and the
  partitionLiveIps logic the second occurrence could arrive after the
  32 s findtime window, silently preventing any ban (fixes #4163)

- x-ui.sh: fix datepattern (%%Y -> %Y) so fail2ban parses the Go
  log timestamp correctly instead of looking for a literal %%Y string

- x-ui.sh / DockerEntrypoint.sh: fix date command in actionban /
  actionunban echo (%%Y -> %Y) so the ban log records actual dates

- check_client_ip_job.go: replace log.SetOutput / log.SetFlags on
  the global standard-library logger with a local log.New instance,
  eliminating the dangling closed-file-handle between calls and
  stopping unrelated stdlib log output from polluting 3xipl.log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 13:53:34 +02:00
MHSanaei ad30298700 Exclude virtual interfaces from network stats
Switch net.IOCounters to per-interface mode and aggregate traffic while excluding loopback and common virtual/tunnel interfaces. Adds isVirtualInterface helper to filter interfaces by exact names and prefixes (docker, veth, tun, wg, tailscale, etc.), sums BytesSent/BytesRecv across valid interfaces, and assigns the totals to status.NetTraffic. Removes the previous warning branch when no counters were found and preserves NetIO rate calculations using lastStatus.
2026-05-06 17:28:41 +02:00
MHSanaei 9be11e109e fix design 2026-05-06 17:12:08 +02:00
MHSanaei 7117d19fd1 fix: filter view in mobile 2026-05-06 14:45:46 +02:00
MHSanaei c88627a839 outbound: mobile style 2026-05-06 13:27:40 +02:00
MHSanaei c718e7ca5b fix(inbounds): remove stale reverse outbound tags after client deletion 2026-05-06 11:43:21 +02:00
pwnnex 6a483fa987 inbound: check transport in port conflict, allow tcp and udp on same port (#4169)
the panel rejected configurations like vless reality on tcp/443 and
hysteria2 on udp/443 even though those are independent sockets in
linux. the old checkPortExist looked only at port + listen.

inboundTransports now classifies each inbound by L4 transport:
hysteria/hysteria2/wireguard are udp; streamSettings.network=kcp is
udp; shadowsocks reads settings.network ("tcp"/"udp"/"tcp,udp");
mixed (socks/http) adds udp when settings.udp is true; everything
else is tcp. checkPortConflict pulls every row on the same port and
only flags a conflict when transport masks overlap. the listen-
overlap rule (specific addr vs any-addr on the same port) is kept.

inbounds.tag has a unique DB constraint and the controller derives
tags from port ("inbound-443"). without disambiguation a second
inbound on the same port would still hit a unique-constraint error.
generateInboundTag keeps the historical "inbound-<port>" shape when
the base tag is free, so existing routing rules survive the upgrade
unchanged, and appends "-tcp"/"-udp" only when the base is already
taken.

closes #4103.
2026-05-06 11:41:21 +02:00
MHSanaei 47163c1418 Skip 26.5.3 and bump Xray version cutoff
In GetXrayVersions, explicitly ignore the tag "26.5.3" and raise the minimum accepted Xray release from 26.3.10 to 26.4.25. This excludes a specific problematic release and updates the version parsing logic to only include >26 or 26.4.25+ releases.
2026-05-06 10:13:55 +02:00
MHSanaei 09f4f09b84 fix design 2026-05-06 10:06:56 +02:00
MHSanaei 3313086071 fix: Swap left/right classes for client table cells
Swap tr-table-rt and tr-table-lt on the size and totalGB elements in aClientTable.html so the size display and the total GB display are positioned correctly (size on the left, total on the right). This is a UI alignment fix with no functional logic changes.
2026-05-06 09:12:25 +02:00
MHSanaei 03d8ad4d5a Revert "Xray Core v26.5.3" buggy version(vless reverse doesn't work)
This reverts commit 74e97fec4c.
2026-05-06 08:52:36 +02:00
MHSanaei a8dff126c7 outbound: reverse Sniffing 2026-05-06 08:17:27 +02:00
MHSanaei 74e97fec4c Xray Core v26.5.3 2026-05-06 07:07:48 +02:00
MHSanaei 50603fd430 fix: get client reverse tag in the outbound 2026-05-06 00:50:40 +02:00
MHSanaei 8bea0fde2b v2.9.4 2026-05-05 21:31:24 +02:00
MHSanaei b2d32f588f new: vless reverse
legacy reverse removed
2026-05-05 21:00:03 +02:00
lolka1333 8177f6dc66 ws/inbounds: realtime fixes + perf for 10k+ client inbounds (#4123)
* ws/inbounds: realtime fixes + perf for 10k+ client inbounds

- hub: dedup, throttle, panic-restart, deadlock fix, race tests
- client: backoff cap + slow-retry instead of giving up
- broadcast: delta-only payload, count-based invalidate fallback
- filter: fix empty online list (Inbound has no .id, use dbInbound.toInbound)
- perf: O(N²)→O(N) traffic merge, bulk delete, /setEnable endpoint
- traffic: monotonic all_time + UI clamp + propagate in delta handler
- session: persist on update/logout (fixes logout-after-password-change)
- ui: protocol tags flex, traffic bar normalize

* Remove hub_test.go file

* fix: ws hub, inbound service, and frontend correctness

- propagate DelInbound error on disable path in SetInboundEnable
- skip empty emails in updateClientTraffics to avoid constraint violations
- use consistent IN ? clause, drop redundant ErrRecordNotFound guards
- Hub.Unregister: direct removeClient fallback when channel is full
- applyClientStatsDelta: O(1) email lookup via per-inbound Map cache
- WS payload size check: Blob.size instead of .length for real byte count

* fix: chunk large IN ? queries and fix IPv6 same-origin check

* fix: chunk large IN ? queries and fix IPv6 same-origin check

* fix: unify clientStats cache, throttle clarity, hub constants

* fix(ui): align traffic/expiry cell columns across all rows

* style(ui): redesign outbounds table for visual consistency

* style(ui): redesign routing table for visual consistency

* fix:

* fix:

* fix:

* fix:

* fix:

* fix: font

* refactor: simplify outbound tone functions for consistency and maintainability

---------

Co-authored-by: lolka1333 <test123@gmail.com>
2026-05-05 17:27:49 +02:00
MHSanaei 77d94b25d0 Add 'active' filter option to inbounds 2026-05-04 23:33:48 +02:00
MHSanaei 32b7ada549 subpage: enabled state
Track and surface a subscription's enabled state from backend to frontend so the UI can show inactive subscriptions and use it in active-state logic.

Changes:
- sub/subService.go: track hasEnabledClient, set traffic.Enable, add Enabled to PageData and populate it in BuildPageData.
- sub/subController.go: include enabled in the page context.
- web/html/settings/panel/subscription/subpage.html: emit data-enabled attribute and render an "inactive" tag when disabled.
- web/assets/js/subscription.js: read data-enabled and include it in isActive() checks.

This ensures subscriptions with no enabled clients are marked inactive in the UI and excluded from being considered active.
2026-05-04 23:27:57 +02:00
MHSanaei 6099a07ff0 feat: add configurable auto-restart on client auto-disable
Add a configurable option to restart Xray when clients are auto-disabled and persist disable actions.

Changes include:
- New setting restartXrayOnClientDisable (default true), getters/setters in SettingService, UI toggle in general settings, and translations for multiple locales.
- AddTraffic signature updated to return a third bool (clientsDisabled). disableInvalidClients now calls Xray API to remove users, marks client_traffics.enable=false, updates inbound.Settings JSON so clients appear disabled in stored settings, and returns appropriate counts/errors.
- XrayTrafficJob now checks the clientsDisabled flag and restarts Xray when the setting is enabled (with fallback to mark Xray as needing restart on failure).
- XrayService.GetXrayConfig call adjusted to ignore AddTraffic returns.
- Subscription generation (subService/subJson/subClash) no longer filters clients by their enable flag when matching subId.
- Minor fixes: check_client_ip_job now checks scanner.Err and improved API error handling/logging.

These changes ensure auto-disabled clients are propagated to Xray and the stored inbound settings, and provide an option to restart Xray automatically after auto-disable events.
2026-05-04 23:19:25 +02:00
MHSanaei e9806832ec reality: remove apple, icloud 2026-05-04 19:49:28 +02:00
MHSanaei 15ebf3df10 fix: client count for Hysteria
#4143
2026-05-04 17:49:53 +02:00
MHSanaei d44b70682c Update QUIC params defaults and UI validations
#4142
Adjust QUIC parameter defaults and tighten form validation across inbound/outbound components.

- Set default brutalUp/brutalDown to 65537 and only include them in JSON when congestion is 'brutal' or 'force-brutal'.
- Change keepAlivePeriod defaults (inbound QUIC -> 5s, Hysteria stream -> 2s) and enforce minimums in the UI.
- Expose and serialize additional QUIC fields in outbound QuicParams: init/max stream windows, init/max connection windows, maxIdleTimeout, disablePathMTUDiscovery, maxIncomingStreams.
- Add UI min/placeholder constraints: stream/connection receive windows min=16384 and updated placeholders to show defaults, brutal fields min=65537, maxIncomingStreams min=8 (placeholders updated), keepAlive min adjusted.
- Add Wireguard and Hysteria entries to Protocols.

Touched files: web/assets/js/model/inbound.js, web/assets/js/model/outbound.js, web/html/form/outbound.html, web/html/form/stream/stream_finalmask.html.
2026-05-04 17:42:55 +02:00
MHSanaei fb75e3d7c7 Check scanner error in GetXrayLogs
Add a check for scanner.Err() after scanning log lines and return nil if an error occurred. This prevents further processing of potentially incomplete or invalid log entries when the scanner encountered an error.
2026-05-04 17:02:00 +02:00
MHSanaei e9979b6774 API: Check client existence
#3706
2026-05-04 17:00:09 +02:00
MHSanaei 2b83dc047b Bump Go module dependency versions 2026-05-04 16:40:50 +02:00
MHSanaei c90f8a05bf fix(security): sanitize remote IP headers and escape log viewer output
#4135
2026-05-04 16:39:29 +02:00
MHSanaei 9f96ef83ec Freedom outbound: Add finalRules 2026-05-04 15:54:31 +02:00
MHSanaei e19061d513 TLS: Remove ECH Force Query 2026-05-04 13:20:24 +02:00
MHSanaei 51e2fb6dbf translate update
#4117
2026-04-28 19:17:11 +02:00
Farhad H. P. Shirvan f21ed92296 feat: add panel update functionality via web GUI (#4117)
* feat: add panel update functionality via web GUI

* feat: enhance panel update notifications in web GUI

* feat: implement panel update modal and enhance translation strings

* fix design
2026-04-28 18:46:55 +02:00
pwnnex 22de983752 xray-setting: pin api routing rule to index 0 on save (#4124)
when the admin adds a custom outbound (eg vless cascade to a second
server) and a routing rule sending all inbound traffic to it, that
catch-all gets evaluated before the existing api->api rule, so the
panel's internal stats inbound's traffic ends up on the cascade
outbound. the grpc stats query then can't see anything, GetTraffic
returns no inbound/user counters, and every client appears offline
with zero traffic even though the actual proxy path works fine.

before save, find the api rule and move it to the front of
routing.rules. if it's missing entirely, insert a default. other
rules keep their relative order.

closes #4113. probably also fixes the long-standing #2818 where the
documented workaround was "manually move the api rule to the top".
2026-04-28 17:49:39 +02:00
MHSanaei 0b5c239f98 v2.9.3 2026-04-27 15:31:32 +02:00
MHSanaei 03393c9f52 Minor changes 2026-04-27 15:02:43 +02:00
MHSanaei b56db67759 fix: handle Init error in GetXrayTraffic to prevent nil pointer panic
#3969
2026-04-27 14:11:28 +02:00
MHSanaei 6d05702d00 TCP Masks 2026-04-27 03:06:41 +02:00
MHSanaei 9791b05a4e kcp: noise, header-custom, sudoku 2026-04-27 01:28:06 +02:00
MHSanaei 0aca2d3b3d sub: kcp finalmask 2026-04-26 23:04:47 +02:00
MHSanaei 8529f4f0cf kcp: mtu and tti
Add KCP-specific fields mtu and tti to inbound stream handling in web/assets/js/model/inbound.js. The changes add obj.mtu/obj.tti when serializing the kcp stream and set params for mtu and tti in the various KCP parameter-building branches so these values are preserved and transmitted where KCP is used.
2026-04-26 21:32:50 +02:00
MHSanaei abc5cf3439 Increase KCP maxSendingWindow to 2MiB 2026-04-26 20:49:02 +02:00
MHSanaei a7e7788e29 Bump Xray release to v26.4.25 2026-04-26 20:45:00 +02:00
MHSanaei 8620344925 Replace with-block with explicit settings 2026-04-26 20:37:03 +02:00
MHSanaei 47e229e323 Default to dark theme when unset 2026-04-26 20:16:27 +02:00
MHSanaei 4521beab7c wireguard: link 2026-04-26 20:06:24 +02:00
MHSanaei a62c637632 DNS outbound: Add rules 2026-04-26 17:34:31 +02:00
dependabot[bot] 35609b7b13 Bump github.com/Azure/go-ntlmssp (#4094)
Bumps the go_modules group with 1 update in the / directory: [github.com/Azure/go-ntlmssp](https://github.com/Azure/go-ntlmssp).


Updates `github.com/Azure/go-ntlmssp` from 0.1.0 to 0.1.1
- [Release notes](https://github.com/Azure/go-ntlmssp/releases)
- [Commits](https://github.com/Azure/go-ntlmssp/compare/v0.1.0...v0.1.1)

---
updated-dependencies:
- dependency-name: github.com/Azure/go-ntlmssp
  dependency-version: 0.1.1
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 10:41:11 +02:00
pwnnex a4b1b3d06d Merge pull request #4092 from pwnnex/fix/iplimit-live-only-slot-count
iplimit: dont count idle db-only ips toward the per-client limit (#4091)
2026-04-23 21:36:37 +03:00
pwnnex 5f7c7c5f3d iplimit: dont count idle db-only ips toward the per-client limit
after #4083 the staleness window is 30 minutes, which still lets an ip
that stopped connecting a few minutes ago sit in the db blob and keep
the protected slot on the ascending sort. the ip that is actually
connecting right now gets classified as excess and sent to fail2ban,
and never lands in inbound_client_ips.ips so the panel doesnt show it
until you clear the log by hand.

only count ips observed in the current scan toward the limit. db-only
entries stay in the blob for display but dont participate in the ban
decision. live subset still uses the "protect oldest, ban newcomer"
rule.

closes #4091. followup to #4077.
2026-04-23 21:11:45 +03:00
Rs.Nest 6bcaf61c44 Feature: Copy clients between inbounds (#4087)
* feat: copy clients between inbounds

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* revert: undo install.sh/deploy.sh changes; i18n: add copy-clients translations for all languages

---------

Co-authored-by: Нестеров Руслан <r.nesterov@comagic.dev>
2026-04-23 15:19:07 +02:00
MHSanaei ff25072690 SS: remove unsupported cipher method 2026-04-22 21:44:39 +02:00
pwnnex 530c1597b8 Merge pull request #4086 from pwnnex/fix/hysteria2-protocol-aliases
hysteria: accept "hysteria2" as a protocol string (#4081)
2026-04-22 16:02:05 +00:00
pwnnex c8e16d8c41 Merge pull request #4085 from pwnnex/fix/iplimit-install-nftables
x-ui.sh: bundle nftables when installing fail2ban
2026-04-22 15:58:00 +00:00
pwnnex 17f67ef3a5 sub: dont panic on bad externalProxy entry in genHysteriaLink
The externalProxy fanout from #4073 did `int(ep["port"].(float64))`
with no ok-check. If any entry is missing port or has the wrong
type it panics, and since this runs in the /sub/<id> handler the
whole subscription returns 500. Skip malformed entries instead.
2026-04-22 18:55:27 +03:00
pwnnex eb4791a1cd hysteria: also accept "hysteria2" protocol string
UI stores v1 and v2 both as "hysteria" with settings.version, but
inbounds that came in from imports / manual SQL can carry the
literal "hysteria2" string and get silently dropped everywhere we
switch on protocol.

Add Hysteria2 constant + IsHysteria helper, use it in the places
that gate on protocol (sub SQL, getLink, genHysteriaLink, clash
buildProxy, json gen, inbound.go validation, xray AddUser).

Existing "hysteria" inbounds are untouched.

closes #4081
2026-04-22 18:55:09 +03:00
pwnnex 71ac920436 x-ui.sh: install nftables alongside fail2ban in install_iplimit
On fresh Debian 12+, Ubuntu 24+ and recent RHEL-family minimal images
the fail2ban package ships with `banaction = nftables-multiport` as
the default in /etc/fail2ban/jail.conf but does not pull in the
`nftables` package as a dependency. The first SSH brute-force attempt
hits the default sshd jail and fail2ban logs

    stderr: /bin/sh: 1: nft: not found
    returned 127 -- HINT on 127: "Command not found"

repeatedly, which users mistake for a 3x-ui regression (see the
discussion on #4083). The 3x-ipl jail itself is unaffected — it uses
an iptables-based action configured in create_iplimit_jails — so this
is only stray noise, but noisy enough to look like a real failure on
first install.

Add `nftables` to the package list in every branch of install_iplimit
so new installs end up with a working default sshd jail out of the
box. Existing installs where `nftables` is already present are a
no-op.
2026-04-22 18:50:42 +03:00
pwnnex e6d0c33937 Merge pull request #4083 from pwnnex/fix/iplimit-stale-db-evict
Fix IP Limit continuous ban loop after a legitimate ban expires (#4077)
2026-04-22 14:09:55 +00:00
pwnnex eef2d311f4 Fix IP Limit continuous ban loop from stale DB entries (#4077)
After 60abeaa flipped the excess-IP selector to "oldest wins,
newest loses" (to protect the original/current connections), the
per-client IP table in `inbound_client_ips.ips` never evicted IPs
that stopped connecting. Their stored timestamp stayed ancient, so
on every subsequent run they counted as the "oldest protected"
slot(s) and whichever IP was actually using the config now was
classified as "new excess" and re-banned via fail2ban.

This is exactly the #4077 scenario: two IPs connect once and get
recorded, the ban lifts after the configured duration, the lone
legitimate IP that reconnects gets banned again, and again, and
again — a permanent 3xipl.log loop with no real abuser anywhere.

Fix: when merging the persisted `old` list with the freshly
observed `new` log lines, drop entries whose last-seen timestamp
is older than `ipStaleAfterSeconds` (30 minutes). A client that's
actually still active refreshes its timestamp any time xray emits
a new `accepted` line for a fresh TCP, so the cutoff is far above
even idle streaming sessions; a client that's genuinely gone falls
out of the table in bounded time and frees its slot.

Extracted the merge into `mergeClientIps` so it can be exercised
by unit tests without spinning up the full DB-backed job.

Tests cover:
- stale old entry is dropped (the #4077 regression)
- fresh old entries are still carried forward (access-log rotation
  is still backed by the persisted table)
- newer timestamp wins when the same IP appears in both lists
- a clock-skewed old `new` entry can't resurrect a stale IP
- a zero cutoff never over-evicts

Closes #4077
2026-04-22 16:53:32 +03:00
MHSanaei 772d2b6de4 v2.9.2 2026-04-22 11:20:56 +02:00
MHSanaei 8f30d14716 Extract bot command setup into trySetBotCommands 2026-04-22 10:47:30 +02:00
pwnnex 9611c9def6 Fix Hysteria External Proxy + include Hysteria in Clash subscription (#4053) (#4073)
* Fix Hysteria External Proxy + include Hysteria in Clash subscription (#4053)

Two related gaps on the Hysteria side of the subscription layer:

1) `genHysteriaLink` ignored `externalProxy` entirely, so an admin who
   pointed a Hysteria inbound at an alternate endpoint (e.g. a CDN
   hostname forwarding UDP back to the node) still got a link with the
   original server address. Mirror what `genVlessLink` / `genTrojanLink`
   already do: fan out one link per entry, substituting `dest` / `port`
   and picking up the entry's remark suffix. As a bonus, the salamander
   obfs password is now copied into the URL too — the panel-side link
   generator already did this, so the subscription output was lagging
   behind it.

2) `buildProxy` in `subClashService.go` had a protocol switch with cases
   for VMESS / VLESS / Trojan / Shadowsocks and a `default: return nil`.
   Hysteria inbounds fell into the default branch and silently vanished
   from the Clash YAML. Route Hysteria to a dedicated
   `buildHysteriaProxy` helper before the transport/security helpers run
   (applyTransport / applySecurity model xray streams, which Hysteria
   doesn't use).

   `buildHysteriaProxy` reads `inbound.StreamSettings` directly instead
   of going through `streamData` / `tlsData`, because those prune
   fields (`allowInsecure`, the salamander `finalmask.udp` block) that
   the mihomo Hysteria proxy wants preserved. Output shape matches
   mihomo's expectations:

     type: hysteria2                  # or "hysteria" for v1
     password / auth-str: <client auth>
     sni, alpn, skip-cert-verify, client-fingerprint
     obfs: salamander
     obfs-password: <finalmask.udp[salamander].settings.password>

The existing `getProxies` fanout over `externalProxy` already plugs in
for Clash, so with Hysteria now recognised, External Proxy entries
also flow through to the Clash output for Hysteria inbounds.

Closes #4053

* gofmt: align map keys in buildHysteriaProxy

---------

Co-authored-by: pwnnex <eternxles@gmail.com>
2026-04-22 10:01:21 +02:00
Imgodmaoyouknow 292eb992f4 fix(panel): set ALPN to h3 when switching to Hysteria protocol (#4076)
- Automatically explicitly set ALPN to ['h3'] for Hysteria to prevent QUIC handshake mismatch.
2026-04-22 09:56:03 +02:00
MHSanaei 814e6ad69c Lower minimum Xray version
Update GetXrayVersions filter to accept Xray releases >= 26.3.10 instead of the previous >= 26.4.17. This changes the conditional in web/service/server.go so releases from 26.3.10 onward are included when building the versions list.
2026-04-21 21:20:59 +02:00
MHSanaei 0a38624ba7 Add None option VLESS auth selection 2026-04-21 21:18:59 +02:00
MHSanaei b86473df02 Run cache cleanup daily and reduce cutoff to 1 day 2026-04-21 20:36:28 +02:00
pwnnex 15be803da9 Fix blank Xray Settings page from wrapped xrayTemplateConfig (#4059) (#4069)
`getXraySetting` builds its response as

    { "xraySetting": <db value>, "inboundTags": ..., "outboundTestUrl": ... }

and embeds the raw DB value as the `xraySetting` field without
checking whether the stored value already has that exact shape.

The frontend pulls the textarea content from `result.xraySetting`
and saves it back verbatim. If the DB ever ends up holding the
response-shaped wrapper instead of a real xray config (older
installs where this happened at least once, users who imported a
copy-pasted response into the textarea, a botched migration, etc.),
the next save nests another layer, the one after that nests a
third, and the Vue-side JSON.parse of the resulting blob silently
fails — the Xray Settings page goes blank.

Fix both ends of the round-trip:

* Add `service.UnwrapXrayTemplateConfig`. It peels off any number of
  `xraySetting`-keyed layers, leaving a real xray config behind.
  The check is conservative: if the outer object already contains
  any top-level xray key (`inbounds`, `outbounds`, `routing`, `api`,
  `dns`, `log`, `policy`, `stats`), it is returned unchanged, and
  there is a depth cap to avoid pathological inputs.

* `SaveXraySetting` unwraps before validation so a round-tripped
  wrapper from an already-corrupted page can no longer re-poison
  the DB on save.

* `getXraySetting` unwraps on read and, when it finds a wrapper,
  rewrites the DB with the corrected value. Existing broken installs
  heal themselves on the next visit to the page.

Includes unit tests for the passthrough, single-wrap, multi-wrap,
string-encoded-inner, and false-positive cases.

Co-authored-by: pwnnex <eternxles@gmail.com>
2026-04-21 20:30:02 +02:00
MHSanaei c79b45e512 Readme: Remove custom GeoSite/GeoIP DAT section
Remove the "Custom GeoSite / GeoIP DAT" section from the main README and all localized READMEs (ar_EG, es_ES, fa_IR, ru_RU, zh_CN). Also apply minor formatting cleanups: normalize language header spacing and remove trailing spaces from the Stargazers badge lines.
2026-04-21 20:20:43 +02:00
MHSanaei 86a8eb16b4 fix timelocation for windows
Co-Authored-By: Alireza Ahmadi <alireza7@gmail.com>
2026-04-21 20:05:52 +02:00
MHSanaei 0fd0389d5c sub json fix fragment noises effect
Co-Authored-By: Alireza Ahmadi <alireza7@gmail.com>
2026-04-21 20:02:39 +02:00
pwnnex 2983ac3f8e Fix xhttp xPadding settings missing from generated links (panel + subs) (#4065)
* Fix: propagate xhttp xPadding settings into generated subscription links

The four `genXLink` helpers in `sub/subService.go` only copied `path`,
`host` and `mode` out of `xhttpSettings` when building vmess:// /
vless:// / trojan:// / ss:// URLs. Everything else — `xPaddingBytes`,
`xPaddingObfsMode`, `xPaddingKey`, `xPaddingHeader`,
`xPaddingPlacement`, `xPaddingMethod` — was silently dropped.

That meant an admin who set, say, `xPaddingBytes: "80-600"` plus obfs
mode with a custom `xPaddingKey` on the inbound had a server config
that no client could match from the copy-pasted link: the client kept
the xray/sing-box internal defaults (`100-1000`, `x_padding`,
`Referer`), hit the server, and was rejected by

    invalid padding (queryInHeader=Referer, key=x_padding) length: 0

The user-visible symptom on OpenWRT / Podkop / sing-box was
"xhttp inbound just won't connect" — no obvious pointer to what was
actually wrong because the link itself *looks* complete.

Fix:

  * New helper `applyXhttpPaddingParams(xhttp, params)` writes
    `x_padding_bytes=<range>` (flat, sing-box family reads this) and
    an `extra=<url-encoded-json>` blob carrying the full set of xhttp
    settings (xray-core family reads this). Both encodings are emitted
    side-by-side so every mainstream client can pick at least one up.
  * All four link generators (`genVmessLink` via the obj map,
    `genVlessLink`, `genTrojanLink`, `genShadowsocksLink`) now invoke
    the copy.
  * Obfs-only fields (`xPaddingKey`, `xPaddingHeader`,
    `xPaddingPlacement`, `xPaddingMethod`) are only included when
    `xPaddingObfsMode` is actually true and the admin filled them in.
    An inbound with no custom padding produces exactly the same URL
    as before — existing subscriptions are unaffected.

* Also propagate xhttp xPadding settings into the panel's own Info/QR links

The previous commit covered the subscription service
(sub/subService.go). The admin-panel side — the "Copy URL" / QR /
Info buttons inside inbound details — has four more
xhttp-emitting link generators in `web/assets/js/model/inbound.js`
(`genVmessLink`, `genVLESSLink`, `genTrojanLink`, `genSSLink`) that
had the exact same gap: only `path`, `host` and `mode` were copied.

Mirror the server-side fix on the client:

* Add two static helpers on `Inbound`:
  - `Inbound.applyXhttpPaddingToParams(xhttp, params)` for
    `vless://` / `trojan://` / `ss://` style URLs — writes
    `x_padding_bytes=<range>` (sing-box family) and
    `extra=<url-encoded-json>` (xray-core family).
  - `Inbound.applyXhttpPaddingToObj(xhttp, obj)` for the VMess base64
    JSON body — sets the same fields directly on the object.
* Call them from all four link generators so an admin who enables
  obfs mode + a custom `xPaddingKey` / `xPaddingHeader` actually
  gets a working URL from the panel.
* Only non-empty fields are emitted, so default inbounds produce
  exactly the same URL as before.

Also fixes a latent positional-args bug in
`web/assets/js/model/outbound.js`: both VMess-JSON (L933) and
`fromParamLink` (L975) were calling
`new xHTTPStreamSettings(path, host, mode)` — but the 3rd positional
arg of the constructor is `headers`, not `mode`, so `mode` was
landing in the `headers` slot and the actual `mode` field stayed at
its default. Construct explicitly and set `mode` by name; while
here, also pick up `x_padding_bytes` and the `extra` JSON blob from
the imported URL so the symmetric case of importing a padded link
works too.

---------

Co-authored-by: pwnnex <eternxles@gmail.com>
2026-04-21 19:15:51 +02:00
pwnnex 975d6d1bad Fix: hysteria link gen crashes when echConfigList is a string (#4064)
`genHysteriaLink` was calling `.join(',')` on
`this.stream.tls.settings.echConfigList`, but that field is bound to an
`<a-input>` (single-line string) in `tls_settings.html` and defaults to
`''` in `TlsStreamSettings.Settings`. Calling `.join()` on a string
throws `TypeError: echConfigList.join is not a function`, which breaks
the Info / QR buttons for every hysteria / hysteria2 inbound.

All three sibling link generators (`genVmessLink`, `genVlessLink`,
`genTrojanLink`) already pass the value directly:

    params.set("ech", this.stream.tls.settings.echConfigList)

`URLSearchParams.set` will stringify arrays with `,` on its own, so the
same one-liner works for both string and array inputs. Align
`genHysteriaLink` with the other three.

Fixes #4063

Co-authored-by: pwnnex <eternxles@gmail.com>
2026-04-21 19:05:53 +02:00
MHSanaei ab7a7f7c6b Reduce observatory probe intervals and timeout 2026-04-21 18:47:38 +02:00
MHSanaei 733f44ef0f balancerTags with a default empty entry 2026-04-21 17:24:42 +02:00
MHSanaei faec3ca038 CodeQL: ignore v* tag pushes 2026-04-21 15:17:59 +02:00
MHSanaei df163854bd v2.9.1 2026-04-21 15:02:06 +02:00
MHSanaei 1af795fad8 kcp : default value maxSendingWindow
MaxSendingWindow must be >= Mtu
2026-04-21 15:00:12 +02:00
MHSanaei 085cb8c216 Set CWND multiplier default and min to 1 2026-04-21 14:50:37 +02:00
MHSanaei 2a9ba2badc salamander obfs and remove auth field 2026-04-21 14:13:55 +02:00
MHSanaei 53fb4fe8f9 fix: prevent AddUser panic on nil flow for VLESS XHTTP clients
#4056
2026-04-21 13:04:39 +02:00
MHSanaei 8d512d55e5 revert Fix geosite:ru rule (ram leak)
#4050 #4055
2026-04-21 12:55:16 +02:00
MHSanaei a9d8905393 v2.9.0 2026-04-20 21:43:49 +02:00
MHSanaei ca2fd3814f Bump Xray version cutoff to 26.4.17
Update GetXrayVersions in web/service/server.go to select releases newer than 26.4.17 (previously 26.2.6). This relaxes the version filter so releases >= 26.4.17 are included.
2026-04-20 20:03:27 +02:00
MHSanaei 394fafd29b Update Xray-core to v26.4.17 2026-04-20 20:02:43 +02:00
MHSanaei 9f0055d193 bug fix 2026-04-20 19:46:21 +02:00
MHSanaei 88dafa6cdf XDNS finalmask: Support resolvers (client) and domains (server)
Treat the xdns mask type as a multi-value setting and update forms accordingly. Inbound/outbound UdpMask models now return arrays for xdns (inbound: settings.domains, outbound: settings.resolvers) using Array.isArray checks. UI templates were split so 'header-dns' still uses a single domain string, while 'xdns' renders a tags-style <a-select> for multiple entries (domains/resolvers). Conditionals were made explicit (mask.type === ...) instead of using includes(). Changed files: web/assets/js/model/inbound.js, web/assets/js/model/outbound.js, web/html/form/outbound.html, web/html/form/stream/stream_finalmask.html.
2026-04-20 19:09:45 +02:00
MHSanaei 2b3b2770b4 Sniffing: Add ipsExcluded, domainsExcluded (supports IP, CIDR, "geoip:", "ext:") 2026-04-20 18:22:43 +02:00
MHSanaei 094ea9faaa tun: dual MTU, gateway, DNS, auto routing
Change TunSettings to support separate IPv4/IPv6 MTU values and add gateway, DNS, autoSystemRoutingTable and autoOutboundsInterface properties. Introduces _normalizeMtu to accept legacy single-value or array forms and provide sensible defaults. Update fromJson/toJson to handle new fields and preserve backward compatibility. Update tun form UI to expose MTU IPv4/IPv6 inputs, Gateway/DNS tag selects, Auto Routing Table and Auto Outbounds input.
2026-04-20 18:14:32 +02:00
MHSanaei eb16cca551 Add ipsBlocked to Freedom
Expose an ipsBlocked array on Outbound.FreedomSettings and wire it into the outbound form. The constructor now defaults fragment to {} and noises/ipsBlocked to arrays for robustness; fromJson/toJson handle ipsBlocked and omit it when empty. The outbound HTML adds a tag-style <a-select> bound to outbound.settings.ipsBlocked (with comma tokenization and placeholder) so users can enter IP/CIDR/geoip entries.
2026-04-20 18:02:39 +02:00
MHSanaei aef0503f8f Bump Go version and update dependencies
Update Go toolchain to 1.26.2 and upgrade multiple direct and indirect dependencies for bug fixes, compatibility and improvements. Notable bumps include github.com/mymmrac/telego v1.7.0→v1.8.0, github.com/valyala/fasthttp v1.69.0→v1.70.0, golang.org/x/crypto v0.49.0→v0.50.0, golang.org/x/sys v0.42.0→v0.43.0, golang.org/x/text v0.35.0→v0.36.0, go.mongodb.org/mongo-driver/v2 v2.5.0→v2.5.1, and updates to several mattn, pires, sagernet and golang.org/x/* packages. Regenerated go.sum to reflect the updated module checksums.
2026-04-20 17:45:34 +02:00
MHSanaei 86304226a9 mKCP transport: Add cwndMultiplier
Replace legacy KCP buffer options with cwndMultiplier and maxSendingWindow across models and UI. Updated KcpStreamSettings in web/assets/js/model/inbound.js and web/assets/js/model/outbound.js (constructor, fromJson and toJson) to remove congestion/readBuffer/writeBuffer and use cwndMultiplier/maxSendingWindow instead. Updated web/html/form/outbound.html to reflect the new KCP fields in the stream form and to include extensive template formatting/markup cleanup for consistency and readability.
2026-04-20 17:45:14 +02:00
MHSanaei 6d0e7ec495 reset button for auth password 2026-04-20 17:25:18 +02:00
MHSanaei 04b4fb4384 finalmask
Co-Authored-By: Alireza Ahmadi <alireza7@gmail.com>
2026-04-20 16:55:06 +02:00
MHSanaei ae5ad505d0 add hysteria inbound
Co-Authored-By: Alireza Ahmadi <alireza7@gmail.com>
2026-04-20 16:05:27 +02:00
MHSanaei c188056f64 Centralize session options and adjust cookies
Configure session cookie options centrally in initRouter and remove per-login MaxAge handling. Deleted SetMaxAge helper and its use in the login flow; session.Options are now applied once using basePath with HttpOnly and SameSite defaults, and MaxAge is set only when the stored setting is available and >0. Also make CookieManager.setCookie treat exdays as optional (only add expires when provided) and stop using a hardcoded 150-day expiry for the lang cookie in the JS language manager.

Co-Authored-By: Alireza Ahmadi <alireza7@gmail.com>
2026-04-20 14:00:18 +02:00
MHSanaei 0a424a9f16 Use vnext/users structure for VLESS outbound
Replace the previous flat settings map for VLESS outbound with a vnext/users structure. Encryption is now pulled from inbound settings into the user object, a level field (8) is added, and client id and flow are preserved. Address and port are nested under vnext and outbound.Settings is set to {vnext: [...]}, aligning the outbound format with the expected VLESS schema.

Co-Authored-By: Alireza Ahmadi <alireza7@gmail.com>
2026-04-20 13:44:52 +02:00
Peter Liu 36b2a58675 feat: Add NordVPN NordLynx (WireGuard) integration (#3827)
* feat: Add NordVPN NordLynx (WireGuard) integration with dedicated UI and backend services.

* remove limit=10 to get all servers

* feat: add city selector to NordVPN modal

* feat: auto-select best server on country/city change

* feat: simplify filter logic and enforce > 7% load

* fix

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-04-20 00:41:50 +02:00
MHSanaei 59e9859225 Enable CodeQL file coverage on PRs 2026-04-20 00:38:15 +02:00
dependabot[bot] 4e5f144def Bump actions/checkout from 4 to 6 (#4045)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 00:26:07 +02:00
Sanaei ea53da9341 Add SSRF protection (#4044)
* Add SSRF protection for custom geo downloads

Introduce SSRF-safe HTTP transport for custom geo operations by adding ssrfSafeTransport and isBlockedIP helpers. The transport resolves hosts and blocks loopback, private, link-local and unspecified addresses, returning ErrCustomGeoSSRFBlocked on violations. Update probeCustomGeoURLWithGET, probeCustomGeoURL and downloadToPathOnce to use the safe transport. Also add the new error ErrCustomGeoSSRFBlocked and necessary imports. Minor whitespace/formatting adjustments in subClashService.go, web/entity/entity.go and web/service/setting.go.

* Add path traversal protection for custom geo

Prevent path traversal when handling custom geo downloads by adding ErrCustomGeoPathTraversal and a validateDestPath() helper that ensures destination paths stay inside the bin folder. Call validateDestPath from downloadToPathOnce, Update and Delete paths and wrap errors appropriately. Reconstruct sanitized URLs in sanitizeURL to break taint propagation before use. Map the new path-traversal error to a user-facing i18n message in the controller.

* fix
2026-04-20 00:18:20 +02:00
MHSanaei 3e1a102e9d Add CodeQL Advanced GitHub Actions workflow
Introduce a CodeQL analysis workflow (CodeQL Advanced) that runs on push, pull_request, and a weekly schedule. It initializes and runs github/codeql-action for a matrix of languages (actions, go, javascript-typescript), configures build-mode per-language, sets minimal read/write permissions for security-events, packages, actions and contents, and selects macOS for Swift or Ubuntu otherwise.
2026-04-19 23:39:10 +02:00
zhuzn d580086361 feat add clash yaml convert (#3916)
* docs(agents): add AI agent guidance documentation

* feat(sub): add Clash/Mihomo YAML subscription service

Add SubClashService to convert subscription links to Clash/Mihomo
YAML format for direct client compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sub): integrate Clash YAML endpoint into subscription system

- Add Clash route handler in SUBController
- Update BuildURLs to include Clash URL
- Pass Clash settings through subscription pipeline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web): add Clash settings to entity and service

- Add SubClashEnable, SubClashPath, SubClashURI fields
- Add getter methods for Clash configuration
- Set default Clash path to /clash/ and enable by default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ui): add Clash settings to subscription panels

- Add Clash enable switch in general subscription settings
- Add Clash path/URI configuration in formats panel
- Display Clash QR code on subscription page
- Rename JSON tab to "Formats" for clarity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(js): add Clash support to frontend models

- Add subClashEnable, subClashPath, subClashURI to AllSetting
- Generate and display Clash QR code on subscription page
- Handle Clash URL in subscription data binding

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-04-19 22:26:13 +02:00
HamidReza Sadeghzadeh 1e3b366fba revert: Disconnect client due to exceeded IP limit (#3948)
* fix: Ban new IPs with fail2ban  instead of disconnected the client.

* fix: Remove unused strconv import

* fix: Revert log fail2ban format

* fix: Disconnect the client to remove the banned IPs connections

* fix: Fix getting the xray inbound api port

* fix: Run go formatter

* fix: Disconnect only the supported protocols client

* fix: Ensure the required "cipher" field is present  in the shadowsocks protocol

* fix: Log the errors in the resolveXrayAPIPort function

* fix: Run go formatter
2026-04-19 21:52:40 +02:00
Troodi c2a2a36f56 Fix geosite:ru rule (Normalization to RU vs lowercase ru) (#3971)
* Fix geosite:ru rule (Normalization to RU vs lowercase ru)

* fix
2026-04-19 21:44:51 +02:00
Andrew Smirnov e986a133f8 Add new hourly reset traffic (#3966)
* Add new hourly reset traffic

* fix
2026-04-19 21:37:34 +02:00
Vladislav Tupikin 7466916e02 Add custom geosite/geoip URL sources (#3980)
* feat: add custom geosite/geoip URL sources

Register DB model, panel API, index/xray UI, and i18n.

* fix
2026-04-19 21:24:24 +02:00
Nikita Nemirovsky 96b568b838 fix(sub): use safe type assertion for xhttp mode field (#3990)
Unsafe type assertion `xhttp["mode"].(string)` panics when mode is
nil (e.g., when xhttpSettings only contains path without mode). The
panic is caught by Gin's recovery middleware and returned as HTTP 500.

Use comma-ok pattern matching the fix already applied to gRPC's
authority field in 21d98813.

Fixes #3987
2026-04-19 21:12:11 +02:00
lolka1333 fec714a243 fix: enhance WebSocket stability, resolve XHTTP configurations and fix UI loading shifts (#3997)
* feat: implement real-time traffic monitoring and UI updates using a high-performance WebSocket hub and background job system

* feat: add bulk client management support and improve inbound data handling

* Fix bug

* **Fixes & Changes:**
1. **Fixed XPadding Placement Dropdown**:
   - Added the missing `cookie` and `query` options to `xPaddingPlacement` (`stream_xhttp.html`).
   - *Why:* Previously, users wanting `cookie` obfuscation were forced to use the `header` placement string. This caused Xray-core to blindly intercept the entire monolithic HTTP Cookie header, failing internal padding-length validations and causing the inbound to silently drop the connection.
2. **Fixed Uplink Data Placement Validation**:
   - Replaced the unsupported `query` option with `cookie` in `uplinkDataPlacement`.
   - *Why:* Xray-core's `transport_internet.go` explicitly forbids `query` as an uplink placement option. Selecting it from the UI previously sent a payload that would cause Xray-core to instantly throw an `unsupported uplink data placement: query` panic. Adding `cookie` perfectly aligns the UI with Xray-core restrictions.
### Related Issues
- Resolves #3992

* This commit fixes structural payload issues preventing XHTTP from functioning correctly and eliminates WebSocket log spam.
- **[Fix X-Padding UI]** Added missing `cookie` and `query` options to X-Padding Placement. Fixes the issue where using Cookie fallback triggers whole HTTP Cookie header interception and silent drop in Xray-core. (Resolves [#3992](https://github.com/MHSanaei/3x-ui/issues/3992))
- **[Fix Uplink Data Options]** Replaced the invalid `query` option with `cookie` in Uplink Data Placement dropdown to prevent Xray-core backend panic `unsupported uplink data placement: query`.
- **[Fix WebSockets Spam]** Boosted `maxMessageSize` boundary to 100MB and gracefully handled fallback fetch signals via `broadcastInvalidate` to avoid buffer dropping spam. (Resolves [#3984](https://github.com/MHSanaei/3x-ui/issues/3984))

* Fix

* gofmt

* fix(websocket): resolve channel race condition and graceful shutdown deadlock

* Fix: inbounds switch

* Change max quantity from 10000 to 500

* fix
2026-04-19 21:01:00 +02:00
Yunheng Liu e02f78ac68 Fix SSL domain setup on reinstall: reuse existing certs and avoid false success/failure logs (#4004)
* perf: replace /dev/urandom | tr with openssl rand to fix CPU spike

* fix: add cron to default package installation and improve SSL certificate handling

* Reworked `--installcert` success criteria, cleanup behavior adjusted.
2026-04-17 12:19:45 +02:00
Yunheng Liu 169b216d7e perf: replace /dev/urandom | tr with openssl rand to fix CPU spike (#3887) 2026-04-01 13:59:48 +02:00
MHSanaei 7e6d80efa5 Bump Go and dependency versions
Update go toolchain to 1.26.1 and upgrade multiple direct and indirect modules (examples: github.com/gin-contrib/gzip v1.2.6, github.com/gin-contrib/sessions v1.1.0, github.com/go-ldap/ldap/v3 v3.4.13, github.com/goccy/go-json v0.10.6, github.com/pelletier/go-toml/v2 v2.3.0, github.com/shirou/gopsutil/v4 v4.26.3, github.com/xtls/xray-core v1.260327.0, golang.org/x/crypto v0.49.0, google.golang.org/grpc v1.80.0). go.sum updated accordingly to lock the new versions. Routine dependency refresh to pull in fixes and improvements.
2026-04-01 13:47:27 +02:00
kazan417 38d87230d3 Update x-ui.sh (#3947)
looks like now cert management is option 19
2026-03-18 19:45:45 +01:00
MHSanaei f0f98c7122 Add Go code analyzer workflow 2026-03-17 23:01:15 +01:00
Abdalrahman 554981d9d3 feat(tgbot): send connection links and qrs on client creation (closes #3320)\n\n- Refactored inline keyboards into getCommonClientButtons to respect DRY\n- Extended SubmitAddClient callback handlers to dispatch individual links and QR codes to the bot chat on success. (#3888) 2026-03-17 22:09:49 +01:00
Nikolay a08f1c6c13 Update translate.ru_RU.toml (#3889)
Change to plural (geofiles, not geofile)
2026-03-17 21:24:09 +01:00
Alimpo 7f7ae0c547 fix: stop overwriting client_traffics.enable with JSON enable in GetClientTrafficByEmail (#3931)
When a client hit traffic/expiry limit, disableInvalidClients sets
client_traffics.enable=false and removes the user from Xray. GetClientTrafficByEmail
was overwriting that with settings.clients[].enable (admin config), so
ResetClientTraffic never saw the client as disabled and did not re-add
the user. Clients could not connect until manually disabled/re-enabled.
Now the DB runtime enable flag is preserved; reset correctly re-adds
the user to Xray.
2026-03-17 21:20:24 +01:00
HamidReza Sadeghzadeh 60abeaad66 fix: Ban new IPs with fail2ban instead of disconnected the client. (#3919)
* fix: Ban new IPs with fail2ban  instead of disconnected the client.

* fix: Remove unused strconv import

* fix: Revert log fail2ban format
2026-03-17 21:18:10 +01:00
dependabot[bot] a6d0100381 Bump docker/metadata-action from 5 to 6 (#3942)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 21:10:09 +01:00
dependabot[bot] 6767f76ccf Bump actions/upload-artifact from 4 to 7 (#3941)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 21:09:56 +01:00
dependabot[bot] e4add73c9e Bump actions/checkout from 5 to 6 (#3940)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 21:05:43 +01:00
dependabot[bot] ff72090e1a Bump docker/setup-buildx-action from 3 to 4 (#3938)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 21:05:28 +01:00
dependabot[bot] a3e1bd59df Bump docker/build-push-action from 6 to 7 (#3937)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 21:05:07 +01:00
dependabot[bot] 5bbb48a8fd Bump docker/setup-qemu-action from 3 to 4 (#3936)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 21:04:54 +01:00
dependabot[bot] ee84d585f9 Bump docker/login-action from 3 to 4 (#3939)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 21:04:41 +01:00
Sanaei 7b03346cfc Set package ecosystem to GitHub Actions in dependabot.yml 2026-03-17 21:03:32 +01:00
MHSanaei 258b08fff3 Update fail2ban filter regex in x-ui.sh 2026-03-08 11:53:34 +01:00
Aleksei Sidorenko a2097ad062 feat: mask password in telegram notification on 2FA failure (#3884) 2026-03-04 18:26:53 +01:00
MHSanaei 52fdf5d429 v2.8.11 2026-03-04 13:54:01 +01:00
MHSanaei 34d8885075 Adjust KCP MTU when selecting xDNS mask 2026-03-04 13:39:14 +01:00
MHSanaei 5740996436 update dependencies 2026-03-04 13:05:29 +01:00
Artur 874aae8080 Add cron to ubuntu packages (#3875) 2026-03-04 12:36:45 +01:00
子寒 842fae18d7 Add 'default' runlevel to x-ui service in Alpine (#3854)
it should be 'default' runlevel when add x-ui service to openrc, default is 'sysinit' runlevel. 'sysinit' runlevel is unnecessary,maybe.
if not, there is an error when call to function 'check_enabled()' as command 'grep default -c' can`t print 'default' runlevel.

check_enabled() {
    if [[ $release == "alpine" ]]; then
        if [[ $(rc-update show | grep -F 'x-ui' | grep default -c) == 1 ]]; then
            return 0
        else
            return 1
        fi
2026-03-04 12:32:01 +01:00
Happ-dev ccd223aeea Fix DeepLink for Happ, remove encoding URL (#3863)
Co-authored-by: y.sivushkin <y.sivushkin@corp.101xp.com>
2026-03-04 12:29:46 +01:00
Aleksei Sidorenko 96b8fe472c Fix: escape HTML characters in tgbot start command (#3883) 2026-03-04 11:35:24 +01:00
Nabi KaramAliZadeh 59b695ba83 fix: remove excluded paths from gzip middleware in router initialization (#3860) 2026-03-01 15:18:16 +01:00
Alireza Ahmadi 159b85f979 Merge pull request #3828 from MHSanaei/restartXrayOption
[feat] restart xray-core from cli #3825
2026-02-25 21:09:42 +01:00
Alireza Ahmadi 3ec5b3589f fix windows build 2026-02-20 02:07:46 +01:00
Alireza Ahmadi 2b1d3e7347 [feat] restart xray-core from cli #3825 2026-02-20 00:03:16 +01:00
MHSanaei 37f0880f8f Bump Go to 1.26 2026-02-16 01:10:43 +01:00
MHSanaei 5b796672e9 Improve telego client robustness and retries
Add a createRobustFastHTTPClient helper to configure fasthttp.Client with better timeouts, connection limits, retries and optional SOCKS5 proxy dialing. Validate and sanitize proxy and API server URLs instead of returning early on invalid values, and build telego.Bot options dynamically. Reduce long-polling timeout to detect connection issues faster and adjust update retrieval comments. Implement exponential-backoff retry logic for SendMessage calls to handle transient connection/timeouts and improve delivery reliability; also reduce inter-message delay for better throughput.
2026-02-14 22:49:19 +01:00
MHSanaei 3fa0da38c9 Add timeouts and delays to backup sends
Add rate-limit friendly delays and context timeouts when sending backups via Telegram. Iterate admin IDs with index to sleep 1s between sends; add 30s context.WithTimeout for each SendDocument call and defer file.Close() for opened files; insert a 500ms pause between sending DB and config files. These changes improve resource cleanup and reduce chance of Telegram rate-limit/timeout failures.
2026-02-14 22:31:41 +01:00
MHSanaei 8eb1225734 translate bug fix #3789 2026-02-14 21:41:20 +01:00
MHSanaei e5c0fe3edf bug fix #3785 2026-02-11 22:21:09 +01:00
MHSanaei f4057989f5 Require HTTP 200 from curl before using IP
Replace simple curl+trim checks with a response+http_code parse to ensure the remote URL returns HTTP 200 and a non-empty body before assigning server_ip. Changes applied to install.sh, update.sh and x-ui.sh: use curl -w to append the status code, extract http_code and ip_result, and only set server_ip when http_code == 200 and ip_result is non-empty. This makes the IP discovery more robust against error pages or partial responses while keeping the existing timeout behavior.
2026-02-11 21:32:23 +01:00
MHSanaei 84013b0b3f v2.8.10 2026-02-11 18:21:43 +01:00
MHSanaei 511adffc5b Remove allowInsecure
Remove the deprecated `allowInsecure`
2026-02-11 18:21:23 +01:00
bakatrouble fc6344b840 Fix ipv6 hostname parsing for subscriptions (#3782) 2026-02-11 15:33:53 +01:00
emirjorge b3555ce1b8 Update translate.es_ES.toml (#3766)
Fix some trasnslations :)
2026-02-09 23:40:03 +01:00
MHSanaei c2f409c3c4 fix security issue 2026-02-09 23:36:10 +01:00
Nebulosa 0994f8756f refactor: set default ProfileUrl (#3773) 2026-02-09 21:45:25 +01:00
surbiks 4779939424 Add url speed test for outbound (#3767)
* add outbound testing functionality with configurable test URL

* use no kernel tun for conflict errors
2026-02-09 21:43:17 +01:00
MHSanaei 4a455aa532 Xray Core v26.2.6 and dependency updates
Update Xray download URLs to v26.2.6 in the GitHub Actions release workflow and DockerInit script. Bump Go toolchain to 1.25.7 and refresh several module versions (telego, xtls/xray-core, klauspost/compress, pires/go-proxyproto, golang.org/x/arch, golang.org/x/sys, google.golang.org/genproto, etc.). Update go.sum to match the new dependency versions.
2026-02-09 12:49:32 +01:00
Nebulosa 25f64738e4 refactor: set header only if it not empty (#3763) 2026-02-07 23:01:05 +01:00
Sanaei 5bb87fd3d4 fix : Uncontrolled data used in path expression
Co-Authored-By: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-02-07 22:54:40 +01:00
Mojtaba Arezoomand 491e3f9f8b feat: add openssl to dockerfile (#3762) 2026-02-07 22:30:03 +01:00
Aung Ye Zaw d8fb09faae feat: implement 'last IP wins' policy for IP limitation (#3735)
- Add timestamp tracking for each client IP address
- Sort IPs by connection time (newest first) instead of alphabetically
- Automatically disconnect old connections when IP limit exceeded
- Keep only the most recent N IPs based on LimitIP setting
- Force disconnection via Xray API (RemoveUser + AddUser)
- Prevents account sharing while allowing legitimate network switching
- Log format: [LIMIT_IP] Email = user@example.com || Disconnecting OLD IP = 1.2.3.4 || Timestamp = 1738521234

This ensures users can seamlessly switch between networks (mobile/WiFi)
and the system maintains connections from their most recent IPs only.

Fixes account sharing prevention for VPN providers selling per-IP licenses.

Co-authored-by: Aung Ye Zaw <zaw.a.y@phluid.world>
2026-02-04 00:38:11 +01:00
MHSanaei f87c68ea68 Add workflow to clean old GitHub Actions caches
Adds a scheduled GitHub Actions workflow (.github/workflows/cleanup_caches.yml) that runs weekly (and via workflow_dispatch) to delete Actions caches not accessed in the last 3 days. The job uses the gh CLI with the repository token and actions: write permission to list caches, filter by last_accessed_at against a 3-day cutoff, and delete matching cache IDs.
2026-02-03 00:19:44 +01:00
Ebrahim Tahernejad 687e8cf1ba [Windows] Use MSYS2 to fix the runtime CGO problem (#3689)
* Use MSYS2 to fix the runtime CGO problem

* macOS build workflow

* Remove macOS build steps and update Windows packaging

Removed macOS build steps from the release workflow and updated Windows packaging step.

* Rename step to copy and download resources
2026-02-02 23:26:04 +01:00
Nebulosa 03f04194f2 Update geofiles according 304 http respond (#3690)
* feat: enhance geofile update process with conditional GET and modification time handling

* style: improve formatting in UpdateGeofile function
2026-02-02 23:20:57 +01:00
Alimpo 248700a8a3 fix: trim whitespace from comma-separated list values in routing rules (#3734) 2026-02-02 23:19:30 +01:00
MHSanaei ff128a7275 Xray Core v26.2.2 2026-02-02 17:57:56 +01:00
MHSanaei e8d2973be7 Finalmask: Add XICMP 2026-02-02 17:50:30 +01:00
MHSanaei f3d47ebb3f Refactor TLS peer cert verification settings
Removed verifyPeerCertByNames and pinnedPeerCertSha256 from inbound TLS settings and UI. Added verifyPeerCertByName and pinnedPeerCertSha256 to outbound TLS settings and updated the outbound form to support these fields. This change streamlines and clarifies certificate verification configuration between inbound and outbound settings.
2026-02-01 14:03:46 +01:00
MHSanaei 06c49b92f8 v2.8.9 2026-02-01 04:05:02 +01:00
MHSanaei e35213bc73 Update Xray-core to v26.1.31 and related dependencies
Bump Xray-core version to v26.1.31 in build scripts and server logic. Update Go dependencies including gopsutil, bytedance/sonic, circl, miekg/dns, go-proxyproto, sagernet/sing, and others to their latest versions. Adjust version check in GetXrayVersions to require at least v26.1.31.
2026-02-01 03:30:09 +01:00
MHSanaei aa6a886977 Add UDP hop interval min/max support for Hysteria
Replaces single UDP hop interval with separate min and max values in Hysteria stream settings. Updates model, JSON serialization, URL param parsing, and form fields for backward compatibility and enhanced configuration flexibility.
2026-02-01 03:20:29 +01:00
MHSanaei 9d603c5ad2 Add pinnedPeerCertSha256 support to TLS settings
Introduces the pinnedPeerCertSha256 field to TlsStreamSettings in the JS model and adds a corresponding input in the TLS settings form. This allows users to specify SHA256 fingerprints for peer certificate pinning, enhancing security configuration options.
2026-02-01 03:12:54 +01:00
MHSanaei a973fa6d68 XHTTP transport: New options for bypassing CDN's detection
https://github.com/XTLS/Xray-core/pull/5414
2026-02-01 02:58:18 +01:00
MHSanaei 3af6497577 inbound : finalmask 2026-02-01 02:36:57 +01:00
MHSanaei c59f54bb0e outbound: finalmask 2026-02-01 01:56:23 +01:00
lillinlin 6b3da4fe5e Update reality_targets.js (#3724) 2026-01-31 23:50:29 +01:00
Farhad H. P. Shirvan ea0da32e81 fix: rename verifyPeerCertInNames to verifyPeerCertByName to be compatible with xray-core v26.1.31 (#3723) 2026-01-31 19:50:08 +01:00
Sam Mosleh d5ea8d0f38 Fix default CA by enforcing it everywhere (#3719) 2026-01-30 16:35:24 +01:00
Danil S. fd5f591737 feat: more subscription information fields (#3701)
* feat: more subscription information fields

* fix: incorrect translation

* feat: implement field for Happ custom routing rules
2026-01-26 23:06:01 +01:00
Sam Mosleh 8a4c9a98cb Fix modifying default CA (#3708) 2026-01-26 23:05:15 +01:00
sviatoslav-gusev 70b365171f feat: add option to use existing custom SSL certificates (#3688) 2026-01-21 16:47:36 +01:00
mr-shura 328ba3b45e fix Telegram bot ignores reverse proxy setting #3673 (#3684)
Refactor URL construction to use pre-configured URIs if available, otherwise fallback to default scheme and host.
2026-01-19 12:33:17 +01:00
Nebulosa 5370b6943a Add hysteria2 protocol in hint text (#3686) 2026-01-19 12:31:49 +01:00
MHSanaei d8c783a296 v2.8.8 2026-01-18 18:01:58 +01:00
MHSanaei 809f69729a Update minimum Xray version requirement
Raised the minimum required Xray version from 25.9.11 to 26.1.18 in GetXrayVersions. This ensures only newer versions are considered valid.
2026-01-18 17:50:00 +01:00
MHSanaei 93b7ce199f Add UDP mask support for Hysteria outbound
Introduces a 'congestion' option to Hysteria stream settings and updates the form to allow selection between BBR (Auto) and Brutal. Adds support for UDP masks, including model, serialization, and UI for adding/removing masks with type and password fields.
2026-01-18 17:38:05 +01:00
MHSanaei 2a76cec804 Add Hysteria2 outbound protocol support
Introduces support for the Hysteria2 protocol in outbound settings, including model, parsing, and form UI integration. Adds Hysteria2-specific stream and protocol settings, updates protocol selection, and enables configuration of Hysteria2 parameters in the outbound form.
2026-01-18 17:13:34 +01:00
MHSanaei 88eab032be Add TUN protocol for inbound
Introduces TUN protocol to inbound.js, including a new TunSettings class. Updates inbound form to support TUN protocol and adds a dedicated form template for TUN settings. Translation files are updated with TUN-related strings for all supported languages.
2026-01-18 16:47:01 +01:00
MHSanaei 20ec863f51 Xray Core v26.1.18 2026-01-18 16:06:19 +01:00
Nebulosa 2f4018bbe5 feat: improve BBR management with sysctl.d and backup support (#3658) 2026-01-18 15:47:02 +01:00
Vorontsov Amadey f273708f6d Feature: Use of username and passwords consisting of several words (#3647) 2026-01-18 15:44:49 +01:00
Nebulosa e6318d57e4 Add x-ui.service.arch file (#3650)
* Add a service file for Arch-based OSs

* Update release.yml with arch service file

* Update x-ui.service.arch
2026-01-18 15:41:07 +01:00
lolka1333 77fa976ee9 Enhance WebSocket client connection logic and improve event listener management (#3636)
- Updated WebSocketClient to allow connection during CONNECTING state.
- Introduced a flag for reconnection attempts.
- Improved event listener registration to prevent duplicate callbacks.
- Refactored online clients update logic in inbounds.html for better performance and clarity.
- Added CSS styles for subscription link boxes in subpage.html to enhance UI consistency and interactivity.

Co-authored-by: lolka1333 <test123@gmail.com>
2026-01-18 15:38:57 +01:00
MHSanaei 8098d2b1b1 Return nil if no error in GetXrayErr
Added a check to return nil immediately if p.GetErr() returns nil in GetXrayErr, preventing further error handling when no error is present.
2026-01-13 17:40:52 +01:00
VolgaIgor a691eaea8d Fixed incorrect filtering for IDN top-level domains (#3666) 2026-01-12 02:53:43 +01:00
VolgaIgor da447e5669 Added curl package to Dockerfile (#3665) 2026-01-11 20:18:54 +01:00
MHSanaei f8c9aac97c Add port selection and checks for ACME HTTP-01 listener
Introduces user prompts to select the port for ACME HTTP-01 certificate validation (default 80), checks if the chosen port is available, and provides guidance for port forwarding. Adds is_port_in_use helper to all scripts and improves messaging for certificate issuance and error handling.
2026-01-11 15:28:43 +01:00
MHSanaei e42c17f2b2 Default listen address to 0.0.0.0 in GenXrayInboundConfig
When the listen address is empty, it now defaults to 0.0.0.0 to ensure proper dual-stack IPv4/IPv6 binding, improving compatibility on systems with bindv6only=0.
2026-01-09 20:22:33 +01:00
Nebulosa 427b7b67d8 Refactor ca-certificate dependency (#3655) 2026-01-09 17:05:55 +01:00
Nebulosa ccf08086ac refactor update geofiles fuctions (#3653) 2026-01-09 17:03:53 +01:00
MHSanaei 7b0a3929ff v2.8.7 2026-01-05 19:00:36 +01:00
MHSanaei 570ab8e5e0 Update OpenSSL installer to version 3.6.0
Replaced Win64OpenSSL_Light-3_5_3.exe with Win64OpenSSL_Light-3_6_0.exe in the windows_files/SSL directory to provide the latest OpenSSL version.
2026-01-05 18:49:30 +01:00
MHSanaei 1240e4c962 Update fasthttp to v1.69.0
Bump github.com/valyala/fasthttp from v1.68.0 to v1.69.0 in go.mod and go.sum to use the latest version.
2026-01-05 18:44:42 +01:00
MHSanaei c117b8b272 mtu to 1250 2026-01-05 18:10:06 +01:00
Ilya Kryuchkov 6041d10e3d Refactor code and fix linter warnings (#3627)
* refactor: use any instead of empty interface

* refactor: code cleanup
2026-01-05 05:54:56 +01:00
lolka1333 4800f8fb70 feat: Real-time Outbound Traffic, UI Improvements & Fix (#3629)
* Refactor HTML and JavaScript for improved UI and functionality

- Cleaned up JavaScript methods in subscription.js for better readability.
- Updated inbounds.html to clarify traffic update handling and removed unnecessary comments.
- Enhanced xray.html by correcting casing in routingDomainStrategies.
- Added mobile touch scrolling styles in page.html for better tab navigation on small screens.
- Streamlined vless.html by removing redundant line breaks and improving form layout.
- Refined subscription subpage.html for better structure and user experience.
- Adjusted outbounds.html to improve button visibility and functionality.
- Updated xray_traffic_job.go to ensure accurate traffic updates and real-time UI refresh.

* Refactor client traffic handling in InboundService

- Updated addClientTraffic method to initialize onlineClients as an empty slice instead of nil.
- Improved clarity and consistency in handling empty onlineUsers scenario.

* Add WebSocket support for outbounds traffic updates

- Implemented WebSocket connection in xray.html to handle real-time updates for outbounds traffic.
- Enhanced xray_traffic_job.go to retrieve and broadcast outbounds traffic updates.
- Introduced MessageTypeOutbounds in hub.go for managing outbounds messages.
- Added BroadcastOutbounds function in notifier.go to facilitate broadcasting outbounds updates to connected clients.

---------

Co-authored-by: lolka1333 <test123@gmail.com>
2026-01-05 05:50:40 +01:00
Sanaei a9770e1da2 ip cert (#3631) 2026-01-05 05:47:15 +01:00
MHSanaei 3f15d21f13 fix #3622 2026-01-03 22:31:31 +01:00
lolka1333 a6b3623634 Added curl dependency to Dockerfile for improved functionality (#3617)
Co-authored-by: lolka1333 <test123@gmail.com>
2026-01-03 17:18:28 +01:00
MHSanaei 947fd4fae1 fix 2026-01-03 07:27:39 +01:00
MHSanaei e69a31dd59 v2.8.6 2026-01-03 06:44:39 +01:00
Nebulosa 719ae0e014 Remove wget dependency from everywhere (#3598)
* Remove wget dependency

* Merge branch 'curl_only' of https://github.com/nebulosa2007/3x-ui into nebulosa2007-curl_only

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-01-03 06:41:40 +01:00
MHSanaei 5bcf6a8aeb minor changes 2026-01-03 05:56:35 +01:00
MHSanaei 945fefde12 update dependencies 2026-01-03 05:36:05 +01:00
lolka1333 313a2acbf6 feat: Add WebSocket support for real-time updates and enhance VLESS settings (#3605)
* feat: add support for trusted X-Forwarded-For and testseed parameters in VLESS settings

* chore: update Xray Core version to 25.12.8 in release workflow

* chore: update Xray Core version to 25.12.8 in Docker initialization script

* chore: bump version to 2.8.6 and add watcher for security changes in inbound modal

* refactor: remove default and random seed buttons from outbound form

* refactor: update VLESS form to rename 'Test Seed' to 'Vision Seed' and change button functionality for seed generation

* refactor: enhance TLS settings form layout with improved button styling and spacing

* feat: integrate WebSocket support for real-time updates on inbounds and Xray service status

* chore: downgrade version to 2.8.5

* refactor: translate comments to English

* fix: ensure testseed is initialized correctly for VLESS protocol and improve client handling in inbound modal

* refactor: simplify VLESS divider condition by removing unnecessary flow checks

* fix: add fallback date formatting for cases when IntlUtil is not available

* refactor: simplify WebSocket message handling by removing batching and ensuring individual message delivery

* refactor: disable WebSocket notifications in inbound and index HTML files

* refactor: enhance VLESS testseed initialization and button functionality in inbound modal

* fix:

* refactor: ensure proper WebSocket URL construction by normalizing basePath

* fix:

* fix:

* fix:

* refactor: update testseed methods for improved reactivity and binding in VLESS form

* logger info to debug

---------

Co-authored-by: lolka1333 <test123@gmail.com>
2026-01-03 05:26:00 +01:00
Igor Kamyshnikov b747730211 vless: use Inbound Listen address in Subscription service (#3610)
* vless: use Inbound Listen address in Subscription service

vless manual connection link and subscription produced connection link are aligned.
subscription service now returns an IP address configured on Inbound, instead of subscription service IP,
which is consistent when the address, returned by QR code for manual vless link distribution.
2026-01-03 04:39:30 +01:00
Nebulosa 692a73788a Set variables for packaging purposes (#3600)
* Set Variables for settings
2026-01-03 03:57:19 +01:00
Mikhail Grigorev 3287fa4d80 Added EnvironmentFile to systemd unit (#3606)
* Added EnvironmentFile to systemd unit

* Added support for older releases

* Remove ARGS

* Fixed copy unit

* Fixed unit filename

* Update update.sh
2026-01-03 03:37:48 +01:00
weekend sorrow 1393f981bc feat: Add etckeeper compatibility (#3602) 2026-01-03 03:13:00 +01:00
Ilya Kryuchkov 9a2c1c6b43 Fix: panel redirecting to old port after restart (#3594)
* Fix panel redirect logic

* Fix panel redirect logic

* remove duplicate code

* Cr fixes
2026-01-03 03:05:10 +01:00
Vlad Yaroslavlev 278aa1c85c Fix telegram bot issue (#3608)
* fix: improve Telegram bot handling for concurrent starts and graceful shutdown

- Added logic to stop any existing long-polling loop when Start is called again.
- Introduced a mutex to manage access to shared state variables, ensuring thread safety.
- Updated the OnReceive method to prevent multiple concurrent executions.
- Enhanced Stop method to ensure proper cleanup of resources and state management.

* fix: enhance Telegram bot's long-polling management

- Improved handling of concurrent starts by stopping existing long-polling loops.
- Implemented mutex for thread-safe access to shared state variables.
- Updated OnReceive method to prevent multiple executions.
- Enhanced Stop method for better resource cleanup and state management.

* .
2026-01-02 16:13:32 +01:00
Anton Petrov 8fe297ef9d Fix QR codes colors inversion (#3607) 2026-01-02 16:12:30 +01:00
Zhenyu Qi c881d1015a fix: handle GitHub API error responses in GetXrayVersions (#3609)
GitHub API returns JSON object instead of array when encountering errors
(e.g., rate limit exceeded). This causes JSON unmarshal error:
'cannot unmarshal object into Go value of type []service.Release'

Add HTTP status code check to handle error responses gracefully and
return user-friendly error messages instead of JSON parsing errors.

Fixes issue where getXrayVersion fails with unmarshal error when
GitHub API rate limit is exceeded.
2026-01-02 16:12:13 +01:00
Nebulosa c061337ce7 Set log folder variable to /var/log/3x-ui (#3599)
* Set log folder variable to /var/log/3x-ui

* Set log folder as x-ui and create the log folder

* Create the log folder in install and update scripts
2026-01-02 16:11:32 +01:00
Wyatt 260eedf8c4 fix: add missing is_domain helper function to x-ui.sh (#3612)
The is_domain function was being called in ssl_cert_issue() but was never
defined in x-ui.sh, causing 'Invalid domain format' errors for valid domains.

Added is_ipv4, is_ipv6, is_ip, and is_domain helper functions to match
the definitions in install.sh and update.sh.

Co-authored-by: wyatt <wyatt@Wyatts-MacBook-Air.local>
2025-12-28 16:38:26 +01:00
Sanaei 69ccdba734 Self-signed SSL (#3611) 2025-12-28 00:03:33 +01:00
zd 4c797dc154 fix: display of outbound traffic (#3604)
shows the direction of traffic
2025-12-23 15:43:25 +01:00
Борисов Семён f000322a06 fix: handle CPU threshold error to prevent false notifications (#3603)
Previously, when GetTgCpu() failed, the error was ignored and threshold
defaulted to 0, causing notifications to be sent for any CPU usage.

Now the job properly checks for errors and skips notifications if:
- The threshold cannot be retrieved (error)
- The threshold is not set or is 0

This ensures notifications are only sent when CPU usage exceeds the
configured threshold value from settings.
2025-12-12 14:29:27 +01:00
MHSanaei 0ea8b5352a fix 2025-12-04 00:09:13 +01:00
MHSanaei 68240061aa Xray Core 25.12.2 2025-12-03 23:45:28 +01:00
MHSanaei 0695f677ba update dependencies 2025-12-03 23:45:11 +01:00
Danil S. 70f6d6b21a chore: use Intl for date formatting (#3588)
* chore: use `Intl` for date formatting

* fix: show last traffic reset

* chore: use raw timestamps

* fix: remove unnecessary import
2025-12-03 23:37:27 +01:00
JieXu e8c509c720 Update for Red Hat base Linux (#3589)
* Update install.sh

* Update update.sh

* Update x-ui.sh

* Update install.sh

* Update update.sh

* Update x-ui.sh

* fix
2025-12-03 21:40:49 +01:00
Roman Gogolev 83a1c721c7 Fix int64 for 32-bit arch (#3591)
* fix int64 for 32-bit arch

* Update web/service/tgbot.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-03 14:58:54 +01:00
Anton Petrov 7ccc0877a1 Add "Last Online" printing for Telegram bot (#3593) 2025-12-03 14:43:37 +01:00
Evgeny Popov ad659e48cf Update x-ui.sh (#3595)
Add curl & openssl pkgs for acme inside docker container
2025-12-03 14:42:10 +01:00
mhsanaei 784ed39930 update dependencies 2025-11-09 00:56:14 +01:00
fgsfds 538f7fd5d7 Fix: Incorrect time in xray logs (#3587)
* fixed timezone in xray logs

* remove leading / at the address
2025-11-09 00:42:02 +01:00
fgsfds cf38226b5d Add update-all-geofiles key to x-ui.sh (#3586)
* added update-all-geofiles key to x-ui.sh that updated all geofiles

* fix

* text fixes

* typo fix

* cleanup
2025-11-07 19:26:43 +01:00
lillinlin 575ee854c8 Better Random Reality (#3585)
* Update reality_targets.js

* Update inbound.js
2025-11-02 14:46:50 +01:00
OleksandrParshyn 9936af80dd Fix: Invoke service.StopBot() in signal handlers (#3583)
Ensures the global Telegram bot stop function (`service.StopBot()`) is called upon receiving system signals (SIGHUP for restart, SIGINT/SIGTERM for shutdown). This complements the changes in `tgbot.go` to guarantee a clean shutdown of the Telegram bot's Long Polling operation, fully resolving the 409 Conflict issue during panel restarts or shutdowns.

Changes:
- Added `service.StopBot()` call to the `syscall.SIGHUP` handler.
- Added `service.StopBot()` call to the default shutdown handler.
2025-11-01 14:33:35 +01:00
Дмитрий Олегович Саенко 4a75bd0a48 Feature: add setting certs for subscription while generating for panel (#3578) 2025-11-01 13:10:27 +01:00
Rashid Yusubov b0c223c631 fix: improve russian localization (#3576)
* fix: improve russian localization

* fix: updating the Russian translation according to the suggestions
2025-11-01 13:07:49 +01:00
Denis Gorelov 313b51f96f feat: Add random Reality Target/SNI selection from 52 popular services (#3577)
* feat: Add random Reality Target/SNI selection from 52 popular services

- Created reality_targets.js with list of 52 popular services
- Updated RealityStreamSettings to use random targets by default
- Added UI randomize buttons with sync icon in Reality settings form
- Implemented randomizeRealityTarget() method in inbound modal
- Replaces hardcoded google.com with diverse global services

* fix

---------

Co-authored-by: mhsanaei <ho3ein.sanaei@gmail.com>
2025-11-01 13:07:05 +01:00
OleksandrParshyn 020cd63e22 Fix: Graceful Telegram bot shutdown to prevent 409 Conflict (#3580)
* Fix: Graceful Telegram bot shutdown to prevent 409 Conflict

Introduces a `botCancel` context and a global `StopBot()` function to ensure the Telegram bot's Long Polling operation is safely terminated (via context cancellation) before the service restarts. This prevents the "Conflict: another update consumer is running" (409) error upon panel restart.

Changes:
- Added `botCancel context.CancelFunc` to manage context cancellation.
- Implemented global `StopBot()` function.
- Updated `Tgbot.Stop()` to call `StopBot()`.
- Modified `Tgbot.OnReceive()` to use the new cancellable context for `UpdatesViaLongPolling`.

* Fix: Prevent race condition and goroutine leak in TgBot

Addresses a critical race condition on the global `botCancel` variable, which could occur if `Tgbot.OnReceive()` was called concurrently (e.g., during rapid panel restarts or unexpected behavior).

Changes in tgbot.go:
- Added `tgBotMutex sync.Mutex` to ensure thread safety.
- Protected `botCancel` creation and assignment in `OnReceive()` using the mutex, and added a check to prevent overwriting an active context, which avoids goroutine leaks.
- Protected the cancellation and cleanup logic in `StopBot()` with the mutex.

* Refactor: Replace time.Sleep with sync.WaitGroup for reliable TgBot shutdown

Replaced the unreliable `time.Sleep(1 * time.Second)` in `service.StopBot()` with `sync.WaitGroup`. This ensures the Long Polling goroutine is explicitly waited for and reliably exits before the panel continues, preventing potential resource leaks and incomplete shutdowns during restarts.

Changes:
- Added `botWG sync.WaitGroup` variable.
- Updated `service.StopBot()` to call `botWG.Wait()` instead of `time.Sleep()`.
- Modified `Tgbot.OnReceive()` to correctly use `botWG.Add(1)` and `defer botWG.Done()` within the Long Polling goroutine.
- Corrected the goroutine structure in `OnReceive()` to properly encapsulate all message handling logic.
2025-11-01 13:01:44 +01:00
BOplaid 6e46e9b16e Improve English README (#3579) 2025-11-01 12:48:16 +01:00
mhsanaei 713a7328f6 gofmt 2025-10-21 13:02:55 +02:00
mhsanaei 01d4a7488d v2.8.5 2025-10-15 11:40:40 +02:00
mhsanaei 2b2ed3349a Xray-core v25.10.15 2025-10-15 11:40:04 +02:00
mhsanaei d8523bbdac fix(import): prevent sqlite disk I/O error by validating temp DB then swapping 2025-10-14 22:03:17 +02:00
Slava M. 8afa39144e feat: add file logger support (#3575)
* feat: add backend for file logger
2025-10-09 17:39:29 +02:00
fpointsstar 00baeffe74 Update translate.ru_RU.toml (#3574)
Fix RU translation for login title: replace “Приветствие!” with “Добро пожаловать!” to match English “Welcome”.
2025-10-07 16:31:32 +02:00
mhsanaei b578a33518 update dependencies 2025-10-07 13:49:08 +02:00
mhsanaei 8153e0ac05 fragment : MaxSplit 2025-10-07 13:46:30 +02:00
mhsanaei 2eb9d2e2e8 DevTools 2025-10-02 01:47:12 +02:00
Vadim Iskuchekov a824875c4f fix: improve error handling in periodic traffic reset job (#3572) 2025-10-01 23:12:09 +02:00
JieXu cafcb250ec Add support for OpenSUSE Leap (#3573)
* Update update.sh

* Update install.sh

* Update x-ui.sh

* Update x-ui.sh
2025-10-01 23:11:37 +02:00
mhsanaei e7cfee570b first try native CPU implementation 2025-10-01 20:13:32 +02:00
JieXu 90c3529301 [Security] Replace timestamp-based password generation with random generator (#3571)
* Update x-ui.sh

* Update x-ui.sh

* Update x-ui.sh

* Update x-ui.sh
2025-10-01 18:37:31 +02:00
konstpic b65ec83c39 fix: fix delete method (#3569)
Co-authored-by: Пичугин Константин <k.pichugin@comagic.dev>
2025-09-29 18:16:46 +02:00
konstpic 28a17a80ec feat: add ldap component (#3568)
* add ldap component

* fix: fix russian comments, tls cert verify default true

* feat: remove replaces go mod for local dev
2025-09-28 21:04:54 +02:00
Mikhail Grigorev 3056583388 feat: Add update script (#3555)
* feat: Add update script

* Small fix

* Fixed typo

* Fixed typo

* chmod +x

* Update x-ui

* Fixed update message

* Fixed typo

* Added downloading via IPv4

* Remove check_glibc_version

* Fixed self destroy

* Fixed typo

* Fixed self destroy

---------
2025-09-28 14:09:27 +02:00
Дмитрий Олегович Саенко 172f2ddaa7 fix russian translate in tgbot (#3557) 2025-09-25 15:21:40 +02:00
Tara Rostami d69af328dc fix: login animation (#3559)
* Add IPv4 for wget in install

* fix: login animation
2025-09-25 15:16:50 +02:00
mhsanaei ee0e3093ba Add IPv4 for wget in install 2025-09-25 15:08:13 +02:00
mhsanaei 89def9aee6 fix 2025-09-24 21:30:58 +02:00
mhsanaei b2b0024648 login: autocomplete password 2025-09-24 20:41:32 +02:00
mhsanaei 5822758b7c tiny changes 2025-09-24 19:51:01 +02:00
mhsanaei 49430b3991 Update docker.yml 2025-09-24 15:42:01 +02:00
mhsanaei 104526aab2 v2.8.4 2025-09-24 11:47:43 +02:00
mhsanaei a0c07241c0 minor changes 2025-09-24 11:47:14 +02:00
mhsanaei adf3242602 bug fix 2025-09-24 11:44:02 +02:00
mhsanaei 3f62592e4b API improve security: returns 404 for unauthenticated API requests 2025-09-24 11:29:55 +02:00
Дмитрий Олегович Саенко 02bff4db6c max port to 65535 (#3536)
* add EXPOSE port in Dockerfile

* fix: max port 65 531 -> 65 535

* fix

---------

Co-authored-by: mhsanaei <ho3ein.sanaei@gmail.com>
2025-09-23 19:43:56 +02:00
Happ-dev 8ff4e1ff31 Add Happ client export open link (#3542)
Co-authored-by: y.sivushkin <y.sivushkin@corp.101xp.com>
2025-09-23 16:46:45 +02:00
mhsanaei 26c6438ec2 fix api : subid, uuid from inbound settings 2025-09-23 11:52:40 +02:00
Evgeny Volferts b3e96230c4 Add Alpine Linux support (#3534)
* Add Alpine linux support

* Fix for reading logs
2025-09-22 21:56:43 +02:00
mhsanaei 1016f3b4f9 fix: outbound address for vless 2025-09-22 00:20:05 +02:00
mhsanaei 020bc9d77c v2.8.3 2025-09-21 21:20:45 +02:00
mhsanaei 5620d739c6 improved sub: BuildURLs 2025-09-21 21:20:37 +02:00
mhsanaei d518979e4f pageSize to 25 2025-09-21 20:47:34 +02:00
mhsanaei 83f8a03b50 TGbot: improved (5x faster) 2025-09-21 19:27:05 +02:00
mhsanaei b45e63a14a API: UUID for getClientTraffics 2025-09-21 19:16:54 +02:00
Дмитрий Олегович Саенко 3007bcff97 add EXPOSE port in Dockerfile (#3523) 2025-09-21 19:03:36 +02:00
mhsanaei 55f1d72af5 security fix: Uncontrolled data used in path expression 2025-09-21 18:51:54 +02:00
Sanaei 806ecbd7c5 Merge pull request #3528 from MHSanaei/security
Security issue fixed
2025-09-21 18:05:26 +02:00
mhsanaei ae79b43cdb security fix: Use of insufficient randomness as the key of a cryptographic algorithm 2025-09-21 17:59:17 +02:00
mhsanaei e64e6327ef security fix: Uncontrolled data used in path expression 2025-09-21 17:52:18 +02:00
mhsanaei 9f024b9e6a security fix: Workflow with permissions CWE-275 2025-09-21 17:47:16 +02:00
mhsanaei eacfbc86b5 security fix: Command built from user-controlled sources CWE-78
https://cwe.mitre.org/data/definitions/78.html
https://owasp.org/www-community/attacks/Command_Injection
2025-09-21 17:39:30 +02:00
mhsanaei 37c17357fc undo vnext for vmess 2025-09-20 13:10:57 +02:00
mhsanaei b35d339665 update dependencies 2025-09-20 09:48:54 +02:00
Tara Rostami 5e7a3db873 Minor Fixes (#3520) 2025-09-20 09:36:56 +02:00
mhsanaei 6ced549dea docs: add comments for all functions 2025-09-20 09:35:50 +02:00
mhsanaei f60682a6b7 new: VACUUM database 2025-09-19 17:14:39 +02:00
mhsanaei 50bd7a8040 better design for dns presets 2025-09-19 15:44:00 +02:00
mhsanaei 7465768ff7 fix: subpath panic 2025-09-19 14:39:21 +02:00
mhsanaei 5b00a52c65 fix: ineffectual assignment to needRestart 2025-09-19 10:47:28 +02:00
mhsanaei 151f1173a1 Fix ineffassign “date” 2025-09-19 10:46:49 +02:00
mhsanaei e262132b9d misspell 2025-09-19 10:35:03 +02:00
mhsanaei ca0a7aeb5a readme: Go Report Card,Go Reference 2025-09-19 10:29:34 +02:00
mhsanaei 7447cec17e go package correction v2 2025-09-19 10:05:43 +02:00
mhsanaei 0ffd27c0aa v2.8.2 2025-09-19 00:22:15 +02:00
mhsanaei 054cb1dea0 go package correction 2025-09-18 23:12:14 +02:00
Drahonn 3757ae0b11 cpu history timeframe (#3509) 2025-09-18 20:52:31 +02:00
mhsanaei e3883fca87 donate: nowpayments 2025-09-18 20:14:10 +02:00
mhsanaei b46a0b404b enhancements 2025-09-18 16:28:09 +02:00
mhsanaei 0ce58a095a vscode: Debug for developer 2025-09-18 14:33:51 +02:00
mhsanaei 59ea2645db new: subJsonEnable
after this subEnable by default is true
and subJsonEnable is false
2025-09-18 13:56:04 +02:00
mhsanaei 8c8d280f14 minor change 2025-09-18 12:20:21 +02:00
Harry NG c720008187 chore: update sub page URL (#3505)
* Fix: Shadowrocket link using base64 encoding

* chore: update url
2025-09-18 12:11:52 +02:00
mhsanaei 170d24499e fix PeriodicTrafficResetJob: log only when there are matching inbound 2025-09-18 11:41:11 +02:00
mhsanaei 99c79d4056 fix: online 2025-09-17 20:02:58 +02:00
RahGozar fcdeb1fc79 feat: add UUID to ClientTraffic (#3491)
* Update client_traffic.go

* Update inbound.go
2025-09-17 17:45:28 +02:00
Harry NG 0a58b5e745 Fix: Shadowrocket link using base64 encoding (#3489) 2025-09-17 17:43:09 +02:00
Tara Rostami db7e7dcd29 css [fixes] (#3487) 2025-09-17 15:47:04 +02:00
mhsanaei 01b8a27996 bug fix 2025-09-17 15:46:03 +02:00
mhsanaei 3764ece26c v2.8.1 2025-09-17 13:51:41 +02:00
Tara Rostami d7efc2aef9 Minor Fixes (#3483)
* Minor Fixes

* Minor Fixes 2

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2025-09-17 13:47:01 +02:00
fgsfds 2eb8abf61e Improved xray logs display handling (#3475)
* improved xray logs handling

* fix download Xray Logs

* Update index.html
2025-09-17 13:19:55 +02:00
mhsanaei 299572a4c2 API: subid to getClientTraffics
/getClientTraffics/:email
/getClientTrafficsById/:id
2025-09-17 01:29:22 +02:00
mhsanaei 22afa50901 fix CPU History intervals 2025-09-17 01:08:59 +02:00
mhsanaei bc274d1e1f Reality: placeholder for min, max 2025-09-16 18:57:27 +02:00
mhsanaei dc21f41932 bug fix: del Depleted 2025-09-16 18:28:02 +02:00
mhsanaei f137b1af76 bug fix: enable 2025-09-16 14:57:31 +02:00
mhsanaei c4871ef8fe sub page: improved 2025-09-16 14:38:18 +02:00
mhsanaei ecfffa882a CPU History, CPU Utilization 2025-09-16 14:15:18 +02:00
mhsanaei 3af5026abe tgbot: subscription, qrcode, link - for admin 2025-09-16 13:41:48 +02:00
mhsanaei 1de7accd7c vnext removed 2025-09-16 13:41:05 +02:00
Tara Rostami 76afff2a6f UI Improvements and Fixes (#3470) 2025-09-16 09:25:21 +02:00
Vadim Iskuchekov 9623e87511 feat: Simple periodic traffic reset (for Inbounds) – daily | weekly | monthly (#3407)
* Add periodic traffic reset feature model and ui with localization support

* Remove periodic traffic reset fields from client

* fix: add periodicTrafficReset field to inbound data structure

* feat: implement periodic traffic reset job and integrate with cron scheduler

* feat: enhance periodic traffic reset functionality with scheduling and inbound filtering

* refactor: rename periodicTrafficReset to trafficReset and add lastTrafficResetTime field

* feat: add periodic client traffic reset job and schedule tasks

* Update web/job/periodic_traffic_reset_job.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update web/job/periodic_client_traffic_reset_job.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update web/service/inbound.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor: rename periodicTrafficReset to trafficReset and add lastTrafficResetTime

* feat: add last traffic reset time display and update logic in inbound service

* fix: correct log message for completed periodic traffic reset

* refactor: update traffic reset fields in Inbound model and remove unused client traffic reset job

* refactor: remove unused traffic reset logic and clean up client model fields

* cleanup comments

* fix
2025-09-16 09:24:32 +02:00
Alireza Ahmadi bc0518391e sub template enhancements 2025-09-14 23:08:09 +02:00
mhsanaei 5408a2f82c v2.8.0 2025-09-14 22:09:36 +02:00
mhsanaei c8d71ea748 minify css 2025-09-14 20:05:15 +02:00
mhsanaei 46de886b53 windows: error filter 2025-09-14 20:04:12 +02:00
Sanaei 6d41320ed7 Merge pull request #3466 from MHSanaei/Subscription
Subscription,tgbot,rule
2025-09-14 20:03:32 +02:00
mhsanaei bf9d2e6aeb rule: Vless Route 2025-09-14 19:53:05 +02:00
mhsanaei ed96fa090b tgbot: subscription,qrcode, link 2025-09-14 19:51:57 +02:00
Alireza Ahmadi 3ac1d7f546 enhancements 2025-09-14 19:44:26 +02:00
mhsanaei 10025ffa66 Subscription 2025-09-14 18:56:31 +02:00
mhsanaei 5ee62b25ca clean html files
move styles to css
2025-09-12 18:46:20 +02:00
mhsanaei 311d11a3c1 cookie: MaxAge
and minor changes
2025-09-12 13:04:36 +02:00
Copilot 40b6d7707a Fix critical bugs in ObjectUtil.equals() and filterInbounds() functions (#3451)
* Initial plan

* Fix ObjectUtil.equals asymmetric comparison and filterInbounds null pointer bugs

Co-authored-by: MHSanaei <33454419+MHSanaei@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: MHSanaei <33454419+MHSanaei@users.noreply.github.com>
2025-09-11 11:48:30 +02:00
mhsanaei cbf316db31 Update check_client_ip_job.go 2025-09-11 11:10:17 +02:00
Ivan Korney 33a36ada4b fix: ru top level domain regexp option (#3450) 2025-09-11 07:06:51 +02:00
mhsanaei 82ddd10627 Fixed: update Xray Core on Windows 2025-09-10 21:12:37 +02:00
mhsanaei 2401c99817 rules: source to sourceIP 2025-09-10 18:30:40 +02:00
mhsanaei 2f36a4047c API: delClientByEmail 2025-09-10 16:36:12 +02:00
mhsanaei dc3b0d218a Xray Core v25.9.11 2025-09-10 14:39:07 +02:00
mhsanaei 610d29765a outbound: mixed to socks 2025-09-10 12:19:09 +02:00
mhsanaei b1ea8005e4 v2.7.0 2025-09-10 08:43:46 +02:00
mhsanaei 3f0bfa2472 Remove the buggy version of Xray core 2025-09-10 08:43:10 +02:00
mhsanaei 1e2ff650ad Xray Core v25.9.10 + GO v1.25.1 2025-09-10 08:40:08 +02:00
Sanaei c2d6dd923f windows workflow (#3439) 2025-09-09 18:41:44 +02:00
mhsanaei 723ec25fb2 renamed dest to target 2025-09-09 14:35:21 +02:00
mhsanaei 7dc52e9a53 dokodemo-door, socks renamed to mixed, tunnel 2025-09-09 13:57:40 +02:00
Sanaei fe9f0d1d0e api (#3434) 2025-09-09 02:32:05 +02:00
mhsanaei 18d74d54ca outbound: ECH Config List 2025-09-08 21:25:30 +02:00
mhsanaei c7ba6ae909 add clear button 2025-09-08 21:17:48 +02:00
mhsanaei 3edf79e589 actions/setup-go@v6 2025-09-08 14:33:04 +02:00
mhsanaei 5420e643cf minor change 2025-09-08 14:32:49 +02:00
mhsanaei 9fcd0387ca Update release.yml 2025-09-08 01:12:27 +02:00
mhsanaei 7b039d219e v2.6.8 2025-09-08 00:29:30 +02:00
mhsanaei dbec28b915 remove unsupported cipher method 2025-09-07 22:55:37 +02:00
mhsanaei e5126806d7 xray core v25.9.5 2025-09-07 22:45:20 +02:00
Sanaei b008ff4ad2 Vlessenc (#3426)
* mlkem768

* VlessEnc
2025-09-07 22:35:38 +02:00
Danil S. da6b89fdcd chore: login improvements (#3408)
- added client-side form validation
- now with slow internet otp input does not appear later than all input
2025-09-04 12:11:52 +02:00
MHSanaei d7882c25d1 removed domainMatcher 2025-09-04 12:07:39 +02:00
Ali Golzar ed2a0a0bcf fix: prevent client updated_at from resetting when parent inbound is updated 2025-09-02 13:30:41 +03:30
Ali Golzar 4a0914cb1e feat: add "Last Online" column to client list and modal (Closes #3402) (#3405)
* feat: persist client last online and expose API

* feat(ui): show client last online in table and info modal

* i18n: add “Last Online” across locales

* chore: format timestamps as HH:mm:ss
2025-08-31 18:33:50 +02:00
Darkcyankitty 664269d513 x-ui.service hardneing (#3397) 2025-08-31 15:26:46 +02:00
Ali Golzar d0796b26c9 fix(ui): hide Created/Updated columns and fix issues in small displays (#3400)
- Hide the “Created” and “Updated” columns in the clients
- Ensures the “All-time Traffic” column no longer overlaps with adjacent columns.
- Improves layout readability and prevents UI cluttering after the v2.6.7 update.

Closes #3399
2025-08-30 23:01:57 +02:00
mhsanaei 2750f46c01 v2.6.7 2025-08-30 16:05:33 +02:00
mhsanaei 023eb513e4 Xray Core v25.8.29 2025-08-30 10:03:32 +02:00
mhsanaei 0c7b59ed47 removed: Allocate 2025-08-28 10:15:04 +02:00
Ali Golzar 3087c1b123 Add all-time traffic for inbounds and clients (#3387)
* feat(db): add allTime field to Inbound and ClientTraffic models

* feat(inbound): increment all_time for inbounds and clients on traffic updates

calculate correct all_time traffic on migrate command

* feat(ui): show all-time traffic column for inbounds and its clients

* i18n: add pages.inbounds.allTimeTraffic label across locales

* Add All Time Traffic Usage in inbounds page top banner
2025-08-28 01:10:50 +02:00
Ali Golzar 2198397197 Created / Updated fields for clients (#3384)
* feat(backend): add created_at/updated_at to clients and maintain on create/update
backfill existing clients and set updated_at on mutations

* feat(frontend): carry created_at/updated_at in client models and round-trip via JSON

* feat(frontend): display Created and Updated columns in client table with proper date formatting

* i18n: add pages.inbounds.createdAt/updatedAt across all locales

* Update inbound.go

Remove duplicate code
2025-08-27 19:30:49 +02:00
Igor Finagin d10c312e62 AutoFill OTP (#3381)
https://developer.apple.com/documentation/security/enabling-password-autofill-on-an-html-input-element
2025-08-25 13:42:02 +02:00
mhsanaei 24a3411465 more list for public IP address 2025-08-21 14:24:25 +02:00
Alireza Ahmand 2198e7a28f feat: Add remaining time to tgbot #3355 (#3360) 2025-08-17 13:43:25 +02:00
mhsanaei 6b23b416a7 minor changes 2025-08-17 13:37:49 +02:00
mhsanaei 16f53ce4c2 go v1.25 2025-08-17 12:27:21 +02:00
mhsanaei 27445b30e9 DNS outbound: Set "reject" as the default value for nonIPQuery 2025-08-17 12:22:33 +02:00
mhsanaei 3d0212c21d fix: fail2ban on Debian 12 #1701 2025-08-15 13:34:02 +02:00
mhsanaei 978755960f actions/checkout from 4 to 5 2025-08-14 18:41:53 +02:00
mhsanaei 9b51e9a5c5 Freedom: Add maxSplit fragment option; Add applyTo noises option 2025-08-14 18:38:56 +02:00
fgsfds 6879a8fbcb Moved DB to same app folder on Windows (#3340)
* moved db to user folder on windows

* moved db to local appdata

* made getDBFolderPath func private

* added getWindowsDbPath() func

* fix

---------

Co-authored-by: mhsanaei <ho3ein.sanaei@gmail.com>
2025-08-13 23:19:59 +02:00
mhsanaei 7258841491 Update FUNDING.yml 2025-08-12 13:00:16 +02:00
mhsanaei 23dd80fbb0 remove unnecessary ant files 2025-08-12 12:57:02 +02:00
mhsanaei 6556884c7f remove unnecessary vue files 2025-08-12 12:56:49 +02:00
Alireza Ahmadi d5c532c64f fix saving sockopt 2025-08-09 16:07:33 +02:00
mhsanaei ad5f774a1e Axios v1.11.0 2025-08-09 13:46:28 +02:00
g0l4 aa285914fa chore: update polygon token name (#3338) 2025-08-09 08:18:56 +02:00
mhsanaei 4d02756e1e v2.6.6 2025-08-08 20:52:04 +02:00
Alireza Ahmadi 825d93d95f upgrade telego (#3334) 2025-08-08 20:41:06 +02:00
mhsanaei 5ea6386815 better musl libc usage
Co-Authored-By: Alireza Ahmadi <alireza7@gmail.com>
2025-08-08 19:55:24 +02:00
mhsanaei d064e85ecd update dependencies 2025-08-08 18:56:47 +02:00
mhsanaei 9fc03bd10a remove ocspStapling 2025-08-08 18:55:52 +02:00
fgsfds ae08a29cde fix: Xray restarting after being manually stopped (#2896) (#3329) 2025-08-07 23:35:11 +05:00
mhsanaei 4f25eb230e musl: new version 2025-08-06 23:35:20 +02:00
somebodywashere ce72d53d1a fix: inbounds slow loading (#3228) (#3322)
especially encountered on big amount of clients
2025-08-06 15:42:32 +02:00
fgsfds 5e641ff9e8 Added Update all geofiles button (#3318)
* added Update all geofiles button

* localized update all string
2025-08-06 11:20:07 +02:00
Sanaei 58898e5758 Merge pull request #3317 from MHSanaei/dekodemo_sockopt
add sockopt to dockodemo
2025-08-05 16:51:26 +02:00
Alireza Ahmadi 569550d5f6 add sockopt to dockodemo 2025-08-05 14:02:23 +02:00
fgsfds 419ea63dd0 Added filters to the xray logs viewer (#3314)
* added filters to xray logs viewer

* better freedom/blackhole tags handling

* better freedom/blackhole tags handling 2

* fix comments

* fix comments 2
2025-08-05 12:10:14 +02:00
mhsanaei 6a17285935 remove: glibc check
now you can install on all OS like ubuntu 20 or 18
2025-08-04 19:16:56 +02:00
mhsanaei 4b03e9d919 v2.6.5 2025-08-04 19:12:37 +02:00
mhsanaei 7e9c3bdbaf fix: sub enable warning 2025-08-04 19:09:01 +02:00
fgsfds 957f3dbb54 Added xray access log viewer (#3309)
* added xray access log viewer

* made modal window width adaptive

* hide logs button if xray logs are disabled
2025-08-04 18:47:48 +02:00
mhsanaei 05e60af283 fix: IPLimitlog display 2025-08-04 18:23:37 +02:00
mhsanaei 5e40458116 fix: simplify error handling 2025-08-04 18:01:32 +02:00
Sanaei baf6fdd29d fix portMap json (#3312)
Co-authored-by: Alireza Ahmadi <alireza7@gmail.com>
2025-08-04 17:16:11 +02:00
Sanaei 45f78d3521 Merge pull request #3311 from MHSanaei/dokodemo_portMap
add dokodemo port mapping
2025-08-04 17:01:57 +02:00
Alireza Ahmadi 01f984e054 add dokodemo port mapping 2025-08-04 16:45:09 +02:00
Sanaei e4ba5ba53a add ech support (#3310)
Co-authored-by: Alireza Ahmadi <alireza7@gmail.com>
2025-08-04 16:27:57 +02:00
mhsanaei 6ff555c8bb runs-on: ubuntu-latest 2025-08-04 14:39:12 +02:00
elseif 3c1634ca7c use musl libc toolchains for build statically linked binaries (#3191) 2025-08-04 11:50:39 +02:00
mhsanaei 561c4810be default Max Age to 360min 2025-08-04 11:38:23 +02:00
mhsanaei eb1b96643d update languages 2025-08-04 11:22:09 +02:00
mhsanaei de5314c01f fix: pqv for sub #3306 2025-08-04 10:24:21 +02:00
mhsanaei 1088d1faf3 minor changes 2025-08-04 01:30:01 +02:00
mhsanaei 267024c43f xray core v25.8.3 2025-08-04 01:28:14 +02:00
mhsanaei 0d595f56e4 change a-input to a-textarea 2025-08-04 00:57:06 +02:00
fgsfds a4c4f9efb3 kill process instead of sending SIGTERM on Windows (#3304) 2025-08-04 00:45:50 +02:00
mhsanaei 73a5722cca v2.6.3 2025-08-03 12:22:28 +02:00
mhsanaei 30264043f8 Xray core: old version removed 2025-08-03 12:22:06 +02:00
mhsanaei c6062eb15c outbound: mldsa65Verify 2025-08-03 12:09:37 +02:00
mhsanaei f1b7944828 pqv: mldsa65Verify 2025-08-03 12:01:49 +02:00
mhsanaei 7a57b31ff3 remove password type 2025-07-28 14:44:00 +02:00
mhsanaei 6e1b949081 Reality: min & max client ver 2025-07-28 13:45:47 +02:00
Mikhail Grigorev 0ad708b1b6 Added list of services for get public IP address (IP v4 and v6) (#3216)
* Fixed get public IP address

* Remove https://ifconfig.io/ip and https://ipinfo.tw/ip

---------

Co-authored-by: Mikhail Grigorev <grigorev_mm@magnit.ru>
2025-07-27 17:24:11 +02:00
X-Oracle 71f13ebcbd small improvement (#3277) 2025-07-27 17:22:59 +02:00
mhsanaei f5f4a530cc xray core v25.7.26 2025-07-27 16:54:35 +02:00
Sanaei 702f03e4b7 Merge pull request #3279 from MHSanaei/mldsa65
add mldsa65
2025-07-27 16:52:44 +02:00
Alireza Ahmadi 487ec74e0b add mldsa65 2025-07-25 01:22:01 +02:00
mhsanaei b4dae36345 xray core v25.7.24 2025-07-24 15:26:55 +02:00
mhsanaei 761728255c GO v1.24.5 + update dependencies 2025-07-24 15:17:01 +02:00
Azavax b1ab156e42 Endpoint for updating client traffic by email (#3259)
* Update api.go

* Update inbound.go

* Update inbound.go
2025-07-22 23:43:48 +02:00
xujie86 fa45bf87de Update install.sh (#3267) 2025-07-22 23:28:56 +02:00
xujie86 75416eebd7 Increase the number of characters for webBasePath (#3239) 2025-07-22 12:53:12 +02:00
mhsanaei 87042d77ba ocspStapling set to 0 2025-07-22 12:49:02 +02:00
Danilo Nascimento 011e0f309a fix writeCrashReport() typo (#3218) 2025-07-10 02:36:02 +02:00
mhsanaei b7164805f8 v2.6.2 2025-07-06 20:58:10 +02:00
mhsanaei bbdeb65291 new alternative to get public IP address 2025-07-06 20:45:58 +02:00
Shishkevich D. 038cf34219 chore: return automatic generation of shadowsocks keys 2025-07-06 15:20:41 +07:00
Shishkevich D. 98a1517470 fix: login title shifts the input fields
* chore: revert "fix: reduced login title font-size for mobile (#3105)"

* chore: short login title translation for russian

* chore: change login title translation for ukrainian
2025-07-06 13:22:09 +07:00
mhsanaei ce76cedb0d fixed: mux #3185 2025-07-04 14:02:33 +02:00
mhsanaei 24a313d605 fixed: type #3186 2025-07-04 13:25:24 +02:00
mhsanaei c81c27073c v2.6.1 2025-07-02 11:28:24 +02:00
Shishkevich D. 5d11e6e13f chore: reset two-factor authentication after changing admin credentials (#3029)
* chore: add `resetTwoFactor` argument for main.go

fixes #3025

* chore: reset two-factor authentication after changing admin credentials

* chore: reset two-factor authentication after changing admin credentials

---------

Co-authored-by: somebodywashere <68244480+somebodywashere@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2025-07-02 11:25:25 +02:00
mhsanaei f3d0b92e4a update dependencies 2025-07-02 11:17:26 +02:00
Shishkevich D. c8c0e77714 chore: mark 2053 port as unsecured 2025-06-25 00:38:03 +07:00
Shishkevich D. 49b8f46864 fix: selecting a supported language
english could be selected by default at first load, even if the user's language was supported by the panel
2025-06-25 00:30:08 +07:00
Shishkevich D. cad07be847 chore: russian language improvements 2025-06-23 19:42:44 +07:00
Shishkevich D. 4b20f16024 refactor: new loading logic, icons for tabs 2025-06-22 00:27:09 +07:00
Shishkevich D. d642774a44 refactor: use new page templates 2025-06-21 15:38:43 +07:00
Shishkevich D. 1644904755 chore: clients comment improvement
- use default ant components instead custom styles
- remove comments in inbound info modal (duplicates information)
- enhance tooltip usage
2025-06-21 15:24:52 +07:00
Vadim Iskuchekov 5c10035bd9 feat: add comments under client id (#3131) 2025-06-21 14:55:35 +07:00
Shishkevich D. 2e6faf69e6 fix: generate correct keys for shadowsocks inbounds 2025-06-20 19:30:46 +07:00
Sanaei f88b7b07f0 Merge pull request #3125 from xujie86/patch-2
Add RHEL on x-ui.sh
2025-06-19 11:36:53 +03:30
xujie86 e5752239f4 Update x-ui.sh 2025-06-19 14:38:24 +08:00
Shishkevich D. cb22b4ad47 chore: add new dns features from v25.6.8
* chore: add new dns params

* chore: add `DNS Presets` modal

* chore: edit file names
2025-06-18 23:24:18 +07:00
spatiumstas 6a2e0071cf fix: reduced login title font-size for mobile (#3105) 2025-06-18 22:31:10 +07:00
Shishkevich D. f86219f4de refactor: use math.MaxUint16 when checking port 2025-06-17 22:45:03 +07:00
Shishkevich D. e272c160b1 chore: add download config button for wireguard 2025-06-17 22:25:24 +07:00
xujie86 ba50c99c10 chore: add RHEL system to install.sh
fixed #3097
2025-06-16 18:11:23 +07:00
Shishkevich D. 00b61de646 chore: add translations for routing table 2025-06-16 12:26:49 +07:00
Shishkevich D. dff4ad31ff chore: up minimal xray-core version to v25.6.8 2025-06-16 12:22:56 +07:00
Shishkevich D. 13baf77893 chore: build image in docker compose
this makes it possible to run development build using Docker.
2025-06-13 15:25:13 +07:00
Sanaei 4531574de3 Merge pull request #3087 from rammiah/main
feat: support metrics config
2025-06-11 17:13:46 +03:30
rammiah d1e07954c5 feat: support metrics config 2025-06-11 18:42:41 +08:00
Jay d9922d93af chore: update the installation command (#3069)
* install command had an extra $ at the first of the line

* install command had an extra $ at the first of the line

* install command had an extra $ at the first of the line

* install command had an extra $ at the first of the line

* install command had an extra $ at the first of the line

* install command had an extra $ at the first of the line
2025-06-10 19:17:31 +03:30
Shishkevich D. c7d315f848 chore: clean readme, add link to wiki (#3045) 2025-06-05 18:56:33 +07:00
Shishkevich D. 1781790dce fix: don't show ip limit for some protocols (#3064)
this causes the modal window to fail because protocols like wireguard, dokodemo and so on do not have clients (like vless/vmess, shadowsocks, etc).
2025-06-05 18:12:06 +07:00
spatiumstas 29f950046a feat: add command list in telegram bot (#3027) 2025-05-28 15:26:29 +07:00
Shishkevich D. 5dae785786 chore: X_UI_ENABLE_FAIL2BAN -> XUI_ENABLE_FAIL2BAN (#3030) 2025-05-22 08:21:23 +02:00
Ali Golzar 1b1cbfff42 feat: support .env file (#3013) 2025-05-17 12:33:22 +02:00
Pk-web6936 c93467b852 Code refactoring (#3011)
* Code refactoring 

read without -r will mangle backslashes
https://github.com/koalaman/shellcheck/wiki/SC2162

* Update x-ui.sh
2025-05-16 20:23:57 +02:00
Shishkevich D. c988d55256 fix: handle inbounds interaction errors (#3009)
eliminates messages like: “Inbound created successfully (Port 100 is already busy)”.
2025-05-16 23:56:56 +07:00
mhsanaei ef625c75d8 v2.6.0 2025-05-16 14:26:20 +02:00
mhsanaei 182e591c48 Xray-core v25.5.16 2025-05-16 13:05:46 +02:00
Columbiysky 3666d1193f fix: Restore from .db file fails (#2988)
* fix: issue 2953. Restore from .db file fails because

* Update server.go
2025-05-14 17:35:53 +02:00
mhsanaei 7a5a833af3 update dependencies 2025-05-11 13:05:45 +02:00
Tara Rostami 58f978bb0a fix: 2fa qr-code (#2996) 2025-05-11 02:12:43 +02:00
ckun52880 6d47496069 chore: simplified chinese translate improved 2025-05-10 22:42:23 +07:00
Shishkevich D. e5c19759db fix: remove duplicate path 2025-05-10 22:25:24 +07:00
Shishkevich D. 295a8b6e37 chore: сonfiguring paths for CI
no need for automatic build when changing files that do not affect panel operation
2025-05-10 22:24:49 +07:00
Shishkevich D. 384e23aeb2 chore: customizing triggers for builds
Now CI triggers on commit to main, or on release
2025-05-10 22:00:20 +07:00
Shishkevich D. 23293813bb chore: add translations for a-table 2025-05-10 21:47:59 +07:00
Columbiysky c15ec5315a chore: russian translate improved (#2990) 2025-05-10 19:41:53 +07:00
Shishkevich D. 1ddfe4aba3 chore: toasts translation refactoring 2025-05-09 10:46:29 +07:00
Shishkevich D. fe3b1c9b52 chore: implement 2fa auth (#2968)
* chore: implement 2fa auth

from #2786

* chore: format code

* chore: replace two factor token input with qr-code

* chore: requesting confirmation of setting/removing two-factor authentication

otpauth library was taken from cdnjs

* chore: revert changes in `ClipboardManager`

don't need it.

* chore: removing twoFactor prop in settings page

* chore: remove `twoFactorQr` object in `mounted` function
2025-05-08 16:20:58 +02:00
nistootsin d39ccf4b8f Added 3 new buttons to telegram bot (#2965)
* Add a new button to but : Reset All Clients

* handel translation for `Reset All Clients` button

* refactoring

* add a new button to telegram bot >> `Sorted Traffic Usage Report`

* - refactoring

* add ip limit conifg on new client adding time
2025-05-06 18:27:17 +02:00
Shishkevich D. 1aed2d8cdc feat: implement geofiles update in panel (#2971)
solves #2672

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2025-05-06 18:10:58 +02:00
mhsanaei c3084aaece geosite : category-porn 2025-05-06 09:55:06 +02:00
Shishkevich D. 13cf7271d6 fix: check default credentials during a fresh installation 2025-05-06 09:05:32 +07:00
Shishkevich D. 63edc63ab0 chore: do not show the current login and password (#2969) 2025-05-03 18:00:25 +07:00
Columbiysky 85cbad3ef4 feat: hashing user passwords
solves problems #2944, #2783
2025-05-03 16:27:53 +07:00
mhsanaei 3d54e33051 v2.5.8 2025-04-30 11:24:06 +02:00
mhsanaei 01be9fec95 Xray Core v25.4.30 2025-04-30 10:56:55 +02:00
mhsanaei 0306e75c2a update dependencies 2025-04-30 10:51:49 +02:00
Павел 255ff9cc20 refactror: add check ipv6 path
fix issues #1669 #2882
2025-04-29 20:32:54 +07:00
Tara Rostami 2fbb1ca6c9 chore: minor fixes for login page 2025-04-27 11:22:43 +07:00
Shishkevich D. 3b47028060 chore: new templates for issues and pull requests (#2935)
* chore: new issue templates

* chore: fixing templates

* chore: add pull request template
also edited bug report

* chore: add checklist for question and feat request template

* chore: remove title prefix

* fix: template title

* fix: re-fixing the template title

* chore: remove checklist for pull request

* chore: remove emojies

* fix: elimination of minor defects
2025-04-23 09:04:36 +02:00
Shishkevich D. d9ab8b4ce4 fix: qr modal header 2025-04-19 22:43:24 +07:00
Shishkevich D. e6389f3fb3 chore: move qr params in a-popover 2025-04-19 22:36:17 +07:00
AKILA INDUNIL 96fd7d0e7c feat: add a toggle to use public IPv4 in QR/URI 2025-04-19 22:32:22 +07:00
Pk-web6936 cf02f02210 automatic Build and Release (#2919)
* Update release.yml

* Update release.yml

* Update release.yml
2025-04-18 22:32:38 +02:00
Pk-web6936 4dc8974af0 docs: Update README (#2921)
* Update README.es_ES.md

* Update README.ru_RU.md
2025-04-18 22:31:35 +02:00
006lp b527a528ea docs: Update README.zh_CN.md (#2920) 2025-04-18 21:16:39 +02:00
Shishkevich D. 1a53af0434 chore: deleting unnecessary functions 2025-04-18 17:55:09 +07:00
nistootsin be8d55dadb feat: add Submit As Enable in telegram bot 2025-04-16 15:16:55 +07:00
Shishkevich D. d54e7a9b14 fix: encoding subscription title in base64 2025-04-15 19:29:54 +07:00
Shishkevich D. 45c3d730d4 fix: Error when generating shadowsocks keys in Blake3_AES_256_GCM 2025-04-15 18:33:26 +07:00
Columbiysky aab01ff11a fix docker-compose.yml: the attribute version is obsolete (#2891) 2025-04-12 08:19:55 +02:00
mhsanaei 236dddf482 v2.5.7 2025-04-11 12:11:39 +02:00
mhsanaei 8e472838d8 update dependencies 2025-04-11 11:09:43 +02:00
Pk-web6936 b75a1ef5e1 Code refactoring (#2877)
* read without -r will mangle backslashes.

https://github.com/koalaman/shellcheck/wiki/SC2162

* read without -r will mangle backslashes.
2025-04-09 11:12:14 +02:00
kmoshax d956f78347 feat: add Arabic language (#2880)
* translation: add Arabic support into language manager

* translation: add Arabic language support

* translation: add Arabic language support in README files
2025-04-08 22:26:05 +07:00
Shishkevich D. 8ef447a997 chore: create FileManager class for downloading files 2025-04-08 22:17:29 +07:00
Shishkevich D. 520f7a2d15 fix: current tab highlight in sidebar (#2874) 2025-04-07 08:28:02 +07:00
mhsanaei 3ded4ee658 minor changes 2025-04-07 00:45:52 +02:00
Shishkevich D. bea19a263d Code refactoring (#2865)
* refactor: use vue inline styles in entire application

* refactor: setting row in dashboard page

* refactor: use blob for download file in text modal

* refactor: move all html templates in `web/html` folder

* refactor: `DeviceUtils` -> `MediaQueryMixin`
The transition to mixins has been made, as they can update themselves.

* chore: pretty right buttons in `outbounds` tab in xray settings

* refactor: add translations for system status

* refactor: adjust gutter spacing in setting list item

* refactor: use native `a-input-password` for password field

* chore: return old system status
with new translations

* chore: add missing translation
2025-04-06 11:40:33 +02:00
mhsanaei 878e0d02cd Xray Core v25.3.31
+update dependencies
2025-04-04 21:20:07 +02:00
Pk-web6936 b15ea1f74d chore: update Go to v1.24.2 (#2866)
* Go v1.24.2

* Update dependencies
2025-04-04 21:25:48 +07:00
Shishkevich D. 431d7350a5 chore: simplify login page (#2851)
* chore: change login page

* chore: minor improvements on login page

* chore: add login button padding

* fix: delete unnecessary attributes

* fix: Restore headline animation with DOMContentLoaded
2025-04-01 14:24:03 +02:00
Shishkevich D. 127bea7f73 fix: opening links in the sidebar 2025-03-30 22:02:05 +07:00
Pk-web6936 7c58bcbb46 Consolidate and Optimize .gitignore Files (#2838)
* Update .gitignore
2025-03-30 11:32:42 +07:00
Columbiysky fec9b25248 locs(RU\UA): a bit better translate (#2841) 2025-03-30 11:29:35 +07:00
nistootsin 728166bd1a Add Admin-Controlled Client Management to Telegram Bot (#2788)
* Add feature to add clients to inbound:
- Implement buttons for adding new clients
- Handle client addition process (submission remains to be completed)
- Support for multiple languages

* update the go.mod

* feat: complete submission process for adding a client to inbounds

* - Add client variables: client_method, client_sh_password, client_tr_password
- Exclude specific inbound protocols (HTTP, WireGuard, Socks, DOKODEMO) from addclient inline button

* - customize the add client message and json for each protocol

* - handle password input rather than id for shadow and trojan protocols

* - remove add_client_as_enable button in bot

* restructrure the add client bot feature

* update all files in web/translation/

* Refactoring

* - add traffic button to add client bot feature

* - fix a mistake in the email prompt message

* - add expire data button to add client telegram process.

* Refactroring

* remove refresh button in add client

* - delete message after cancel

* - uptimize the process of adding client by deleting main message on
  getting text inputs.
2025-03-26 19:16:35 +01:00
Sanaei d376ce057c Merge pull request #2823 from shishkevichd/refactor/refactor-5
Code refactoring
2025-03-26 13:04:42 +01:00
Sanaei 5e6e900e64 Merge branch 'main' into refactor/refactor-5 2025-03-26 11:29:38 +01:00
danilshishkevich 19f7938617 chore: giving keys for each a-collapse-panel 2025-03-25 15:13:17 +00:00
danilshishkevich fe791b6e99 chore: move client table into components 2025-03-24 15:17:45 +00:00
danilshishkevich a02bf3195d chore: improve styles
- elements with class `.collapse-title` are missing
- changed paddings in `.ant-xray-version-list-item` element
2025-03-24 12:22:12 +00:00
Shishkevich D. 3ea05d30c1 chore: transforming a common sidebar into a separate component
- also added saving collapsed state
2025-03-24 11:19:27 +00:00
Shishkevich D. 40ebf2902e fix: dashboard won't load 2025-03-24 10:02:01 +00:00
Shishkevich D. 14253c3586 chore: moving the modals to a separate directory 2025-03-24 09:57:37 +00:00
Shishkevich D. 7b15274c84 chore: moving the settings tabs to a separate directory 2025-03-24 09:45:15 +00:00
mhsanaei 6545d8b61d glibc version
replace with OS check
2025-03-22 07:48:50 +01:00
Shishkevich D. 6f4eefe601 chore: make class to get the device form factor 2025-03-21 15:09:05 +00:00
Shishkevich D. 510c35f450 chore: meta tag change
- `X-UA-Compatible` is only used by Internet Explorer, so it removed
- `robots` allows the panel not to be indexed by a search engine
2025-03-21 14:49:49 +00:00
Shishkevich D. 00addb0dd9 chore: delete display.css
these styles are not used anywhere
2025-03-21 14:38:41 +00:00
Shishkevich D. db140a1e9b chore: improve russian translation (#2802)
* chore: improve russian translation

* chore: corrections in translation
2025-03-21 18:54:02 +07:00
mhsanaei db945e2fbd OS: Alma Linux 9.5+ 2025-03-21 12:49:23 +01:00
mhsanaei 667fac15f4 OS: Rocky Linux 9.5+ 2025-03-21 12:40:13 +01:00
mhsanaei 29033a7828 OS: Debian 12+ 2025-03-20 19:45:29 +01:00
387 changed files with 65326 additions and 135834 deletions
+4
View File
@@ -0,0 +1,4 @@
XUI_DEBUG=true
XUI_DB_FOLDER=x-ui
XUI_LOG_FOLDER=x-ui
XUI_BIN_FOLDER=x-ui
+2 -2
View File
@@ -1,6 +1,6 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
github: MHSanaei
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
@@ -11,4 +11,4 @@ issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: mhsanaei
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
custom: https://nowpayments.io/donation/hsanaei
-24
View File
@@ -1,24 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Version (please complete the following information):**
- 3X-UI Version : [e.g. 2.3.5]
- Xray Version : [e.g. 1.8.13]
**Additional context**
Add any other context about the problem here.
+77
View File
@@ -0,0 +1,77 @@
name: Bug report
description: Create a report to help us improve
title: "Bug report"
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thank you for reporting a bug! Please fill out the following information.
- type: textarea
id: what-happened
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is.
placeholder: My problem is...
validations:
required: true
- type: textarea
id: how-repeat-problem
attributes:
label: How to repeat the problem?
description: Sequence of actions that allow you to reproduce the bug
placeholder: |
1. Open `Inbounds` page
2. ...
validations:
required: true
- type: textarea
id: expected-action
attributes:
label: Expected action
description: What's going to happen
placeholder: Must be...
validations:
required: false
- type: textarea
id: received-action
attributes:
label: Received action
description: What's really happening
placeholder: It's actually happening...
validations:
required: false
- type: input
id: xui-version
attributes:
label: 3x-ui Version
description: Which version of 3x-ui are you using?
placeholder: 2.X.X
validations:
required: true
- type: input
id: xray-version
attributes:
label: Xray-core Version
description: Which version of Xray-core are you using?
placeholder: 2.X.X
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please check all the checkboxes
options:
- label: This bug report is written entirely in English.
required: true
- label: This bug report is new and no one has reported it before me.
required: true
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -0,0 +1,39 @@
name: Feature request
description: Suggest an idea for this project
title: "Feature request"
labels: ["enhancement"]
body:
- type: textarea
id: is-related-problem
attributes:
label: Is your feature request related to a problem?
description: A clear and concise description of what the problem is.
placeholder: I'm always frustrated when...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please check all the checkboxes
options:
- label: This feature report is written entirely in English.
required: true
-10
View File
@@ -1,10 +0,0 @@
---
name: 'Question '
about: Describe this issue template's purpose here.
title: ''
labels: question
assignees: ''
---
+22
View File
@@ -0,0 +1,22 @@
name: Question
description: Describe this issue template's purpose here.
title: "Question"
labels: ["question"]
body:
- type: textarea
id: question
attributes:
label: Question
placeholder: I have a question, ..., how can I solve it?
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please check all the checkboxes
options:
- label: This question is written entirely in English.
required: true
+158
View File
@@ -0,0 +1,158 @@
# 3X-UI Development Guide
## Project Overview
3X-UI is a web-based control panel for managing Xray-core servers. It's a Go application using Gin web framework with embedded static assets and SQLite database. The panel manages VPN/proxy inbounds, monitors traffic, and provides Telegram bot integration.
## Architecture
### Core Components
- **main.go**: Entry point that initializes database, web server, and subscription server. Handles graceful shutdown via SIGHUP/SIGTERM signals
- **web/**: Primary web server with Gin router, HTML templates, and static assets embedded via `//go:embed`
- **xray/**: Xray-core process management and API communication for traffic monitoring
- **database/**: GORM-based SQLite database with models in `database/model/`
- **sub/**: Subscription server running alongside main web server (separate port)
- **web/service/**: Business logic layer containing InboundService, SettingService, TgBot, etc.
- **web/controller/**: HTTP handlers using Gin context (`*gin.Context`)
- **web/job/**: Cron-based background jobs for traffic monitoring, CPU checks, LDAP sync
### Key Architectural Patterns
1. **Embedded Resources**: All web assets (HTML, CSS, JS, translations) are embedded at compile time using `embed.FS`:
- `web/assets``assetsFS`
- `web/html``htmlFS`
- `web/translation``i18nFS`
2. **Dual Server Design**: Main web panel + subscription server run concurrently, managed by `web/global` package
3. **Xray Integration**: Panel generates `config.json` for Xray binary, communicates via gRPC API for real-time traffic stats
4. **Signal-Based Restart**: SIGHUP triggers graceful restart. **Critical**: Always call `service.StopBot()` before restart to prevent Telegram bot 409 conflicts
5. **Database Seeders**: Uses `HistoryOfSeeders` model to track one-time migrations (e.g., password bcrypt migration)
## Development Workflows
### Building & Running
```bash
# Build (creates bin/3x-ui.exe)
go run tasks.json → "go: build" task
# Run with debug logging
XUI_DEBUG=true go run ./main.go
# Or use task: "go: run"
# Test
go test ./...
```
### Command-Line Operations
The main.go accepts flags for admin tasks:
- `-reset` - Reset all panel settings to defaults
- `-show` - Display current settings (port, paths)
- Use these by running the binary directly, not via web interface
### Database Management
- DB path: Configured via `config.GetDBPath()`, typically `/etc/x-ui/x-ui.db`
- Models: Located in `database/model/model.go` - Auto-migrated on startup
- Seeders: Use `HistoryOfSeeders` to prevent re-running migrations
- Default credentials: admin/admin (hashed with bcrypt)
### Telegram Bot Development
- Bot instance in `web/service/tgbot.go` (3700+ lines)
- Uses `telego` library with long polling
- **Critical Pattern**: Must call `service.StopBot()` before any server restart to prevent 409 bot conflicts
- Bot handlers use `telegohandler.BotHandler` for routing
- i18n via embedded `i18nFS` passed to bot startup
## Code Conventions
### Service Layer Pattern
Services inject dependencies (like xray.XrayAPI) and operate on GORM models:
```go
type InboundService struct {
xrayApi xray.XrayAPI
}
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
// Business logic here
}
```
### Controller Pattern
Controllers use Gin context and inherit from BaseController:
```go
func (a *InboundController) getInbounds(c *gin.Context) {
// Use I18nWeb(c, "key") for translations
// Check auth via checkLogin middleware
}
```
### Configuration Management
- Environment vars: `XUI_DEBUG`, `XUI_LOG_LEVEL`, `XUI_MAIN_FOLDER`
- Config embedded files: `config/version`, `config/name`
- Use `config.GetLogLevel()`, `config.GetDBPath()` helpers
### Internationalization
- Translation files: `web/translation/<lang>.json` (one nested-namespace file per locale,
e.g. `en-US.json`). Vue SPA imports these via `import.meta.glob` from `frontend/src/i18n/`,
and the Go binary embeds the same files via `web/web.go`'s `//go:embed translation/*`.
- Access from Go via `locale.I18n(locale.Web, "pages.login.loginAgain")` (see
`web/locale/locale.go`); access from Vue via `useI18n()` and `t('pages.login.loginAgain')`.
- Use `locale.I18nType` enum (Web, Bot).
## External Dependencies & Integration
### Xray-core
- Binary management: Download platform-specific binary (`xray-{os}-{arch}`) to bin folder
- Config generation: Panel creates `config.json` dynamically from inbound/outbound settings
- Process control: Start/stop via `xray/process.go`
- gRPC API: Real-time stats via `xray/api.go` using `google.golang.org/grpc`
### Critical External Paths
- Xray binary: `{bin_folder}/xray-{os}-{arch}`
- Xray config: `{bin_folder}/config.json`
- GeoIP/GeoSite: `{bin_folder}/geoip.dat`, `geosite.dat`
- Logs: `{log_folder}/3xipl.log`, `3xipl-banned.log`
### Job Scheduling
Uses `robfig/cron/v3` for periodic tasks:
- Traffic monitoring: `xray_traffic_job.go`
- CPU alerts: `check_cpu_usage.go`
- IP tracking: `check_client_ip_job.go`
- LDAP sync: `ldap_sync_job.go`
Jobs registered in `web/web.go` during server initialization
## Deployment & Scripts
### Installation Script Pattern
Both `install.sh` and `x-ui.sh` follow these patterns:
- Multi-distro support via `$release` variable (ubuntu, debian, centos, arch, etc.)
- Port detection with `is_port_in_use()` using ss/netstat/lsof
- Systemd service management with distro-specific unit files (`.service.debian`, `.service.arch`, `.service.rhel`)
### Docker Build
Multi-stage Dockerfile:
1. **Builder**: CGO-enabled build, runs `DockerInit.sh` to download Xray binary
2. **Final**: Alpine-based with fail2ban pre-configured
### Key File Locations (Production)
- Binary: `/usr/local/x-ui/`
- Database: `/etc/x-ui/x-ui.db`
- Logs: `/var/log/x-ui/`
- Service: `/etc/systemd/system/x-ui.service.*`
## Testing & Debugging
- Set `XUI_DEBUG=true` for detailed logging
- Check Xray process: `x-ui.sh` script provides menu for status/logs
- Database inspection: Direct SQLite access to x-ui.db
- Traffic debugging: Check `3xipl.log` for IP limit tracking
- Telegram bot: Logs show bot initialization and command handling
## Common Gotchas
1. **Bot Restart**: Always stop Telegram bot before server restart to avoid 409 conflict
2. **Embedded Assets**: Changes to HTML/CSS require recompilation (not hot-reload)
3. **Password Migration**: Seeder system tracks bcrypt migration - check `HistoryOfSeeders` table
4. **Port Binding**: Subscription server uses different port from main panel
5. **Xray Binary**: Must match OS/arch exactly - managed by installer scripts
6. **Session Management**: Uses `gin-contrib/sessions` with cookie store
7. **IP Limitation**: Implements "last IP wins" - when client exceeds LimitIP, oldest connections are automatically disconnected via Xray API to allow newest IPs
+11
View File
@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "github-actions" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+20
View File
@@ -0,0 +1,20 @@
## What is the pull request?
<!-- Briefly describe the changes introduced by this pull request -->
## Which part of the application is affected by the change?
- [ ] Frontend
- [ ] Backend
## Type of Changes
- [ ] Bug fix
- [ ] New feature
- [ ] Refactoring
- [ ] Other
## Screenshots
<!-- Add screenshots to illustrate the changes -->
<!-- Remove this section if it is not applicable. -->
+31
View File
@@ -0,0 +1,31 @@
name: Cleanup Caches
on:
schedule:
- cron: "0 3 * * *" # every day
workflow_dispatch:
jobs:
cleanup:
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Delete caches older than 1 day
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CUTOFF_DATE=$(date -d "1 days ago" -Ins --utc | sed 's/+0000/Z/')
echo "Deleting caches older than: $CUTOFF_DATE"
CACHE_IDS=$(gh api --paginate repos/${{ github.repository }}/actions/caches \
--jq ".actions_caches[] | select(.last_accessed_at < \"$CUTOFF_DATE\") | .id" 2>/dev/null)
if [ -z "$CACHE_IDS" ]; then
echo "No old caches found to delete."
else
echo "$CACHE_IDS" | while read CACHE_ID; do
echo "Deleting cache: $CACHE_ID"
gh api -X DELETE repos/${{ github.repository }}/actions/caches/$CACHE_ID
done
echo "Old caches deleted successfully."
fi
+65
View File
@@ -0,0 +1,65 @@
name: "CodeQL Advanced"
on:
push:
tags-ignore:
- "v*"
pull_request:
schedule:
- cron: "18 2 * * 2"
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
env:
CODEQL_ACTION_FILE_COVERAGE_ON_PRS: true
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v6
# The Go binary embeds web/dist/ via //go:embed all:dist (web/web.go).
# web/dist/ is .gitignored, so CodeQL's autobuild for Go will fail with
# "pattern all:dist: no matching files found" unless vite emits it first.
- name: Setup Node.js
if: matrix.language == 'go'
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Build frontend bundle
if: matrix.language == 'go'
run: |
npm ci
npm run build
working-directory: frontend
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
+44 -41
View File
@@ -1,4 +1,9 @@
name: Release 3X-UI for Docker
permissions:
contents: read
packages: write
on:
workflow_dispatch:
push:
@@ -7,51 +12,49 @@ on:
jobs:
build:
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
hsanaeii/3x-ui
ghcr.io/mhsanaei/3x-ui
tags: |
type=ref,event=branch
type=ref,event=tag
type=pep440,pattern={{version}}
- uses: actions/checkout@v6
with:
submodules: true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: |
hsanaeii/3x-ui
ghcr.io/mhsanaei/3x-ui
tags: |
type=ref,event=branch
type=ref,event=tag
type=semver,pattern={{version}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
install: true
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64, linux/arm64/v8, linux/arm/v7, linux/arm/v6, linux/386
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6,linux/386
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+185 -49
View File
@@ -3,11 +3,27 @@ name: Release 3X-UI
on:
workflow_dispatch:
push:
branches:
- "**"
tags:
- "v*.*.*"
paths:
- "**.js"
- "**.css"
- "**.html"
- "**.sh"
- "**.go"
- "go.mod"
- "go.sum"
- "x-ui.service.debian"
- "x-ui.service.arch"
- "x-ui.service.rhel"
pull_request:
jobs:
build:
permissions:
contents: write
strategy:
matrix:
platform:
@@ -18,72 +34,78 @@ jobs:
- 386
- armv5
- s390x
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- name: Install dependencies
# Frontend dist must be built BEFORE go build — Go's //go:embed
# all:dist directive in web/web.go requires web/dist/ to exist
# at compile time. web/dist/ is .gitignored, so on a fresh CI
# checkout it doesn't exist until vite emits it.
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Build frontend bundle
run: |
sudo apt-get update
if [ "${{ matrix.platform }}" == "arm64" ]; then
sudo apt install gcc-aarch64-linux-gnu
elif [ "${{ matrix.platform }}" == "armv7" ]; then
sudo apt install gcc-arm-linux-gnueabihf
elif [ "${{ matrix.platform }}" == "armv6" ]; then
sudo apt install gcc-arm-linux-gnueabihf
elif [ "${{ matrix.platform }}" == "386" ]; then
sudo apt install gcc-i686-linux-gnu
elif [ "${{ matrix.platform }}" == "armv5" ]; then
sudo apt install gcc-arm-linux-gnueabi
elif [ "${{ matrix.platform }}" == "s390x" ]; then
sudo apt install gcc-s390x-linux-gnu
fi
npm ci
npm run build
working-directory: frontend
- name: Build x-ui
- name: Build 3X-UI
run: |
export CGO_ENABLED=1
export GOOS=linux
export GOARCH=${{ matrix.platform }}
if [ "${{ matrix.platform }}" == "arm64" ]; then
export GOARCH=arm64
export CC=aarch64-linux-gnu-gcc
elif [ "${{ matrix.platform }}" == "armv7" ]; then
export GOARCH=arm
export GOARM=7
export CC=arm-linux-gnueabihf-gcc
elif [ "${{ matrix.platform }}" == "armv6" ]; then
export GOARCH=arm
export GOARM=6
export CC=arm-linux-gnueabihf-gcc
elif [ "${{ matrix.platform }}" == "386" ]; then
export GOARCH=386
export CC=i686-linux-gnu-gcc
elif [ "${{ matrix.platform }}" == "armv5" ]; then
export GOARCH=arm
export GOARM=5
export CC=arm-linux-gnueabi-gcc
elif [ "${{ matrix.platform }}" == "s390x" ]; then
export GOARCH=s390x
export CC=s390x-linux-gnu-gcc
fi
go build -ldflags "-w -s" -o xui-release -v main.go
# Use Bootlin prebuilt cross-toolchains (musl 1.2.5 in stable series)
case "${{ matrix.platform }}" in
amd64) BOOTLIN_ARCH="x86-64" ;;
arm64) BOOTLIN_ARCH="aarch64" ;;
armv7) BOOTLIN_ARCH="armv7-eabihf"; export GOARCH=arm GOARM=7 ;;
armv6) BOOTLIN_ARCH="armv6-eabihf"; export GOARCH=arm GOARM=6 ;;
armv5) BOOTLIN_ARCH="armv5-eabi"; export GOARCH=arm GOARM=5 ;;
386) BOOTLIN_ARCH="x86-i686" ;;
s390x) BOOTLIN_ARCH="s390x-z13" ;;
esac
echo "Resolving Bootlin musl toolchain for arch=$BOOTLIN_ARCH (platform=${{ matrix.platform }})"
TARBALL_BASE="https://toolchains.bootlin.com/downloads/releases/toolchains/$BOOTLIN_ARCH/tarballs/"
TARBALL_URL=$(curl -fsSL "$TARBALL_BASE" | grep -oE "${BOOTLIN_ARCH}--musl--stable-[^\"]+\\.tar\\.xz" | sort -r | head -n1)
[ -z "$TARBALL_URL" ] && { echo "Failed to locate Bootlin musl toolchain for arch=$BOOTLIN_ARCH" >&2; exit 1; }
echo "Downloading: $TARBALL_URL"
cd /tmp
curl -fL -sS -o "$(basename "$TARBALL_URL")" "$TARBALL_BASE/$TARBALL_URL"
tar -xf "$(basename "$TARBALL_URL")"
TOOLCHAIN_DIR=$(find . -maxdepth 1 -type d -name "${BOOTLIN_ARCH}--musl--stable-*" | head -n1)
export PATH="$(realpath "$TOOLCHAIN_DIR")/bin:$PATH"
export CC=$(realpath "$(find "$TOOLCHAIN_DIR/bin" -name '*-gcc.br_real' -type f -executable | head -n1)")
[ -z "$CC" ] && { echo "No gcc.br_real found in $TOOLCHAIN_DIR/bin" >&2; exit 1; }
cd -
go build -ldflags "-w -s -linkmode external -extldflags '-static'" -o xui-release -v main.go
file xui-release
ldd xui-release || echo "Static binary confirmed"
mkdir x-ui
cp xui-release x-ui/
cp x-ui.service x-ui/
cp x-ui.service.debian x-ui/
cp x-ui.service.arch x-ui/
cp x-ui.service.rhel x-ui/
cp x-ui.sh x-ui/
mv x-ui/xui-release x-ui/x-ui
mkdir x-ui/bin
cd x-ui/bin
# Download dependencies
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v25.3.6/"
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
if [ "${{ matrix.platform }}" == "amd64" ]; then
wget -q ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip
@@ -122,21 +144,135 @@ jobs:
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
mv xray xray-linux-${{ matrix.platform }}
cd ../..
- name: Package
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
- name: Upload files to Artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: x-ui-linux-${{ matrix.platform }}
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
- name: Upload files to GH release
uses: svenstaro/upload-release-action@v2
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref }}
tag: ${{ github.ref_name }}
file: x-ui-linux-${{ matrix.platform }}.tar.gz
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
overwrite: true
prerelease: true
# =================================
# Windows Build
# =================================
build-windows:
name: Build for Windows
permissions:
contents: write
strategy:
matrix:
platform:
- amd64
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
# Frontend dist must be built BEFORE go build — see comment on the
# Linux job above. This step is identical except npm runs on the
# Windows runner here.
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Build frontend bundle
shell: pwsh
run: |
npm ci
npm run build
working-directory: frontend
- name: Install MSYS2
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
update: true
install: >-
mingw-w64-x86_64-gcc
mingw-w64-x86_64-sqlite3
mingw-w64-x86_64-pkg-config
- name: Build 3X-UI for Windows (CGO)
shell: msys2 {0}
run: |
export PATH="/c/hostedtoolcache/windows/go/$(ls /c/hostedtoolcache/windows/go | sort -V | tail -n1)/x64/bin:$PATH"
export CGO_ENABLED=1
export GOOS=windows
export GOARCH=amd64
export CC=x86_64-w64-mingw32-gcc
which go
go version
gcc --version
go build -ldflags "-w -s" -o xui-release.exe -v main.go
- name: Copy and download resources
shell: pwsh
run: |
mkdir x-ui
Copy-Item xui-release.exe x-ui\x-ui.exe
mkdir x-ui\bin
cd x-ui\bin
# Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip"
Remove-Item geoip.dat, geosite.dat -ErrorAction SilentlyContinue
Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip.dat"
Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite.dat"
Invoke-WebRequest -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat" -OutFile "geoip_IR.dat"
Invoke-WebRequest -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat" -OutFile "geosite_IR.dat"
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat"
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
Rename-Item xray.exe xray-windows-amd64.exe
cd ..
Copy-Item -Path ..\windows_files\* -Destination . -Recurse
cd ..
- name: Package to Zip
shell: pwsh
run: |
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
- name: Upload files to Artifacts
uses: actions/upload-artifact@v7
with:
name: x-ui-windows-amd64
path: ./x-ui-windows-amd64.zip
- name: Upload files to GH release
uses: svenstaro/upload-release-action@v2
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
file: x-ui-windows-amd64.zip
asset_name: x-ui-windows-amd64.zip
overwrite: true
prerelease: true
+35 -8
View File
@@ -1,16 +1,43 @@
.idea
.vscode
.cache
# Ignore editor and IDE settings
.idea/
.vscode/
.claude/
.cache/
.sync*
*.tar.gz
# Ignore log files
*.log
access.log
error.log
tmp
main
# Ignore temporary files
tmp/
*.tar.gz
# Ignore build and distribution directories
backup/
bin/
dist/
release/
node_modules/
# Ignore compiled binaries
main
# Ignore script and executable files
/release.sh
/x-ui
# Ignore OS specific files
.DS_Store
Thumbs.db
# Ignore Go build files
*.exe
x-ui.db
x-ui.db-shm
x-ui.db-wal
# Ignore Docker specific files
docker-compose.override.yml
# Ignore .env (Environment Variables) file
.env
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "vscode://schemas/launch",
"version": "0.2.0",
"configurations": [
{
"name": "Run 3x-ui (Debug)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "${workspaceFolder}",
"env": {
"XUI_DEBUG": "true"
},
"console": "integratedTerminal"
},
{
"name": "Run 3x-ui (Debug, custom env)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "${workspaceFolder}",
"env": {
// Set to true to serve assets/templates directly from disk for development
"XUI_DEBUG": "true",
// Uncomment to override DB folder location (by default uses working dir on Windows when debug)
// "XUI_DB_FOLDER": "${workspaceFolder}",
// Example: override log level (debug|info|notice|warn|error)
// "XUI_LOG_LEVEL": "debug"
},
"console": "integratedTerminal"
}
]
}
+75
View File
@@ -0,0 +1,75 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "go: build",
"type": "shell",
"command": "go",
"args": [
"build",
"-o",
"bin/3x-ui.exe",
"./main.go"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
],
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "go: run",
"type": "shell",
"command": "go",
"args": [
"run",
"./main.go"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"XUI_DEBUG": "true"
}
},
"problemMatcher": [
"$go"
]
},
{
"label": "go: test",
"type": "shell",
"command": "go",
"args": [
"test",
"./..."
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
],
"group": "test"
},
{
"label": "go: vet",
"type": "shell",
"command": "go",
"args": [
"vet",
"./..."
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": [
"$go"
]
}
]
}
+5
View File
@@ -0,0 +1,5 @@
## Local Development Setup
- Create a directory named `x-ui` in the project root
- Rename `.env.example` to `.env `
- Run `main.go`
+56 -2
View File
@@ -1,7 +1,61 @@
#!/bin/sh
# Start fail2ban
[ $X_UI_ENABLE_FAIL2BAN == "true" ] && fail2ban-client -x start
# Start fail2ban with the 3x-ipl jail
if [ "$XUI_ENABLE_FAIL2BAN" = "true" ]; then
LOG_FOLDER="${XUI_LOG_FOLDER:-/var/log/x-ui}"
mkdir -p "$LOG_FOLDER"
touch "$LOG_FOLDER/3xipl.log" "$LOG_FOLDER/3xipl-banned.log"
mkdir -p /etc/fail2ban/jail.d /etc/fail2ban/filter.d /etc/fail2ban/action.d
cat > /etc/fail2ban/jail.d/3x-ipl.conf << EOF
[3x-ipl]
enabled=true
backend=auto
filter=3x-ipl
action=3x-ipl
logpath=$LOG_FOLDER/3xipl.log
maxretry=1
findtime=32
bantime=30m
EOF
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
[Definition]
datepattern = ^%Y/%m/%d %H:%M:%S
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
ignoreregex =
EOF
cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
[INCLUDES]
before = iptables-allports.conf
[Definition]
actionstart = <iptables> -N f2b-<name>
<iptables> -A f2b-<name> -j <returntype>
<iptables> -I <chain> -p <protocol> -j f2b-<name>
actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
<actionflush>
<iptables> -X f2b-<name>
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
[Init]
name = default
protocol = tcp
chain = INPUT
EOF
fail2ban-client -x start
fi
# Run x-ui
exec /app/x-ui
+8 -8
View File
@@ -27,14 +27,14 @@ case $1 in
esac
mkdir -p build/bin
cd build/bin
wget -q "https://github.com/XTLS/Xray-core/releases/download/v25.3.6/Xray-linux-${ARCH}.zip"
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/Xray-linux-${ARCH}.zip"
unzip "Xray-linux-${ARCH}.zip"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
mv xray "xray-linux-${FNAME}"
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
wget -q -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
wget -q -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
cd ../../
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
curl -sfLRo geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
curl -sfLRo geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
curl -sfLRo geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
cd ../../
+20 -4
View File
@@ -1,17 +1,29 @@
# ========================================================
# Stage: Frontend (Vite)
# ========================================================
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
COPY web/translation /src/web/translation
RUN npm run build
# ========================================================
# Stage: Builder
# ========================================================
FROM golang:1.24-alpine AS builder
FROM golang:1.26-alpine AS builder
WORKDIR /app
ARG TARGETARCH
RUN apk --no-cache --update add \
build-base \
gcc \
wget \
curl \
unzip
COPY . .
COPY --from=frontend /src/web/dist ./web/dist
ENV CGO_ENABLED=1
ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
@@ -29,11 +41,14 @@ RUN apk add --no-cache --update \
ca-certificates \
tzdata \
fail2ban \
bash
bash \
curl \
openssl
COPY --from=builder /app/build/ /app/
COPY --from=builder /app/DockerEntrypoint.sh /app/
COPY --from=builder /app/x-ui.sh /usr/bin/x-ui
COPY --from=builder /app/web/translation /app/web/translation
# Configure fail2ban
@@ -48,7 +63,8 @@ RUN chmod +x \
/app/x-ui \
/usr/bin/x-ui
ENV X_UI_ENABLE_FAIL2BAN="true"
ENV XUI_ENABLE_FAIL2BAN="true"
EXPOSE 2053
VOLUME [ "/etc/x-ui" ]
CMD [ "./x-ui" ]
ENTRYPOINT [ "/app/DockerEntrypoint.sh" ]
+56
View File
@@ -0,0 +1,56 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
<img alt="3x-ui" src="./media/3x-ui-light.png">
</picture>
</p>
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
**3X-UI** — لوحة تحكم متقدمة مفتوحة المصدر تعتمد على الويب مصممة لإدارة خادم Xray-core. توفر واجهة سهلة الاستخدام لتكوين ومراقبة بروتوكولات VPN والوكيل المختلفة.
> [!IMPORTANT]
> هذا المشروع مخصص للاستخدام الشخصي والاتصال فقط، يرجى عدم استخدامه لأغراض غير قانونية، يرجى عدم استخدامه في بيئة الإنتاج.
كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية.
## البدء السريع
```
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
للحصول على الوثائق الكاملة، يرجى زيارة [ويكي المشروع](https://github.com/MHSanaei/3x-ui/wiki).
## شكر خاص إلى
- [alireza0](https://github.com/alireza0/)
## الاعتراف
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (الترخيص: **GPL-3.0**): _قواعد توجيه v2ray/xray و v2ray/xray-clients المحسنة مع النطاقات الإيرانية المدمجة وتركيز على الأمان وحظر الإعلانات._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (الترخيص: **GPL-3.0**): _يحتوي هذا المستودع على قواعد توجيه V2Ray محدثة تلقائيًا بناءً على بيانات النطاقات والعناوين المحظورة في روسيا._
## دعم المشروع
**إذا كان هذا المشروع مفيدًا لك، فقد ترغب في إعطائه**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## النجوم عبر الزمن
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+30 -561
View File
@@ -1,4 +1,4 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
@@ -7,582 +7,51 @@
</picture>
</p>
**Un Panel Web Avanzado • Construido sobre Xray Core**
[![](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](#)
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
> **Descargo de responsabilidad:** Este proyecto es solo para aprendizaje personal y comunicación, por favor no lo uses con fines ilegales, por favor no lo uses en un entorno de producción
**3X-UI** — panel de control avanzado basado en web de código abierto diseñado para gestionar el servidor Xray-core. Ofrece una interfaz fácil de usar para configurar y monitorear varios protocolos VPN y proxy.
**Si este proyecto te es útil, podrías considerar darle una**:star2:
> [!IMPORTANT]
> Este proyecto es solo para uso personal y comunicación, por favor no lo use para fines ilegales, por favor no lo use en un entorno de producción.
<p align="left">
<a href="https://buymeacoffee.com/mhsanaei" target="_blank">
<img src="./media/buymeacoffe.png" alt="Image">
</a>
</p>
Como una versión mejorada del proyecto X-UI original, 3X-UI proporciona mayor estabilidad, soporte más amplio de protocolos y características adicionales.
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC`
- MATIC (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A`
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv`
## Instalar y Actualizar
## Inicio Rápido
```
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
## Instalar versión antigua (no recomendamos)
Para documentación completa, visita la [Wiki del proyecto](https://github.com/MHSanaei/3x-ui/wiki).
Para instalar la versión deseada, utiliza el siguiente comando de instalación. Por ejemplo, ver `v1.7.9`:
```
VERSION=v1.7.9 && <(curl -Ls "https://raw.githubusercontent.com/mhsanaei/3x-ui/$VERSION/install.sh") $VERSION
```
## Certificado SSL
<details>
<summary>Haga clic para ver los detalles del certificado SSL</summary>
### ACME
Para gestionar certificados SSL utilizando ACME:
1. Asegúrate de que tu dominio esté correctamente resuelto al servidor.
2. Ejecuta el comando `x-ui` en la terminal y elige `Gestión de Certificados SSL`.
3. Se te presentarán las siguientes opciones:
- **Get SSL:** Obtener certificados SSL.
- **Revoke:** Revocar certificados SSL existentes.
- **Force Renew:** Forzar la renovación de certificados SSL.
- **Show Existing Domains:** Mostrar todos los certificados de dominio disponibles en el servidor.
- **Set Certificate Paths for the Panel:** Especificar el certificado para tu dominio que será utilizado por el panel.
### Certbot
Para instalar y usar Certbot:
```sh
apt-get install certbot -y
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d yourdomain.com
certbot renew --dry-run
```
### Cloudflare
El script de gestión incluye una aplicación de certificado SSL integrada para Cloudflare. Para usar este script para solicitar un certificado, necesitas lo siguiente:
- Correo electrónico registrado en Cloudflare
- Clave API Global de Cloudflare
- El nombre de dominio debe estar resuelto al servidor actual a través de Cloudflare
**Cómo obtener la Clave API Global de Cloudflare:**
1. Ejecuta el comando `x-ui` en la terminal y elige `Certificado SSL de Cloudflare`.
2. Visita el enlace: [Tokens de API de Cloudflare](https://dash.cloudflare.com/profile/api-tokens).
3. Haz clic en "Ver Clave API Global" (consulta la captura de pantalla a continuación):
![](media/APIKey1.PNG)
4. Es posible que necesites volver a autenticar tu cuenta. Después de eso, se mostrará la Clave API (consulta la captura de pantalla a continuación):
![](media/APIKey2.png)
Al utilizarlo, simplemente ingresa tu `nombre de dominio`, `correo electrónico` y `CLAVE API`. El diagrama es el siguiente:
![](media/DetailEnter.png)
</details>
## Instalación y Actualización Manual
<details>
<summary>Haz clic para más detalles de la instalación manual</summary>
#### Uso
1. Para descargar la última versión del paquete comprimido directamente en tu servidor, ejecuta el siguiente comando:
```sh
ARCH=$(uname -m)
case "${ARCH}" in
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
i*86 | x86) XUI_ARCH="386" ;;
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
armv7* | armv7) XUI_ARCH="armv7" ;;
armv6* | armv6) XUI_ARCH="armv6" ;;
armv5* | armv5) XUI_ARCH="armv5" ;;
*) XUI_ARCH="amd64" ;;
esac
wget https://github.com/MHSanaei/3x-ui/releases/latest/download/x-ui-linux-${XUI_ARCH}.tar.gz
```
2. Una vez que se haya descargado el paquete comprimido, ejecuta los siguientes comandos para instalar o actualizar x-ui:
```sh
ARCH=$(uname -m)
case "${ARCH}" in
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
i*86 | x86) XUI_ARCH="386" ;;
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
armv7* | armv7) XUI_ARCH="armv7" ;;
armv6* | armv6) XUI_ARCH="armv6" ;;
armv5* | armv5) XUI_ARCH="armv5" ;;
*) XUI_ARCH="amd64" ;;
esac
cd /root/
rm -rf x-ui/ /usr/local/x-ui/ /usr/bin/x-ui
tar zxvf x-ui-linux-${XUI_ARCH}.tar.gz
chmod +x x-ui/x-ui x-ui/bin/xray-linux-* x-ui/x-ui.sh
cp x-ui/x-ui.sh /usr/bin/x-ui
cp -f x-ui/x-ui.service /etc/systemd/system/
mv x-ui/ /usr/local/
systemctl daemon-reload
systemctl enable x-ui
systemctl restart x-ui
```
</details>
## Instalar con Docker
<details>
<summary>Haz clic para más detalles del Docker</summary>
#### Uso
1. Instala Docker:
```sh
bash <(curl -sSL https://get.docker.com)
```
2. Clona el Repositorio del Proyecto:
```sh
git clone https://github.com/MHSanaei/3x-ui.git
cd 3x-ui
```
3. Inicia el Servicio
```sh
docker compose up -d
```
O tambien
```sh
docker run -itd \
-e XRAY_VMESS_AEAD_FORCED=false \
-v $PWD/db/:/etc/x-ui/ \
-v $PWD/cert/:/root/cert/ \
--network=host \
--restart=unless-stopped \
--name 3x-ui \
ghcr.io/mhsanaei/3x-ui:latest
```
actualizar a la última versión
```sh
cd 3x-ui
docker compose down
docker compose pull 3x-ui
docker compose up -d
```
eliminar 3x-ui de docker
```sh
docker stop 3x-ui
docker rm 3x-ui
cd --
rm -r 3x-ui
```
</details>
## Configuración de Nginx
<details>
<summary>Haga clic aquí para configurar el proxy inverso</summary>
#### Proxy inverso Nginx
```nginx
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_redirect off;
proxy_pass http://127.0.0.1:2053;
}
```
#### Nginx sub-path
- EAsegúrese de que la "Ruta Raíz de la URL del Panel" en la configuración del panel `/sub` es la misma.
- El `url` en la configuración del panel debe terminar con `/`.
```nginx
location /sub {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_redirect off;
proxy_pass http://127.0.0.1:2053;
}
```
</details>
## SO Recomendados
- Ubuntu 22.04+
- Debian 11+
- CentOS 8+
- OpenEuler 22.03+
- Fedora 36+
- Arch Linux
- Parch Linux
- Manjaro
- Armbian
- AlmaLinux 8.0+
- Rocky Linux 8+
- Oracle Linux 8+
- OpenSUSE Tubleweed
- Amazon Linux 2023
- Virtuozzo Linux 8+
- Windows x64
## Arquitecturas y Dispositivos Compatibles
<details>
<summary>Haz clic para detalles de arquitecturas y dispositivos compatibles</summary>
Nuestra plataforma ofrece compatibilidad con una amplia gama de arquitecturas y dispositivos, garantizando flexibilidad en diversos entornos informáticos. A continuación se presentan las principales arquitecturas que admitimos:
- **amd64**: Esta arquitectura predominante es la estándar para computadoras personales y servidores, y admite la mayoría de los sistemas operativos modernos sin problemas.
- **x86 / i386**: Ampliamente adoptada en computadoras de escritorio y portátiles, esta arquitectura cuenta con un amplio soporte de numerosos sistemas operativos y aplicaciones, incluidos, entre otros, Windows, macOS y sistemas Linux.
- **armv8 / arm64 / aarch64**: Diseñada para dispositivos móviles y embebidos contemporáneos, como teléfonos inteligentes y tabletas, esta arquitectura está ejemplificada por dispositivos como Raspberry Pi 4, Raspberry Pi 3, Raspberry Pi Zero 2/Zero 2 W, Orange Pi 3 LTS, entre otros.
- **armv7 / arm / arm32**: Sirve como arquitectura para dispositivos móviles y embebidos más antiguos, y sigue siendo ampliamente utilizada en dispositivos como Orange Pi Zero LTS, Orange Pi PC Plus, Raspberry Pi 2, entre otros.
- **armv6 / arm / arm32**: Orientada a dispositivos embebidos muy antiguos, esta arquitectura, aunque menos común, todavía se utiliza. Dispositivos como Raspberry Pi 1, Raspberry Pi Zero/Zero W, dependen de esta arquitectura.
- **armv5 / arm / arm32**: Una arquitectura más antigua asociada principalmente con sistemas embebidos tempranos, es menos común hoy en día pero aún puede encontrarse en dispositivos heredados como versiones antiguas de Raspberry Pi y algunos teléfonos inteligentes más antiguos.
</details>
## Idiomas
- English (inglés)
- Persian (persa)
- Traditional Chinese (chino tradicional)
- Simplified Chinese (chino simplificado)
- Japanese (japonés)
- Russian (ruso)
- Vietnamese (vietnamita)
- Spanish (español)
- Indonesian (indonesio)
- Ukrainian (ucraniano)
- Turkish (turco)
- Português (Brazil) (portugués (Brasil))
## Características
- Monitoreo del Estado del Sistema
- Búsqueda dentro de todas las reglas de entrada y clientes
- Tema Oscuro/Claro
- Soporta multiusuario y multiprotocolo
- Soporta protocolos, incluyendo VMess, VLESS, Trojan, Shadowsocks, Dokodemo-door, Socks, HTTP, wireguard
- Soporta Protocolos nativos XTLS, incluyendo RPRX-Direct, Visión, REALITY
- Estadísticas de tráfico, límite de tráfico, límite de tiempo de vencimiento
- Plantillas de configuración de Xray personalizables
- Soporta acceso HTTPS al panel (dominio proporcionado por uno mismo + certificado SSL)
- Soporta la solicitud y renovación automática de certificados SSL con un clic
- Para elementos de configuración más avanzados, consulta el panel
- Corrige rutas de API (la configuración del usuario se creará con la API)
- Soporta cambiar las configuraciones por diferentes elementos proporcionados en el panel.
- Soporta exportar/importar base de datos desde el panel
## Configuración Predeterminada del Panel
<details>
<summary>Haz clic para ver los detalles de la configuración predeterminada</summary>
### Nombre de usuario, Contraseña, Puerto y Ruta Base Web
Si elige no modificar estas configuraciones, se generarán aleatoriamente (esto no se aplica a Docker).
**Configuraciones predeterminadas para Docker:**
- **Nombre de usuario:** admin
- **Contraseña:** admin
- **Puerto:** 2053
### Gestión de la Base de Datos:
Puedes realizar copias de seguridad y restauraciones de la base de datos directamente desde el panel.
- **Ruta de la Base de Datos:**
- `/etc/x-ui/x-ui.db`
### Ruta Base Web
1. **Restablecer la Ruta Base Web:**
- Abre tu terminal.
- Ejecuta el comando `x-ui`.
- Selecciona la opción `Restablecer la Ruta Base Web`.
2. **Generar o Personalizar la Ruta:**
- La ruta se generará aleatoriamente, o puedes ingresar una ruta personalizada.
3. **Ver Configuración Actual:**
- Para ver tu configuración actual, utiliza el comando `x-ui settings` en el terminal o selecciona `Ver Configuración Actual` en `x-ui`.
### Recomendación de Seguridad:
- Para mayor seguridad, utiliza una palabra larga y aleatoria en la estructura de tu URL.
**Ejemplos:**
- `http://ip:port/*webbasepath*/panel`
- `http://domain:port/*webbasepath*/panel`
</details>
## Configuración de WARP
<details>
<summary>Haz clic para ver los detalles de la configuración de WARP</summary>
#### Uso
**Para versiones `v2.1.0` y posteriores:**
WARP está integrado, no se requiere instalación adicional. Simplemente habilita la configuración necesaria en el panel.
</details>
## Límite de IP
<details>
<summary>Haz clic para ver los detalles del límite de IP</summary>
#### Uso
**Nota:** El Límite de IP no funcionará correctamente cuando uses Túnel IP.
- **Para versiones hasta `v1.6.1`:**
- El límite de IP está integrado en el panel.
**Para versiones `v1.7.0` y posteriores:**
Para habilitar la funcionalidad de límite de IP, necesitas instalar `fail2ban` y los archivos requeridos siguiendo estos pasos:
1. Ejecuta el comando `x-ui` en el terminal, luego elige `Gestión de Límite de IP`.
2. Verás las siguientes opciones:
- **Cambiar la Duración del Bloqueo:** Ajustar la duración de los bloqueos.
- **Desbloquear a Todos:** Levantar todos los bloqueos actuales.
- **Revisar los Registros:** Revisar los registros.
- **Estado de Fail2ban:** Verificar el estado de `fail2ban`.
- **Reiniciar Fail2ban:** Reiniciar el servicio `fail2ban`.
- **Desinstalar Fail2ban:** Desinstalar Fail2ban con la configuración.
3. Agrega una ruta para el registro de acceso en el panel configurando `Xray Configs/log/Access log` a `./access.log`, luego guarda y reinicia Xray.
- **Para versiones anteriores a `v2.1.3`:**
- Necesitas configurar manualmente la ruta del registro de acceso en tu configuración de Xray:
```sh
"log": {
"access": "./access.log",
"dnsLog": false,
"loglevel": "warning"
},
```
- **Para versiones `v2.1.3` y posteriores:**
- Hay una opción para configurar `access.log` directamente desde el panel.
</details>
## Bot de Telegram
<details>
<summary>Haz clic para más detalles del bot de Telegram</summary>
#### Uso
El panel web admite tráfico diario, inicio de sesión en el panel, copia de seguridad de la base de datos, estado del sistema, información del cliente y otras notificaciones y funciones a través del Bot de Telegram. Para usar el bot, debes establecer los parámetros relacionados con el bot en el panel, que incluyen:
- Token de Telegram
- ID de chat de administrador(es)
- Hora de Notificación (en sintaxis cron)
- Notificación de Fecha de Caducidad
- Notificación de Capacidad de Tráfico
- Copia de seguridad de la base de datos
- Notificación de Carga de CPU
**Sintaxis de referencia:**
- `30 \* \* \* \* \*` - Notifica a los 30s de cada punto
- `0 \*/10 \* \* \* \*` - Notifica en el primer segundo de cada 10 minutos
- `@hourly` - Notificación por hora
- `@daily` - Notificación diaria (00:00 de la mañana)
- `@weekly` - Notificación semanal
- `@every 8h` - Notifica cada 8 horas
### Funcionalidades del Bot de Telegram
- Reporte periódico
- Notificación de inicio de sesión
- Notificación de umbral de CPU
- Umbral de Notificación para Fecha de Caducidad y Tráfico para informar con anticipación
- Soporte para menú de reporte de cliente si el nombre de usuario de Telegram del cliente se agrega a las configuraciones de usuario
- Soporte para reporte de tráfico de Telegram buscado con UUID (VMESS/VLESS) o Contraseña (TROJAN) - anónimamente
- Bot basado en menú
- Buscar cliente por correo electrónico (solo administrador)
- Ver todas las Entradas
- Ver estado del servidor
- Ver clientes agotados
- Recibir copia de seguridad bajo demanda y en informes periódicos
- Bot multilingüe
### Configuración del Bot de Telegram
- Inicia [Botfather](https://t.me/BotFather) en tu cuenta de Telegram:
![Botfather](./media/botfather.png)
- Crea un nuevo bot usando el comando /newbot: Te hará 2 preguntas, Un nombre y un nombre de usuario para tu bot. Ten en cuenta que el nombre de usuario debe terminar con la palabra "bot".
![Create new bot](./media/newbot.png)
- Inicia el bot que acabas de crear. Puedes encontrar el enlace a tu bot aquí.
![token](./media/token.png)
- Ingresa a tu panel y configura los ajustes del bot de Telegram como se muestra a continuación:
![Panel Config](./media/panel-bot-config.png)
Ingresa el token de tu bot en el campo de entrada número 3.
Ingresa el ID de chat de usuario en el campo de entrada número 4. Las cuentas de Telegram con esta ID serán los administradores del bot. (Puedes ingresar más de uno, solo sepáralos con ,)
- ¿Cómo obtener el ID de chat de Telegram? Usa este [bot](https://t.me/useridinfobot), Inicia el bot y te dará el ID de chat del usuario de Telegram.
![User ID](./media/user-id.png)
</details>
## Rutas de API
<details>
<summary>Haz clic para más detalles de las rutas de API</summary>
#### Uso
- [Documentación de API](https://www.postman.com/hsanaei/3x-ui/collection/q1l5l0u/3x-ui)
- `/login` con `POST` datos de usuario: `{username: '', password: ''}` para iniciar sesión
- `/panel/api/inbounds` base para las siguientes acciones:
| Método | Ruta | Acción |
| :----: | ---------------------------------- | --------------------------------------------------------- |
| `GET` | `"/list"` | Obtener todas los Entradas |
| `GET` | `"/get/:id"` | Obtener Entrada con inbound.id |
| `GET` | `"/getClientTraffics/:email"` | Obtener Tráficos del Cliente con email |
| `GET` | `"/createbackup"` | El bot de Telegram envía copia de seguridad a los admins |
| `POST` | `"/add"` | Agregar Entrada |
| `POST` | `"/del/:id"` | Eliminar Entrada |
| `POST` | `"/update/:id"` | Actualizar Entrada |
| `POST` | `"/clientIps/:email"` | Dirección IP del Cliente |
| `POST` | `"/clearClientIps/:email"` | Borrar Dirección IP del Cliente |
| `POST` | `"/addClient"` | Agregar Cliente a la Entrada |
| `POST` | `"/:id/delClient/:clientId"` | Eliminar Cliente por clientId\* |
| `POST` | `"/updateClient/:clientId"` | Actualizar Cliente por clientId\* |
| `POST` | `"/:id/resetClientTraffic/:email"` | Restablecer Tráfico del Cliente |
| `POST` | `"/resetAllTraffics"` | Restablecer tráfico de todos las Entradas |
| `POST` | `"/resetAllClientTraffics/:id"` | Restablecer tráfico de todos los clientes en una Entrada |
| `POST` | `"/delDepletedClients/:id"` | Eliminar clientes agotados de la entrada (-1: todos) |
| `POST` | `"/onlines"` | Obtener usuarios en línea (lista de correos electrónicos) |
\*- El campo `clientId` debe llenarse por:
- `client.id` para VMESS y VLESS
- `client.password` para TROJAN
- `client.email` para Shadowsocks
- [<img src="https://run.pstmn.io/button.svg" alt="Run In Postman" style="width: 128px; height: 32px;">](https://app.getpostman.com/run-collection/5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5%26entityType%3Dcollection%26workspaceId%3Dd64f609f-485a-4951-9b8f-876b3f917124)
</details>
## Variables de Entorno
<details>
<summary>Haz clic para más detalles de las variables de entorno</summary>
#### Uso
| Variable | Tipo | Predeterminado |
| -------------- | :--------------------------------------------: | :------------- |
| XUI_LOG_LEVEL | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` |
| XUI_DEBUG | `boolean` | `false` |
| XUI_BIN_FOLDER | `string` | `"bin"` |
| XUI_DB_FOLDER | `string` | `"/etc/x-ui"` |
| XUI_LOG_FOLDER | `string` | `"/var/log"` |
Ejemplo:
```sh
XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
```
</details>
## Vista previa
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
<img alt="3x-ui" src="./media/01-overview-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-inbounds-dark.png">
<img alt="3x-ui" src="./media/02-inbounds-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-inbound-dark.png">
<img alt="3x-ui" src="./media/03-add-inbound-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/04-add-client-dark.png">
<img alt="3x-ui" src="./media/04-add-client-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/05-settings-dark.png">
<img alt="3x-ui" src="./media/05-settings-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/06-configs-dark.png">
<img alt="3x-ui" src="./media/06-configs-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/07-bot-dark.png">
<img alt="3x-ui" src="./media/07-bot-light.png">
</picture>
## Un agradecimiento especial a
## Un Agradecimiento Especial a
- [alireza0](https://github.com/alireza0/)
## Reconocimientos
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Licencia: **GPL-3.0**): _Reglas de enrutamiento mejoradas de v2ray/xray y v2ray/xray-clients con dominios iraníes integrados y un enfoque en seguridad y bloqueo de anuncios._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _Este repositorio contiene reglas de enrutamiento de V2Ray actualizadas automáticamente basadas en datos de dominios y direcciones bloqueados en Rusia._
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Licencia: **GPL-3.0**): _Reglas de enrutamiento mejoradas para v2ray/xray y v2ray/xray-clients con dominios iraníes incorporados y un enfoque en seguridad y bloqueo de anuncios._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Licencia: **GPL-3.0**): _Este repositorio contiene reglas de enrutamiento V2Ray actualizadas automáticamente basadas en datos de dominios y direcciones bloqueadas en Rusia._
## Estrellas a lo largo del tiempo
## Apoyar el Proyecto
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
**Si este proyecto te es útil, puedes darle una**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Estrellas a lo Largo del Tiempo
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+31 -502
View File
@@ -1,4 +1,4 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
@@ -7,522 +7,51 @@
</picture>
</p>
**یک پنل وب پیشرفته • ساخته شده بر پایه Xray Core**
[![](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](#)
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
> **سلب مسئولیت:** این پروژه صرفاً برای اهداف آموزشی و تحقیقاتی است. استفاده از آن برای مقاصد غیرقانونی یا در محیط‌های عملیاتی ممنوع است.
**3X-UI** — یک پنل کنترل پیشرفته مبتنی بر وب با کد باز که برای مدیریت سرور Xray-core طراحی شده است. این پنل یک رابط کاربری آسان برای پیکربندی و نظارت بر پروتکل‌های مختلف VPN و پراکسی ارائه می‌دهد.
**اگر این پروژه برای شما مفید بوده، می‌توانید با دادن یک**:star2: از آن حمایت کنید.
> [!IMPORTANT]
> این پروژه فقط برای استفاده شخصی و ارتباطات است، لطفاً از آن برای اهداف غیرقانونی استفاده نکنید، لطفاً از آن در محیط تولید استفاده نکنید.
<p align="left">
<a href="https://buymeacoffee.com/mhsanaei" target="_blank">
<img src="./media/buymeacoffe.png" alt="Image">
</a>
</p>
به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گسترده‌تر از پروتکل‌ها و ویژگی‌های اضافی را ارائه می‌دهد.
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC`
- MATIC (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A`
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv`
## نصب و ارتقا
## شروع سریع
```
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
## نصب نسخه‌های قدیمی (توصیه نمی‌شود)
برای مستندات کامل، لطفاً به [ویکی پروژه](https://github.com/MHSanaei/3x-ui/wiki) مراجعه کنید.
برای نصب نسخه خاصی از دستور زیر استفاده کنید. مثال برای نسخه `v1.7.9`:
```
VERSION=v1.7.9 && bash <(curl -Ls "https://raw.githubusercontent.com/mhsanaei/3x-ui/$VERSION/install.sh") $VERSION
```
## گواهی SSL
<details>
<summary>جزئیات گواهی SSL</summary>
### ACME
برای مدیریت گواهی‌های SSL با استفاده از ACME:
1. اطمینان حاصل کنید دامنه شما به درستی به سرور متصل است.
2. دستور `x-ui` را در ترمینال اجرا کرده و گزینه `مدیریت گواهی SSL` را انتخاب کنید.
3. گزینه‌های زیر نمایش داده می‌شوند:
- **دریافت SSL:** دریافت گواهی SSL
- **لغو:** لغو گواهی‌های موجود
- **تمدید اجباری:** تمدید اجباری گواهی‌ها
- **نمایش دامنه‌های موجود:** نمایش تمام دامنه‌های دارای گواهی
- **تنظیم مسیر گواهی برای پنل:** تنظیم مسیر گواهی برای دامنه شما
### Certbot
نصب و استفاده از Certbot:
```sh
apt-get install certbot -y
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d yourdomain.com
certbot renew --dry-run
```
### Cloudflare
اسکریپت داخلی برای دریافت گواهی SSL از Cloudflare. نیازمند:
- ایمیل ثبت‌شده در Cloudflare
- کلید API جهانی Cloudflare
- دامنه باید از طریق Cloudflare به سرور متصل باشد
**دریافت کلید API جهانی Cloudflare:**
1. دستور `x-ui` را اجرا و گزینه `گواهی SSL کلادفلر` را انتخاب کنید.
2. به لینک [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens) مراجعه کنید.
3. روی "View Global API Key" کلیک کنید:
![](media/APIKey1.PNG)
4. پس از احراز هویت، کلید API نمایش داده می‌شود:
![](media/APIKey2.png)
در هنگام استفاده، نام دامنه، ایمیل و کلید API را وارد کنید:
![](media/DetailEnter.png)
</details>
## نصب دستی و ارتقا
<details>
<summary>جزئیات نصب دستی</summary>
#### استفاده
1. دریافت آخرین نسخه از سرور:
```sh
ARCH=$(uname -m)
case "${ARCH}" in
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
i*86 | x86) XUI_ARCH="386" ;;
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
armv7* | armv7) XUI_ARCH="armv7" ;;
armv6* | armv6) XUI_ARCH="armv6" ;;
armv5* | armv5) XUI_ARCH="armv5" ;;
s390x) echo 's390x' ;;
*) XUI_ARCH="amd64" ;;
esac
wget https://github.com/MHSanaei/3x-ui/releases/latest/download/x-ui-linux-${XUI_ARCH}.tar.gz
```
2. نصب یا ارتقا:
```sh
ARCH=$(uname -m)
case "${ARCH}" in
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
i*86 | x86) XUI_ARCH="386" ;;
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
armv7* | armv7) XUI_ARCH="armv7" ;;
armv6* | armv6) XUI_ARCH="armv6" ;;
armv5* | armv5) XUI_ARCH="armv5" ;;
s390x) echo 's390x' ;;
*) XUI_ARCH="amd64" ;;
esac
cd /root/
rm -rf x-ui/ /usr/local/x-ui/ /usr/bin/x-ui
tar zxvf x-ui-linux-${XUI_ARCH}.tar.gz
chmod +x x-ui/x-ui x-ui/bin/xray-linux-* x-ui/x-ui.sh
cp x-ui/x-ui.sh /usr/bin/x-ui
cp -f x-ui/x-ui.service /etc/systemd/system/
mv x-ui/ /usr/local/
systemctl daemon-reload
systemctl enable x-ui
systemctl restart x-ui
```
</details>
## نصب با Docker
<details>
<summary>جزئیات Docker</summary>
#### استفاده
1. **نصب Docker:**
```sh
bash <(curl -sSL https://get.docker.com)
```
2. **کلون پروژه:**
```sh
git clone https://github.com/MHSanaei/3x-ui.git
cd 3x-ui
```
3. **راه‌اندازی سرویس:**
```sh
docker compose up -d
```
یا
```sh
docker run -itd \
-e XRAY_VMESS_AEAD_FORCED=false \
-v $PWD/db/:/etc/x-ui/ \
-v $PWD/cert/:/root/cert/ \
--network=host \
--restart=unless-stopped \
--name 3x-ui \
ghcr.io/mhsanaei/3x-ui:latest
```
4. **به‌روزرسانی:**
```sh
cd 3x-ui
docker compose down
docker compose pull 3x-ui
docker compose up -d
```
5. **حذف:**
```sh
docker stop 3x-ui
docker rm 3x-ui
cd --
rm -r 3x-ui
```
</details>
## تنظیمات Nginx
<details>
<summary>پیکربندی Reverse Proxy</summary>
#### Nginx Reverse Proxy
```nginx
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_redirect off;
proxy_pass http://127.0.0.1:2053;
}
```
#### مسیر فرعی در Nginx
- اطمینان حاصل کنید "URI Path" در تنظیمات پنل یکسان باشد.
- `url` در تنظیمات پنل باید با `/` پایان یابد.
```nginx
location /sub {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_redirect off;
proxy_pass http://127.0.0.1:2053;
}
```
</details>
## سیستم‌عامل‌های توصیه شده
- Ubuntu 22.04+
- Debian 11+
- CentOS 8+
- OpenEuler 22.03+
- Fedora 36+
- Arch Linux
- Parch Linux
- Manjaro
- Armbian
- AlmaLinux 8.0+
- Rocky Linux 8+
- Oracle Linux 8+
- OpenSUSE Tubleweed
- Amazon Linux 2023
- Virtuozzo Linux 8+
- Windows x64
## معماری‌ها و دستگاه‌های پشتیبانی شده
<details>
<summary>جزئیات معماری‌ها و دستگاه‌ها</summary>
- **amd64**: معماری استاندارد برای کامپیوترهای شخصی و سرورها
- **x86 / i386**: سیستم‌های دسکتاپ و لپ‌تاپ
- **armv8 / arm64 / aarch64**: دستگاه‌های موبایل و embedded مانند Raspberry Pi 4
- **armv7 / arm / arm32**: دستگاه‌های قدیمی مانند Orange Pi Zero
- **armv6 / arm / arm32**: دستگاه‌های بسیار قدیمی مانند Raspberry Pi 1
- **armv5 / arm / arm32**: سیستم‌های embedded قدیمی
- **s390x**: کامپیوترهای IBM mainframe
</details>
## زبان‌های پشتیبانی شده
- انگلیسی
- فارسی
- چینی سنتی
- چینی ساده‌شده
- ژاپنی
- روسی
- ویتنامی
- اسپانیایی
- اندونزیایی
- اوکراینی
- ترکی
- پرتغالی (برزیل)
## ویژگی‌ها
- مانیتورینگ وضعیت سیستم
- جستجو در بین inboundها و کلاینت‌ها
- تم تاریک/روشن
- پشتیبانی از چند کاربر و پروتکل
- پروتکل‌های VMESS، VLESS، Trojan، Shadowsocks، Dokodemo-door، Socks، HTTP، WireGuard
- پشتیبانی از XTLS شامل RPRX-Direct، Vision، REALITY
- آمار ترافیک، محدودیت ترافیک، محدودیت زمانی
- تنظیمات سفارشی Xray
- پشتیبانی از HTTPS برای پنل
- دریافت خودکار گواهی SSL
- مسیرهای API اصلاح شده
- پشتیبانی از تغییر تنظیمات از طریق پنل
- امکان export/import دیتابیس
## تنظیمات پیش‌فرض پنل
<details>
<summary>جزئیات تنظیمات پیش‌فرض</summary>
### نام کاربری، رمز عبور، پورت و مسیر وب
در صورت عدم تغییر، این موارد به صورت تصادفی ایجاد می‌شوند (به جز Docker).
**تنظیمات پیش‌فرض Docker:**
- **نام کاربری:** admin
- **رمز عبور:** admin
- **پورت:** 2053
### مدیریت دیتابیس:
امکان Backup و Restore دیتابیس از طریق پنل.
- **مسیر دیتابیس:**
- `/etc/x-ui/x-ui.db`
### مسیر پایه وب
1. **بازنشانی مسیر:**
- اجرای دستور `x-ui`
- انتخاب گزینه `Reset Web Base Path`
2. **ساخت یا تنظیم مسیر:**
- مسیر به صورت تصادفی ساخته شده یا قابل تنظیم است
3. **مشاهده تنظیمات فعلی:**
- استفاده از دستور `x-ui settings` یا `View Current Settings` در `x-ui`
**توصیه امنیتی:**
- استفاده از مسیرهای طولانی و تصادفی برای افزایش امنیت
**مثال:**
- `http://ip:port/*webbasepath*/panel`
- `http://domain:port/*webbasepath*/panel`
</details>
## پیکربندی WARP
<details>
<summary>جزئیات WARP</summary>
#### استفاده
**برای نسخه‌های `v2.1.0` و جدیدتر:**
WARP به صورت داخلی پشتیبانی می‌شود. تنها نیاز به فعال‌سازی در پنل است.
</details>
## محدودیت IP
<details>
<summary>جزئیات محدودیت IP</summary>
#### استفاده
**توجه:** محدودیت IP در صورت استفاده از IP Tunnel کار نمی‌کند.
- **تا نسخه `v1.6.1`:**
- محدودیت IP به صورت داخلی در پنل وجود دارد
**برای نسخه‌های `v1.7.0` و جدیدتر:**
برای فعال‌سازی نیاز به نصب `fail2ban` است:
1. اجرای دستور `x-ui` و انتخاب `مدیریت محدودیت IP`
2. گزینه‌های موجود:
- **تغییر مدت زمان Ban**
- **حذف تمام Banها**
- **مشاهده لاگ‌ها**
- **وضعیت Fail2ban**
- **راه‌اندازی مجدد Fail2ban**
- **حذف Fail2ban**
3. تنظیم مسیر `Access log` در پنل به `./access.log` و ذخیره و راه‌اندازی مجدد Xray
- **قبل از نسخه `v2.1.3`:**
- تنظیم دستی `access.log` در تنظیمات Xray:
```sh
"log": {
"access": "./access.log",
"dnsLog": false,
"loglevel": "warning"
},
```
- **از نسخه `v2.1.3`:**
- امکان تنظیم `access.log` از طریق پنل
</details>
## ربات تلگرام
<details>
<summary>جزئیات ربات تلگرام</summary>
#### استفاده
ربات تلگرام برای اطلاع‌رسانی ترافیک، ورود به پنل، Backup دیتابیس و ... استفاده می‌شود. نیازمند تنظیم:
- توکن تلگرام
- Chat ID ادمین‌ها
- زمان اطلاع‌رسانی (Cron syntax)
- اطلاع‌رسانی انقضا
- اطلاع‌رسانی ترافیک
- Backup دیتابیس
- اطلاع‌رسانی مصرف CPU
**سینتکس نمونه:**
- `30 \* \* \* \* \*` - اطلاع در ثانیه 30 هر دقیقه
- `@hourly` - هر ساعت
- `@daily` - هر روز
### ویژگی‌های ربات
- گزارش دوره‌ای
- اطلاع ورود به پنل
- اطلاع مصرف CPU
- اطلاع پیش‌از موعد انقضا و ترافیک
- گزارش ترافیک کلاینت‌ها
- منوی مبتنی بر دستور
- جستجوی کلاینت بر اساس ایمیل
- بررسی inboundها
- بررسی وضعیت سرور
- دریافت Backup
- چندزبانه
### راه‌اندازی ربات
- شروع [Botfather](https://t.me/BotFather) در تلگرام:
![Botfather](./media/botfather.png)
- ساخت ربات جدید با دستور /newbot:
![Create new bot](./media/newbot.png)
- شروع ربات ساخته شده:
![token](./media/token.png)
- تنظیمات پنل:
![Panel Config](./media/panel-bot-config.png)
وارد کردن توکن و Chat ID (دریافت از [این ربات](https://t.me/useridinfobot)):
![User ID](./media/user-id.png)
</details>
## مسیرهای API
<details>
<summary>جزئیات API</summary>
#### استفاده
- [مستندات API](https://www.postman.com/hsanaei/3x-ui/collection/q1l5l0u/3x-ui)
- `/login` با `POST` داده کاربر: `{username: '', password: ''}`
| Method | مسیر | عملکرد |
| :----: | ---------------------------------- | ------------------------------------------- |
| `GET` | `"/list"` | دریافت تمام inboundها |
| `GET` | `"/get/:id"` | دریافت inbound بر اساس id |
| `POST` | `"/add"` | افزودن inbound |
| `POST` | `"/del/:id"` | حذف inbound |
- [<img src="https://run.pstmn.io/button.svg" alt="Run In Postman" style="width: 128px; height: 32px;">](https://app.getpostman.com/run-collection/5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5%26entityType%3Dcollection%26workspaceId%3Dd64f609f-485a-4951-9b8f-876b3f917124)
</details>
## متغیرهای محیطی
<details>
<summary>جزئیات متغیرها</summary>
#### استفاده
| متغیر | نوع | پیش‌فرض |
| ------------- | :--------------------------------------------: | :------------ |
| XUI_LOG_LEVEL | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` |
| XUI_DEBUG | `boolean` | `false` |
| XUI_BIN_FOLDER| `string` | `"bin"` |
مثال:
```sh
XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
```
</details>
## پیش‌نمایش
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
<img alt="3x-ui" src="./media/01-overview-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-inbounds-dark.png">
<img alt="3x-ui" src="./media/02-inbounds-light.png">
</picture>
## قدردانی ویژه از
## تشکر ویژه از
- [alireza0](https://github.com/alireza0/)
## تشکر و قدردانی
## قدردانی
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (مجوز: **GPL-3.0**)
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (مجوز: **GPL-3.0**)
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (مجوز: **GPL-3.0**): _قوانین مسیریابی بهبود یافته v2ray/xray و v2ray/xray-clients با دامنه‌های ایرانی داخلی و تمرکز بر امنیت و مسدود کردن تبلیغات._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (مجوز: **GPL-3.0**): _این مخزن شامل قوانین مسیریابی V2Ray به‌روزرسانی شده خودکار بر اساس داده‌های دامنه‌ها و آدرس‌های مسدود شده در روسیه است._
## Stargazers over Time
## پشتیبانی از پروژه
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
**اگر این پروژه برای شما مفید است، می‌توانید به آن یک**:star2: بدهید
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## ستاره‌ها در طول زمان
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+27 -567
View File
@@ -1,4 +1,4 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
@@ -7,581 +7,28 @@
</picture>
</p>
**An Advanced Web Panel • Built on Xray Core**
[![](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](#)
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
> **Disclaimer:** This project is only for personal learning and communication, please do not use it for illegal purposes, please do not use it in a production environment
**3X-UI** — advanced, open-source web-based control panel designed for managing Xray-core server. It offers a user-friendly interface for configuring and monitoring various VPN and proxy protocols.
**If this project is helpful to you, you may wish to give it a**:star2:
> [!IMPORTANT]
> This project is only for personal usage, please do not use it for illegal purposes, and please do not use it in a production environment.
<p align="left">
<a href="https://buymeacoffee.com/mhsanaei" target="_blank">
<img src="./media/buymeacoffe.png" alt="Image">
</a>
</p>
As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC`
- MATIC (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A`
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv`
## Quick Start
## Install & Upgrade
```
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
## Install legacy Version (we don't recommend)
To install your desired version, use following installation command. e.g., ver `v1.7.9`:
```
VERSION=v1.7.9 && bash <(curl -Ls "https://raw.githubusercontent.com/mhsanaei/3x-ui/$VERSION/install.sh") $VERSION
```
## SSL Certificate
<details>
<summary>Click for SSL Certificate details</summary>
### ACME
To manage SSL certificates using ACME:
1. Ensure your domain is correctly resolved to the server.
2. Run the `x-ui` command in the terminal, then choose `SSL Certificate Management`.
3. You will be presented with the following options:
- **Get SSL:** Obtain SSL certificates.
- **Revoke:** Revoke existing SSL certificates.
- **Force Renew:** Force renewal of SSL certificates.
- **Show Existing Domains:** Display all domain certificates available on the server.
- **Set Certificate Paths for the Panel:** Specify the certificate for your domain to be used by the panel.
### Certbot
To install and use Certbot:
```sh
apt-get install certbot -y
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d yourdomain.com
certbot renew --dry-run
```
### Cloudflare
The management script includes a built-in SSL certificate application for Cloudflare. To use this script to apply for a certificate, you need the following:
- Cloudflare registered email
- Cloudflare Global API Key
- The domain name must be resolved to the current server through Cloudflare
**How to get the Cloudflare Global API Key:**
1. Run the `x-ui` command in the terminal, then choose `Cloudflare SSL Certificate`.
2. Visit the link: [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens).
3. Click on "View Global API Key" (see the screenshot below):
![](media/APIKey1.PNG)
4. You may need to re-authenticate your account. After that, the API Key will be shown (see the screenshot below):
![](media/APIKey2.png)
When using, just enter your `domain name`, `email`, and `API KEY`. The diagram is as follows:
![](media/DetailEnter.png)
</details>
## Manual Install & Upgrade
<details>
<summary>Click for manual install details</summary>
#### Usage
1. To download the latest version of the compressed package directly to your server, run the following command:
```sh
ARCH=$(uname -m)
case "${ARCH}" in
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
i*86 | x86) XUI_ARCH="386" ;;
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
armv7* | armv7) XUI_ARCH="armv7" ;;
armv6* | armv6) XUI_ARCH="armv6" ;;
armv5* | armv5) XUI_ARCH="armv5" ;;
s390x) echo 's390x' ;;
*) XUI_ARCH="amd64" ;;
esac
wget https://github.com/MHSanaei/3x-ui/releases/latest/download/x-ui-linux-${XUI_ARCH}.tar.gz
```
2. Once the compressed package is downloaded, execute the following commands to install or upgrade x-ui:
```sh
ARCH=$(uname -m)
case "${ARCH}" in
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
i*86 | x86) XUI_ARCH="386" ;;
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
armv7* | armv7) XUI_ARCH="armv7" ;;
armv6* | armv6) XUI_ARCH="armv6" ;;
armv5* | armv5) XUI_ARCH="armv5" ;;
s390x) echo 's390x' ;;
*) XUI_ARCH="amd64" ;;
esac
cd /root/
rm -rf x-ui/ /usr/local/x-ui/ /usr/bin/x-ui
tar zxvf x-ui-linux-${XUI_ARCH}.tar.gz
chmod +x x-ui/x-ui x-ui/bin/xray-linux-* x-ui/x-ui.sh
cp x-ui/x-ui.sh /usr/bin/x-ui
cp -f x-ui/x-ui.service /etc/systemd/system/
mv x-ui/ /usr/local/
systemctl daemon-reload
systemctl enable x-ui
systemctl restart x-ui
```
</details>
## Install with Docker
<details>
<summary>Click for Docker details</summary>
#### Usage
1. **Install Docker:**
```sh
bash <(curl -sSL https://get.docker.com)
```
2. **Clone the Project Repository:**
```sh
git clone https://github.com/MHSanaei/3x-ui.git
cd 3x-ui
```
3. **Start the Service:**
```sh
docker compose up -d
```
Add ```--pull always``` flag to make docker automatically recreate container if a newer image is pulled. See https://docs.docker.com/reference/cli/docker/container/run/#pull for more info.
**OR**
```sh
docker run -itd \
-e XRAY_VMESS_AEAD_FORCED=false \
-v $PWD/db/:/etc/x-ui/ \
-v $PWD/cert/:/root/cert/ \
--network=host \
--restart=unless-stopped \
--name 3x-ui \
ghcr.io/mhsanaei/3x-ui:latest
```
4. **Update to the Latest Version:**
```sh
cd 3x-ui
docker compose down
docker compose pull 3x-ui
docker compose up -d
```
5. **Remove 3x-ui from Docker:**
```sh
docker stop 3x-ui
docker rm 3x-ui
cd --
rm -r 3x-ui
```
</details>
## Nginx Settings
<details>
<summary>Click for Reverse Proxy Configuration</summary>
#### Nginx Reverse Proxy
```nginx
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_redirect off;
proxy_pass http://127.0.0.1:2053;
}
```
#### Nginx sub-path
- Ensure that the "URI Path" in the `/sub` panel settings is the same.
- The `url` in the panel settings needs to end with `/`.
```nginx
location /sub {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_redirect off;
proxy_pass http://127.0.0.1:2053;
}
```
</details>
## Recommended OS
- Ubuntu 22.04+
- Debian 11+
- CentOS 8+
- OpenEuler 22.03+
- Fedora 36+
- Arch Linux
- Parch Linux
- Manjaro
- Armbian
- AlmaLinux 8.0+
- Rocky Linux 8+
- Oracle Linux 8+
- OpenSUSE Tubleweed
- Amazon Linux 2023
- Virtuozzo Linux 8+
- Windows x64
## Supported Architectures and Devices
<details>
<summary>Click for Supported Architectures and devices details</summary>
Our platform offers compatibility with a diverse range of architectures and devices, ensuring flexibility across various computing environments. The following are key architectures that we support:
- **amd64**: This prevalent architecture is the standard for personal computers and servers, accommodating most modern operating systems seamlessly.
- **x86 / i386**: Widely adopted in desktop and laptop computers, this architecture enjoys broad support from numerous operating systems and applications, including but not limited to Windows, macOS, and Linux systems.
- **armv8 / arm64 / aarch64**: Tailored for contemporary mobile and embedded devices, such as smartphones and tablets, this architecture is exemplified by devices like Raspberry Pi 4, Raspberry Pi 3, Raspberry Pi Zero 2/Zero 2 W, Orange Pi 3 LTS, and more.
- **armv7 / arm / arm32**: Serving as the architecture for older mobile and embedded devices, it remains widely utilized in devices like Orange Pi Zero LTS, Orange Pi PC Plus, Raspberry Pi 2, among others.
- **armv6 / arm / arm32**: Geared towards very old embedded devices, this architecture, while less prevalent, is still in use. Devices such as Raspberry Pi 1, Raspberry Pi Zero/Zero W, rely on this architecture.
- **armv5 / arm / arm32**: An older architecture primarily associated with early embedded systems, it is less common today but may still be found in legacy devices like early Raspberry Pi versions and some older smartphones.
- **s390x**: This architecture is commonly used in IBM mainframe computers and offers high performance and reliability for enterprise workloads.
</details>
## Languages
- English
- Persian
- Traditional Chinese
- Simplified Chinese
- Japanese
- Russian
- Vietnamese
- Spanish
- Indonesian
- Ukrainian
- Turkish
- Português (Brazil)
## Features
- System Status Monitoring
- Search within all inbounds and clients
- Dark/Light theme
- Supports multi-user and multi-protocol
- Supports protocols, including VMESS, VLESS, Trojan, Shadowsocks, Dokodemo-door, Socks, HTTP, wireguard
- Supports XTLS native Protocols, including RPRX-Direct, Vision, REALITY
- Traffic statistics, traffic limit, expiration time limit
- Customizable Xray configuration templates
- Supports HTTPS access panel (self-provided domain name + SSL certificate)
- Supports One-Click SSL certificate application and automatic renewal
- For more advanced configuration items, please refer to the panel
- Fixes API routes (user setting will be created with API)
- Supports changing configs by different items provided in the panel.
- Supports export/import database from the panel
## Default Panel Settings
<details>
<summary>Click for default settings details</summary>
### Username, Password, Port, and Web Base Path
If you choose not to modify these settings, they will be generated randomly (this does not apply to Docker).
**Default Settings for Docker:**
- **Username:** admin
- **Password:** admin
- **Port:** 2053
### Database Management:
You can conveniently perform database Backups and Restores directly from the panel.
- **Database Path:**
- `/etc/x-ui/x-ui.db`
### Web Base Path
1. **Reset Web Base Path:**
- Open your terminal.
- Run the `x-ui` command.
- Select the option to `Reset Web Base Path`.
2. **Generate or Customize Path:**
- The path will be randomly generated, or you can enter a custom path.
3. **View Current Settings:**
- To view your current settings, use the `x-ui settings` command in the terminal or `View Current Settings` in `x-ui`
### Security Recommendation:
- For enhanced security, use a long, random word in your URL structure.
**Examples:**
- `http://ip:port/*webbasepath*/panel`
- `http://domain:port/*webbasepath*/panel`
</details>
## WARP Configuration
<details>
<summary>Click for WARP configuration details</summary>
#### Usage
**For versions `v2.1.0` and later:**
WARP is built-in, and no additional installation is required. Simply turn on the necessary configuration in the panel.
</details>
## IP Limit
<details>
<summary>Click for IP limit details</summary>
#### Usage
**Note:** IP Limit won't work correctly when using IP Tunnel.
- **For versions up to `v1.6.1`:**
- The IP limit is built-in to the panel
**For versions `v1.7.0` and newer:**
To enable the IP Limit functionality, you need to install `fail2ban` and its required files by following these steps:
1. Run the `x-ui` command in the terminal, then choose `IP Limit Management`.
2. You will see the following options:
- **Change Ban Duration:** Adjust the duration of bans.
- **Unban Everyone:** Lift all current bans.
- **Check Logs:** Review the logs.
- **Fail2ban Status:** Check the status of `fail2ban`.
- **Restart Fail2ban:** Restart the `fail2ban` service.
- **Uninstall Fail2ban:** Uninstall Fail2ban with configuration.
3. Add a path for the access log on the panel by setting `Xray Configs/log/Access log` to `./access.log` then save and restart xray.
- **For versions before `v2.1.3`:**
- You need to set the access log path manually in your Xray configuration:
```sh
"log": {
"access": "./access.log",
"dnsLog": false,
"loglevel": "warning"
},
```
- **For versions `v2.1.3` and newer:**
- There is an option for configuring `access.log` directly from the panel.
</details>
## Telegram Bot
<details>
<summary>Click for Telegram bot details</summary>
#### Usage
The web panel supports daily traffic, panel login, database backup, system status, client info, and other notification and functions through the Telegram Bot. To use the bot, you need to set the bot-related parameters in the panel, including:
- Telegram Token
- Admin Chat ID(s)
- Notification Time (in cron syntax)
- Expiration Date Notification
- Traffic Cap Notification
- Database Backup
- CPU Load Notification
**Reference syntax:**
- `30 \* \* \* \* \*` - Notify at the 30s of each point
- `0 \*/10 \* \* \* \*` - Notify at the first second of each 10 minutes
- `@hourly` - Hourly notification
- `@daily` - Daily notification (00:00 in the morning)
- `@weekly` - weekly notification
- `@every 8h` - Notify every 8 hours
### Telegram Bot Features
- Report periodic
- Login notification
- CPU threshold notification
- Threshold for Expiration time and Traffic to report in advance
- Support client report menu if client's telegram username added to the user's configurations
- Support telegram traffic report searched with UUID (VMESS/VLESS) or Password (TROJAN) - anonymously
- Menu-based bot
- Search client by email (only admin)
- Check all inbounds
- Check server status
- Check depleted users
- Receive backup by request and in periodic reports
- Multi-language bot
### Setting up Telegram bot
- Start [Botfather](https://t.me/BotFather) in your Telegram account:
![Botfather](./media/botfather.png)
- Create a new Bot using /newbot command: It will ask you 2 questions, A name and a username for your bot. Note that the username has to end with the word "bot".
![Create new bot](./media/newbot.png)
- Start the bot you've just created. You can find the link to your bot here.
![token](./media/token.png)
- Enter your panel and config Telegram bot settings like below:
![Panel Config](./media/panel-bot-config.png)
Enter your bot token in input field number 3.
Enter the user ID in input field number 4. The Telegram accounts with this id will be the bot admin. (You can enter more than one, Just separate them with ,)
- How to get Telegram user ID? Use this [bot](https://t.me/useridinfobot), Start the bot and it will give you the Telegram user ID.
![User ID](./media/user-id.png)
</details>
## API Routes
<details>
<summary>Click for API routes details</summary>
#### Usage
- [API Documentation](https://www.postman.com/hsanaei/3x-ui/collection/q1l5l0u/3x-ui)
- `/login` with `POST` user data: `{username: '', password: ''}` for login
- `/panel/api/inbounds` base for following actions:
| Method | Path | Action |
| :----: | ---------------------------------- | ------------------------------------------- |
| `GET` | `"/list"` | Get all inbounds |
| `GET` | `"/get/:id"` | Get inbound with inbound.id |
| `GET` | `"/getClientTraffics/:email"` | Get Client Traffics with email |
| `GET` | `"/getClientTrafficsById/:id"` | Get client's traffic By ID |
| `GET` | `"/createbackup"` | Telegram bot sends backup to admins |
| `POST` | `"/add"` | Add inbound |
| `POST` | `"/del/:id"` | Delete Inbound |
| `POST` | `"/update/:id"` | Update Inbound |
| `POST` | `"/clientIps/:email"` | Client Ip address |
| `POST` | `"/clearClientIps/:email"` | Clear Client Ip address |
| `POST` | `"/addClient"` | Add Client to inbound |
| `POST` | `"/:id/delClient/:clientId"` | Delete Client by clientId\* |
| `POST` | `"/updateClient/:clientId"` | Update Client by clientId\* |
| `POST` | `"/:id/resetClientTraffic/:email"` | Reset Client's Traffic |
| `POST` | `"/resetAllTraffics"` | Reset traffics of all inbounds |
| `POST` | `"/resetAllClientTraffics/:id"` | Reset traffics of all clients in an inbound |
| `POST` | `"/delDepletedClients/:id"` | Delete inbound depleted clients (-1: all) |
| `POST` | `"/onlines"` | Get Online users ( list of emails ) |
\*- The field `clientId` should be filled by:
- `client.id` for VMESS and VLESS
- `client.password` for TROJAN
- `client.email` for Shadowsocks
- [<img src="https://run.pstmn.io/button.svg" alt="Run In Postman" style="width: 128px; height: 32px;">](https://app.getpostman.com/run-collection/5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5%26entityType%3Dcollection%26workspaceId%3Dd64f609f-485a-4951-9b8f-876b3f917124)
</details>
## Environment Variables
<details>
<summary>Click for environment variables details</summary>
#### Usage
| Variable | Type | Default |
| -------------- | :--------------------------------------------: | :------------ |
| XUI_LOG_LEVEL | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` |
| XUI_DEBUG | `boolean` | `false` |
| XUI_BIN_FOLDER | `string` | `"bin"` |
| XUI_DB_FOLDER | `string` | `"/etc/x-ui"` |
| XUI_LOG_FOLDER | `string` | `"/var/log"` |
Example:
```sh
XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
```
</details>
## Preview
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
<img alt="3x-ui" src="./media/01-overview-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-inbounds-dark.png">
<img alt="3x-ui" src="./media/02-inbounds-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-inbound-dark.png">
<img alt="3x-ui" src="./media/03-add-inbound-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/04-add-client-dark.png">
<img alt="3x-ui" src="./media/04-add-client-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/05-settings-dark.png">
<img alt="3x-ui" src="./media/05-settings-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/06-configs-dark.png">
<img alt="3x-ui" src="./media/06-configs-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/07-bot-dark.png">
<img alt="3x-ui" src="./media/07-bot-light.png">
</picture>
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
## A Special Thanks to
@@ -592,6 +39,19 @@ XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _This repository contains automatically updated V2Ray routing rules based on data on blocked domains and addresses in Russia._
## Support project
**If this project is helpful to you, you may wish to give it a**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Stargazers over Time
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+29 -567
View File
@@ -1,4 +1,4 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
@@ -7,579 +7,28 @@
</picture>
</p>
**Продвинутая веб-панель • Построена на основе Xray Core**
[![](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](#)
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
> **Отказ от ответственности:** Этот проект предназначен только для личного обучения и общения. Пожалуйста, не используйте его в незаконных целях и не применяйте в производственной среде.
**3X-UI** — продвинутая панель управления с открытым исходным кодом на основе веб-интерфейса, разработанная для управления сервером Xray-core. Предоставляет удобный интерфейс для настройки и мониторинга различных VPN и прокси-протоколов.
**Если этот проект оказался полезным для вас, вы можете оценить его, поставив звёздочку** :star2:
> [!IMPORTANT]
> Этот проект предназначен только для личного использования, пожалуйста, не используйте его в незаконных целях и в производственной среде.
<p align="left">
<a href="https://buymeacoffee.com/mhsanaei" target="_blank">
<img src="./media/buymeacoffe.png" alt="Image">
</a>
</p>
Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции.
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC`
- MATIC (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A`
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv`
## Установка и обновление
## Быстрый старт
```
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
## Установить старую версию (мы не рекомендуем)
Чтобы установить желаемую версию, используйте следующую команду установки. Например, ver `v1.7.9`:
```
VERSION=v1.7.9 && <(curl -Ls "https://raw.githubusercontent.com/mhsanaei/3x-ui/$VERSION/install.sh") $VERSION
```
## SSL Сертификат
<details>
<summary>Нажмите для получения информации об SSL сертификате</summary>
### ACME
Для управления SSL сертификатами с помощью ACME:
1. Убедитесь, что ваш домен правильно настроен и указывает на сервер.
2. Выполните команду `x-ui` в терминале, затем выберите `SSL Certificate Management`.
3. Вам будут предложены следующие опции:
- **Get SSL:** Получить SSL сертификаты.
- **Revoke:** Отозвать существующие SSL сертификаты.
- **Force Renew:** Принудительно перевыпустить SSL сертификаты.
- **Show Existing Domains:** Отобразить все сертификаты доменов, доступные на сервере.
- **Set Certificate Paths for the Panel:** Укажите сертификат для вашего домена, который будет использоваться панелью.
### Certbot
Для установки и использования Certbot:
```sh
apt-get install certbot -y
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d вашдомен.com
certbot renew --dry-run
```
### Cloudflare
Скрипт управления включает встроенное приложение для получения SSL сертификата через Cloudflare. Чтобы использовать этот скрипт для запроса сертификата, вам потребуется следующее:
- Email, зарегистрированный в Cloudflare
- Глобальный API-ключ Cloudflare
- Доменное имя должно указывать на текущий сервер через Cloudflare
**Как получить глобальный API-ключ Cloudflare:**
1. Выполните команду `x-ui` в терминале, затем выберите `Cloudflare SSL Certificate`.
2. Перейдите по ссылке: [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens).
3. Нажмите на "View Global API Key" (см. скриншот ниже):
![](media/APIKey1.PNG)
4. Возможно, вам потребуется повторно пройти аутентификацию. После этого ключ API будет отображён (см. скриншот ниже):
![](media/APIKey2.png)
При использовании просто введите ваше `доменное имя`, `email` и `API-ключ`. Схема приведена ниже:
![](media/DetailEnter.png)
</details>
## Ручная установка и обновление
<details>
<summary>Нажмите для получения информации о ручной установке</summary>
#### Использование
1. Чтобы скачать последнюю версию архива напрямую на ваш сервер, выполните следующую команду:
```sh
ARCH=$(uname -m)
case "${ARCH}" in
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
i*86 | x86) XUI_ARCH="386" ;;
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
armv7* | armv7) XUI_ARCH="armv7" ;;
armv6* | armv6) XUI_ARCH="armv6" ;;
armv5* | armv5) XUI_ARCH="armv5" ;;
s390x) echo 's390x' ;;
*) XUI_ARCH="amd64" ;;
esac
wget https://github.com/MHSanaei/3x-ui/releases/latest/download/x-ui-linux-${XUI_ARCH}.tar.gz
```
2. После загрузки архива выполните следующие команды для установки или обновления x-ui:
```sh
ARCH=$(uname -m)
case "${ARCH}" in
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
i*86 | x86) XUI_ARCH="386" ;;
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
armv7* | armv7) XUI_ARCH="armv7" ;;
armv6* | armv6) XUI_ARCH="armv6" ;;
armv5* | armv5) XUI_ARCH="armv5" ;;
s390x) echo 's390x' ;;
*) XUI_ARCH="amd64" ;;
esac
cd /root/
rm -rf x-ui/ /usr/local/x-ui/ /usr/bin/x-ui
tar zxvf x-ui-linux-${XUI_ARCH}.tar.gz
chmod +x x-ui/x-ui x-ui/bin/xray-linux-* x-ui/x-ui.sh
cp x-ui/x-ui.sh /usr/bin/x-ui
cp -f x-ui/x-ui.service /etc/systemd/system/
mv x-ui/ /usr/local/
systemctl daemon-reload
systemctl enable x-ui
systemctl restart x-ui
```
</details>
## Установка с помощью Docker
<details>
<summary>Нажмите для получения информации о Docker</summary>
#### Использование
1. **Установите Docker:**
```sh
bash <(curl -sSL https://get.docker.com)
```
2. **Склонируйте репозиторий проекта:**
```sh
git clone https://github.com/MHSanaei/3x-ui.git
cd 3x-ui
```
3. **Запустите сервис:**
```sh
docker compose up -d
```
Добавьте параметр ```--pull always``` для автоматического обновления контейнера, когда публикуется новый образ. Подробности: https://docs.docker.com/reference/cli/docker/container/run/#pull
**ИЛИ**
```sh
docker run -itd \
-e XRAY_VMESS_AEAD_FORCED=false \
-v $PWD/db/:/etc/x-ui/ \
-v $PWD/cert/:/root/cert/ \
--network=host \
--restart=unless-stopped \
--name 3x-ui \
ghcr.io/mhsanaei/3x-ui:latest
```
4. **Обновление до последней версии:**
```sh
cd 3x-ui
docker compose down
docker compose pull 3x-ui
docker compose up -d
```
5. **Удаление 3x-ui из Docker:**
```sh
docker stop 3x-ui
docker rm 3x-ui
cd --
rm -r 3x-ui
```
</details>
## Настройки Nginx
<details>
<summary>Нажмите чтобы просмотреть конфигурацию обратного прокси-сервера</summary>
#### Обратный прокси-сервер Nginx
```nginx
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_redirect off;
proxy_pass http://127.0.0.1:2053;
}
```
#### Nginx sub-path
- Убедитесь, что "корневой путь URL адреса панели" в настройках панели и `/sub` совпадают.
- В настройках панели `url` должен заканчиваться на `/`.
```nginx
location /sub {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_redirect off;
proxy_pass http://127.0.0.1:2053;
}
```
</details>
## Рекомендуемые ОС
- Ubuntu 22.04+
- Debian 11+
- CentOS 8+
- OpenEuler 22.03+
- Fedora 36+
- Arch Linux
- Parch Linux
- Manjaro
- Armbian
- AlmaLinux 8.0+
- Rocky Linux 8+
- Oracle Linux 8+
- OpenSUSE Tubleweed
- Amazon Linux 2023
- Virtuozzo Linux 8+
- Windows x64
## Поддерживаемые архитектуры и устройства
<details>
<summary>Нажмите для получения информации о поддерживаемых архитектурах и устройствах</summary>
Наша платформа поддерживает разнообразные архитектуры и устройства, обеспечивая гибкость в различных вычислительных средах. Вот основные архитектуры, которые мы поддерживаем:
- **amd64**: Эта распространенная архитектура является стандартом для персональных компьютеров и серверов, обеспечивая беспроблемную работу большинства современных операционных систем.
- **x86 / i386**: Широко используется в настольных и портативных компьютерах. Эта архитектура имеет широкую поддержку со стороны множества операционных систем и приложений, включая, но не ограничиваясь, Windows, macOS и Linux.
- **armv8 / arm64 / aarch64**: Предназначена для современных мобильных и встроенных устройств, таких как смартфоны и планшеты. Эта архитектура представлена устройствами, такими как Raspberry Pi 4, Raspberry Pi 3, Raspberry Pi Zero 2/Zero 2 W, Orange Pi 3 LTS и другими.
- **armv7 / arm / arm32**: Служит архитектурой для старых мобильных и встроенных устройств, оставаясь широко используемой в таких устройствах, как Orange Pi Zero LTS, Orange Pi PC Plus, Raspberry Pi 2 и других.
- **armv6 / arm / arm32**: Ориентирована на очень старые встроенные устройства, эта архитектура, хотя и менее распространенная, всё ещё используется. Например, такие устройства, как Raspberry Pi 1, Raspberry Pi Zero/Zero W, полагаются на эту архитектуру.
- **armv5 / arm / arm32**: Более старая архитектура, ассоциируемая с ранними встроенными системами, сегодня менее распространена, но всё ещё может быть найдена в устаревших устройствах, таких как ранние версии Raspberry Pi и некоторые старые смартфоны.
- **s390x**: Эта архитектура обычно используется в мейнфреймах IBM и обеспечивает высокую производительность и надежность для корпоративных рабочих нагрузок.
</details>
## Языки
- English (английский)
- Persian (персидский)
- Traditional Chinese (традиционный китайский)
- Simplified Chinese (упрощенный китайский)
- Japanese (японский)
- Russian (русский)
- Vietnamese (вьетнамский)
- Spanish (испанский)
- Indonesian (индонезийский)
- Ukrainian (украинский)
- Turkish (турецкий)
- Português (Brazil) (португальский (Бразилия))
## Возможности
- Мониторинг состояния системы
- Поиск по всем входящим подключениям и клиентам
- Тёмная/светлая тема
- Поддержка нескольких пользователей и протоколов
- Поддержка протоколов, включая VMESS, VLESS, Trojan, Shadowsocks, Dokodemo-door, Socks, HTTP, WireGuard
- Поддержка протоколов XTLS, включая RPRX-Direct, Vision, REALITY
- Статистика трафика, ограничение трафика, ограничение по времени истечения
- Настраиваемые шаблоны конфигурации Xray
- Поддержка HTTPS доступа к панели (ваше доменное имя + SSL сертификат)
- Поддержка установки SSL-сертификата в один клик и автоматического перевыпуска
- Для получения более продвинутых настроек обращайтесь к панели
- Исправляет маршруты API (настройка пользователя будет создана через API)
- Поддержка изменения конфигураций по различным элементам, предоставленным в панели
- Поддержка экспорта/импорта базы данных из панели
## Настройки панели по умолчанию
<details>
<summary>Нажмите для получения информации о настройках по умолчанию</summary>
### Имя пользователя, Пароль, Порт и Web Base Path
Если вы не измените эти настройки, они будут сгенерированы случайным образом (это не относится к Docker).
**Настройки по умолчанию для Docker:**
- **Имя пользователя:** admin
- **Пароль:** admin
- **Порт:** 2053
### Управление базой данных:
Вы можете удобно выполнять резервное копирование и восстановление базы данных прямо из панели.
- **Путь к базе данных:**
- `/etc/x-ui/x-ui.db`
### Webbasepath
1. **Сбросить webbasepath:**
- Откройте терминал.
- Выполните команду `x-ui`.
- Выберите опцию `Reset Web Base Path`.
2. **Генерация или настройка пути:**
- Путь будет сгенерирован случайным образом, или вы можете ввести собственный путь.
3. **Просмотр текущих настроек:**
- Чтобы просмотреть текущие настройки, используйте команду `x-ui settings` в терминале или опцию `View Current Settings` в `x-ui`.
### Рекомендации по безопасности:
- Для повышения безопасности используйте длинное случайное слово в структуре вашего URL.
**Примеры:**
- `http://ip_адрес:порт/*webbasepath*/panel`
- `http://домен:порт/*webbasepath*/panel`
</details>
## Настройка WARP
<details>
<summary>Нажмите для получения информации о настройке WARP</summary>
#### Использование
**Для версий `v2.1.0` и новее:**
WARP встроен, и дополнительная установка не требуется. Просто включите необходимую конфигурацию в панели.
</details>
## Ограничение IP
<details>
<summary>Нажмите для получения информации об ограничении IP</summary>
#### Использование
**Примечание:** Ограничение IP не будет работать корректно при использовании IP Tunnel.
- **Для версий до `v1.6.1`:**
- Ограничение IP встроено в панель.
**Для версий `v1.7.0` и новее:**
Чтобы включить функциональность ограничения IP, вам нужно установить `fail2ban` и его необходимые файлы, выполнив следующие шаги:
1. Выполните команду `x-ui` в терминале, затем выберите `IP Limit Management`.
2. Вам будут предложены следующие опции:
- **Change Ban Duration:** Отрегулировать длительность блокировок.
- **Unban Everyone:** Снять все текущие блокировки.
- **Check Logs:** Просмотреть логи.
- **Fail2ban Status:** Проверить статус `fail2ban`.
- **Restart Fail2ban:** Перезапустить службу `fail2ban`.
- **Uninstall Fail2ban:** Удалить Fail2ban с его конфигурацией.
3. Добавьте путь к логам доступа в панели, установив `Xray Configs/log/Access log` в `./access.log`, затем сохраните и перезапустите xray.
- **Для версий до `v2.1.3`:**
- Вам нужно вручную установить путь к логам доступа в вашей конфигурации Xray:
```sh
"log": {
"access": "./access.log",
"dnsLog": false,
"loglevel": "warning"
},
```
- **Для версий `v2.1.3` и новее:**
- Есть возможность настройки `access.log` непосредственно из панели.
</details>
## Телеграм-бот
<details>
<summary>Нажмите для получения информации о телеграм-боте</summary>
#### Использование
Веб-панель поддерживает уведомления и функции, такие как ежедневный трафик, вход в панель, резервное копирование базы данных, состояние системы, информация о клиентах и другие, через телеграм-бота. Чтобы использовать бота, вам нужно настроить параметры, связанные с ботом, в панели, включая:
- Токен Telegram
- ID чата админа(-ов)
- Время уведомлений (в синтаксисе cron)
- Уведомления о дате истечения
- Уведомления о лимите трафика
- Резервное копирование базы данных
- Уведомления о загрузке CPU
**Примеры синтаксиса:**
- `30 * * * * *` - Уведомлять на 30-й секунде каждого часа
- `0 */10 * * * *` - Уведомлять на первой секунде каждых 10 минут
- `@hourly` - Ежечасное уведомление
- `@daily` - Ежедневное уведомление (в 00:00)
- `@weekly` - Еженедельное уведомление
- `@every 8h` - Уведомлять каждые 8 часов
### Возможности телеграм-бота
- Периодические отчеты
- Уведомления о входе
- Уведомления о пороге загруженности процессора
- Уведомления о времени истечения и трафике заранее
- Поддерживает меню отчетов клиента, если имя пользователя телеграм клиента добавлено в конфигурации пользователя
- Поддержка отчета о трафике через Telegram, поиск по UUID (VMESS/VLESS) или паролю (TROJAN) - анонимно
- Бот, основанный на меню
- Поиск клиента по email (только администратор)
- Проверка всех входящих соединений
- Проверка состояния сервера
- Проверка истекших пользователей
- Получение резервных копий по запросу и в периодических отчётах
- Многоязычный бот
### Настройка телеграм-бота
- Запустите [Botfather](https://t.me/BotFather) в вашем аккаунте Telegram:
![Botfather](./media/botfather.png)
- Создайте нового бота с помощью команды /newbot: у вас спросят 2 вопроса: отображаемое имя и имя пользователя для вашего бота. Обратите внимание, что имя пользователя должно заканчиваться на слово "bot".
![Создать нового бота](./media/newbot.png)
- Запустите созданного бота. Ссылку на вашего бота можно найти здесь.
![токен](./media/token.png)
- Перейдите в панель и настройте параметры телеграм-бота следующим образом:
![Настройки панели](./media/panel-bot-config.png)
Введите токен вашего бота в поле ввода номер 3.
Введите ID пользователя в поле ввода номер 4. Telegram-аккаунты с этим ID будут администраторами бота. (Вы можете ввести несколько ID, разделяя их запятой)
- Как получить ID пользователя Telegram? Используйте этот [бот](https://t.me/useridinfobot). Запустите бота, и он отобразит ваш ID пользователя Telegram.
![ID пользователя](./media/user-id.png)
</details>
## Маршруты API
<details>
<summary>Нажмите для получения информации о маршрутах API</summary>
#### Использование
- [API документация](https://www.postman.com/hsanaei/3x-ui/collection/q1l5l0u/3x-ui)
- `/login` с `POST`-данными: `{username: '', password: ''}` для входа
- `/panel/api/inbounds` это базовый путь для следующих действий:
| Метод | Путь | Действие
| :----: | -----------------------------------| -------------------------------------------
| `GET` | `"/list"` | Получить все входящие соединения
| `GET` | `"/get/:id"` | Получить входящее соединение с inbound.id
| `GET` | `"/getClientTraffics/:email"` | Получить трафик клиента по email
| `GET` | `"/getClientTrafficsById/:id"` | Получить трафик клиента по ID
| `GET` | `"/createbackup"` | Telegram-бот отправит резервную копию администраторам
| `POST` | `"/add"` | Добавить входящее соединение
| `POST` | `"/del/:id"` | Удалить входящее соединение
| `POST` | `"/update/:id"` | Обновить входящее соединение
| `POST` | `"/clientIps/:email"` | IP-адрес клиента
| `POST` | `"/clearClientIps/:email"` | Очистить IP-адреса клиента
| `POST` | `"/addClient"` | Добавить клиента к входящему соединению
| `POST` | `"/:id/delClient/:clientId"` | Удалить клиента по clientId\*
| `POST` | `"/updateClient/:clientId"` | Обновить клиента по clientId\*
| `POST` | `"/:id/resetClientTraffic/:email"` | Сбросить трафик клиента
| `POST` | `"/resetAllTraffics"` | Сбросить трафик всех входящих соединений
| `POST` | `"/resetAllClientTraffics/:id"` | Сбросить трафик всех клиентов в входящем соединении
| `POST` | `"/delDepletedClients/:id"` | Удалить истекших клиентов в входящем соединении (-1: всех)
| `POST` | `"/onlines"` | Получить пользователей, которые находятся онлайн (список email'ов)
\*- Поле `clientId` должно быть заполнено следующим образом:
- `client.id` для VMESS и VLESS
- `client.password` для TROJAN
- `client.email` для Shadowsocks
</details>
- [<img src="https://run.pstmn.io/button.svg" alt="Run In Postman" style="width: 128px; height: 32px;">](https://app.getpostman.com/run-collection/5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5%26entityType%3Dcollection%26workspaceId%3Dd64f609f-485a-4951-9b8f-876b3f917124)
</details>
## Переменные среды
<details>
<summary>Нажмите для получения информации о переменных среды</summary>
#### Использование
| Переменная | Тип | Значение по умолчанию |
| ---------------- | :------------------------------------------: | :-------------------- |
| XUI_LOG_LEVEL | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` |
| XUI_DEBUG | `boolean` | `false` |
| XUI_BIN_FOLDER | `string` | `"bin"` |
| XUI_DB_FOLDER | `string` | `"/etc/x-ui"` |
| XUI_LOG_FOLDER | `string` | `"/var/log"` |
Пример:
```sh
XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
```
</details>
## Предварительный Просмотр
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
<img alt="3x-ui" src="./media/01-overview-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-inbounds-dark.png">
<img alt="3x-ui" src="./media/02-inbounds-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-inbound-dark.png">
<img alt="3x-ui" src="./media/03-add-inbound-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/04-add-client-dark.png">
<img alt="3x-ui" src="./media/04-add-client-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/05-settings-dark.png">
<img alt="3x-ui" src="./media/05-settings-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/06-configs-dark.png">
<img alt="3x-ui" src="./media/06-configs-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/07-bot-dark.png">
<img alt="3x-ui" src="./media/07-bot-light.png">
</picture>
Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki).
## Особая благодарность
@@ -587,9 +36,22 @@ XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
## Благодарности
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _Этот репозиторий содержит автоматически обновляемые правила маршрутизации V2Ray на основе данных о заблокированных доменах и адресах в России._
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Лицензия: **GPL-3.0**): _Улучшенные правила маршрутизации для v2ray/xray и v2ray/xray-clients со встроенными иранскими доменами и фокусом на безопасность и блокировку рекламы._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Лицензия: **GPL-3.0**): _Этот репозиторий содержит автоматически обновляемые правила маршрутизации V2Ray на основе данных о заблокированных доменах и адресах в России._
## Число звёзд со временем
## Поддержка проекта
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
**Если этот проект полезен для вас, вы можете поставить ему**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Звезды с течением времени
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+29 -560
View File
@@ -1,4 +1,4 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
@@ -7,572 +7,28 @@
</picture>
</p>
**一个更好的面板 • 基于Xray Core构建**
[![](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](#)
[![Release](https://img.shields.io/github/v/release/mhsanaei/3x-ui.svg)](https://github.com/MHSanaei/3x-ui/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg)](https://github.com/MHSanaei/3x-ui/actions)
[![GO Version](https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](#)
[![Downloads](https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg)](https://github.com/MHSanaei/3x-ui/releases/latest)
[![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg)](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
[![Go Report Card](https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3)](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
> **Disclaimer:** 此项目仅供个人学习交流,请不要用于非法目的,请不要在生产环境中使用
**3X-UI** — 一个基于网页的高级开源控制面板,专为管理 Xray-core 服务器而设计。它提供了用户友好的界面,用于配置和监控各种 VPN 和代理协议
**如果此项目对你有用,请给一个**:star2:
> [!IMPORTANT]
> 本项目仅用于个人使用和通信,请勿将其用于非法目的,请勿在生产环境中使用。
<p align="left">
<a href="https://buymeacoffee.com/mhsanaei" target="_blank">
<img src="./media/buymeacoffe.png" alt="Image">
</a>
</p>
作为原始 X-UI 项目的增强版本,3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC`
- MATIC (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A`
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv`
## 安装 & 升级
## 快速开始
```
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
## 安装旧版本 (我们不建议)
要安装您想要的版本,请使用以下安装命令。例如,ver `v1.7.9`:
```
VERSION=v1.7.9 && <(curl -Ls "https://raw.githubusercontent.com/mhsanaei/3x-ui/$VERSION/install.sh") $VERSION
```
### SSL证书
<details>
<summary>点击查看SSL证书详情</summary>
### ACME
使用ACME管理SSL证书:
1. 确保您的域名正确解析到服务器。
2. 在终端中运行 `x-ui` 命令,然后选择 `SSL证书管理`
3. 您将看到以下选项:
- **Get SSL:** 获取SSL证书。
- **Revoke:** 吊销现有的SSL证书。
- **Force Renew:** 强制更新SSL证书。
- **Show Existing Domains:** 显示服务器上所有可用的域证书。
- **Set Certificate Paths for the Panel:** 指定用于面板的域证书。
### Certbot
安装并使用Certbot
```sh
apt-get install certbot -y
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d yourdomain.com
certbot renew --dry-run
```
### Cloudflare
管理脚本内置了Cloudflare的SSL证书申请。要使用此脚本申请证书,您需要以下信息:
- Cloudflare注册的电子邮件
- Cloudflare全局API密钥
- 域名必须通过Cloudflare解析到当前服务器
**如何获取Cloudflare全局API密钥:**
1. 在终端中运行 `x-ui` 命令,然后选择 `Cloudflare SSL证书`
2. 访问链接:[Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens)。
3. 点击“查看全局API密钥”(参见下图):
![](media/APIKey1.PNG)
4. 您可能需要重新验证您的账户。之后将显示API密钥(参见下图):
![](media/APIKey2.png)
使用时,只需输入您的 `域名``电子邮件``API密钥`。如下图所示:
![](media/DetailEnter.png)
</details>
## 手动安装 & 升级
<details>
<summary>点击查看 手动安装 & 升级</summary>
#### 使用
1. 若要将最新版本的压缩包直接下载到服务器,请运行以下命令:
```sh
ARCH=$(uname -m)
case "${ARCH}" in
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
i*86 | x86) XUI_ARCH="386" ;;
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
armv7* | armv7) XUI_ARCH="armv7" ;;
armv6* | armv6) XUI_ARCH="armv6" ;;
armv5* | armv5) XUI_ARCH="armv5" ;;
*) XUI_ARCH="amd64" ;;
esac
wget https://github.com/MHSanaei/3x-ui/releases/latest/download/x-ui-linux-${XUI_ARCH}.tar.gz
```
2. 下载压缩包后,执行以下命令安装或升级 x-ui:
```sh
ARCH=$(uname -m)
case "${ARCH}" in
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
i*86 | x86) XUI_ARCH="386" ;;
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
armv7* | armv7) XUI_ARCH="armv7" ;;
armv6* | armv6) XUI_ARCH="armv6" ;;
armv5* | armv5) XUI_ARCH="armv5" ;;
*) XUI_ARCH="amd64" ;;
esac
cd /root/
rm -rf x-ui/ /usr/local/x-ui/ /usr/bin/x-ui
tar zxvf x-ui-linux-${XUI_ARCH}.tar.gz
chmod +x x-ui/x-ui x-ui/bin/xray-linux-* x-ui/x-ui.sh
cp x-ui/x-ui.sh /usr/bin/x-ui
cp -f x-ui/x-ui.service /etc/systemd/system/
mv x-ui/ /usr/local/
systemctl daemon-reload
systemctl enable x-ui
systemctl restart x-ui
```
</details>
## 通过Docker安装
<details>
<summary>点击查看 通过Docker安装</summary>
#### 使用
1. 安装Docker
```sh
bash <(curl -sSL https://get.docker.com)
```
2. 克隆仓库:
```sh
git clone https://github.com/MHSanaei/3x-ui.git
cd 3x-ui
```
3. 运行服务:
```sh
docker compose up -d
```
```sh
docker run -itd \
-e XRAY_VMESS_AEAD_FORCED=false \
-v $PWD/db/:/etc/x-ui/ \
-v $PWD/cert/:/root/cert/ \
--network=host \
--restart=unless-stopped \
--name 3x-ui \
ghcr.io/mhsanaei/3x-ui:latest
```
更新至最新版本
```sh
cd 3x-ui
docker compose down
docker compose pull 3x-ui
docker compose up -d
```
从Docker中删除3x-ui
```sh
docker stop 3x-ui
docker rm 3x-ui
cd --
rm -r 3x-ui
```
</details>
## Nginx 设置
<details>
<summary>点击查看 反向代理配置</summary>
#### Nginx反向代理
```nginx
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_redirect off;
proxy_pass http://127.0.0.1:2053;
}
```
#### Nginx子路径
- 确保 `/sub` 面板设置中的"面板url根路径"一致
- 面板设置中的 `url` 需要以 `/` 结尾
```nginx
location /sub {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_redirect off;
proxy_pass http://127.0.0.1:2053;
}
```
</details>
## 建议使用的操作系统
- Ubuntu 22.04+
- Debian 11+
- CentOS 8+
- OpenEuler 22.03+
- Fedora 36+
- Arch Linux
- Parch Linux
- Manjaro
- Armbian
- AlmaLinux 8.0+
- Rocky Linux 8+
- Oracle Linux 8+
- OpenSUSE Tubleweed
- Amazon Linux 2023
- Virtuozzo Linux 8+
- Windows x64
## 支持的架构和设备
<details>
<summary>点击查看 支持的架构和设备</summary>
我们的平台提供与各种架构和设备的兼容性,确保在各种计算环境中的灵活性。以下是我们支持的关键架构:
- **amd64**: 这种流行的架构是个人计算机和服务器的标准,可以无缝地适应大多数现代操作系统。
- **x86 / i386**: 这种架构在台式机和笔记本电脑中被广泛采用,得到了众多操作系统和应用程序的广泛支持,包括但不限于 Windows、macOS 和 Linux 系统。
- **armv8 / arm64 / aarch64**: 这种架构专为智能手机和平板电脑等当代移动和嵌入式设备量身定制,以 Raspberry Pi 4、Raspberry Pi 3、Raspberry Pi Zero 2/Zero 2 W、Orange Pi 3 LTS 等设备为例。
- **armv7 / arm / arm32**: 作为较旧的移动和嵌入式设备的架构,它仍然广泛用于Orange Pi Zero LTS、Orange Pi PC Plus、Raspberry Pi 2等设备。
- **armv6 / arm / arm32**: 这种架构面向非常老旧的嵌入式设备,虽然不太普遍,但仍在使用中。Raspberry Pi 1、Raspberry Pi Zero/Zero W 等设备都依赖于这种架构。
- **armv5 / arm / arm32**: 它是一种主要与早期嵌入式系统相关的旧架构,目前不太常见,但仍可能出现在早期 Raspberry Pi 版本和一些旧智能手机等传统设备中。
</details>
## Languages
- English(英语)
- Persian(波斯语)
- Traditional Chinese(繁体中文)
- Simplified Chinese(简体中文)
- Japanese(日语)
- Russian(俄语)
- Vietnamese(越南语)
- Spanish(西班牙语)
- Indonesian(印尼语)
- Ukrainian(乌克兰语)
- Turkish(土耳其语)
- Português (Brazil)(葡萄牙语(巴西))
## Features
- 系统状态监控
- 在所有入站和客户端中搜索
- 深色/浅色主题
- 支持多用户和多协议
- 支持多种协议,包括 VMess、VLESS、Trojan、Shadowsocks、Dokodemo-door、Socks、HTTP、wireguard
- 支持 XTLS 原生协议,包括 RPRX-Direct、Vision、REALITY
- 流量统计、流量限制、过期时间限制
- 可自定义的 Xray配置模板
- 支持HTTPS访问面板(自建域名+SSL证书)
- 支持一键式SSL证书申请和自动续费
- 更多高级配置项目请参考面板
- 修复了 API 路由(用户设置将使用 API 创建)
- 支持通过面板中提供的不同项目更改配置。
- 支持从面板导出/导入数据库
## 默认面板设置
<details>
<summary>点击查看默认设置详情</summary>
### 用户名、密码、端口和 Web Base Path
如果您选择不修改这些设置,它们将随机生成(不适用于 Docker)。
**Docker 的默认设置:**
- **用户名:** admin
- **密码:** admin
- **端口:** 2053
### 数据库管理:
您可以直接在面板中方便地进行数据库备份和还原。
- **数据库路径:**
- `/etc/x-ui/x-ui.db`
### Web 基础路径
1. **重置 Web 基础路径:**
- 打开终端。
- 运行 `x-ui` 命令。
- 选择 `重置 Web 基础路径` 选项。
2. **生成或自定义路径:**
- 路径将会随机生成,或者您可以输入自定义路径。
3. **查看当前设置:**
- 要查看当前设置,请在终端中使用 `x-ui settings` 命令,或在 `x-ui` 面板中点击 `查看当前设置`。
### 安全建议:
- 为了提高安全性,建议在URL结构中使用一个长的随机词。
**示例:**
- `http://ip:port/*webbasepath*/panel`
- `http://domain:port/*webbasepath*/panel`
</details>
## WARP 配置
<details>
<summary>点击查看 WARP 配置详情</summary>
#### 使用方法
**对于 `v2.1.0` 及之后的版本:**
WARP 已内置,无需额外安装。只需在面板中开启相关配置即可。
</details>
## IP 限制
<details>
<summary>点击查看 IP 限制详情</summary>
#### 使用方法
**注意:** 当使用 IP 隧道时,IP 限制将无法正常工作。
- **对于 `v1.6.1` 及之前的版本:**
- IP 限制功能已内置于面板中。
**对于 `v1.7.0` 及更新的版本:**
要启用 IP 限制功能,您需要安装 `fail2ban` 及其所需的文件,步骤如下:
1. 在终端中运行 `x-ui` 命令,然后选择 `IP 限制管理`。
2. 您将看到以下选项:
- **更改封禁时长:** 调整封禁时长。
- **解除所有封禁:** 解除当前的所有封禁。
- **查看日志:** 查看日志。
- **Fail2ban 状态:** 检查 `fail2ban` 的状态。
- **重启 Fail2ban:** 重启 `fail2ban` 服务。
- **卸载 Fail2ban:** 卸载带有配置的 Fail2ban。
3. 在面板中通过设置 `Xray 配置/log/访问日志` 为 `./access.log` 添加访问日志路径,然后保存并重启 Xray。
- **对于 `v2.1.3` 之前的版本:**
- 您需要在 Xray 配置中手动设置访问日志路径:
```sh
"log": {
"access": "./access.log",
"dnsLog": false,
"loglevel": "warning"
},
```
- **对于 `v2.1.3` 及之后的版本:**
- 面板中直接提供了配置 `access.log` 的选项。
</details>
## Telegram 机器人
<details>
<summary>点击查看 Telegram 机器人</summary>
#### 使用
Web 面板通过 Telegram Bot 支持每日流量、面板登录、数据库备份、系统状态、客户端信息等通知和功能。要使用机器人,您需要在面板中设置机器人相关参数,包括:
- 电报令牌
- 管理员聊天 ID
- 通知时间(cron 语法)
- 到期日期通知
- 流量上限通知
- 数据库备份
- CPU 负载通知
**参考:**
- `30 \* \* \* \* \*` - 在每个点的 30 秒处通知
- `0 \*/10 \* \* \* \*` - 每 10 分钟的第一秒通知
- `@hourly` - 每小时通知
- `@daily` - 每天通知 (00:00)
- `@weekly` - 每周通知
- `@every 8h` - 每8小时通知
### Telegram Bot 功能
- 定期报告
- 登录通知
- CPU 阈值通知
- 提前报告的过期时间和流量阈值
- 如果将客户的电报用户名添加到用户的配置中,则支持客户端报告菜单
- 支持使用UUIDVMESS/VLESS)或密码(TROJAN)搜索报文流量报告 - 匿名
- 基于菜单的机器人
- 通过电子邮件搜索客户端(仅限管理员)
- 检查所有入库
- 检查服务器状态
- 检查耗尽的用户
- 根据请求和定期报告接收备份
- 多语言机器人
### 注册 Telegram bot
- 与 [Botfather](https://t.me/BotFather) 对话:
![Botfather](./media/botfather.png)
- 使用 /newbot 创建新机器人:你需要提供机器人名称以及用户名,注意名称中末尾要包含“bot”
![创建机器人](./media/newbot.png)
- 启动您刚刚创建的机器人。可以在此处找到机器人的链接。
![令牌](./media/token.png)
- 输入您的面板并配置 Telegram 机器人设置,如下所示:
![面板设置](./media/panel-bot-config.png)
在输入字段编号 3 中输入机器人令牌。
在输入字段编号 4 中输入用户 ID。具有此 id 的 Telegram 帐户将是机器人管理员。 (您可以输入多个,只需将它们用“ ,”分开即可)
- 如何获取TG ID? 使用 [bot](https://t.me/useridinfobot) 启动机器人,它会给你 Telegram 用户 ID。
![用户 ID](./media/user-id.png)
</details>
## API 路由
<details>
<summary>点击查看 API 路由</summary>
#### 使用
- [API 文档](https://www.postman.com/hsanaei/3x-ui/collection/q1l5l0u/3x-ui)
- `/login` 使用 `POST` 用户名称 & 密码: `{username: '', password: ''}` 登录
- `/panel/api/inbounds` 以下操作的基础:
| 方法 | 路径 | 操作 |
| :----: | ---------------------------------- | --------------------------------- |
| `GET` | `"/list"` | 获取所有入站 |
| `GET` | `"/get/:id"` | 获取所有入站以及inbound.id |
| `GET` | `"/getClientTraffics/:email"` | 通过电子邮件获取客户端流量 |
| `GET` | `"/createbackup"` | Telegram 机器人向管理员发送备份 |
| `POST` | `"/add"` | 添加入站 |
| `POST` | `"/del/:id"` | 删除入站 |
| `POST` | `"/update/:id"` | 更新入站 |
| `POST` | `"/clientIps/:email"` | 客户端 IP 地址 |
| `POST` | `"/clearClientIps/:email"` | 清除客户端 IP 地址 |
| `POST` | `"/addClient"` | 将客户端添加到入站 |
| `POST` | `"/:id/delClient/:clientId"` | 通过 clientId\* 删除客户端 |
| `POST` | `"/updateClient/:clientId"` | 通过 clientId\* 更新客户端 |
| `POST` | `"/:id/resetClientTraffic/:email"` | 重置客户端的流量 |
| `POST` | `"/resetAllTraffics"` | 重置所有入站的流量 |
| `POST` | `"/resetAllClientTraffics/:id"` | 重置入站中所有客户端的流量 |
| `POST` | `"/delDepletedClients/:id"` | 删除入站耗尽的客户端 (-1: all) |
| `POST` | `"/onlines"` | 获取在线用户 ( 电子邮件列表 ) |
\*- `clientId` 项应该使用下列数据
- `client.id` VMESS and VLESS
- `client.password` TROJAN
- `client.email` Shadowsocks
- [<img src="https://run.pstmn.io/button.svg" alt="Run In Postman" style="width: 128px; height: 32px;">](https://app.getpostman.com/run-collection/5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5%26entityType%3Dcollection%26workspaceId%3Dd64f609f-485a-4951-9b8f-876b3f917124)
</details>
## 环境变量
<details>
<summary>点击查看 环境变量</summary>
#### Usage
| 变量 | Type | 默认 |
| -------------- | :--------------------------------------------: | :------------ |
| XUI_LOG_LEVEL | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` |
| XUI_DEBUG | `boolean` | `false` |
| XUI_BIN_FOLDER | `string` | `"bin"` |
| XUI_DB_FOLDER | `string` | `"/etc/x-ui"` |
| XUI_LOG_FOLDER | `string` | `"/var/log"` |
例子:
```sh
XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
```
</details>
## 预览
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
<img alt="3x-ui" src="./media/01-overview-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-inbounds-dark.png">
<img alt="3x-ui" src="./media/02-inbounds-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-inbound-dark.png">
<img alt="3x-ui" src="./media/03-add-inbound-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/04-add-client-dark.png">
<img alt="3x-ui" src="./media/04-add-client-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/05-settings-dark.png">
<img alt="3x-ui" src="./media/05-settings-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/06-configs-dark.png">
<img alt="3x-ui" src="./media/06-configs-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/07-bot-dark.png">
<img alt="3x-ui" src="./media/07-bot-light.png">
</picture>
完整文档请参阅 [项目Wiki](https://github.com/MHSanaei/3x-ui/wiki)。
## 特别感谢
@@ -580,9 +36,22 @@ XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
## 致谢
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _This repository contains automatically updated V2Ray routing rules based on data on blocked domains and addresses in Russia._
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (许可证: **GPL-3.0**): _增强的 v2ray/xray v2ray/xray-clients 路由规则,内置伊朗域名,专注于安全性和广告拦截。_
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (许可证: **GPL-3.0**): _此仓库包含基于俄罗斯被阻止域名和地址数据自动更新的 V2Ray 路由规则。_
## Star趋势
## 支持项目
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
**如果这个项目对您有帮助,您可以给它一个**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## 随时间变化的星标数
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+94 -13
View File
@@ -1,9 +1,14 @@
// Package config provides configuration management utilities for the 3x-ui panel,
// including version information, logging levels, database paths, and environment variable handling.
package config
import (
_ "embed"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
)
@@ -13,24 +18,29 @@ var version string
//go:embed name
var name string
// LogLevel represents the logging level for the application.
type LogLevel string
// Logging level constants
const (
Debug LogLevel = "debug"
Info LogLevel = "info"
Notice LogLevel = "notice"
Warn LogLevel = "warn"
Error LogLevel = "error"
Debug LogLevel = "debug"
Info LogLevel = "info"
Notice LogLevel = "notice"
Warning LogLevel = "warning"
Error LogLevel = "error"
)
// GetVersion returns the version string of the 3x-ui application.
func GetVersion() string {
return strings.TrimSpace(version)
}
// GetName returns the name of the 3x-ui application.
func GetName() string {
return strings.TrimSpace(name)
}
// GetLogLevel returns the current logging level based on environment variables or defaults to Info.
func GetLogLevel() LogLevel {
if IsDebug() {
return Debug
@@ -42,10 +52,12 @@ func GetLogLevel() LogLevel {
return LogLevel(logLevel)
}
// IsDebug returns true if debug mode is enabled via the XUI_DEBUG environment variable.
func IsDebug() bool {
return os.Getenv("XUI_DEBUG") == "true"
}
// GetBinFolderPath returns the path to the binary folder, defaulting to "bin" if not set via XUI_BIN_FOLDER.
func GetBinFolderPath() string {
binFolderPath := os.Getenv("XUI_BIN_FOLDER")
if binFolderPath == "" {
@@ -54,22 +66,91 @@ func GetBinFolderPath() string {
return binFolderPath
}
func GetDBFolderPath() string {
dbFolderPath := os.Getenv("XUI_DB_FOLDER")
if dbFolderPath == "" {
dbFolderPath = "/etc/x-ui"
func getBaseDir() string {
exePath, err := os.Executable()
if err != nil {
return "."
}
return dbFolderPath
exeDir := filepath.Dir(exePath)
exeDirLower := strings.ToLower(filepath.ToSlash(exeDir))
if strings.Contains(exeDirLower, "/appdata/local/temp/") || strings.Contains(exeDirLower, "/go-build") {
wd, err := os.Getwd()
if err != nil {
return "."
}
return wd
}
return exeDir
}
// GetDBFolderPath returns the path to the database folder based on environment variables or platform defaults.
func GetDBFolderPath() string {
dbFolderPath := os.Getenv("XUI_DB_FOLDER")
if dbFolderPath != "" {
return dbFolderPath
}
if runtime.GOOS == "windows" {
return getBaseDir()
}
return "/etc/x-ui"
}
// GetDBPath returns the full path to the database file.
func GetDBPath() string {
return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
}
// GetLogFolder returns the path to the log folder based on environment variables or platform defaults.
func GetLogFolder() string {
logFolderPath := os.Getenv("XUI_LOG_FOLDER")
if logFolderPath == "" {
logFolderPath = "/var/log"
if logFolderPath != "" {
return logFolderPath
}
return logFolderPath
if runtime.GOOS == "windows" {
return filepath.Join(".", "log")
}
return "/var/log/x-ui"
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
if err != nil {
return err
}
return out.Sync()
}
func init() {
if runtime.GOOS != "windows" {
return
}
if os.Getenv("XUI_DB_FOLDER") != "" {
return
}
oldDBFolder := "/etc/x-ui"
oldDBPath := fmt.Sprintf("%s/%s.db", oldDBFolder, GetName())
newDBFolder := GetDBFolderPath()
newDBPath := fmt.Sprintf("%s/%s.db", newDBFolder, GetName())
_, err := os.Stat(newDBPath)
if err == nil {
return // new exists
}
_, err = os.Stat(oldDBPath)
if os.IsNotExist(err) {
return // old does not exist
}
_ = copyFile(oldDBPath, newDBPath) // ignore error
}
+1 -1
View File
@@ -1 +1 @@
2.5.6
3.0.1
+131 -13
View File
@@ -1,16 +1,21 @@
// Package database provides database initialization, migration, and management utilities
// for the 3x-ui panel using GORM with SQLite.
package database
import (
"bytes"
"errors"
"io"
"io/fs"
"log"
"os"
"path"
"slices"
"time"
"x-ui/config"
"x-ui/database/model"
"x-ui/xray"
"github.com/mhsanaei/3x-ui/v3/config"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/util/crypto"
"github.com/mhsanaei/3x-ui/v3/xray"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
@@ -22,7 +27,6 @@ var db *gorm.DB
const (
defaultUsername = "admin"
defaultPassword = "admin"
defaultSecret = ""
)
func initModels() error {
@@ -33,6 +37,9 @@ func initModels() error {
&model.Setting{},
&model.InboundClientIps{},
&xray.ClientTraffic{},
&model.HistoryOfSeeders{},
&model.CustomGeoResource{},
&model.Node{},
}
for _, model := range models {
if err := db.AutoMigrate(model); err != nil {
@@ -43,6 +50,7 @@ func initModels() error {
return nil
}
// initUser creates a default admin user if the users table is empty.
func initUser() error {
empty, err := isTableEmpty("users")
if err != nil {
@@ -50,25 +58,82 @@ func initUser() error {
return err
}
if empty {
hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword)
if err != nil {
log.Printf("Error hashing default password: %v", err)
return err
}
user := &model.User{
Username: defaultUsername,
Password: defaultPassword,
LoginSecret: defaultSecret,
Username: defaultUsername,
Password: hashedPassword,
}
return db.Create(user).Error
}
return nil
}
// runSeeders migrates user passwords to bcrypt and records seeder execution to prevent re-running.
func runSeeders(isUsersEmpty bool) error {
empty, err := isTableEmpty("history_of_seeders")
if err != nil {
log.Printf("Error checking if users table is empty: %v", err)
return err
}
if empty && isUsersEmpty {
hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash",
}
return db.Create(hashSeeder).Error
} else {
var seedersHistory []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
log.Printf("Error fetching seeder history: %v", err)
return err
}
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
var users []model.User
if err := db.Find(&users).Error; err != nil {
log.Printf("Error fetching users for password migration: %v", err)
return err
}
for _, user := range users {
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
if err != nil {
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
return err
}
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
log.Printf("Error updating password for user '%s': %v", user.Username, err)
return err
}
}
hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash",
}
return db.Create(hashSeeder).Error
}
}
return nil
}
// isTableEmpty returns true if the named table contains zero rows.
func isTableEmpty(tableName string) (bool, error) {
var count int64
err := db.Table(tableName).Count(&count).Error
return count == 0, err
}
// InitDB sets up the database connection, migrates models, and runs seeders.
func InitDB(dbPath string) error {
dir := path.Dir(dbPath)
err := os.MkdirAll(dir, fs.ModePerm)
err := os.MkdirAll(dir, 0755)
if err != nil {
return err
}
@@ -84,21 +149,45 @@ func InitDB(dbPath string) error {
c := &gorm.Config{
Logger: gormLogger,
}
db, err = gorm.Open(sqlite.Open(dbPath), c)
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
db, err = gorm.Open(sqlite.Open(dsn), c)
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
return err
}
if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
return err
}
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
return err
}
sqlDB.SetMaxOpenConns(8)
sqlDB.SetMaxIdleConns(4)
sqlDB.SetConnMaxLifetime(time.Hour)
if err := initModels(); err != nil {
return err
}
if err := initUser(); err != nil {
isUsersEmpty, err := isTableEmpty("users")
if err != nil {
return err
}
return nil
if err := initUser(); err != nil {
return err
}
return runSeeders(isUsersEmpty)
}
// CloseDB closes the database connection if it exists.
func CloseDB() error {
if db != nil {
sqlDB, err := db.DB()
@@ -110,14 +199,16 @@ func CloseDB() error {
return nil
}
// GetDB returns the global GORM database instance.
func GetDB() *gorm.DB {
return db
}
func IsNotFound(err error) bool {
return err == gorm.ErrRecordNotFound
return errors.Is(err, gorm.ErrRecordNotFound)
}
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
func IsSQLiteDB(file io.ReaderAt) (bool, error) {
signature := []byte("SQLite format 3\x00")
buf := make([]byte, len(signature))
@@ -128,6 +219,7 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
return bytes.Equal(buf, signature), nil
}
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
func Checkpoint() error {
// Update WAL
err := db.Exec("PRAGMA wal_checkpoint;").Error
@@ -136,3 +228,29 @@ func Checkpoint() error {
}
return nil
}
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
// and runs a PRAGMA integrity_check to ensure the file is structurally sound.
// It does not mutate global state or run migrations.
func ValidateSQLiteDB(dbPath string) error {
if _, err := os.Stat(dbPath); err != nil { // file must exist
return err
}
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
if err != nil {
return err
}
sqlDB, err := gdb.DB()
if err != nil {
return err
}
defer sqlDB.Close()
var res string
if err := gdb.Raw("PRAGMA integrity_check;").Scan(&res).Error; err != nil {
return err
}
if res != "ok" {
return errors.New("sqlite integrity check failed: " + res)
}
return nil
}
+127 -38
View File
@@ -1,44 +1,64 @@
// Package model defines the database models and data structures used by the 3x-ui panel.
package model
import (
"fmt"
"x-ui/util/json_util"
"x-ui/xray"
"github.com/mhsanaei/3x-ui/v3/util/json_util"
"github.com/mhsanaei/3x-ui/v3/xray"
)
// Protocol represents the protocol type for Xray inbounds.
type Protocol string
// Protocol constants for different Xray inbound protocols
const (
VMESS Protocol = "vmess"
VLESS Protocol = "vless"
DOKODEMO Protocol = "dokodemo-door"
Tunnel Protocol = "tunnel"
HTTP Protocol = "http"
Trojan Protocol = "trojan"
Shadowsocks Protocol = "shadowsocks"
Socks Protocol = "socks"
Mixed Protocol = "mixed"
WireGuard Protocol = "wireguard"
// UI stores Hysteria v1 and v2 both as "hysteria" and uses
// settings.version to discriminate. Imports from outside the panel
// can carry the literal "hysteria2" string, so IsHysteria below
// accepts both.
Hysteria Protocol = "hysteria"
Hysteria2 Protocol = "hysteria2"
)
type User struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Username string `json:"username"`
Password string `json:"password"`
LoginSecret string `json:"loginSecret"`
// IsHysteria returns true for both "hysteria" and "hysteria2".
// Use instead of a bare ==model.Hysteria check: a v2 inbound stored
// with the literal v2 string would otherwise fall through (#4081).
func IsHysteria(p Protocol) bool {
return p == Hysteria || p == Hysteria2
}
type Inbound struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
UserId int `json:"-"`
Up int64 `json:"up" form:"up"`
Down int64 `json:"down" form:"down"`
Total int64 `json:"total" form:"total"`
Remark string `json:"remark" form:"remark"`
Enable bool `json:"enable" form:"enable"`
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"`
// User represents a user account in the 3x-ui panel.
type User struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Username string `json:"username"`
Password string `json:"password"`
}
// config part
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
type Inbound struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
UserId int `json:"-"` // Associated user ID
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
Down int64 `json:"down" form:"down"` // Download traffic in bytes
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"` // All-time traffic usage
Remark string `json:"remark" form:"remark"` // Human-readable remark
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"` // Traffic reset schedule
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
// Xray configuration fields
Listen string `json:"listen" form:"listen"`
Port int `json:"port" form:"port"`
Protocol Protocol `json:"protocol" form:"protocol"`
@@ -46,9 +66,15 @@ type Inbound struct {
StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique"`
Sniffing string `json:"sniffing" form:"sniffing"`
Allocate string `json:"allocate" form:"allocate"`
// NodeID points at the remote panel (Node) where this inbound's xray
// actually runs. NULL means the inbound runs on the local xray (the
// pre-multi-node behaviour). Existing rows migrate to NULL with no
// backfill.
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
}
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
type OutboundTraffics struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Tag string `json:"tag" form:"tag" gorm:"unique"`
@@ -57,17 +83,28 @@ type OutboundTraffics struct {
Total int64 `json:"total" form:"total" gorm:"default:0"`
}
// InboundClientIps stores IP addresses associated with inbound clients for access control.
type InboundClientIps struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
ClientEmail string `json:"clientEmail" form:"clientEmail" gorm:"unique"`
Ips string `json:"ips" form:"ips"`
}
// HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.
type HistoryOfSeeders struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
SeederName string `json:"seederName"`
}
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
listen := i.Listen
if listen != "" {
listen = fmt.Sprintf("\"%v\"", listen)
// Default to 0.0.0.0 (all interfaces) when listen is empty
// This ensures proper dual-stack IPv4/IPv6 binding in systems where bindv6only=0
if listen == "" {
listen = "0.0.0.0"
}
listen = fmt.Sprintf("\"%v\"", listen)
return &xray.InboundConfig{
Listen: json_util.RawMessage(listen),
Port: i.Port,
@@ -76,28 +113,80 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
StreamSettings: json_util.RawMessage(i.StreamSettings),
Tag: i.Tag,
Sniffing: json_util.RawMessage(i.Sniffing),
Allocate: json_util.RawMessage(i.Allocate),
}
}
// Setting stores key-value configuration settings for the 3x-ui panel.
type Setting struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Key string `json:"key" form:"key"`
Value string `json:"value" form:"value"`
}
type Client struct {
ID string `json:"id"`
Security string `json:"security"`
Password string `json:"password"`
Flow string `json:"flow"`
Email string `json:"email"`
LimitIP int `json:"limitIp"`
TotalGB int64 `json:"totalGB" form:"totalGB"`
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
Enable bool `json:"enable" form:"enable"`
TgID int64 `json:"tgId" form:"tgId"`
SubID string `json:"subId" form:"subId"`
Comment string `json:"comment" form:"comment"`
Reset int `json:"reset" form:"reset"`
// Node represents a remote 3x-ui panel registered with the central panel.
// The central panel polls each node's existing /panel/api/server/status
// endpoint over HTTP using the per-node ApiToken to populate the runtime
// status fields below.
type Node struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
Remark string `json:"remark" form:"remark"`
Scheme string `json:"scheme" form:"scheme"`
Address string `json:"address" form:"address"`
Port int `json:"port" form:"port"`
BasePath string `json:"basePath" form:"basePath"`
ApiToken string `json:"apiToken" form:"apiToken"`
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
// the row is otherwise unchanged so the UI's "last seen" tooltip is
// truthful without us having to read LastHeartbeat separately.
Status string `json:"status" gorm:"default:unknown"` // online|offline|unknown
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never
LatencyMs int `json:"latencyMs"`
XrayVersion string `json:"xrayVersion"`
CpuPct float64 `json:"cpuPct"`
MemPct float64 `json:"memPct"`
UptimeSecs uint64 `json:"uptimeSecs"`
LastError string `json:"lastError"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
}
type CustomGeoResource struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Type string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
Alias string `json:"alias" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias"`
Url string `json:"url" gorm:"not null"`
LocalPath string `json:"localPath" gorm:"column:local_path"`
LastUpdatedAt int64 `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
LastModified string `json:"lastModified" gorm:"column:last_modified"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime;column:created_at"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
}
type ClientReverse struct {
Tag string `json:"tag"`
}
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
type Client struct {
ID string `json:"id,omitempty"` // Unique client identifier
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
Password string `json:"password,omitempty"` // Client password
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
Email string `json:"email"` // Client email identifier
LimitIP int `json:"limitIp"` // IP limit for this client
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
SubID string `json:"subId" form:"subId"` // Subscription identifier
Comment string `json:"comment" form:"comment"` // Client comment
Reset int `json:"reset" form:"reset"` // Reset period in days
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
}
+22
View File
@@ -0,0 +1,22 @@
package model
import "testing"
func TestIsHysteria(t *testing.T) {
cases := []struct {
in Protocol
want bool
}{
{Hysteria, true},
{Hysteria2, true},
{VLESS, false},
{Shadowsocks, false},
{Protocol(""), false},
{Protocol("hysteria3"), false},
}
for _, c := range cases {
if got := IsHysteria(c.in); got != c.want {
t.Errorf("IsHysteria(%q) = %v, want %v", c.in, got, c.want)
}
}
}
+8 -9
View File
@@ -1,17 +1,16 @@
---
version: "3"
services:
3x-ui:
image: ghcr.io/mhsanaei/3x-ui:latest
container_name: 3x-ui
hostname: yourhostname
3xui:
build:
context: .
dockerfile: ./Dockerfile
container_name: 3xui_app
# hostname: yourhostname <- optional
volumes:
- $PWD/db/:/etc/x-ui/
- $PWD/cert/:/root/cert/
environment:
XRAY_VMESS_AEAD_FORCED: "false"
X_UI_ENABLE_FAIL2BAN: "true"
XUI_ENABLE_FAIL2BAN: "true"
tty: true
network_mode: host
restart: unless-stopped
restart: unless-stopped
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.vite/
*.log
+75
View File
@@ -0,0 +1,75 @@
# 3x-ui frontend
Vue 3 + Ant Design Vue 4 + Vite. Multi-page app — one HTML entry per
panel route — built into `../web/dist/` and embedded into the Go binary
via `embed.FS`.
## Dev
```sh
npm install
npm run dev
```
Vite serves on `http://localhost:5173/`. API calls and `/panel/*` routes
proxy to the Go panel at `http://localhost:2053/`, so start the Go panel
first (`go run main.go`) and then Vite.
The proxy auto-rewrites `/panel`, `/panel/settings`, `/panel/inbounds`,
`/panel/xray` to the matching Vite-served HTML in dev mode (see
`MIGRATED_ROUTES` in `vite.config.js`), so the sidebar's
production-style links work without round-tripping through Go.
## Production build
```sh
npm run build
```
Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
`assets/`). The Go binary embeds this directory at compile time and
`web/controller/dist.go` serves the per-page HTML.
## Lint
```sh
npm run lint
```
ESLint 10 with `eslint.config.js` (flat config) — `vue3-recommended`
plus a few rule overrides for the project's formatting style.
## Layout
```
frontend/
├── *.html # Vite entry HTML, one per panel route
├── eslint.config.js
├── vite.config.js
└── src/
├── entries/ # Per-page bootstrap (createApp + mount)
├── pages/ # One folder per route, each with the page
│ ├── index/ # component + helpers + sub-components
│ ├── login/
│ ├── inbounds/
│ ├── xray/
│ ├── settings/
│ └── sub/
├── components/ # Cross-page Vue components
├── composables/ # Reusable reactive logic (useTheme, …)
├── api/ # Axios setup, CSRF interceptor
├── i18n/ # vue-i18n init (locales live in web/translation/)
├── models/ # Inbound, Outbound, Status, … domain classes
└── utils/ # HttpUtil, ObjectUtil, LanguageManager, …
```
## Adding a new page
1. Add `frontend/<page>.html` referencing `/src/entries/<page>.js`.
2. Add `src/entries/<page>.js` that imports the page component and
mounts it.
3. Add the page component under `src/pages/<page>/`.
4. Register the entry in `rollupOptions.input` in `vite.config.js`.
5. If the page is reachable from the sidebar at `/panel/<route>`, add
it to `MIGRATED_ROUTES` so the dev proxy serves the Vite HTML.
6. Wire the Go controller to `serveDistPage(c, "<page>.html")`.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · API Docs</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/api-docs.js"></script>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
import js from '@eslint/js';
import vue from 'eslint-plugin-vue';
import vueParser from 'vue-eslint-parser';
import globals from 'globals';
export default [
{ ignores: ['node_modules/**', '../web/dist/**'] },
js.configs.recommended,
...vue.configs['flat/recommended'],
{
files: ['**/*.{js,vue}'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parser: vueParser,
parserOptions: {
ecmaFeatures: { jsx: false },
},
globals: {
...globals.browser,
...globals.node,
// Legacy script tags inject a couple of helpers on window before
// the SPA boots; declared here so no-undef stops flagging them.
getRandomRealityTarget: 'readonly',
},
},
rules: {
'no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}],
'no-empty': ['error', { allowEmptyCatch: true }],
'no-case-declarations': 'off',
// Stylistic rules from vue/recommended that don't match the
// existing codebase formatting. Disable rather than churn the
// whole tree to satisfy them.
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off',
'vue/html-self-closing': 'off',
'vue/max-attributes-per-line': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/html-indent': 'off',
'vue/html-closing-bracket-newline': 'off',
'vue/attributes-order': 'off',
'vue/first-attribute-linebreak': 'off',
'vue/one-component-per-file': 'off',
'vue/order-in-components': 'off',
'vue/attribute-hyphenation': 'off',
'vue/v-on-event-hyphenation': 'off',
// Pervasive in form components ported from the Vue 2 codebase
// (parent passes a reactive object; child mutates it in place).
// Properly fixing this means rewiring those components to emit
// updates — a meaningful architectural change, separate task.
'vue/no-mutating-props': 'off',
},
},
];
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Inbounds</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/inbounds.js"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/index.js"></script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex,nofollow" />
<title>3x-ui — Sign in</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/login.js"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Nodes</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/nodes.js"></script>
</body>
</html>
+2742
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "3x-ui-frontend",
"private": true,
"version": "0.0.2",
"type": "module",
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.2.6",
"axios": "^1.7.9",
"dayjs": "^1.11.20",
"otpauth": "^9.5.1",
"qs": "^6.13.1",
"vue": "^3.5.13",
"vue-i18n": "^11.1.4",
"vue3-persian-datetime-picker": "^1.2.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@vitejs/plugin-vue": "^6.0.6",
"eslint": "^10.3.0",
"eslint-plugin-vue": "^10.9.1",
"globals": "^17.6.0",
"vite": "^8.0.11",
"vue-eslint-parser": "^10.4.0"
},
"overrides": {
"moment-jalaali": "^0.10.4"
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Settings</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/settings.js"></script>
</body>
</html>
+126
View File
@@ -0,0 +1,126 @@
import axios from 'axios';
import qs from 'qs';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
// Public CSRF endpoint — works pre-login (the panel-scoped
// /panel/csrf-token sits behind checkLogin and would 401 a fresh
// login page that hasn't authenticated yet).
const CSRF_TOKEN_PATH = '/csrf-token';
// Cached session CSRF token. The legacy panel injects it via a
// <meta name="csrf-token"> tag rendered by Go; the new SPA pages
// fetch it once from /panel/csrf-token instead. Module-level so
// every axios POST sees the latest value.
let csrfToken = null;
let csrfFetchPromise = null;
function readMetaToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
}
// Fetch the token via a bare fetch() (not axios) so the call doesn't
// recurse through this same interceptor.
async function fetchCsrfToken() {
try {
const basePath = window.X_UI_BASE_PATH;
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
: CSRF_TOKEN_PATH);
const res = await fetch(url, {
method: 'GET',
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok) return null;
const json = await res.json();
return json?.success && typeof json.obj === 'string' ? json.obj : null;
} catch (_e) {
return null;
}
}
async function ensureCsrfToken() {
if (csrfToken) return csrfToken;
const meta = readMetaToken();
if (meta) {
csrfToken = meta;
return csrfToken;
}
if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken();
const fetched = await csrfFetchPromise;
csrfFetchPromise = null;
if (fetched) csrfToken = fetched;
return csrfToken;
}
// Apply the panel's axios defaults + interceptors. Call once at app
// startup before any HTTP call goes out.
export function setupAxios() {
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
const basePath = window.X_UI_BASE_PATH;
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
axios.defaults.baseURL = basePath;
}
// Seed the cache from the meta tag if a server-rendered page injected
// one — saves a round trip on legacy templates that still embed it.
csrfToken = readMetaToken();
axios.interceptors.request.use(
async (config) => {
config.headers = config.headers || {};
const method = (config.method || 'get').toUpperCase();
if (!SAFE_METHODS.has(method)) {
const token = await ensureCsrfToken();
if (token) config.headers['X-CSRF-Token'] = token;
}
if (config.data instanceof FormData) {
config.headers['Content-Type'] = 'multipart/form-data';
} else {
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
}
return config;
},
(error) => Promise.reject(error),
);
axios.interceptors.response.use(
(response) => response,
async (error) => {
const status = error.response?.status;
if (status === 401) {
// 401 → session is gone. In production, the panel routes
// are gated by Go's checkLogin which redirects to base_path
// serving the login page; a reload is enough. In dev, Vite
// serves /index.html directly at "/", so a reload would put
// the user right back on the dashboard and the interceptor
// would loop. Navigate to the dev login entry instead.
if (import.meta.env.DEV) {
const basePath = window.X_UI_BASE_PATH || '/';
window.location.href = `${basePath}login.html`;
} else {
window.location.reload();
}
return Promise.reject(error);
}
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
const cfg = error.config;
if (status === 403 && cfg && !cfg.__csrfRetried) {
csrfToken = null;
cfg.__csrfRetried = true;
const token = await ensureCsrfToken();
if (token) {
cfg.headers = cfg.headers || {};
cfg.headers['X-CSRF-Token'] = token;
// axios re-stringifies on retry, so unwind our qs.stringify before
// letting the same request flow through the interceptor again.
if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data);
return axios(cfg);
}
}
return Promise.reject(error);
},
);
}
+231
View File
@@ -0,0 +1,231 @@
/**
* WebSocket client for real-time panel updates.
*
* Public API (kept stable for index.html / inbounds.html / xray.html):
* - connect() — open the connection (idempotent)
* - disconnect() — close and stop reconnecting
* - on(event, callback) — subscribe to event
* - off(event, callback) — unsubscribe
* - send(data) — send JSON to the server
* - isConnected — boolean, current state
* - reconnectAttempts — number, attempts since last success
* - maxReconnectAttempts — number, give-up threshold
*
* Built-in events:
* 'connected', 'disconnected', 'error', 'message',
* plus any server-emitted message type (status, traffic, client_stats, ...).
*/
export class WebSocketClient {
static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10 MB, mirrors hub maxMessageSize.
static #BASE_RECONNECT_MS = 1000;
static #MAX_RECONNECT_MS = 30_000;
// After exhausting maxReconnectAttempts we switch to a polite slow-retry
// cadence rather than giving up forever — a panel that recovers an hour
// later should reconnect without a manual page reload.
static #SLOW_RETRY_MS = 60_000;
constructor(basePath = '') {
this.basePath = basePath;
this.maxReconnectAttempts = 10;
this.reconnectAttempts = 0;
this.isConnected = false;
this.ws = null;
this.shouldReconnect = true;
this.reconnectTimer = null;
this.listeners = new Map(); // event → Set<callback>
}
// Open the connection. Safe to call repeatedly — no-op if already
// open/connecting. Re-enables reconnects if previously disabled. Cancels
// any pending reconnect timer so an external connect() can't race a
// delayed retry into spawning a second socket.
connect() {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
this.shouldReconnect = true;
this.#cancelReconnect();
this.#openSocket();
}
// Close the connection and stop any pending reconnect attempt. Resets the
// attempt counter so a future connect() starts fresh from the small backoff.
disconnect() {
this.shouldReconnect = false;
this.#cancelReconnect();
this.reconnectAttempts = 0;
if (this.ws) {
try { this.ws.close(1000, 'client disconnect'); } catch { /* ignore */ }
this.ws = null;
}
this.isConnected = false;
}
// Subscribe to an event. Re-subscribing the same callback is a no-op.
on(event, callback) {
if (typeof callback !== 'function') return;
let set = this.listeners.get(event);
if (!set) {
set = new Set();
this.listeners.set(event, set);
}
set.add(callback);
}
// Unsubscribe from an event.
off(event, callback) {
const set = this.listeners.get(event);
if (!set) return;
set.delete(callback);
if (set.size === 0) this.listeners.delete(event);
}
// Send JSON to the server. Drops silently if not connected — callers
// should rely on connect()/server pushes rather than client-initiated sends.
send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
// ───── internals ─────
#openSocket() {
const url = this.#buildUrl();
let socket;
try {
socket = new WebSocket(url);
} catch (err) {
console.error('WebSocket: failed to construct connection', err);
this.#emit('error', err);
this.#scheduleReconnect();
return;
}
this.ws = socket;
// Every handler must check `this.ws !== socket` first. A previous socket
// can still fire events (especially `close`) after we've moved on to a
// new one — e.g. connect() called while the old socket is in CLOSING
// state. Without the guard, a stale close would null out the freshly
// opened socket and silently break send().
socket.addEventListener('open', () => {
if (this.ws !== socket) return;
this.isConnected = true;
this.reconnectAttempts = 0;
this.#emit('connected');
});
socket.addEventListener('message', (event) => {
if (this.ws !== socket) return;
this.#onMessage(event);
});
socket.addEventListener('error', (event) => {
if (this.ws !== socket) return;
// Browsers fire 'error' before 'close' on failure. We surface it for
// consumers (so polling fallbacks can engage) but don't log every blip
// — bad networks would flood the console otherwise.
this.#emit('error', event);
});
socket.addEventListener('close', () => {
if (this.ws !== socket) return;
this.isConnected = false;
this.ws = null;
this.#emit('disconnected');
if (this.shouldReconnect) this.#scheduleReconnect();
});
}
#buildUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// basePath comes from window.X_UI_BASE_PATH which is only injected
// by the Go binary in production. In dev (Vite serves directly) the
// global is missing and basePath would be '' — without the fallback to
// '/' we'd build `ws://host:portws` (no separator) and the WebSocket
// constructor throws a SyntaxError.
let basePath = this.basePath || '/';
if (!basePath.startsWith('/')) basePath = '/' + basePath;
if (!basePath.endsWith('/')) basePath += '/';
return `${protocol}//${window.location.host}${basePath}ws`;
}
#onMessage(event) {
const data = event.data;
// Reject oversized payloads up front. We compare actual UTF-8 byte
// length (via Blob.size) against the limit — string.length counts
// UTF-16 code units, which can undercount real bytes by up to 4× for
// payloads with non-ASCII characters and bypass the cap.
if (typeof data === 'string') {
const byteLen = new Blob([data]).size;
if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
try { this.ws?.close(1009, 'message too big'); } catch { /* ignore */ }
return;
}
}
let message;
try {
message = JSON.parse(data);
} catch (err) {
console.error('WebSocket: invalid JSON message', err);
return;
}
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
console.error('WebSocket: malformed message envelope');
return;
}
this.#emit(message.type, message.payload, message.time);
this.#emit('message', message);
}
#emit(event, ...args) {
const set = this.listeners.get(event);
if (!set) return;
for (const callback of set) {
try {
callback(...args);
} catch (err) {
console.error(`WebSocket: handler for "${event}" threw`, err);
}
}
}
#scheduleReconnect() {
if (!this.shouldReconnect) return;
this.#cancelReconnect();
let base;
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts += 1;
// Exponential backoff inside the active window.
const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
} else {
// Active window exhausted — keep trying once a minute. The page-level
// polling fallback runs in parallel; this just brings WS back when the
// network recovers.
base = WebSocketClient.#SLOW_RETRY_MS;
}
// ±25% jitter so reloads after a panel restart don't reconnect in lockstep.
const delay = base * (0.75 + Math.random() * 0.5);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
// clearTimeout doesn't cancel a callback that has already fired but
// whose macrotask hasn't run yet — re-check shouldReconnect here so
// disconnect() called in that window can't be overridden.
if (!this.shouldReconnect) return;
this.#openSocket();
}, delay);
}
#cancelReconnect() {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
+423
View File
@@ -0,0 +1,423 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
DashboardOutlined,
UserOutlined,
SettingOutlined,
ToolOutlined,
ClusterOutlined,
LogoutOutlined,
CloseOutlined,
MenuOutlined,
ApiOutlined,
} from '@ant-design/icons-vue';
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
const { t } = useI18n();
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const props = defineProps({
basePath: { type: String, default: '' },
// Current request URI so the matching menu item highlights.
requestUri: { type: String, default: '' },
});
const iconByName = {
dashboard: DashboardOutlined,
user: UserOutlined,
setting: SettingOutlined,
tool: ToolOutlined,
cluster: ClusterOutlined,
logout: LogoutOutlined,
apidocs: ApiOutlined,
};
const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
const tabs = computed(() => [
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') },
]);
const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
const utilTabs = computed(() => tabs.value.filter((tab) => tab.icon === 'logout'));
const activeTab = ref([props.requestUri]);
const drawerOpen = ref(false);
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
const drawerWidth = 'min(82vw, 320px)';
function openLink(key) {
if (key.startsWith('http')) {
window.open(key);
} else {
window.location.href = key;
}
}
function onCollapse(isCollapsed, type) {
// Only persist explicit toggle clicks, not breakpoint-triggered collapses.
if (type === 'clickTrigger') {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, isCollapsed);
collapsed.value = isCollapsed;
}
}
function toggleDrawer() {
drawerOpen.value = !drawerOpen.value;
}
function closeDrawer() {
drawerOpen.value = false;
}
function cycleTheme() {
pauseAnimationsUntilLeave('theme-cycle');
if (!theme.isDark) {
toggleTheme();
if (theme.isUltra) toggleUltra();
} else if (!theme.isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
}
</script>
<template>
<div class="ant-sidebar">
<a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
<div class="sider-brand" :class="{ 'sider-brand-collapsed': collapsed }">
<span class="brand-text">{{ collapsed ? '3X' : '3X-UI' }}</span>
<button v-if="!collapsed" id="theme-cycle" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
:title="t('menu.theme')" @click="cycleTheme">
<svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
<svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
</svg>
</button>
</div>
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-nav"
@click="({ key }) => openLink(key)">
<a-menu-item v-for="tab in navTabs" :key="tab.key">
<component :is="iconByName[tab.icon]" />
<span>{{ tab.title }}</span>
</a-menu-item>
</a-menu>
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-utility"
@click="({ key }) => openLink(key)">
<a-menu-item v-for="tab in utilTabs" :key="tab.key">
<component :is="iconByName[tab.icon]" />
<span>{{ tab.title }}</span>
</a-menu-item>
</a-menu>
</a-layout-sider>
<a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
:wrap-style="{ padding: 0 }" :width="drawerWidth"
:body-style="{ padding: 0, display: 'flex', flexDirection: 'column', height: '100%' }"
:header-style="{ display: 'none' }" @close="closeDrawer">
<div class="drawer-header">
<span class="drawer-brand">3X-UI</span>
<div class="drawer-header-actions">
<button id="theme-cycle-drawer" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
:title="t('menu.theme')" @click="cycleTheme">
<svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
<svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
</svg>
</button>
<button class="drawer-close" type="button" :aria-label="t('close')" @click="closeDrawer">
<CloseOutlined />
</button>
</div>
</div>
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-nav"
@click="({ key }) => openLink(key)">
<a-menu-item v-for="tab in navTabs" :key="tab.key">
<component :is="iconByName[tab.icon]" />
<span>{{ tab.title }}</span>
</a-menu-item>
</a-menu>
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-utility"
@click="({ key }) => openLink(key)">
<a-menu-item v-for="tab in utilTabs" :key="tab.key">
<component :is="iconByName[tab.icon]" />
<span>{{ tab.title }}</span>
</a-menu-item>
</a-menu>
</a-drawer>
<button v-show="!drawerOpen" class="drawer-handle" type="button" :aria-label="t('menu.dashboard')"
@click="toggleDrawer">
<MenuOutlined />
</button>
</div>
</template>
<style scoped>
.ant-sidebar>.ant-layout-sider {
position: sticky;
top: 0;
height: 100vh;
align-self: flex-start;
}
.sider-brand,
.drawer-brand {
font-weight: 600;
font-size: 18px;
letter-spacing: 0.5px;
color: rgba(0, 0, 0, 0.88);
}
.sider-brand {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 14px;
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
user-select: none;
}
/* Collapsed sider only has room for the '3X' brand — center it and
* hide the theme cycle button (which is `v-if`-ed out in template). */
.sider-brand-collapsed {
justify-content: center;
font-size: 16px;
padding: 14px 4px;
letter-spacing: 0;
}
.brand-text {
flex: 1 1 auto;
}
.sider-brand-collapsed .brand-text {
flex: 0 0 auto;
}
.theme-cycle {
background: transparent;
border: none;
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: rgba(0, 0, 0, 0.75);
padding: 0;
flex-shrink: 0;
transition: background-color 0.2s, transform 0.15s, color 0.2s;
}
.theme-cycle:hover,
.theme-cycle:focus-visible {
background-color: rgba(64, 150, 255, 0.1);
color: #4096ff;
transform: scale(1.08);
outline: none;
}
.theme-cycle svg {
width: 16px;
height: 16px;
}
.drawer-header-actions {
display: inline-flex;
align-items: center;
gap: 4px;
}
.drawer-handle {
position: fixed;
top: 12px;
left: 12px;
z-index: 1100;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border: none;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
}
.drawer-close {
background: transparent;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
color: rgba(0, 0, 0, 0.65);
}
.drawer-close:hover,
.drawer-close:focus-visible {
background: rgba(128, 128, 128, 0.18);
}
.drawer-menu :deep(.ant-menu-item) {
height: 48px;
line-height: 48px;
margin: 0;
border-radius: 0;
}
.drawer-menu :deep(.ant-menu-item .anticon) {
font-size: 16px;
}
.drawer-utility {
margin-top: auto;
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children) {
display: flex;
flex-direction: column;
height: 100%;
}
.sider-brand {
flex: 0 0 auto;
}
.sider-nav {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.sider-utility {
flex: 0 0 auto;
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
@media (max-width: 768px) {
.drawer-handle {
display: inline-flex;
}
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
display: none;
}
.ant-sidebar>.ant-layout-sider {
flex: 0 0 0 !important;
max-width: 0 !important;
min-width: 0 !important;
width: 0 !important;
}
}
</style>
<style>
body.dark .drawer-brand,
body.dark .sider-brand {
color: rgba(255, 255, 255, 0.92);
}
html[data-theme='ultra-dark'] .drawer-brand,
html[data-theme='ultra-dark'] .sider-brand {
color: #ffffff;
}
body.dark .drawer-close {
color: rgba(255, 255, 255, 0.75);
}
html[data-theme='ultra-dark'] .drawer-close {
color: rgba(255, 255, 255, 0.85);
}
body.dark .theme-cycle {
color: rgba(255, 255, 255, 0.85);
}
html[data-theme='ultra-dark'] .theme-cycle {
color: rgba(255, 255, 255, 0.92);
}
body.dark .ant-drawer .ant-drawer-content,
body.dark .ant-drawer .ant-drawer-body {
background: #252526 !important;
}
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
background: #0a0a0a !important;
}
.sider-nav .ant-menu-item-selected,
.sider-utility .ant-menu-item-selected,
.drawer-menu .ant-menu-item-selected {
background-color: rgba(64, 150, 255, 0.2) !important;
color: #4096ff !important;
}
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
background-color: rgba(64, 150, 255, 0.1) !important;
color: #4096ff !important;
}
</style>
@@ -0,0 +1,31 @@
<script setup>
defineProps({
title: { type: String, default: '' },
value: { type: [String, Number], default: '' },
});
</script>
<template>
<a-statistic :title="title" :value="value">
<template #prefix>
<slot name="prefix" />
</template>
<template #suffix>
<slot name="suffix" />
</template>
</a-statistic>
</template>
<style scoped>
:deep(.ant-statistic-content) {
font-size: 16px;
}
:global(body.dark .ant-statistic-content) {
color: var(--dark-color-text-primary);
}
:global(body.dark .ant-statistic-title) {
color: rgba(255, 255, 255, 0.55);
}
</style>
+366
View File
@@ -0,0 +1,366 @@
<script setup>
import { computed } from 'vue';
import dayjs from 'dayjs';
import PersianDatePicker from 'vue3-persian-datetime-picker';
import { useDatepicker } from '@/composables/useDatepicker.js';
// Drop-in replacement for <a-date-picker> that swaps to a real Jalali
// calendar (vue3-persian-datetime-picker, backed by moment-jalaali)
// when the panel's "Calendar Type" setting is `jalalian`.
//
// The v-model contract matches AD-Vue: the parent works with a dayjs
// object (or null). For the persian picker we serialize to/from the
// `YYYY-MM-DD HH:mm:ss` string it expects so callers don't need to
// know which renderer is active.
const props = defineProps({
value: { type: [Object, null], default: null },
showTime: { type: Boolean, default: true },
format: { type: String, default: 'YYYY-MM-DD HH:mm:ss' },
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(['update:value']);
const { datepicker } = useDatepicker();
const isJalali = computed(() => datepicker.value === 'jalalian');
const ISO_FORMAT = 'YYYY-MM-DD HH:mm:ss';
// Persian picker's display format — `j…` tokens come from moment-jalaali
// and render Jalali year/month/day.
const persianDisplayFormat = computed(() =>
props.showTime ? 'jYYYY/jMM/jDD HH:mm:ss' : 'jYYYY/jMM/jDD',
);
// Persian picker stores the date as a Gregorian string in the format
// it was given via `format`. We normalize on `YYYY-MM-DD HH:mm:ss` so
// dayjs(...) round-trips cleanly.
const stringValue = computed({
get() {
const v = props.value;
if (!v) return '';
return dayjs.isDayjs(v) ? v.format(ISO_FORMAT) : dayjs(v).format(ISO_FORMAT);
},
set(next) {
if (!next) {
emit('update:value', null);
return;
}
const parsed = dayjs(next, ISO_FORMAT);
emit('update:value', parsed.isValid() ? parsed : null);
},
});
function onAntChange(next) {
emit('update:value', next || null);
}
</script>
<template>
<PersianDatePicker v-if="isJalali" v-model="stringValue" :format="ISO_FORMAT" :display-format="persianDisplayFormat"
:placeholder="placeholder" :disabled="disabled" color="#1677ff" auto-submit append-to="body"
input-class="ant-input persian-datepicker-input" class="jalali-datepicker" />
<a-date-picker v-else :value="value" :show-time="showTime ? { format: 'HH:mm:ss' } : false" :format="format"
:placeholder="placeholder" :disabled="disabled" :style="{ width: '100%' }" @update:value="onAntChange" />
</template>
<style scoped>
.jalali-datepicker {
width: 100%;
}
</style>
<!-- Theme overrides for the picker. AD-Vue 4 doesn't expose CSS variables
by default (its tokens live in JS), so we hardcode hexes per theme
class — `body.dark` for the navy theme, `[data-theme="ultra-dark"]`
for the neutral ultra-dark variant. The popup stays inside the
wrapper's subtree (no teleport) so global selectors reach it cleanly. -->
<style>
/* ===== Light (default) =================================================== */
.persian-datepicker-input {
width: 100%;
box-sizing: border-box;
padding: 4px 11px;
font-size: 14px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
color: rgba(0, 0, 0, 0.88);
transition: border-color 0.2s, box-shadow 0.2s;
}
.persian-datepicker-input:hover {
border-color: #4096ff;
}
.persian-datepicker-input:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
outline: none;
}
/* Light theme keeps the picker's brand-blue calendar button (set via
* inline style on .vpd-icon-btn) — only its border + corner radius are
* normalized so it sits flush with the input. Dark/ultra-dark themes
* below override the inline blue so the control matches the form. */
.vpd-main .vpd-icon-btn {
color: #fff;
border: 1px solid transparent;
border-radius: 6px 0 0 6px;
}
/* Match the input's left edge (no rounded left, no double border at the
* seam) so it sits flush against the icon-btn. */
.persian-datepicker-input {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.vpd-main .vpd-clear-btn {
color: rgba(0, 0, 0, 0.45);
background: transparent;
}
/* Width is exactly 316px so the 7-day grid (7 × 40px + 36px padding)
* fits flush. Don't add `border` here — box-sizing: border-box would
* eat 2px from the content width and the 7th day-cell of each row
* wraps. Use box-shadow + a wider radius for the visual edge instead. */
.vpd-wrapper .vpd-content {
background: #fff;
color: rgba(0, 0, 0, 0.88);
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
border-radius: 8px;
overflow: hidden;
}
.vpd-wrapper .vpd-header {
background: #1677ff;
color: #fff;
border-radius: 8px 8px 0 0;
}
.vpd-wrapper .vpd-header .vpd-year-label,
.vpd-wrapper .vpd-header .vpd-date,
.vpd-wrapper .vpd-header .vpd-locales li {
color: #fff;
}
.vpd-wrapper .vpd-body {
background: #fff;
color: rgba(0, 0, 0, 0.88);
}
.vpd-wrapper .vpd-body .vpd-month-label,
.vpd-wrapper .vpd-body .vpd-month-label>span {
color: rgba(0, 0, 0, 0.88);
}
.vpd-wrapper .vpd-body .vpd-week,
.vpd-wrapper .vpd-body .vpd-weekday {
color: rgba(0, 0, 0, 0.55);
}
.vpd-wrapper .vpd-body .vpd-controls .vpd-next,
.vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
color: rgba(0, 0, 0, 0.65);
}
/* The picker's <arrow> component renders an inline SVG with a hardcoded
* `fill="#000"` attribute. Override the path fill via CSS so the arrow
* is visible in every theme. */
.vpd-wrapper .vpd-next svg path,
.vpd-wrapper .vpd-prev svg path {
fill: rgba(0, 0, 0, 0.65);
}
.vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
.vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
fill: #1677ff;
}
/* The picker paints disabled days as `darken(#fff, 20%)` (~#cccccc) which
* is invisible on white and dark themes alike. Reset the day text color
* across all states so days are always readable. */
.vpd-wrapper .vpd-day,
.vpd-wrapper .vpd-day .vpd-day-text {
color: rgba(0, 0, 0, 0.88) !important;
}
.vpd-wrapper .vpd-day[disabled='true'],
.vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
color: rgba(0, 0, 0, 0.25) !important;
}
.vpd-wrapper .vpd-day:not([disabled='true']):hover .vpd-day-text,
.vpd-wrapper .vpd-day.vpd-selected .vpd-day-text {
color: #fff !important;
}
.vpd-wrapper .vpd-actions button {
color: rgba(0, 0, 0, 0.88);
background: transparent;
}
.vpd-wrapper .vpd-actions button:hover {
background: rgba(0, 0, 0, 0.04);
color: #1677ff;
}
.vpd-wrapper .vpd-addon-list,
.vpd-wrapper .vpd-addon-list-content {
background: #fff;
color: rgba(0, 0, 0, 0.88);
}
.vpd-wrapper .vpd-addon-list-item {
color: rgba(0, 0, 0, 0.88);
border-color: #fff;
}
.vpd-wrapper .vpd-addon-list-item.vpd-selected,
.vpd-wrapper .vpd-addon-list-item:hover {
background: rgba(0, 0, 0, 0.04);
}
.vpd-wrapper .vpd-close-addon {
color: rgba(0, 0, 0, 0.65);
background: rgba(0, 0, 0, 0.06);
}
/* ===== Dark (navy) ======================================================= */
body.dark .persian-datepicker-input {
background: #252526;
border-color: #3c3c3c;
color: rgba(255, 255, 255, 0.88);
}
body.dark .persian-datepicker-input:hover {
border-color: #4096ff;
}
body.dark .persian-datepicker-input:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.18);
}
body.dark .vpd-main .vpd-icon-btn {
background: rgba(255, 255, 255, 0.04) !important;
border: 1px solid #3c3c3c !important;
border-right: none !important;
border-radius: 6px 0 0 6px !important;
color: rgba(255, 255, 255, 0.75) !important;
}
body.dark .vpd-wrapper .vpd-content {
background: #2d2d30;
color: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
0 3px 6px -4px rgba(0, 0, 0, 0.48),
0 9px 28px 8px rgba(0, 0, 0, 0.2);
}
body.dark .vpd-wrapper .vpd-body {
background: #2d2d30;
color: rgba(255, 255, 255, 0.88);
}
body.dark .vpd-wrapper .vpd-body .vpd-month-label,
body.dark .vpd-wrapper .vpd-body .vpd-month-label>span {
color: rgba(255, 255, 255, 0.88);
}
body.dark .vpd-wrapper .vpd-body .vpd-week,
body.dark .vpd-wrapper .vpd-body .vpd-weekday {
color: rgba(255, 255, 255, 0.55);
}
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next,
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
color: rgba(255, 255, 255, 0.65);
}
body.dark .vpd-wrapper .vpd-next svg path,
body.dark .vpd-wrapper .vpd-prev svg path {
fill: rgba(255, 255, 255, 0.75);
}
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
fill: #4096ff;
}
body.dark .vpd-wrapper .vpd-day,
body.dark .vpd-wrapper .vpd-day .vpd-day-text {
color: rgba(255, 255, 255, 0.88) !important;
}
body.dark .vpd-wrapper .vpd-day[disabled='true'],
body.dark .vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
color: rgba(255, 255, 255, 0.25) !important;
}
body.dark .vpd-wrapper .vpd-actions button {
color: rgba(255, 255, 255, 0.88);
}
body.dark .vpd-wrapper .vpd-actions button:hover {
background: rgba(255, 255, 255, 0.06);
}
body.dark .vpd-wrapper .vpd-addon-list,
body.dark .vpd-wrapper .vpd-addon-list-content {
background: #2d2d30;
color: rgba(255, 255, 255, 0.88);
}
body.dark .vpd-wrapper .vpd-addon-list-item {
color: rgba(255, 255, 255, 0.88);
border-color: transparent;
}
body.dark .vpd-wrapper .vpd-addon-list-item.vpd-selected,
body.dark .vpd-wrapper .vpd-addon-list-item:hover {
background: rgba(255, 255, 255, 0.06);
}
body.dark .vpd-wrapper .vpd-close-addon {
color: rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.08);
}
/* ===== Ultra-dark (neutral black) ======================================= */
html[data-theme='ultra-dark'] .persian-datepicker-input {
background: #0a0a0a;
border-color: #303030;
color: rgba(255, 255, 255, 0.88);
}
html[data-theme='ultra-dark'] .vpd-main .vpd-icon-btn {
background: rgba(255, 255, 255, 0.04) !important;
border: 1px solid #303030 !important;
border-right: none !important;
border-radius: 6px 0 0 6px !important;
color: rgba(255, 255, 255, 0.75) !important;
}
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-content {
background: #141414;
color: rgba(255, 255, 255, 0.88);
}
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-body {
background: #141414;
}
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list,
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list-content {
background: #141414;
}
</style>
+510
View File
@@ -0,0 +1,510 @@
<script setup>
import { computed } from 'vue';
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import { RandomUtil } from '@/utils';
import { Protocols } from '@/models/inbound.js';
// Mirrors web/html/form/stream/stream_finalmask.html. Used by both the
// inbound and outbound modals — they share the same StreamSettings
// shape (`stream.finalmask`, `stream.addTcpMask()`, etc.) so a single
// component handles both. The host modal passes its protocol through
// so we know whether to show only the Hysteria-specific UDP types.
const props = defineProps({
stream: { type: Object, required: true },
protocol: { type: String, default: '' },
});
const isHysteria = computed(() => props.protocol === Protocols.HYSTERIA);
const network = computed(() => props.stream?.network || '');
const showTcp = computed(() => ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(network.value));
const showUdp = computed(() => isHysteria.value || network.value === 'kcp');
const showQuic = computed(() => isHysteria.value || network.value === 'xhttp');
// Reset the per-row settings shape when the user picks a different
// type — mirrors the legacy `mask._getDefaultSettings(type, {})` call.
function changeMaskType(mask, type) {
mask.type = type;
mask.settings = mask._getDefaultSettings(type, {});
}
// Special case from the legacy form: switching a UDP mask to xdns
// shrinks the kcp MTU; everything else needs the default 1350.
function changeUdpMaskType(mask, type) {
changeMaskType(mask, type);
if (network.value === 'kcp' && props.stream.kcp) {
props.stream.kcp.mtu = type === 'xdns' ? 900 : 1350;
}
}
// header-custom and noise rows share the same per-item shape — the
// type select rewires the packet field. Pulled out so the click
// handlers in the template stay readable.
function changeItemType(item, type) {
item.type = type;
if (type === 'base64') item.packet = RandomUtil.randomBase64();
else if (type === 'array') { item.rand = 0; item.packet = []; }
else item.packet = '';
}
function addUdpMaskWithDefault() {
const def = isHysteria.value ? 'salamander' : 'mkcp-aes128gcm';
props.stream.addUdpMask(def);
}
function newClientServerItem() {
return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
}
function newUdpClientServerItem() {
return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
}
function newNoiseItem() {
return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20' };
}
</script>
<template>
<a-form v-if="showTcp || showUdp || showQuic" :colon="false" :label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }">
<!-- ============================== TCP MASKS ============================== -->
<template v-if="showTcp">
<a-form-item label="TCP Masks">
<a-button type="primary" size="small" @click="stream.addTcpMask('fragment')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(mask, mIdx) in (stream.finalmask.tcp || [])" :key="`tcp-${mIdx}`">
<a-divider :style="{ margin: '0' }">
TCP Mask {{ mIdx + 1 }}
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="stream.delTcpMask(mIdx)" />
</a-divider>
<a-form-item label="Type">
<a-select :value="mask.type" @change="(t) => changeMaskType(mask, t)">
<a-select-option value="fragment">Fragment</a-select-option>
<a-select-option value="header-custom">Header Custom</a-select-option>
<a-select-option value="sudoku">Sudoku</a-select-option>
</a-select>
</a-form-item>
<!-- Fragment -->
<template v-if="mask.type === 'fragment'">
<a-form-item label="Packets">
<a-select v-model:value="mask.settings.packets">
<a-select-option value="tlshello">tlshello</a-select-option>
<a-select-option value="1-3">1-3</a-select-option>
<a-select-option value="1-5">1-5</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Length">
<a-input v-model:value="mask.settings.length" placeholder="e.g. 100-200" />
</a-form-item>
<a-form-item label="Delay">
<a-input v-model:value="mask.settings.delay" placeholder="e.g. 10-20" />
</a-form-item>
<a-form-item label="Max Split">
<a-input v-model:value="mask.settings.maxSplit" placeholder="e.g. 3-6" />
</a-form-item>
</template>
<!-- Sudoku -->
<template v-if="mask.type === 'sudoku'">
<a-form-item label="Password">
<a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
</a-form-item>
<a-form-item label="ASCII">
<a-input v-model:value="mask.settings.ascii" placeholder="ASCII" />
</a-form-item>
<a-form-item label="Custom Table">
<a-input v-model:value="mask.settings.customTable" placeholder="Custom Table" />
</a-form-item>
<a-form-item label="Custom Tables">
<a-input v-model:value="mask.settings.customTables" placeholder="Custom Tables" />
</a-form-item>
<a-form-item label="Padding Min">
<a-input-number v-model:value="mask.settings.paddingMin" :min="0" />
</a-form-item>
<a-form-item label="Padding Max">
<a-input-number v-model:value="mask.settings.paddingMax" :min="0" />
</a-form-item>
</template>
<!-- Header Custom clients/servers as 2D groups -->
<template v-if="mask.type === 'header-custom'">
<!-- Clients -->
<a-form-item label="Clients">
<a-button type="primary" size="small" @click="mask.settings.clients.push([newClientServerItem()])">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(group, gi) in mask.settings.clients" :key="`tcp-cg-${mIdx}-${gi}`">
<a-divider :style="{ margin: '0' }">
Clients Group {{ gi + 1 }}
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.clients.splice(gi, 1)" />
</a-divider>
<template v-for="(item, ii) in group" :key="`tcp-ci-${mIdx}-${gi}-${ii}`">
<a-form-item label="Type">
<a-select :value="item.type" @change="(t) => changeItemType(item, t)">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Delay (ms)">
<a-input-number v-model:value="item.delay" :min="0" />
</a-form-item>
<template v-if="item.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model:value="item.rand" :min="0" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model:value="item.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group v-if="item.type === 'base64'" compact>
<a-input v-model:value="item.packet" placeholder="binary data"
:style="{ width: 'calc(100% - 32px)' }" />
<a-button @click="item.packet = RandomUtil.randomBase64()">
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-input-group>
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
<!-- Servers -->
<a-form-item label="Servers">
<a-button type="primary" size="small" @click="mask.settings.servers.push([newClientServerItem()])">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(group, gi) in mask.settings.servers" :key="`tcp-sg-${mIdx}-${gi}`">
<a-divider :style="{ margin: '0' }">
Servers Group {{ gi + 1 }}
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.servers.splice(gi, 1)" />
</a-divider>
<template v-for="(item, ii) in group" :key="`tcp-si-${mIdx}-${gi}-${ii}`">
<a-form-item label="Type">
<a-select :value="item.type" @change="(t) => changeItemType(item, t)">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Delay (ms)">
<a-input-number v-model:value="item.delay" :min="0" />
</a-form-item>
<template v-if="item.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model:value="item.rand" :min="0" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model:value="item.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group v-if="item.type === 'base64'" compact>
<a-input v-model:value="item.packet" placeholder="binary data"
:style="{ width: 'calc(100% - 32px)' }" />
<a-button @click="item.packet = RandomUtil.randomBase64()">
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-input-group>
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
</template>
</template>
</template>
<!-- ============================== UDP MASKS ============================== -->
<template v-if="showUdp">
<a-form-item label="UDP Masks">
<a-button type="primary" size="small" @click="addUdpMaskWithDefault">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(mask, mIdx) in (stream.finalmask.udp || [])" :key="`udp-${mIdx}`">
<a-divider :style="{ margin: '0' }">
UDP Mask {{ mIdx + 1 }}
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="stream.delUdpMask(mIdx)" />
</a-divider>
<a-form-item label="Type">
<a-select :value="mask.type" @change="(t) => changeUdpMaskType(mask, t)">
<template v-if="isHysteria">
<a-select-option value="salamander">Salamander (Hysteria2)</a-select-option>
</template>
<template v-else>
<a-select-option value="mkcp-aes128gcm">mKCP AES-128-GCM</a-select-option>
<a-select-option value="header-dns">Header DNS</a-select-option>
<a-select-option value="header-dtls">Header DTLS 1.2</a-select-option>
<a-select-option value="header-srtp">Header SRTP</a-select-option>
<a-select-option value="header-utp">Header uTP</a-select-option>
<a-select-option value="header-wechat">Header WeChat Video</a-select-option>
<a-select-option value="header-wireguard">Header WireGuard</a-select-option>
<a-select-option value="mkcp-original">mKCP Original</a-select-option>
<a-select-option value="xdns">xDNS</a-select-option>
<a-select-option value="xicmp">xICMP</a-select-option>
<a-select-option value="header-custom">Header Custom</a-select-option>
<a-select-option value="noise">Noise</a-select-option>
</template>
</a-select>
</a-form-item>
<a-form-item v-if="['mkcp-aes128gcm', 'salamander'].includes(mask.type)" label="Password">
<a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
</a-form-item>
<a-form-item v-if="mask.type === 'header-dns'" label="Domain">
<a-input v-model:value="mask.settings.domain" placeholder="e.g., www.example.com" />
</a-form-item>
<a-form-item v-if="mask.type === 'xdns'" label="Domains">
<a-select v-model:value="mask.settings.domains" mode="tags" :style="{ width: '100%' }"
:token-separators="[',']" placeholder="e.g., www.example.com" />
</a-form-item>
<!-- Noise -->
<template v-if="mask.type === 'noise'">
<a-form-item label="Reset">
<a-input-number v-model:value="mask.settings.reset" :min="0" />
</a-form-item>
<a-form-item label="Noise">
<a-button type="primary" size="small" @click="mask.settings.noise.push(newNoiseItem())">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(n, ni) in mask.settings.noise" :key="`udp-noise-${mIdx}-${ni}`">
<a-divider :style="{ margin: '0' }">
Noise {{ ni + 1 }}
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.noise.splice(ni, 1)" />
</a-divider>
<a-form-item label="Type">
<a-select :value="n.type" @change="(t) => changeItemType(n, t)">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<template v-if="n.type === 'array'">
<a-form-item label="Rand">
<a-input v-model:value="n.rand" placeholder="0 or 1-8192" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model:value="n.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group v-if="n.type === 'base64'" compact>
<a-input v-model:value="n.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button @click="n.packet = RandomUtil.randomBase64()">
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-input-group>
<a-input v-else v-model:value="n.packet" placeholder="binary data" />
</a-form-item>
<a-form-item label="Delay">
<a-input v-model:value="n.delay" placeholder="10-20" />
</a-form-item>
</template>
</template>
<!-- Header Custom (UDP) flat client/server lists -->
<template v-if="mask.type === 'header-custom'">
<a-form-item label="Client">
<a-button type="primary" size="small" @click="mask.settings.client.push(newUdpClientServerItem())">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(c, ci) in mask.settings.client" :key="`udp-c-${mIdx}-${ci}`">
<a-divider :style="{ margin: '0' }">
Client {{ ci + 1 }}
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.client.splice(ci, 1)" />
</a-divider>
<a-form-item label="Type">
<a-select :value="c.type" @change="(t) => changeItemType(c, t)">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<template v-if="c.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model:value="c.rand" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model:value="c.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group v-if="c.type === 'base64'" compact>
<a-input v-model:value="c.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button @click="c.packet = RandomUtil.randomBase64()">
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-input-group>
<a-input v-else v-model:value="c.packet" placeholder="binary data" />
</a-form-item>
</template>
<a-divider :style="{ margin: '0' }" />
<a-form-item label="Server">
<a-button type="primary" size="small" @click="mask.settings.server.push(newUdpClientServerItem())">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(s, si) in mask.settings.server" :key="`udp-s-${mIdx}-${si}`">
<a-divider :style="{ margin: '0' }">
Server {{ si + 1 }}
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.server.splice(si, 1)" />
</a-divider>
<a-form-item label="Type">
<a-select :value="s.type" @change="(t) => changeItemType(s, t)">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<template v-if="s.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model:value="s.rand" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model:value="s.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group v-if="s.type === 'base64'" compact>
<a-input v-model:value="s.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button @click="s.packet = RandomUtil.randomBase64()">
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-input-group>
<a-input v-else v-model:value="s.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
<!-- xICMP -->
<template v-if="mask.type === 'xicmp'">
<a-form-item label="IP">
<a-input v-model:value="mask.settings.ip" placeholder="0.0.0.0" />
</a-form-item>
<a-form-item label="ID">
<a-input-number v-model:value="mask.settings.id" :min="0" />
</a-form-item>
</template>
</template>
</template>
<!-- ============================== QUIC PARAMS ============================== -->
<template v-if="showQuic">
<a-form-item label="QUIC Params">
<a-switch v-model:checked="stream.finalmask.enableQuicParams" />
</a-form-item>
<template v-if="stream.finalmask.enableQuicParams && stream.finalmask.quicParams">
<a-form-item label="Congestion">
<a-select v-model:value="stream.finalmask.quicParams.congestion">
<a-select-option value="reno">Reno</a-select-option>
<a-select-option value="bbr">BBR</a-select-option>
<a-select-option value="brutal">Brutal</a-select-option>
<a-select-option value="force-brutal">Force Brutal</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Debug">
<a-switch v-model:checked="stream.finalmask.quicParams.debug" />
</a-form-item>
<template v-if="['brutal', 'force-brutal'].includes(stream.finalmask.quicParams.congestion)">
<a-form-item label="Brutal Up">
<a-input v-model:value="stream.finalmask.quicParams.brutalUp" placeholder="65537" />
</a-form-item>
<a-form-item label="Brutal Down">
<a-input v-model:value="stream.finalmask.quicParams.brutalDown" placeholder="65537" />
</a-form-item>
</template>
<a-form-item label="UDP Hop">
<a-switch v-model:checked="stream.finalmask.quicParams.hasUdpHop" />
</a-form-item>
<template v-if="stream.finalmask.quicParams.hasUdpHop && stream.finalmask.quicParams.udpHop">
<a-form-item label="Hop Ports">
<a-input v-model:value="stream.finalmask.quicParams.udpHop.ports" placeholder="e.g. 20000-50000" />
</a-form-item>
<a-form-item label="Hop Interval (s)">
<a-input-number v-model:value="stream.finalmask.quicParams.udpHop.interval" :min="5" />
</a-form-item>
</template>
<a-form-item label="Max Idle Timeout (s)">
<a-input-number v-model:value="stream.finalmask.quicParams.maxIdleTimeout" :min="4" :max="120" />
</a-form-item>
<a-form-item label="Keep Alive Period (s)">
<a-input-number v-model:value="stream.finalmask.quicParams.keepAlivePeriod" :min="2" :max="60" />
</a-form-item>
<a-form-item label="Disable Path MTU Dis">
<a-switch v-model:checked="stream.finalmask.quicParams.disablePathMTUDiscovery" />
</a-form-item>
<a-form-item label="Max Incoming Streams">
<a-input-number v-model:value="stream.finalmask.quicParams.maxIncomingStreams" :min="8"
placeholder="1024 = default" />
</a-form-item>
<a-form-item label="Init Stream Window">
<a-input-number v-model:value="stream.finalmask.quicParams.initStreamReceiveWindow" :min="16384"
placeholder="8388608 = default" />
</a-form-item>
<a-form-item label="Max Stream Window">
<a-input-number v-model:value="stream.finalmask.quicParams.maxStreamReceiveWindow" :min="16384"
placeholder="8388608 = default" />
</a-form-item>
<a-form-item label="Init Conn Window">
<a-input-number v-model:value="stream.finalmask.quicParams.initConnectionReceiveWindow" :min="16384"
placeholder="20971520 = default" />
</a-form-item>
<a-form-item label="Max Conn Window">
<a-input-number v-model:value="stream.finalmask.quicParams.maxConnectionReceiveWindow" :min="16384"
placeholder="20971520 = default" />
</a-form-item>
</template>
</template>
</a-form>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup>
// Inline ∞ SVG. The Unicode infinity character (U+221E) renders as an
// "m"-shaped glyph in some system fonts (Windows Segoe UI in particular),
// so the inbound list and client row table use this SVG instead. The
// path matches what the legacy panel embedded.
defineProps({
width: { type: [String, Number], default: 14 },
height: { type: [String, Number], default: 10 },
});
</script>
<template>
<svg :width="width" :height="height" viewBox="0 0 640 512" fill="currentColor" aria-hidden="true"
style="vertical-align: -1px; display: inline-block;">
<path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" />
</svg>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup>
import { ref, watch } from 'vue';
// Generic prompt modal — used by features like "import inbound" that
// need a free-form text/textarea input and a confirm callback. The
// parent owns the action; this component only surfaces the value via
// the `confirm` event when the user clicks OK.
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: '' },
okText: { type: String, default: 'OK' },
// 'text' = single-line input; 'textarea' = multi-line.
type: { type: String, default: 'text', validator: (v) => ['text', 'textarea'].includes(v) },
initialValue: { type: String, default: '' },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(['update:open', 'confirm']);
const value = ref('');
watch(() => props.open, (next) => {
if (next) value.value = props.initialValue;
});
function close() { emit('update:open', false); }
function ok() { emit('confirm', value.value); }
// Enter submits when single-line; ctrl+S submits in textarea mode
// (matches legacy keybindings).
function onKeydown(e) {
if (props.type !== 'textarea' && e.key === 'Enter') {
e.preventDefault();
ok();
return;
}
if (props.type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
e.preventDefault();
ok();
}
}
</script>
<template>
<a-modal :open="open" :title="title" :ok-text="okText" cancel-text="Cancel" :mask-closable="false"
:confirm-loading="loading" @ok="ok" @cancel="close">
<a-textarea v-if="type === 'textarea'" v-model:value="value" :auto-size="{ minRows: 10, maxRows: 20 }" autofocus
@keydown="onKeydown" />
<a-input v-else v-model:value="value" autofocus @keydown="onKeydown" />
</a-modal>
</template>
@@ -0,0 +1,35 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
paddings: {
type: String,
default: 'default',
validator: (value) => ['small', 'default'].includes(value),
},
});
const padding = computed(() =>
props.paddings === 'small' ? '10px 20px !important' : '20px !important',
);
</script>
<template>
<a-list-item :style="{ padding }">
<a-row :gutter="[8, 16]">
<a-col :xs="24" :lg="12">
<a-list-item-meta>
<template #title>
<slot name="title" />
</template>
<template #description>
<slot name="description" />
</template>
</a-list-item-meta>
</a-col>
<a-col :xs="24" :lg="12">
<slot name="control" />
</a-col>
</a-row>
</a-list-item>
</template>
+297
View File
@@ -0,0 +1,297 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
const props = defineProps({
data: { type: Array, required: true },
labels: { type: Array, default: () => [] },
vbWidth: { type: Number, default: 320 },
height: { type: Number, default: 80 },
stroke: { type: String, default: '#008771' },
strokeWidth: { type: Number, default: 2 },
maxPoints: { type: Number, default: 120 },
showGrid: { type: Boolean, default: true },
gridColor: { type: String, default: 'rgba(0,0,0,0.1)' },
fillOpacity: { type: Number, default: 0.15 },
showMarker: { type: Boolean, default: true },
markerRadius: { type: Number, default: 2.8 },
showAxes: { type: Boolean, default: false },
yTickStep: { type: Number, default: 25 },
tickCountX: { type: Number, default: 4 },
paddingLeft: { type: Number, default: 32 },
paddingRight: { type: Number, default: 6 },
paddingTop: { type: Number, default: 6 },
paddingBottom: { type: Number, default: 20 },
showTooltip: { type: Boolean, default: false },
// Value-range customization. When valueMax is null the chart auto-scales
// to the running max of the data (useful for unbounded series like
// network throughput or online clients). Defaults preserve the legacy
// 0..100 percent behavior so existing callers don't need to change.
valueMin: { type: Number, default: 0 },
valueMax: { type: [Number, null], default: 100 },
// Y-axis tick formatter. Receives the raw value, returns the label.
// tooltipFormatter formats the hover-readout; falls back to yFormatter.
yFormatter: { type: Function, default: (v) => `${Math.round(v)}%` },
tooltipFormatter: { type: Function, default: null },
});
const hoverIdx = ref(-1);
// Measured CSS width of the SVG. Drives the viewBox so SVG units stay
// 1:1 with rendered pixels — otherwise `preserveAspectRatio="none"`
// stretches the X axis and squashes axis text horizontally on narrow
// containers (mobile). Falls back to the prop until the first measure.
const svgRef = ref(null);
const measuredWidth = ref(0);
const effectiveVbWidth = computed(() => measuredWidth.value > 0 ? measuredWidth.value : props.vbWidth);
let resizeObserver = null;
function measure() {
const el = svgRef.value;
if (!el) return;
const w = el.getBoundingClientRect?.().width || 0;
if (w > 0) measuredWidth.value = Math.round(w);
}
onMounted(() => {
measure();
if (typeof ResizeObserver !== 'undefined' && svgRef.value) {
resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(svgRef.value);
} else {
window.addEventListener('resize', measure);
}
});
onBeforeUnmount(() => {
if (resizeObserver) resizeObserver.disconnect();
else window.removeEventListener('resize', measure);
});
const viewBoxAttr = computed(() => `0 0 ${effectiveVbWidth.value} ${props.height}`);
const drawWidth = computed(() => Math.max(1, effectiveVbWidth.value - props.paddingLeft - props.paddingRight));
const drawHeight = computed(() => Math.max(1, props.height - props.paddingTop - props.paddingBottom));
const nPoints = computed(() => Math.min(props.data.length, props.maxPoints));
const dataSlice = computed(() => {
const n = nPoints.value;
if (n === 0) return [];
return props.data.slice(props.data.length - n);
});
const labelsSlice = computed(() => {
const n = nPoints.value;
if (!props.labels?.length || n === 0) return [];
const start = Math.max(0, props.labels.length - n);
return props.labels.slice(start);
});
// Resolved domain. When valueMax is null we auto-scale; pad the upper
// bound by 10% so the line never touches the top edge — looks more
// natural and gives the axis a sane ceiling. Floor the dynamic range
// at 1 to avoid divide-by-zero on flat-line data (e.g. all zeros).
const yDomain = computed(() => {
const min = props.valueMin;
if (props.valueMax != null) return { min, max: props.valueMax };
let max = min;
for (const v of dataSlice.value) {
const n = Number(v);
if (Number.isFinite(n) && n > max) max = n;
}
if (max <= min) max = min + 1;
return { min, max: max * 1.1 };
});
function project(v) {
const { min, max } = yDomain.value;
const span = max - min;
if (span <= 0) return props.paddingTop + drawHeight.value;
const clipped = Math.max(min, Math.min(max, Number(v) || 0));
const ratio = (clipped - min) / span;
return Math.round(props.paddingTop + (drawHeight.value - ratio * drawHeight.value));
}
const pointsArr = computed(() => {
const n = nPoints.value;
if (n === 0) return [];
const slice = dataSlice.value;
const w = drawWidth.value;
const dx = n > 1 ? w / (n - 1) : 0;
return slice.map((v, i) => {
const x = Math.round(props.paddingLeft + i * dx);
return [x, project(v)];
});
});
const pointsStr = computed(() => pointsArr.value.map((p) => `${p[0]},${p[1]}`).join(' '));
const areaPath = computed(() => {
if (pointsArr.value.length === 0) return '';
const first = pointsArr.value[0];
const last = pointsArr.value[pointsArr.value.length - 1];
const baseY = props.paddingTop + drawHeight.value;
const line = pointsStr.value.replace(/ /g, ' L ');
return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
});
const gridLines = computed(() => {
if (!props.showGrid) return [];
const h = drawHeight.value;
const w = drawWidth.value;
return [0, 0.25, 0.5, 0.75, 1].map((r) => {
const y = Math.round(props.paddingTop + h * r);
return { x1: props.paddingLeft, y1: y, x2: props.paddingLeft + w, y2: y };
});
});
const lastPoint = computed(() => {
if (pointsArr.value.length === 0) return null;
return pointsArr.value[pointsArr.value.length - 1];
});
// Y-axis tick rendering. We pick a small number of evenly spaced values
// inside the resolved domain and run them through yFormatter — that's
// what makes "MB/s" / "clients" / "%" all render correctly without the
// caller having to subclass the component.
const yTicks = computed(() => {
if (!props.showAxes) return [];
const { min, max } = yDomain.value;
const out = [];
// For percent-style domains keep the legacy fixed step; otherwise
// default to 4 evenly spaced ticks (5 lines including the bottom).
if (props.valueMax === 100 && props.valueMin === 0 && props.yTickStep > 0) {
for (let p = min; p <= max; p += props.yTickStep) {
const y = project(p);
out.push({ y, label: props.yFormatter(p) });
}
return out;
}
const ticks = 5;
for (let i = 0; i < ticks; i++) {
const v = min + ((max - min) * i) / (ticks - 1);
out.push({ y: project(v), label: props.yFormatter(v) });
}
return out;
});
const xTicks = computed(() => {
if (!props.showAxes) return [];
const labels = labelsSlice.value;
const n = nPoints.value;
if (n === 0) return [];
const m = Math.max(2, props.tickCountX);
const w = drawWidth.value;
const dx = n > 1 ? w / (n - 1) : 0;
const out = [];
for (let i = 0; i < m; i++) {
const idx = Math.round((i * (n - 1)) / (m - 1));
const label = labels[idx] != null ? String(labels[idx]) : String(idx);
const x = Math.round(props.paddingLeft + idx * dx);
out.push({ x, label });
}
return out;
});
function onMouseMove(evt) {
if (!props.showTooltip || pointsArr.value.length === 0) return;
const rect = evt.currentTarget.getBoundingClientRect();
const px = evt.clientX - rect.left;
const x = (px / rect.width) * effectiveVbWidth.value;
const n = nPoints.value;
const dx = n > 1 ? drawWidth.value / (n - 1) : 0;
const idx = Math.max(0, Math.min(n - 1, Math.round((x - props.paddingLeft) / (dx || 1))));
hoverIdx.value = idx;
}
function onMouseLeave() {
hoverIdx.value = -1;
}
function fmtHoverText() {
const idx = hoverIdx.value;
if (idx < 0 || idx >= dataSlice.value.length) return '';
const raw = Number(dataSlice.value[idx] || 0);
const fmt = props.tooltipFormatter || props.yFormatter;
const val = fmt(Number.isFinite(raw) ? raw : 0);
const lab = labelsSlice.value[idx] != null ? labelsSlice.value[idx] : '';
return `${val}${lab ? ' • ' + lab : ''}`;
}
// Stable per-instance gradient id so multiple sparklines on a page
// don't clobber each other's <defs id="spkGrad">.
const gradId = `spkGrad-${Math.random().toString(36).slice(2, 9)}`;
</script>
<template>
<svg ref="svgRef" width="100%" :height="height" :viewBox="viewBoxAttr" preserveAspectRatio="none"
class="sparkline-svg" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
<defs>
<linearGradient :id="gradId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" :stop-color="stroke" :stop-opacity="fillOpacity" />
<stop offset="100%" :stop-color="stroke" stop-opacity="0" />
</linearGradient>
</defs>
<g v-if="showGrid">
<line v-for="(g, i) in gridLines" :key="i" :x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2" :stroke="gridColor"
stroke-width="1" class="cpu-grid-line" />
</g>
<g v-if="showAxes">
<text v-for="(t, i) in yTicks" :key="'y' + i" class="cpu-grid-y-text" :x="Math.max(0, paddingLeft - 4)"
:y="t.y + 4" text-anchor="end" font-size="10">{{ t.label }}</text>
<text v-for="(t, i) in xTicks" :key="'x' + i" class="cpu-grid-x-text" :x="t.x" :y="paddingTop + drawHeight + 14"
text-anchor="middle" font-size="10">{{ t.label }}</text>
</g>
<path v-if="areaPath" :d="areaPath" :fill="`url(#${gradId})`" stroke="none" />
<polyline :points="pointsStr" fill="none" :stroke="stroke" :stroke-width="strokeWidth" stroke-linecap="round"
stroke-linejoin="round" />
<circle v-if="showMarker && lastPoint" :cx="lastPoint[0]" :cy="lastPoint[1]" :r="markerRadius" :fill="stroke" />
<g v-if="showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx]">
<line class="cpu-grid-h-line" :x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]" :y1="paddingTop"
:y2="paddingTop + drawHeight" stroke="rgba(0,0,0,0.2)" stroke-width="1" />
<circle :cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]" r="3.5" :fill="stroke" />
<text class="cpu-grid-text" :x="pointsArr[hoverIdx][0]" :y="paddingTop + 12" text-anchor="middle"
font-size="11">{{ fmtHoverText() }}</text>
</g>
</svg>
</template>
<style scoped>
.sparkline-svg {
display: block;
width: 100%;
}
</style>
<!-- Axis labels live on SVG <text> elements; Vue's scoped CSS doesn't
reliably hash-attribute SVG descendants, so the dark-mode overrides
have to live in a non-scoped block to actually take effect. The
numbers are also small, so the dark-theme fills run at ~85% opacity
for legibility (the previous 55% was washed out on navy backgrounds). -->
<style>
.sparkline-svg .cpu-grid-y-text,
.sparkline-svg .cpu-grid-x-text {
fill: rgba(0, 0, 0, 0.65);
}
.sparkline-svg .cpu-grid-text {
fill: rgba(0, 0, 0, 0.88);
}
body.dark .sparkline-svg .cpu-grid-y-text,
body.dark .sparkline-svg .cpu-grid-x-text {
fill: rgba(255, 255, 255, 0.85);
}
body.dark .sparkline-svg .cpu-grid-text {
fill: rgba(255, 255, 255, 0.95);
}
body.dark .sparkline-svg .cpu-grid-line {
stroke: rgba(255, 255, 255, 0.12);
}
body.dark .sparkline-svg .cpu-grid-h-line {
stroke: rgba(255, 255, 255, 0.35);
}
</style>
+311
View File
@@ -0,0 +1,311 @@
<script>
// Use defineComponent so we can keep the parent + child components in
// the same file with the provide() <-> inject relationship intact.
import { defineComponent, h, computed, ref, resolveComponent, inject } from 'vue';
import { DragOutlined } from '@ant-design/icons-vue';
const ROW_CLASS = 'sortable-row';
// Sortable a-table — drag-to-reorder rows using Pointer Events.
//
// Why a custom component:
// - Old impl set draggable: true on every row, which broke text selection
// in cells and let HTML5 start drags from anywhere on the row. This
// version only initiates drag from an explicit handle, via Pointer
// Events (one API for mouse + touch + pen).
// - During drag, data-source is reordered live; the source row visually
// slides into the target slot. The live reorder IS the visual feedback.
// - On commit, emits onsort(sourceIndex, targetIndex) — same signature as
// before so existing call sites stay unchanged.
// - Keyboard support: ArrowUp/ArrowDown move the focused handle's row by
// one; Escape cancels an in-flight drag.
export const TableSortableTrigger = defineComponent({
name: 'TableSortableTrigger',
props: {
itemIndex: { type: Number, required: true },
},
setup(props) {
const sortable = inject('sortable', null);
const ariaLabel = computed(() => `Drag to reorder row ${(props.itemIndex ?? 0) + 1}`);
function onPointerDown(e) {
sortable?.startDrag?.(e, props.itemIndex);
}
function onKeyDown(e) {
const move = sortable?.moveByKeyboard;
if (!move) return;
if (e.key === 'ArrowUp') {
e.preventDefault();
move(-1, props.itemIndex);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
move(+1, props.itemIndex);
}
}
return () => h(DragOutlined, {
class: 'sortable-icon',
role: 'button',
tabindex: 0,
'aria-label': ariaLabel.value,
onPointerdown: onPointerDown,
onKeydown: onKeyDown,
});
},
});
export default defineComponent({
name: 'TableSortable',
inheritAttrs: false,
props: {
dataSource: { type: Array, default: () => [] },
customRow: { type: Function, default: null },
rowKey: { type: [String, Function], default: null },
locale: {
type: Object,
default: () => ({ filterConfirm: 'OK', filterReset: 'Reset', emptyText: 'No data' }),
},
},
emits: ['onsort'],
setup(props, { emit, slots, attrs, expose }) {
// null when idle; while dragging:
// { sourceIndex, targetIndex, pointerId, sourceKey }
const drag = ref(null);
const rootRef = ref(null);
const isDragging = computed(() => drag.value !== null);
// Resolve the row key for a record. Used to identify the source row
// even after data-source is reordered live during drag.
function keyOf(record, fallback) {
const rk = props.rowKey;
if (typeof rk === 'function') return rk(record);
if (typeof rk === 'string') return record?.[rk];
return fallback;
}
function attachListeners() {
document.addEventListener('pointermove', onPointerMove, true);
document.addEventListener('pointerup', onPointerUp, true);
document.addEventListener('pointercancel', cancelDrag, true);
document.addEventListener('keydown', cancelDrag, true);
}
function detachListeners() {
document.removeEventListener('pointermove', onPointerMove, true);
document.removeEventListener('pointerup', onPointerUp, true);
document.removeEventListener('pointercancel', cancelDrag, true);
document.removeEventListener('keydown', cancelDrag, true);
}
function startDrag(e, sourceIndex) {
// Primary button only (mouse left / first touch).
if (e.button != null && e.button !== 0) return;
e.preventDefault();
const record = props.dataSource?.[sourceIndex];
drag.value = {
sourceIndex,
targetIndex: sourceIndex,
pointerId: e.pointerId,
sourceKey: keyOf(record, sourceIndex),
};
// Capture the pointer so move/up keep firing even if the cursor
// leaves the icon. Try/catch — some older browsers throw on capture.
if (e.target?.setPointerCapture && e.pointerId != null) {
try { e.target.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
}
attachListeners();
}
function onPointerMove(e) {
const d = drag.value;
if (!d) return;
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
const root = rootRef.value;
if (!root) return;
const rows = root.querySelectorAll(`tr.${ROW_CLASS}`);
if (!rows.length) return;
const y = e.clientY;
const firstRect = rows[0].getBoundingClientRect();
const lastRect = rows[rows.length - 1].getBoundingClientRect();
let target = d.targetIndex;
if (y < firstRect.top) {
target = 0;
} else if (y > lastRect.bottom) {
target = rows.length - 1;
} else {
for (let i = 0; i < rows.length; i++) {
const rect = rows[i].getBoundingClientRect();
if (y >= rect.top && y <= rect.bottom) {
target = i;
break;
}
}
}
if (target !== d.targetIndex) {
drag.value = { ...d, targetIndex: target };
}
}
function onPointerUp(e) {
const d = drag.value;
if (!d) return;
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
detachListeners();
const captured = d;
drag.value = null;
if (captured.sourceIndex !== captured.targetIndex) {
emit('onsort', captured.sourceIndex, captured.targetIndex);
}
}
function cancelDrag(e) {
// Triggered by pointercancel and keydown. For keydown only act on
// Escape; otherwise let the event propagate.
if (e?.type === 'keydown' && e.key !== 'Escape') return;
detachListeners();
drag.value = null;
}
function moveByKeyboard(direction, sourceIndex) {
const target = sourceIndex + direction;
if (target < 0 || target >= (props.dataSource?.length ?? 0)) return;
emit('onsort', sourceIndex, target);
}
function customRowRender(record, index) {
const parent = typeof props.customRow === 'function' ? props.customRow(record, index) || {} : {};
const d = drag.value;
const isSource = d && keyOf(record, index) === d.sourceKey;
// Vue 3 customRow shape: a flat object of attrs/listeners/class —
// no nested props/on like Vue 2.
return {
...parent,
class: { [ROW_CLASS]: true, 'sortable-source-row': !!isSource, ...(parent.class || {}) },
};
}
// Render-data: dataSource with the source row spliced into targetIndex.
// When idle the original list is returned unchanged so a-table can
// diff against a stable reference.
const records = computed(() => {
const d = drag.value;
const src = props.dataSource ?? [];
if (!d || d.sourceIndex === d.targetIndex) return src;
const list = src.slice();
const [item] = list.splice(d.sourceIndex, 1);
list.splice(d.targetIndex, 0, item);
return list;
});
expose({ startDrag, moveByKeyboard });
return {
rootRef, drag, isDragging, records, slots, attrs,
startDrag, moveByKeyboard, customRowRender,
};
},
// provide() needs to live at the options level so child components in
// the rendered subtree resolve the same instance methods.
provide() {
return {
sortable: {
startDrag: (...a) => this.startDrag(...a),
moveByKeyboard: (...a) => this.moveByKeyboard(...a),
},
};
},
beforeUnmount() {
document.removeEventListener('pointermove', this.onPointerMove, true);
document.removeEventListener('pointerup', this.onPointerUp, true);
document.removeEventListener('pointercancel', this.cancelDrag, true);
document.removeEventListener('keydown', this.cancelDrag, true);
},
render() {
// Forward every passed slot to a-table by reusing the slot fn
// directly. Vue 3 slots are scoped by default so no $scopedSlots dance.
const tableSlots = {};
for (const name of Object.keys(this.slots)) {
tableSlots[name] = this.slots[name];
}
// Resolved at runtime so the user's app.use(Antd) registration wins;
// avoids importing Table directly here.
const ATable = resolveComponent('a-table');
return h(
'div',
{ ref: 'rootRef' },
[h(
ATable,
{
...this.attrs,
'data-source': this.records,
'row-key': this.rowKey,
customRow: this.customRowRender,
locale: this.locale,
class: ['sortable-table', { 'sortable-table-dragging': this.isDragging }],
},
tableSlots,
)],
);
},
});
</script>
<style>
.sortable-icon {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: grab;
padding: 6px;
border-radius: 6px;
color: rgba(255, 255, 255, 0.5);
transition: background-color 0.15s ease, color 0.15s ease;
user-select: none;
touch-action: none;
}
.sortable-icon:hover {
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.06);
}
.sortable-icon:active {
cursor: grabbing;
}
.sortable-icon:focus-visible {
outline: 2px solid #008771;
outline-offset: 2px;
}
.light .sortable-icon {
color: rgba(0, 0, 0, 0.45);
}
.light .sortable-icon:hover {
color: rgba(0, 0, 0, 0.85);
background: rgba(0, 0, 0, 0.05);
}
.sortable-table-dragging .sortable-source-row>td {
background: rgba(0, 135, 113, 0.10) !important;
transition: background-color 0.18s ease;
}
.sortable-table-dragging .sortable-source-row .routing-index,
.sortable-table-dragging .sortable-source-row .outbound-index {
opacity: 0.45;
}
.sortable-table-dragging .sortable-row>td {
transition: background-color 0.18s ease;
}
.sortable-table-dragging,
.sortable-table-dragging * {
user-select: none;
}
</style>
+66
View File
@@ -0,0 +1,66 @@
<script setup>
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { ClipboardManager, FileManager } from '@/utils';
// Read-only text modal — used to surface multi-line export blobs
// (subscription URLs, raw inbound JSON, generated share links) the
// way the legacy txtModal did.
defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: '' },
content: { type: String, default: '' },
// When set, surfaces a download button that writes `content` to a
// text file with this name.
fileName: { type: String, default: '' },
});
const emit = defineEmits(['update:open']);
function close() {
emit('update:open', false);
}
async function copy(value) {
const ok = await ClipboardManager.copyText(value || '');
if (ok) {
message.success('Copied');
close();
}
}
function download(content, name) {
if (!name) return;
FileManager.downloadTextFile(content, name);
}
</script>
<template>
<a-modal :open="open" :title="title" :closable="true" @cancel="close">
<a-textarea :value="content" readonly :auto-size="{ minRows: 10, maxRows: 20 }" class="text-modal-content" />
<template #footer>
<a-button v-if="fileName" @click="download(content, fileName)">
<template #icon>
<DownloadOutlined />
</template>
{{ fileName }}
</a-button>
<a-button type="primary" @click="copy(content)">
<template #icon>
<CopyOutlined />
</template>
Copy
</a-button>
</template>
</a-modal>
</template>
<style scoped>
.text-modal-content {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
overflow-y: auto;
}
</style>
+45
View File
@@ -0,0 +1,45 @@
// Module-scoped reactive ref for the panel's "Calendar Type" setting.
// Loaded from /panel/setting/defaultSettings on first use, so any
// component (modals, inbound forms, future pages) can read the same
// value without prop-drilling and without re-fetching.
//
// useInbounds (which already reads defaultSettings for its own state)
// calls setDatepicker() after its fetch so we don't issue a second
// HTTP round-trip on the inbounds page.
import { readonly, ref } from 'vue';
import { HttpUtil } from '@/utils';
const datepicker = ref('gregorian');
let fetched = false;
let pending = null;
async function loadOnce() {
if (fetched) return;
if (pending) {
await pending;
return;
}
pending = (async () => {
try {
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
if (msg?.success) {
datepicker.value = msg.obj?.datepicker || 'gregorian';
}
} finally {
fetched = true;
pending = null;
}
})();
await pending;
}
export function setDatepicker(value) {
fetched = true;
datepicker.value = value || 'gregorian';
}
export function useDatepicker() {
loadOnce();
return { datepicker: readonly(datepicker) };
}
+26
View File
@@ -0,0 +1,26 @@
import { ref, onBeforeUnmount, onMounted } from 'vue';
const MOBILE_BREAKPOINT_PX = 768;
// Vue 3 replacement for the legacy MediaQueryMixin. Returns a reactive
// `isMobile` ref that updates on window resize. Use inside <script setup>:
//
// const { isMobile } = useMediaQuery();
export function useMediaQuery(breakpoint = MOBILE_BREAKPOINT_PX) {
const compute = () => window.innerWidth <= breakpoint;
const isMobile = ref(compute());
const onResize = () => {
isMobile.value = compute();
};
onMounted(() => {
window.addEventListener('resize', onResize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
});
return { isMobile };
}
+42
View File
@@ -0,0 +1,42 @@
// Lightweight composable that fetches the node list once on mount and
// exposes id→name + id→online lookups. Used by the Inbounds page so it
// can render a Node selector and a Node column without pulling the
// full pages/nodes/useNodes.js (which polls and owns CRUD state).
import { onMounted, ref, computed } from 'vue';
import { HttpUtil } from '@/utils';
export function useNodeList() {
const nodes = ref([]);
const fetched = ref(false);
async function refresh() {
const msg = await HttpUtil.get('/panel/api/nodes/list');
if (msg?.success) {
nodes.value = Array.isArray(msg.obj) ? msg.obj : [];
}
fetched.value = true;
}
// Indexed by id for O(1) UI lookups (Node column on N-row tables).
const byId = computed(() => {
const m = new Map();
for (const n of nodes.value) m.set(n.id, n);
return m;
});
function nameFor(id) {
if (id == null) return null;
return byId.value.get(id)?.name || null;
}
function isOnline(id) {
if (id == null) return true;
const n = byId.value.get(id);
return n != null && n.enable && n.status === 'online';
}
onMounted(refresh);
return { nodes, fetched, refresh, byId, nameFor, isOnline };
}
+43
View File
@@ -0,0 +1,43 @@
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
import { HttpUtil } from '@/utils';
import { Status } from '@/models/status.js';
const POLL_INTERVAL_MS = 2000;
// Polls /panel/api/server/status and exposes a reactive Status object
// + a `fetched` flag so consumers can show a spinner before the first
// successful fetch.
//
// WebSocket integration is intentionally deferred to a later sub-phase.
// Polling at 2s is the same fallback the legacy panel falls back to
// when its websocket link drops, so we're shipping the proven path
// first and adding the websocket on top later.
export function useStatus() {
const status = shallowRef(new Status());
const fetched = ref(false);
let timer = null;
async function refresh() {
try {
const msg = await HttpUtil.get('/panel/api/server/status');
if (msg?.success) {
status.value = new Status(msg.obj);
if (!fetched.value) fetched.value = true;
}
} catch (e) {
console.error('Failed to get status:', e);
}
}
onMounted(() => {
refresh();
timer = window.setInterval(refresh, POLL_INTERVAL_MS);
});
onBeforeUnmount(() => {
if (timer != null) window.clearInterval(timer);
});
return { status, fetched, refresh };
}
+128
View File
@@ -0,0 +1,128 @@
import { reactive, computed, watchEffect } from 'vue';
import { theme as antdTheme } from 'ant-design-vue';
// Single shared theme state. `import { theme } from '@/composables/useTheme.js'`
// from any component to read/toggle. Boot side-effects (apply current
// theme to <body>/<html>) run once at module load so the page is in the
// right theme before Vue mounts.
const STORAGE_DARK = 'dark-mode';
const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
function readBool(key, fallback) {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
return raw === 'true';
}
const isDark = readBool(STORAGE_DARK, true);
const isUltra = readBool(STORAGE_ULTRA, false);
export const theme = reactive({
isDark,
isUltra,
});
export const currentTheme = computed(() => (theme.isDark ? 'dark' : 'light'));
// AD-Vue 4 theme config consumed by every page's <a-config-provider>.
// Three modes — light / dark / ultra-dark — all share AD-Vue's vanilla
// blue primary. Dark uses a neutral grey palette modelled on VS Code's
// Dark+ chrome (`#1e1e1e` editor, `#252526` sidebar, `#2d2d30` panel),
// so the panel reads as a familiar modern IDE rather than the older
// navy shade. Ultra-dark stays pure-black on darkAlgorithm.
const DARK_TOKENS = {
colorBgBase: '#1e1e1e',
colorBgLayout: '#1e1e1e',
colorBgContainer: '#252526',
colorBgElevated: '#2d2d30',
};
const ULTRA_DARK_TOKENS = {
colorBgBase: '#000',
colorBgLayout: '#000',
colorBgContainer: '#0a0a0a',
colorBgElevated: '#141414',
};
// AD-Vue 4 hardcodes navy `#001529` / `#002140` as the Layout sider
// + trigger backgrounds and `#001529` / `#000c17` as the dark Menu item
// backgrounds (see node_modules/ant-design-vue/es/{layout,menu}/style/
// index.js). Override at the component-token level so the sider blends
// with darkAlgorithm's neutral surfaces. Sider/trigger use the same
// `#252526` / `#333333` tones VS Code does for its activity bar.
const DARK_LAYOUT_TOKENS = {
colorBgHeader: '#252526',
colorBgTrigger: '#333333',
colorBgBody: '#1e1e1e',
};
const ULTRA_DARK_LAYOUT_TOKENS = {
colorBgHeader: '#0a0a0a',
colorBgTrigger: '#141414',
colorBgBody: '#000',
};
const DARK_MENU_TOKENS = {
colorItemBg: '#252526',
colorSubItemBg: '#1e1e1e',
menuSubMenuBg: '#252526',
};
const ULTRA_DARK_MENU_TOKENS = {
colorItemBg: '#0a0a0a',
colorSubItemBg: '#000',
menuSubMenuBg: '#0a0a0a',
};
export const antdThemeConfig = computed(() => {
if (!theme.isDark) {
return { algorithm: antdTheme.defaultAlgorithm };
}
return {
algorithm: antdTheme.darkAlgorithm,
token: theme.isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
components: {
Layout: theme.isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
Menu: theme.isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
},
};
});
export function toggleTheme() {
theme.isDark = !theme.isDark;
}
export function toggleUltra() {
theme.isUltra = !theme.isUltra;
}
// Briefly disable theme transition animations while a toggle is in
// flight, then re-enable on mouseleave. Mirrors the legacy panel's
// behavior of preventing flicker when hovering the theme menu.
export function pauseAnimationsUntilLeave(elementId) {
document.documentElement.setAttribute('data-theme-animations', 'off');
const el = document.getElementById(elementId);
if (!el) return;
const restore = () => {
document.documentElement.removeAttribute('data-theme-animations');
el.removeEventListener('mouseleave', restore);
el.removeEventListener('touchend', restore);
};
el.addEventListener('mouseleave', restore);
el.addEventListener('touchend', restore);
}
// Apply theme to DOM and persist whenever it changes.
watchEffect(() => {
document.body.setAttribute('class', theme.isDark ? 'dark' : 'light');
localStorage.setItem(STORAGE_DARK, String(theme.isDark));
if (theme.isUltra) {
document.documentElement.setAttribute('data-theme', 'ultra-dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem(STORAGE_ULTRA, String(theme.isUltra));
// Keep the global #message container's class in sync so AD-Vue toasts
// pick up the right styling.
const msg = document.getElementById('message');
if (msg) msg.className = theme.isDark ? 'dark' : 'light';
});
+48
View File
@@ -0,0 +1,48 @@
import { onBeforeUnmount, onMounted } from 'vue';
import { WebSocketClient } from '@/api/websocket.js';
// One client per browser tab (= per multi-page entry). WebSocketClient is
// idempotent: repeated connect() calls while the socket is already open
// are no-ops, so multiple components on the same page can share a single
// underlying connection without each spawning their own.
let sharedClient = null;
function getSharedClient() {
if (sharedClient) return sharedClient;
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
sharedClient = new WebSocketClient(basePath);
return sharedClient;
}
// useWebSocket lets a Vue component subscribe to live server-pushed
// events. Pass a map of { eventName: handler } and the composable wires
// connect()/disconnect() into the component lifecycle and unsubscribes
// every handler on unmount so a stale closure can't fire after the
// page has moved on.
//
// Example:
// useWebSocket({
// traffic: (payload) => applyTrafficEvent(payload),
// client_stats: (payload) => applyClientStatsEvent(payload),
// invalidate: ({ type }) => { if (type === 'inbounds') refresh(); },
// });
//
// Built-in lifecycle events ('connected' / 'disconnected' / 'error')
// can be subscribed to alongside server-emitted types.
export function useWebSocket(handlers) {
const client = getSharedClient();
const entries = Object.entries(handlers || {});
onMounted(() => {
for (const [event, fn] of entries) client.on(event, fn);
client.connect();
});
onBeforeUnmount(() => {
for (const [event, fn] of entries) client.off(event, fn);
// Don't disconnect — another mounted component on the same page may
// still be subscribed. The client closes naturally on page unload.
});
return { client };
}
+17
View File
@@ -0,0 +1,17 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
setupAxios();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
+17
View File
@@ -0,0 +1,17 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
setupAxios();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
+19
View File
@@ -0,0 +1,19 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import IndexPage from '@/pages/index/IndexPage.vue';
setupAxios();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(IndexPage).use(Antd).use(i18n).mount('#app');
+21
View File
@@ -0,0 +1,21 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
// Importing this module triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue renders anything.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import LoginPage from '@/pages/login/LoginPage.vue';
setupAxios();
// Toasts attach to a #message div the page provides — keeps theme
// styling in sync with the rest of the panel.
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(LoginPage).use(Antd).use(i18n).mount('#app');
+17
View File
@@ -0,0 +1,17 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import NodesPage from '@/pages/nodes/NodesPage.vue';
setupAxios();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(NodesPage).use(Antd).use(i18n).mount('#app');
+19
View File
@@ -0,0 +1,19 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import SettingsPage from '@/pages/settings/SettingsPage.vue';
setupAxios();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
+18
View File
@@ -0,0 +1,18 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
// The sub page is served by the subscription HTTP server (sub/sub.go)
// at /<linksPath>/<subId>?html=1. Go injects window.__SUB_PAGE_DATA__
// with the parsed traffic/quota/expiry view-model and the rendered
// share links — the SPA reads those at mount.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import SubPage from '@/pages/sub/SubPage.vue';
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(SubPage).use(Antd).use(i18n).mount('#app');
+17
View File
@@ -0,0 +1,17 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import XrayPage from '@/pages/xray/XrayPage.vue';
setupAxios();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(XrayPage).use(Antd).use(i18n).mount('#app');
+93
View File
@@ -0,0 +1,93 @@
// vue-i18n setup. Locale files live in web/translation/*.json — the same
// directory the Go binary embeds, so SPA + Telegram bot + subscription
// page all read from a single source.
//
// Usage in a component:
// import { useI18n } from 'vue-i18n';
// const { t } = useI18n();
// ...
// <span>{{ t('pages.inbounds.email') }}</span>
//
// Or via the global helper exposed on the app:
// <span>{{ $t('pages.inbounds.email') }}</span>
//
// The locale follows the `lang` cookie that LanguageManager already
// reads/writes — switching language anywhere in the app continues to
// trigger a full page reload (matches legacy ergonomics), so we don't
// need a runtime locale switcher here.
import { createI18n } from 'vue-i18n';
import { LanguageManager } from '@/utils';
// Lazy-loaded locales — Vite splits each one into its own chunk. We
// eager-load only the active language plus the en-US fallback so the
// initial page payload stays small (the inbounds bundle was sitting
// at ~700kB gzipped with all 13 locales eager; now ~480kB).
//
// LanguageManager.setLanguage() does a full reload on change, so
// "lazy" here effectively means "load only what this page needs for
// its lifetime."
const FALLBACK = 'en-US';
const lazyModules = import.meta.glob('../../../web/translation/*.json');
const eagerModules = import.meta.glob('../../../web/translation/*.json', { eager: true });
function moduleKeyFor(code) {
return `../../../web/translation/${code}.json`;
}
// Resolve the active locale via LanguageManager so the cookie set on
// the legacy panel keeps working after a user upgrades. Falls back
// to en-US when the cookie names a language we don't have.
let active = LanguageManager.getLanguage();
if (!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
active = FALLBACK;
}
const messages = {};
// Eagerly include the active locale + the fallback (when distinct)
// so the very first render has strings ready. Vite still emits these
// as their own chunks so the user pays for at most two locales.
for (const code of new Set([active, FALLBACK])) {
const mod = eagerModules[moduleKeyFor(code)];
if (mod) messages[code] = mod.default || mod;
}
export const i18n = createI18n({
legacy: false,
// `composition` mode (legacy: false) so `useI18n()` works in
// <script setup> blocks.
globalInjection: true,
locale: active,
fallbackLocale: FALLBACK,
// Locale JSON is nested by namespace ({pages: {inbounds: {email: ...}}})
// so vue-i18n's default `.`-delimited lookups walk straight into it.
messages,
// The Go side sometimes interpolates `#variable#` into translated
// strings (e.g. xraySwitchVersionDialogDesc). vue-i18n's default
// expects `{var}` — disable warnings about strings that look like
// they don't use the new syntax.
warnHtmlMessage: false,
missingWarn: false,
fallbackWarn: false,
});
// Convenience export for non-component contexts (HTTP error toasts,
// stores, etc.) that need to look up a translation outside a setup
// scope.
export function t(key, params) {
return i18n.global.t(key, params || {});
}
// loadLocale fetches a locale module on demand and registers it with
// vue-i18n. Pages that switch language at runtime (rather than via
// LanguageManager's reload) can call this to swap strings live.
export async function loadLocale(code) {
const key = moduleKeyFor(code);
const loader = lazyModules[key];
if (!loader) return false;
const mod = await loader();
i18n.global.setLocaleMessage(code, mod.default || mod);
i18n.global.locale.value = code;
return true;
}
@@ -1,4 +1,8 @@
class DBInbound {
import dayjs from 'dayjs';
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
import { Inbound, Protocols } from './inbound.js';
export class DBInbound {
constructor(data) {
this.id = 0;
@@ -6,9 +10,12 @@ class DBInbound {
this.up = 0;
this.down = 0;
this.total = 0;
this.allTime = 0;
this.remark = "";
this.enable = true;
this.expiryTime = 0;
this.trafficReset = "never";
this.lastTrafficResetTime = 0;
this.listen = "";
this.port = 0;
@@ -18,6 +25,9 @@ class DBInbound {
this.tag = "";
this.sniffing = "";
this.clientStats = ""
// Optional FK to web/runtime registered Node. null/undefined =
// local panel; otherwise the inbound lives on the named node.
this.nodeId = null;
if (data == null) {
return;
}
@@ -48,8 +58,8 @@ class DBInbound {
return this.protocol === Protocols.SHADOWSOCKS;
}
get isSocks() {
return this.protocol === Protocols.SOCKS;
get isMixed() {
return this.protocol === Protocols.MIXED;
}
get isHTTP() {
@@ -72,7 +82,7 @@ class DBInbound {
if (this.expiryTime === 0) {
return null;
}
return moment(this.expiryTime);
return dayjs(this.expiryTime);
}
set _expiryTime(t) {
@@ -87,7 +97,16 @@ class DBInbound {
return this.expiryTime < new Date().getTime();
}
invalidateCache() {
this._cachedInbound = null;
this._clientStatsMap = null;
}
toInbound() {
if (this._cachedInbound) {
return this._cachedInbound;
}
let settings = {};
if (!ObjectUtil.isEmpty(this.settings)) {
settings = JSON.parse(this.settings);
@@ -113,7 +132,21 @@ class DBInbound {
sniffing: sniffing,
clientStats: this.clientStats,
};
return Inbound.fromJson(config);
this._cachedInbound = Inbound.fromJson(config);
return this._cachedInbound;
}
getClientStats(email) {
if (!this._clientStatsMap) {
this._clientStatsMap = new Map();
if (this.clientStats && Array.isArray(this.clientStats)) {
for (const stats of this.clientStats) {
this._clientStatsMap.set(stats.email, stats);
}
}
}
return this._clientStatsMap.get(email);
}
isMultiUser() {
@@ -121,6 +154,7 @@ class DBInbound {
case Protocols.VMESS:
case Protocols.VLESS:
case Protocols.TROJAN:
case Protocols.HYSTERIA:
return true;
case Protocols.SHADOWSOCKS:
return this.toInbound().isSSMultiUser;
@@ -135,14 +169,15 @@ class DBInbound {
case Protocols.VLESS:
case Protocols.TROJAN:
case Protocols.SHADOWSOCKS:
case Protocols.HYSTERIA:
return true;
default:
return false;
}
}
genInboundLinks(remarkModel) {
genInboundLinks(remarkModel, hostOverride = '') {
const inbound = this.toInbound();
return inbound.genInboundLinks(this.remark, remarkModel);
return inbound.genInboundLinks(this.remark, remarkModel, hostOverride);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
// List of popular services for VLESS Reality Target/SNI randomization
export const REALITY_TARGETS = [
{ target: 'www.amazon.com:443', sni: 'www.amazon.com' },
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
{ target: 'www.oracle.com:443', sni: 'www.oracle.com' },
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
{ target: 'www.amd.com:443', sni: 'www.amd.com' },
{ target: 'www.intel.com:443', sni: 'www.intel.com' },
{ target: 'www.sony.com:443', sni: 'www.sony.com' }
];
/**
* Returns a random Reality target configuration from the predefined list
* @returns {Object} Object with target and sni properties
*/
export function getRandomRealityTarget() {
const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
const selected = REALITY_TARGETS[randomIndex];
// Return a copy to avoid reference issues
return {
target: selected.target,
sni: selected.sni
};
}
+100
View File
@@ -0,0 +1,100 @@
// Mirrors web/assets/js/model/setting.js — every field on this class is
// round-tripped through `/panel/setting/all` and `/panel/setting/update`,
// so adding a field here without a matching Go-side change will silently
// drop it on save. Defaults match the legacy panel.
import { ObjectUtil } from '@/utils';
export class AllSetting {
constructor(data) {
this.webListen = "";
this.webDomain = "";
this.webPort = 2053;
this.webCertFile = "";
this.webKeyFile = "";
this.webBasePath = "/";
this.sessionMaxAge = 360;
this.pageSize = 25;
this.expireDiff = 0;
this.trafficDiff = 0;
this.remarkModel = "-ieo";
this.datepicker = "gregorian";
this.tgBotEnable = false;
this.tgBotToken = "";
this.tgBotProxy = "";
this.tgBotAPIServer = "";
this.tgBotChatId = "";
this.tgRunTime = "@daily";
this.tgBotBackup = false;
this.tgBotLoginNotify = true;
this.tgCpu = 80;
this.tgLang = "en-US";
this.twoFactorEnable = false;
this.twoFactorToken = "";
this.xrayTemplateConfig = "";
this.subEnable = true;
this.subJsonEnable = false;
this.subTitle = "";
this.subSupportUrl = "";
this.subProfileUrl = "";
this.subAnnounce = "";
this.subEnableRouting = true;
this.subRoutingRules = "";
this.subListen = "";
this.subPort = 2096;
this.subPath = "/sub/";
this.subJsonPath = "/json/";
this.subClashEnable = true;
this.subClashPath = "/clash/";
this.subDomain = "";
this.externalTrafficInformEnable = false;
this.externalTrafficInformURI = "";
this.restartXrayOnClientDisable = true;
this.subCertFile = "";
this.subKeyFile = "";
this.subUpdates = 12;
this.subEncrypt = true;
this.subShowInfo = true;
this.subURI = "";
this.subJsonURI = "";
this.subClashURI = "";
this.subJsonFragment = "";
this.subJsonNoises = "";
this.subJsonMux = "";
this.subJsonRules = "";
this.timeLocation = "Local";
// LDAP settings
this.ldapEnable = false;
this.ldapHost = "";
this.ldapPort = 389;
this.ldapUseTLS = false;
this.ldapBindDN = "";
this.ldapPassword = "";
this.ldapBaseDN = "";
this.ldapUserFilter = "(objectClass=person)";
this.ldapUserAttr = "mail";
this.ldapVlessField = "vless_enabled";
this.ldapSyncCron = "@every 1m";
this.ldapFlagField = "";
this.ldapTruthyValues = "true,1,yes,on";
this.ldapInvertFlag = false;
this.ldapInboundTags = "";
this.ldapAutoCreate = false;
this.ldapAutoDelete = false;
this.ldapDefaultTotalGB = 0;
this.ldapDefaultExpiryDays = 0;
this.ldapDefaultLimitIP = 0;
if (data == null) {
return
}
ObjectUtil.cloneProps(this, data);
}
equals(other) {
return ObjectUtil.equals(this, other);
}
}
+71
View File
@@ -0,0 +1,71 @@
import { NumberFormatter } from '@/utils';
export class CurTotal {
constructor(current, total) {
this.current = current;
this.total = total;
}
get percent() {
if (this.total === 0) return 0;
return NumberFormatter.toFixed((this.current / this.total) * 100, 2);
}
get color() {
// Match AD-Vue 4's semantic palette so the gauges fit the
// global blue/gold/red theme instead of the legacy teal/orange.
const p = this.percent;
if (p < 80) return '#1677ff'; // primary
if (p < 90) return '#faad14'; // warning
return '#ff4d4f'; // danger
}
}
const XRAY_STATE_COLORS = {
running: 'green',
stop: 'orange',
error: 'red',
};
export class Status {
constructor(data) {
this.cpu = new CurTotal(0, 0);
this.cpuCores = 0;
this.logicalPro = 0;
this.cpuSpeedMhz = 0;
this.disk = new CurTotal(0, 0);
this.loads = [0, 0, 0];
this.mem = new CurTotal(0, 0);
this.netIO = { up: 0, down: 0 };
this.netTraffic = { sent: 0, recv: 0 };
this.publicIP = { ipv4: 0, ipv6: 0 };
this.swap = new CurTotal(0, 0);
this.tcpCount = 0;
this.udpCount = 0;
this.uptime = 0;
this.appUptime = 0;
this.appStats = { threads: 0, mem: 0, uptime: 0 };
this.xray = { state: 'stop', errorMsg: '', version: '', color: '' };
if (data == null) return;
this.cpu = new CurTotal(data.cpu, 100);
this.cpuCores = data.cpuCores;
this.logicalPro = data.logicalPro;
this.cpuSpeedMhz = data.cpuSpeedMhz;
this.disk = new CurTotal(data.disk?.current ?? 0, data.disk?.total ?? 0);
this.loads = (data.loads || [0, 0, 0]).map((v) => NumberFormatter.toFixed(v, 2));
this.mem = new CurTotal(data.mem?.current ?? 0, data.mem?.total ?? 0);
this.netIO = data.netIO ?? this.netIO;
this.netTraffic = data.netTraffic ?? this.netTraffic;
this.publicIP = data.publicIP ?? this.publicIP;
this.swap = new CurTotal(data.swap?.current ?? 0, data.swap?.total ?? 0);
this.tcpCount = data.tcpCount ?? 0;
this.udpCount = data.udpCount ?? 0;
this.uptime = data.uptime ?? 0;
this.appUptime = data.appUptime ?? 0;
this.appStats = data.appStats ?? this.appStats;
this.xray = { ...this.xray, ...(data.xray || {}) };
this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray';
}
}
+339
View File
@@ -0,0 +1,339 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import {
KeyOutlined,
ReloadOutlined,
CopyOutlined,
EyeOutlined,
EyeInvisibleOutlined,
} from '@ant-design/icons-vue';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import AppSidebar from '@/components/AppSidebar.vue';
import { HttpUtil, ClipboardManager } from '@/utils/index.js';
import { sections } from './endpoints.js';
import EndpointSection from './EndpointSection.vue';
const { t } = useI18n();
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
const apiToken = ref('');
const tokenLoading = ref(false);
const tokenRotating = ref(false);
const tokenVisible = ref(false);
const curlExample = `curl -X GET \\
-H "Authorization: Bearer YOUR_API_TOKEN" \\
-H "Accept: application/json" \\
https://your-panel.example.com/panel/api/inbounds/list`;
async function loadApiToken() {
tokenLoading.value = true;
try {
const msg = await HttpUtil.get('/panel/setting/getApiToken');
if (msg?.success) apiToken.value = msg.obj || '';
} finally {
tokenLoading.value = false;
}
}
function regenerateApiToken() {
Modal.confirm({
title: t('pages.nodes.regenerateConfirm'),
okText: t('confirm'),
cancelText: t('cancel'),
okType: 'danger',
onOk: async () => {
tokenRotating.value = true;
try {
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
if (msg?.success) {
apiToken.value = msg.obj || '';
message.success(t('success'));
}
} finally {
tokenRotating.value = false;
}
},
});
}
async function copyApiToken() {
if (!apiToken.value) return;
const ok = await ClipboardManager.copy(apiToken.value);
if (ok) message.success(t('success'));
}
function scrollToSection(id) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
onMounted(() => {
loadApiToken();
});
</script>
<template>
<a-config-provider :theme="antdThemeConfig">
<a-layout class="api-docs-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
<a-layout class="content-shell">
<a-layout-content class="content-area">
<div class="docs-wrapper">
<header class="docs-header">
<h1 class="docs-title">API Documentation</h1>
<p class="docs-lead">
The 3x-ui panel exposes a REST API under <code>/panel/api/</code>. Authenticate with the panel session
cookie, or with the <code>Authorization: Bearer &lt;token&gt;</code> header below. Every endpoint
returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted.
</p>
</header>
<a-card class="token-card" size="small">
<div class="token-card-head">
<div class="token-card-title">
<KeyOutlined />
<span>API Token</span>
</div>
<a-space size="small" wrap>
<a-button size="small" @click="tokenVisible = !tokenVisible">
<template #icon>
<EyeInvisibleOutlined v-if="tokenVisible" />
<EyeOutlined v-else />
</template>
{{ tokenVisible ? 'Hide' : 'Show' }}
</a-button>
<a-button size="small" :disabled="!apiToken" @click="copyApiToken">
<template #icon>
<CopyOutlined />
</template>
Copy
</a-button>
<a-button size="small" danger :loading="tokenRotating" @click="regenerateApiToken">
<template #icon>
<ReloadOutlined />
</template>
Regenerate
</a-button>
</a-space>
</div>
<a-spin :spinning="tokenLoading" size="small">
<pre
class="token-value">{{ tokenVisible ? (apiToken || '—') : (apiToken ? '••••••••••••••••••••••••••••' : '—') }}</pre>
</a-spin>
<p class="token-hint">
Send it on every request as <code>Authorization: Bearer &lt;token&gt;</code>. Token-authenticated
callers skip CSRF and don't need a session cookie. Regenerating rotates the secret immediately
running bots will need the new value.
</p>
</a-card>
<a-card class="curl-card" size="small" title="Quick example">
<pre class="code-block">{{ curlExample }}</pre>
</a-card>
<nav class="toc-nav">
<span class="toc-label">On this page:</span>
<a v-for="s in sections" :key="s.id" class="toc-link" :href="`#${s.id}`"
@click.prevent="scrollToSection(s.id)">
{{ s.title }}
</a>
</nav>
<EndpointSection v-for="s in sections" :key="s.id" :section="s" />
</div>
</a-layout-content>
</a-layout>
</a-layout>
</a-config-provider>
</template>
<style scoped>
.api-docs-page {
--bg-page: #e6e8ec;
--bg-card: #ffffff;
min-height: 100vh;
background: var(--bg-page);
}
.api-docs-page.is-dark {
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.api-docs-page.is-dark.is-ultra {
--bg-page: #000;
--bg-card: #0a0a0a;
}
.content-shell {
background: var(--bg-page);
}
.content-area {
padding: 24px;
max-width: 100%;
}
@media (max-width: 768px) {
.content-area {
padding: 16px 12px 12px;
padding-top: 64px;
}
}
.docs-wrapper {
max-width: 1100px;
margin: 0 auto;
}
.docs-header {
margin-bottom: 18px;
}
.docs-title {
font-size: 26px;
font-weight: 700;
margin: 0 0 8px;
color: rgba(0, 0, 0, 0.88);
}
.docs-lead {
margin: 0;
color: rgba(0, 0, 0, 0.65);
line-height: 1.6;
font-size: 14px;
}
.docs-lead code,
.token-hint code {
background: rgba(128, 128, 128, 0.12);
padding: 1px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
}
.token-card,
.curl-card {
margin-bottom: 16px;
}
.token-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.token-card-title {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
}
.token-value {
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 6px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
margin: 0;
word-break: break-all;
white-space: pre-wrap;
}
.token-hint {
margin: 10px 0 0;
color: rgba(0, 0, 0, 0.55);
font-size: 12.5px;
line-height: 1.55;
}
.code-block {
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 6px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
line-height: 1.55;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
.toc-nav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 14px;
padding: 12px 16px;
background: rgba(128, 128, 128, 0.08);
border-radius: 6px;
margin-bottom: 16px;
}
.toc-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(0, 0, 0, 0.5);
}
.toc-link {
color: #1677ff;
text-decoration: none;
cursor: pointer;
font-size: 13px;
}
.toc-link:hover {
color: #4096ff;
text-decoration: underline;
}
</style>
<style>
body.dark .docs-title {
color: rgba(255, 255, 255, 0.92);
}
body.dark .docs-lead,
body.dark .token-hint {
color: rgba(255, 255, 255, 0.7);
}
body.dark .docs-lead code,
body.dark .token-hint code {
background: rgba(255, 255, 255, 0.1);
}
body.dark .token-value,
body.dark .code-block {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.88);
}
body.dark .toc-nav {
background: rgba(255, 255, 255, 0.04);
}
body.dark .toc-label {
color: rgba(255, 255, 255, 0.55);
}
</style>
+128
View File
@@ -0,0 +1,128 @@
<script setup>
import { computed } from 'vue';
import { methodColors } from './endpoints.js';
const props = defineProps({
endpoint: { type: Object, required: true },
});
const tagColor = computed(() => methodColors[props.endpoint.method] || 'default');
const hasParams = computed(() => Array.isArray(props.endpoint.params) && props.endpoint.params.length > 0);
const paramColumns = [
{ title: 'Name', dataIndex: 'name', key: 'name', width: 180 },
{ title: 'In', dataIndex: 'in', key: 'in', width: 100 },
{ title: 'Type', dataIndex: 'type', key: 'type', width: 120 },
{ title: 'Description', dataIndex: 'desc', key: 'desc' },
];
</script>
<template>
<div class="endpoint-row">
<div class="endpoint-header">
<a-tag :color="tagColor" class="method-tag">{{ endpoint.method }}</a-tag>
<code class="endpoint-path">{{ endpoint.path }}</code>
</div>
<p v-if="endpoint.summary" class="endpoint-summary">{{ endpoint.summary }}</p>
<div v-if="hasParams" class="endpoint-block">
<div class="block-label">Parameters</div>
<a-table :columns="paramColumns" :data-source="endpoint.params" :pagination="false" size="small" row-key="name" />
</div>
<div v-if="endpoint.body" class="endpoint-block">
<div class="block-label">Request body</div>
<a-typography-paragraph :copyable="{ text: endpoint.body }">
<pre class="code-block">{{ endpoint.body }}</pre>
</a-typography-paragraph>
</div>
<div v-if="endpoint.response" class="endpoint-block">
<div class="block-label">Response</div>
<a-typography-paragraph :copyable="{ text: endpoint.response }">
<pre class="code-block">{{ endpoint.response }}</pre>
</a-typography-paragraph>
</div>
</div>
</template>
<style scoped>
.endpoint-row {
padding: 12px 0;
}
.endpoint-row + .endpoint-row {
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
.endpoint-header {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.method-tag {
font-weight: 600;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.5px;
min-width: 60px;
text-align: center;
}
.endpoint-path {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
word-break: break-all;
}
.endpoint-summary {
margin: 8px 0 0;
color: rgba(0, 0, 0, 0.65);
line-height: 1.55;
}
.endpoint-block {
margin-top: 12px;
}
.block-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(0, 0, 0, 0.5);
margin-bottom: 6px;
}
.code-block {
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 6px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
line-height: 1.55;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
</style>
<style>
body.dark .endpoint-summary {
color: rgba(255, 255, 255, 0.7);
}
body.dark .block-label {
color: rgba(255, 255, 255, 0.55);
}
body.dark .code-block {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.88);
}
</style>
@@ -0,0 +1,65 @@
<script setup>
import EndpointRow from './EndpointRow.vue';
defineProps({
section: { type: Object, required: true },
});
</script>
<template>
<section :id="section.id" class="api-section">
<h2 class="section-title">{{ section.title }}</h2>
<p v-if="section.description" class="section-description">{{ section.description }}</p>
<div class="endpoints">
<EndpointRow v-for="(endpoint, idx) in section.endpoints" :key="idx" :endpoint="endpoint" />
</div>
</section>
</template>
<style scoped>
.api-section {
background: #fff;
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 8px;
padding: 20px 24px;
margin-bottom: 20px;
scroll-margin-top: 16px;
}
.section-title {
font-size: 20px;
font-weight: 600;
margin: 0;
color: rgba(0, 0, 0, 0.88);
}
.section-description {
margin: 6px 0 14px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.55;
}
.endpoints > :first-child {
padding-top: 0;
}
</style>
<style>
body.dark .api-section {
background: #252526;
border-color: rgba(255, 255, 255, 0.1);
}
html[data-theme='ultra-dark'] .api-section {
background: #0a0a0a;
border-color: rgba(255, 255, 255, 0.08);
}
body.dark .section-title {
color: rgba(255, 255, 255, 0.92);
}
body.dark .section-description {
color: rgba(255, 255, 255, 0.7);
}
</style>
+548
View File
@@ -0,0 +1,548 @@
export const sections = [
{
id: 'auth',
title: 'Authentication',
description:
'Two authentication modes are supported. UI sessions use a cookie set by the login endpoint. Programmatic clients (bots, scripts, remote panels) authenticate with a Bearer token taken from Settings → Security → API Token. Both work for every endpoint under /panel/api/*.',
endpoints: [
{
method: 'POST',
path: '/login',
summary: 'Authenticate with username + password and receive a session cookie. Required before any cookie-based API call.',
params: [
{ name: 'username', in: 'body', type: 'string', desc: 'Panel admin username.' },
{ name: 'password', in: 'body', type: 'string', desc: 'Panel admin password.' },
{ name: 'twoFactorCode', in: 'body', type: 'string', desc: 'OTP code when 2FA is enabled. Omit otherwise.' },
],
body: '{\n "username": "admin",\n "password": "admin",\n "twoFactorCode": "123456"\n}',
response:
'{\n "success": true,\n "msg": "Logged in successfully"\n}',
},
{
method: 'GET',
path: '/logout',
summary: 'Clear the session cookie. Redirects back to the login page; not useful from non-browser clients.',
},
{
method: 'GET',
path: '/csrf-token',
summary: 'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.',
response:
'{\n "success": true,\n "obj": "csrf-token-string"\n}',
},
{
method: 'POST',
path: '/getTwoFactorEnable',
summary: 'Returns whether 2FA is enabled on the panel — used by the login page to decide whether to show the OTP field.',
response: '{\n "success": true,\n "obj": false\n}',
},
],
},
{
id: 'inbounds',
title: 'Inbounds API',
description:
'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour X-Forwarded-Host / X-Forwarded-Proto, so callers behind a reverse proxy get the correct external host in returned URLs.',
endpoints: [
{
method: 'GET',
path: '/panel/api/inbounds/list',
summary: 'List every inbound owned by the authenticated user, including each inbounds clientStats traffic counters.',
response:
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": "{\\"clients\\":[...]}",\n "streamSettings": "{...}",\n "tag": "inbound-443",\n "sniffing": "{...}",\n "clientStats": [...]\n }\n ]\n}',
},
{
method: 'GET',
path: '/panel/api/inbounds/get/:id',
summary: 'Fetch a single inbound by numeric ID.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
],
},
{
method: 'GET',
path: '/panel/api/inbounds/getClientTraffics/:email',
summary: 'Traffic counters for a client identified by email.',
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
],
},
{
method: 'GET',
path: '/panel/api/inbounds/getClientTrafficsById/:id',
summary: 'Traffic counters for a client identified by its UUID/password.',
params: [
{ name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/add',
summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings JSON, streamSettings JSON, sniffing JSON, remark, expiryTime, total, enable).',
body:
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": "{\\"clients\\":[{\\"id\\":\\"...\\",\\"email\\":\\"user1\\"}],\\"decryption\\":\\"none\\",\\"fallbacks\\":[]}",\n "streamSettings": "{\\"network\\":\\"tcp\\",\\"security\\":\\"reality\\",\\"realitySettings\\":{...}}",\n "sniffing": "{\\"enabled\\":true,\\"destOverride\\":[\\"http\\",\\"tls\\"]}"\n}',
},
{
method: 'POST',
path: '/panel/api/inbounds/del/:id',
summary: 'Delete an inbound by ID. Also removes its associated client stats rows.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/update/:id',
summary: 'Replace an inbounds configuration. Body shape mirrors /add. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/setEnable/:id',
summary: 'Toggle only the enable flag without serialising the whole settings JSON. Recommended for UI switches on large inbounds.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
],
body: '{\n "enable": false\n}',
},
{
method: 'POST',
path: '/panel/api/inbounds/clientIps/:email',
summary: 'List source IPs that have connected with the given clients credentials. Returns an array of "ip (timestamp)" strings.',
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/clearClientIps/:email',
summary: 'Reset the recorded IP list for a client.',
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/addClient',
summary: 'Add one or more clients to an existing inbound. The settings field is the JSON-encoded settings.clients array of the target inbound.',
body:
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"newuser\\",\\"limitIp\\":0,\\"totalGB\\":0,\\"expiryTime\\":0,\\"enable\\":true,\\"flow\\":\\"\\"}]}"\n}',
},
{
method: 'POST',
path: '/panel/api/inbounds/:id/copyClients',
summary: 'Copy selected clients from one inbound into another. Useful for duplicating user lists across protocols.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Target inbound ID.' },
{ name: 'sourceInboundId', in: 'body', type: 'number', desc: 'Inbound ID to read clients from.' },
{ name: 'clientEmails', in: 'body', type: 'string[]', desc: 'Emails of clients to copy. Empty means all clients.' },
{ name: 'flow', in: 'body', type: 'string', desc: 'Override the flow field on copied clients (e.g. "xtls-rprx-vision"). Empty to keep source flow.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/:id/delClient/:clientId',
summary: 'Delete a client by its UUID/password from a specific inbound.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/updateClient/:clientId',
summary: 'Update a single client without rewriting the whole settings JSON. Send the target inbound payload with the new client values.',
params: [
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
],
body:
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
},
{
method: 'POST',
path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
summary: 'Zero out upload + download counters for one client.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/resetAllTraffics',
summary: 'Reset upload + download counters on every inbound. Destructive — accounting history is lost.',
},
{
method: 'POST',
path: '/panel/api/inbounds/resetAllClientTraffics/:id',
summary: 'Reset traffic for every client in one inbound.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/delDepletedClients/:id',
summary: 'Delete clients in this inbound whose traffic cap or expiry has elapsed. Pass id=-1 to sweep every inbound.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID, or -1 for all inbounds.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/import',
summary: 'Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single "data" field.',
params: [
{ name: 'data', in: 'body (form)', type: 'string', desc: 'JSON-encoded inbound payload.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/onlines',
summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
response: '{\n "success": true,\n "obj": ["user1", "user2"]\n}',
},
{
method: 'POST',
path: '/panel/api/inbounds/lastOnline',
summary: 'Map of client email → last-seen unix timestamp.',
},
{
method: 'GET',
path: '/panel/api/inbounds/getSubLinks/:subId',
summary:
'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
params: [
{ name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." },
],
response:
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#user1",\n "vmess://eyJ2IjoyLC..."\n ]\n}',
},
{
method: 'GET',
path: '/panel/api/inbounds/getClientLinks/:id/:email',
summary:
"Return the URL(s) for one client on one inbound — the same string the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) return an empty array.",
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
],
response:
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?...#user1"\n ]\n}',
},
{
method: 'POST',
path: '/panel/api/inbounds/updateClientTraffic/:email',
summary: 'Manually adjust a clients upload + download counters. Useful for migrations from external accounting systems.',
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
],
body: '{\n "upload": 1073741824,\n "download": 5368709120\n}',
},
{
method: 'POST',
path: '/panel/api/inbounds/:id/delClientByEmail/:email',
summary: 'Delete a client identified by email rather than UUID.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
],
},
],
},
{
id: 'server',
title: 'Server API',
description:
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
endpoints: [
{
method: 'GET',
path: '/panel/api/server/status',
summary: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load averages, open connections, Xray state. Cached and refreshed every 2 seconds in the background.',
},
{
method: 'GET',
path: '/panel/api/server/cpuHistory/:bucket',
summary: 'Legacy: aggregated CPU history. Use /history/cpu/:bucket instead — same data with a uniform {t, v} shape.',
params: [
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
],
},
{
method: 'GET',
path: '/panel/api/server/history/:metric/:bucket',
summary: 'Aggregated time-series for one metric. Returns an array of {t, v} samples covering the last ~6 hours.',
params: [
{ name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem | swap | netIn | netOut | tcpCount | udpCount | load1 | online.' },
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
],
},
{
method: 'GET',
path: '/panel/api/server/getXrayVersion',
summary: 'List Xray binary versions available for install on this host.',
},
{
method: 'GET',
path: '/panel/api/server/getPanelUpdateInfo',
summary: 'Check whether a newer 3x-ui release is available on GitHub.',
},
{
method: 'GET',
path: '/panel/api/server/getConfigJson',
summary: 'Return the assembled Xray config thats currently running on this host.',
},
{
method: 'GET',
path: '/panel/api/server/getDb',
summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.',
},
{
method: 'GET',
path: '/panel/api/server/getNewUUID',
summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.',
},
{
method: 'GET',
path: '/panel/api/server/getNewX25519Cert',
summary: 'Generate a new X25519 keypair for Reality.',
},
{
method: 'GET',
path: '/panel/api/server/getNewmldsa65',
summary: 'Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.',
},
{
method: 'GET',
path: '/panel/api/server/getNewmlkem768',
summary: 'Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.',
},
{
method: 'GET',
path: '/panel/api/server/getNewVlessEnc',
summary: 'Generate a new VLESS encryption keypair.',
},
{
method: 'POST',
path: '/panel/api/server/stopXrayService',
summary: 'Stop the Xray binary. All proxies go offline immediately.',
},
{
method: 'POST',
path: '/panel/api/server/restartXrayService',
summary: 'Reload Xray with the current config. Typically required after structural inbound or routing changes.',
},
{
method: 'POST',
path: '/panel/api/server/installXray/:version',
summary: 'Download and install the specified Xray version. Pass "latest" for the newest release.',
params: [
{ name: 'version', in: 'path', type: 'string', desc: 'Xray tag (e.g. v25.10.31) or "latest".' },
],
},
{
method: 'POST',
path: '/panel/api/server/updatePanel',
summary: 'Self-update the panel to the latest version. The server restarts on success.',
},
{
method: 'POST',
path: '/panel/api/server/updateGeofile',
summary: 'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.',
},
{
method: 'POST',
path: '/panel/api/server/updateGeofile/:fileName',
summary: 'Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).',
params: [
{ name: 'fileName', in: 'path', type: 'string', desc: 'Filename of the data file to refresh.' },
],
},
{
method: 'POST',
path: '/panel/api/server/logs/:count',
summary: 'Return the last N lines of the panels own log.',
params: [
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
],
body: '{\n "level": "info",\n "syslog": false\n}',
},
{
method: 'POST',
path: '/panel/api/server/xraylogs/:count',
summary: 'Return the last N lines of the Xray process log.',
params: [
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
],
},
{
method: 'POST',
path: '/panel/api/server/importDB',
summary: 'Restore the panel DB from an uploaded SQLite file (multipart form, field name "db"). The panel restarts after restore. Destructive.',
},
{
method: 'POST',
path: '/panel/api/server/getNewEchCert',
summary: 'Generate a new ECH (Encrypted Client Hello) keypair. Body picks the algorithm.',
},
],
},
{
id: 'nodes',
title: 'Nodes API',
description:
'Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes.',
endpoints: [
{
method: 'GET',
path: '/panel/api/nodes/list',
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
},
{
method: 'GET',
path: '/panel/api/nodes/get/:id',
summary: 'Fetch a single node by ID.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
],
},
{
method: 'POST',
path: '/panel/api/nodes/add',
summary: 'Register a new remote node. Provide its URL, apiToken, and optional label/notes.',
body:
'{\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
},
{
method: 'POST',
path: '/panel/api/nodes/update/:id',
summary: 'Replace a nodes connection details. Same body shape as /add.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
],
},
{
method: 'POST',
path: '/panel/api/nodes/del/:id',
summary: 'Delete a node. Inbounds bound to it are not auto-migrated.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
],
},
{
method: 'POST',
path: '/panel/api/nodes/setEnable/:id',
summary: 'Pause or resume traffic sync with this node.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
],
body: '{\n "enable": true\n}',
},
{
method: 'POST',
path: '/panel/api/nodes/test',
summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.',
},
{
method: 'POST',
path: '/panel/api/nodes/probe/:id',
summary: 'Probe an existing node, updating its cached health state.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
],
},
{
method: 'GET',
path: '/panel/api/nodes/history/:id/:metric/:bucket',
summary: 'Aggregated metric history for a node — same shape as /server/history, scoped to one node.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
{ name: 'metric', in: 'path', type: 'string', desc: 'Metric key (cpu, mem, netIn, …).' },
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds.' },
],
},
],
},
{
id: 'customGeo',
title: 'Custom Geo API',
description:
'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
endpoints: [
{
method: 'GET',
path: '/panel/api/custom-geo/list',
summary: 'List configured custom geo sources with their type, alias, URL, status, and last-download timestamp.',
},
{
method: 'GET',
path: '/panel/api/custom-geo/aliases',
summary: 'List geo aliases currently usable in routing rules — both built-in defaults and the user-configured ones.',
},
{
method: 'POST',
path: '/panel/api/custom-geo/add',
summary: 'Register a custom geo source. Alias is auto-normalised; URL must point to a .dat / .json blob.',
body:
'{\n "type": "geoip",\n "alias": "myips",\n "url": "https://example.com/geo/my.dat"\n}',
},
{
method: 'POST',
path: '/panel/api/custom-geo/update/:id',
summary: 'Replace a custom geo source. Same body shape as /add.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
],
},
{
method: 'POST',
path: '/panel/api/custom-geo/delete/:id',
summary: 'Remove a custom geo source and its cached file.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
],
},
{
method: 'POST',
path: '/panel/api/custom-geo/download/:id',
summary: 'Re-download one custom geo source on demand.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
],
},
{
method: 'POST',
path: '/panel/api/custom-geo/update-all',
summary: 'Re-download every configured custom geo source. Errors are reported per-source in the response.',
},
],
},
{
id: 'backup',
title: 'Backup',
description: 'Operations that interact with the configured Telegram bot.',
endpoints: [
{
method: 'GET',
path: '/panel/api/backuptotgbot',
summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.',
},
],
},
];
export const methodColors = {
GET: 'blue',
POST: 'green',
PUT: 'orange',
PATCH: 'orange',
DELETE: 'red',
};
@@ -0,0 +1,280 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs';
import { SyncOutlined } from '@ant-design/icons-vue';
import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
const { t } = useI18n();
import {
Inbound,
Protocols,
USERS_SECURITY,
TLS_FLOW_CONTROL,
} from '@/models/inbound.js';
import DateTimePicker from '@/components/DateTimePicker.vue';
// Bulk-add up to 500 clients in one go. The legacy panel offers five
// generation modes — this component preserves them all:
// 0: Random — N fully-random emails (no prefix)
// 1: Random+Prefix — N random emails preceded by `prefix`
// 2: Random+Prefix+Num — emails like `<rand><prefix><num>` for num in [first..last]
// 3: Random+Prefix+Num+Postfix — same + appended postfix
// 4: Prefix+Num+Postfix — no random part, just `<prefix><num><postfix>`
const props = defineProps({
open: { type: Boolean, default: false },
dbInbound: { type: Object, default: null },
subEnable: { type: Boolean, default: false },
tgBotEnable: { type: Boolean, default: false },
ipLimitEnable: { type: Boolean, default: false },
});
const emit = defineEmits(['update:open', 'saved']);
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
// === Reactive form state ===========================================
// Cloned inbound (so canEnableTlsFlow() works).
const inbound = ref(null);
const saving = ref(false);
const delayedStart = ref(false);
const form = reactive({
emailMethod: 0,
firstNum: 1,
lastNum: 1,
emailPrefix: '',
emailPostfix: '',
quantity: 1,
security: USERS_SECURITY.AUTO,
flow: '',
subId: '',
tgId: 0,
comment: '',
limitIp: 0,
totalGB: 0,
expiryTime: 0, // ms epoch; negative => delayed start days
reset: 0,
});
const expiryDate = computed({
get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
});
const delayedExpireDays = computed({
get: () => (form.expiryTime < 0 ? form.expiryTime / -86400000 : 0),
set: (days) => { form.expiryTime = -86400000 * (days || 0); },
});
watch(() => props.open, (next) => {
if (!next) return;
if (!props.dbInbound) return;
inbound.value = Inbound.fromJson(props.dbInbound.toInbound().toJson());
// Reset all form fields on every open — bulk add is intentionally
// stateless between sessions (legacy resets on .show()).
form.emailMethod = 0;
form.firstNum = 1;
form.lastNum = 1;
form.emailPrefix = '';
form.emailPostfix = '';
form.quantity = 1;
form.security = USERS_SECURITY.AUTO;
form.flow = '';
form.subId = '';
form.tgId = 0;
form.comment = '';
form.limitIp = 0;
form.totalGB = 0;
form.expiryTime = 0;
form.reset = 0;
delayedStart.value = false;
});
function close() {
emit('update:open', false);
}
function makeNewClient(parsed) {
switch (parsed.protocol) {
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
case Protocols.SHADOWSOCKS: {
const method = parsed.settings.shadowsockses[0]?.method || parsed.settings.method;
return new Inbound.ShadowsocksSettings.Shadowsocks(method);
}
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
default: return null;
}
}
function buildClients() {
if (!inbound.value) return [];
const out = [];
const method = form.emailMethod;
let start;
let end;
if (method > 1) {
start = form.firstNum;
end = form.lastNum + 1;
} else {
start = 0;
end = form.quantity;
}
const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
const useNum = method > 1;
const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
for (let i = start; i < end; i++) {
const c = makeNewClient(inbound.value);
if (!c) continue;
if (method === 4) c.email = '';
c.email += useNum ? prefix + String(i) + postfix : prefix + postfix;
if (form.subId.length > 0) c.subId = form.subId;
c.tgId = form.tgId;
if (form.comment.length > 0) c.comment = form.comment;
c.security = form.security;
c.limitIp = form.limitIp;
// Use the clien's totalGB setter (ms epoch and bytes already handled
// identically for bulk and single client paths).
c.totalGB = Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB);
c.expiryTime = form.expiryTime;
if (inbound.value.canEnableTlsFlow()) c.flow = form.flow;
c.reset = form.reset;
out.push(c);
}
return out;
}
async function submit() {
const clients = buildClients();
if (clients.length === 0) return;
saving.value = true;
try {
const payload = {
id: props.dbInbound.id,
// Clients all serialize via toString() — same shape the single-
// client modal posts. Joining with `,` lets the Go side parse the
// outer array directly.
settings: `{"clients": [${clients.map((c) => c.toString()).join(',')}]}`,
};
const msg = await HttpUtil.post('/panel/api/inbounds/addClient', payload);
if (msg?.success) {
emit('saved');
close();
}
} finally {
saving.value = false;
}
}
</script>
<template>
<a-modal :open="open" :title="t('pages.client.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
<a-form v-if="inbound" :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
<a-form-item :label="t('pages.client.method')">
<a-select v-model:value="form.emailMethod">
<a-select-option :value="0">Random</a-select-option>
<a-select-option :value="1">Random + Prefix</a-select-option>
<a-select-option :value="2">Random + Prefix + Num</a-select-option>
<a-select-option :value="3">Random + Prefix + Num + Postfix</a-select-option>
<a-select-option :value="4">Prefix + Num + Postfix</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.first')">
<a-input-number v-model:value="form.firstNum" :min="1" />
</a-form-item>
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.last')">
<a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
</a-form-item>
<a-form-item v-if="form.emailMethod > 0" :label="t('pages.client.prefix')">
<a-input v-model:value="form.emailPrefix" />
</a-form-item>
<a-form-item v-if="form.emailMethod > 2" :label="t('pages.client.postfix')">
<a-input v-model:value="form.emailPostfix" />
</a-form-item>
<a-form-item v-if="form.emailMethod < 2" :label="t('pages.client.clientCount')">
<a-input-number v-model:value="form.quantity" :min="1" :max="500" />
</a-form-item>
<a-form-item v-if="inbound.protocol === Protocols.VMESS" :label="t('security')">
<a-select v-model:value="form.security">
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
<a-select v-model:value="form.flow">
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="subEnable">
<template #label>
{{ t('subscription.title') }}
<SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
</template>
<a-input v-model:value="form.subId" />
</a-form-item>
<a-form-item v-if="tgBotEnable" label="Telegram ID">
<a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
</a-form-item>
<a-form-item :label="t('comment')">
<a-input v-model:value="form.comment" />
</a-form-item>
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
<a-input-number v-model:value="form.limitIp" :min="0" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
</template>
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
</a-form-item>
<a-form-item :label="t('pages.client.delayedStart')">
<a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
</a-form-item>
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
<a-input-number v-model:value="delayedExpireDays" :min="0" />
</a-form-item>
<a-form-item v-else>
<template #label>
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
}}</a-tooltip>
</template>
<DateTimePicker v-model:value="expiryDate" />
</a-form-item>
<a-form-item v-if="form.expiryTime !== 0">
<template #label>
<a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
</template>
<a-input-number v-model:value="form.reset" :min="0" />
</a-form-item>
</a-form>
</a-modal>
</template>
<style scoped>
.random-icon {
margin-left: 4px;
cursor: pointer;
color: var(--ant-primary-color, #1890ff);
}
</style>
@@ -0,0 +1,394 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs';
import { SyncOutlined, RetweetOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import {
HttpUtil,
RandomUtil,
SizeFormatter,
ColorUtils,
} from '@/utils';
import { Inbound, Protocols, USERS_SECURITY, TLS_FLOW_CONTROL } from '@/models/inbound.js';
import DateTimePicker from '@/components/DateTimePicker.vue';
const { t } = useI18n();
// Add OR edit a single client on a multi-user inbound (VMess / VLess /
// Trojan / Shadowsocks-multi / Hysteria). The legacy panel routes both
// flows through the same modal — same here.
//
// On submit we serialize the client via its toString() (which is just
// JSON.stringify of toJson()) and post it inside a one-element clients
// array so the Go side reuses the same parsing path as the inbound
// settings update.
const props = defineProps({
open: { type: Boolean, default: false },
mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
dbInbound: { type: Object, default: null },
clientIndex: { type: Number, default: null },
// Sidecar config from the inbounds page — controls visibility of
// the Subscription, Telegram, and IP-limit fields.
subEnable: { type: Boolean, default: false },
tgBotEnable: { type: Boolean, default: false },
ipLimitEnable: { type: Boolean, default: false },
trafficDiff: { type: Number, default: 0 },
});
const emit = defineEmits(['update:open', 'saved']);
// === Reactive draft =================================================
const inbound = ref(null);
const client = ref(null);
const oldClientId = ref('');
const clientStats = ref(null);
const saving = ref(false);
const delayedStart = ref(false);
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const protocol = computed(() => inbound.value?.protocol);
const isVmessOrVless = computed(() =>
protocol.value === Protocols.VMESS || protocol.value === Protocols.VLESS,
);
const isTrojanOrSS = computed(() =>
protocol.value === Protocols.TROJAN || protocol.value === Protocols.SHADOWSOCKS,
);
const expiryDate = computed({
get: () => (client.value?.expiryTime > 0 ? dayjs(client.value.expiryTime) : null),
set: (next) => { if (client.value) client.value.expiryTime = next ? next.valueOf() : 0; },
});
const delayedExpireDays = computed({
get: () => {
if (!client.value || client.value.expiryTime >= 0) return 0;
return client.value.expiryTime / -86400000;
},
set: (days) => {
if (!client.value) return;
client.value.expiryTime = -86400000 * (days || 0);
},
});
const totalGB = computed({
get: () => {
if (!client.value || !client.value.totalGB) return 0;
return Math.round((client.value.totalGB / SizeFormatter.ONE_GB) * 100) / 100;
},
set: (gb) => {
if (!client.value) return;
client.value.totalGB = Math.round((gb || 0) * SizeFormatter.ONE_GB);
},
});
const isExpired = computed(() => {
if (props.mode !== 'edit' || !client.value) return false;
return client.value.expiryTime > 0 && client.value.expiryTime < Date.now();
});
const isTrafficExhausted = computed(() => {
if (!clientStats.value || clientStats.value.total <= 0) return false;
return clientStats.value.up + clientStats.value.down >= clientStats.value.total;
});
function getClientId(proto, c) {
switch (proto) {
case Protocols.TROJAN: return c.password;
case Protocols.SHADOWSOCKS: return c.email;
case Protocols.HYSTERIA: return c.auth;
default: return c.id;
}
}
function makeNewClient(proto, parsed) {
switch (proto) {
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
case Protocols.SHADOWSOCKS: {
const method = parsed.settings.method;
return new Inbound.ShadowsocksSettings.Shadowsocks(
method,
RandomUtil.randomShadowsocksPassword(method),
);
}
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
default: return null;
}
}
watch(() => props.open, (next) => {
if (!next) return;
if (!props.dbInbound) return;
const parsed = Inbound.fromJson(props.dbInbound.toInbound().toJson());
inbound.value = parsed;
delayedStart.value = false;
if (props.mode === 'edit') {
const idx = props.clientIndex ?? 0;
client.value = parsed.clients[idx];
if (client.value && client.value.expiryTime < 0) delayedStart.value = true;
oldClientId.value = getClientId(parsed.protocol, client.value);
} else {
const c = makeNewClient(parsed.protocol, parsed);
if (c) parsed.clients.push(c);
client.value = parsed.clients[parsed.clients.length - 1];
oldClientId.value = '';
}
clientStats.value = (props.dbInbound.clientStats || []).find(
(s) => s.email === client.value?.email,
) || null;
});
function close() {
emit('update:open', false);
}
function randomEmail() {
if (client.value) client.value.email = RandomUtil.randomLowerAndNum(9);
}
function randomId() {
if (client.value) client.value.id = RandomUtil.randomUUID();
}
function randomPassword() {
if (!client.value || !inbound.value) return;
if (inbound.value.protocol === Protocols.SHADOWSOCKS) {
client.value.password = RandomUtil.randomShadowsocksPassword(
inbound.value.settings.method,
);
} else {
client.value.password = RandomUtil.randomSeq(10);
}
}
function randomAuth() {
if (client.value) client.value.auth = RandomUtil.randomSeq(10);
}
function randomSubId() {
if (client.value) client.value.subId = RandomUtil.randomLowerAndNum(16);
}
const clientIpsText = ref('');
async function loadClientIps() {
if (!client.value?.email) return;
const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${client.value.email}`);
if (!msg?.success) {
clientIpsText.value = msg?.obj || '';
return;
}
let ips = msg.obj;
if (typeof ips === 'string' && ips.startsWith('[') && ips.endsWith(']')) {
try {
const parsed = JSON.parse(ips);
ips = Array.isArray(parsed) ? parsed.join('\n') : ips;
} catch (_e) {
// leave as raw
}
}
clientIpsText.value = ips || '';
}
async function clearClientIps() {
if (!client.value?.email) return;
const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${client.value.email}`);
if (msg?.success) clientIpsText.value = '';
}
async function resetClientTraffic() {
if (!clientStats.value || !client.value?.email) return;
const msg = await HttpUtil.post(
`/panel/api/inbounds/${props.dbInbound.id}/resetClientTraffic/${client.value.email}`,
);
if (msg?.success) {
clientStats.value.up = 0;
clientStats.value.down = 0;
}
}
async function submit() {
if (!client.value || !inbound.value) return;
saving.value = true;
try {
const payload = {
id: props.dbInbound.id,
settings: `{"clients": [${client.value.toString()}]}`,
};
const url = props.mode === 'edit'
? `/panel/api/inbounds/updateClient/${oldClientId.value}`
: '/panel/api/inbounds/addClient';
const msg = await HttpUtil.post(url, payload);
if (msg?.success) {
emit('saved');
close();
}
} finally {
saving.value = false;
}
}
const title = computed(() =>
props.mode === 'edit' ? t('pages.client.edit') : t('pages.client.add'),
);
</script>
<template>
<a-modal :open="open" :title="title"
:ok-text="mode === 'edit' ? t('pages.client.submitEdit') : t('pages.client.submitAdd')" :cancel-text="t('close')"
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
<a-tag v-if="mode === 'edit' && (isExpired || isTrafficExhausted)" color="red" class="status-banner">
{{ t('depleted') }}
</a-tag>
<a-form v-if="client && inbound" layout="horizontal" :colon="false" :label-col="{ sm: { span: 8 } }"
:wrapper-col="{ sm: { span: 14 } }">
<a-form-item :label="t('enable')">
<a-switch v-model:checked="client.enable" />
</a-form-item>
<a-form-item>
<template #label>
{{ t('pages.inbounds.email') }}
<SyncOutlined class="random-icon" @click="randomEmail" />
</template>
<a-input v-model:value="client.email" />
</a-form-item>
<a-form-item v-if="isTrojanOrSS">
<template #label>
{{ t('password') }}
<SyncOutlined class="random-icon" @click="randomPassword" />
</template>
<a-input v-model:value="client.password" />
</a-form-item>
<a-form-item v-if="protocol === Protocols.HYSTERIA">
<template #label>
{{ t('password') }}
<SyncOutlined class="random-icon" @click="randomAuth" />
</template>
<a-input v-model:value="client.auth" />
</a-form-item>
<a-form-item v-if="isVmessOrVless">
<template #label>
ID
<SyncOutlined class="random-icon" @click="randomId" />
</template>
<a-input v-model:value="client.id" />
</a-form-item>
<a-form-item v-if="protocol === Protocols.VMESS" :label="t('security')">
<a-select v-model:value="client.security">
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">
{{ key }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="client.email && subEnable">
<template #label>
{{ t('subscription.title') }}
<SyncOutlined class="random-icon" @click="randomSubId" />
</template>
<a-input v-model:value="client.subId" />
</a-form-item>
<a-form-item v-if="client.email && tgBotEnable" label="Telegram ID">
<a-input-number v-model:value="client.tgId" :min="0" :style="{ width: '50%' }" />
</a-form-item>
<a-form-item v-if="client.email" :label="t('comment')">
<a-input v-model:value="client.comment" />
</a-form-item>
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
<a-input-number v-model:value="client.limitIp" :min="0" />
</a-form-item>
<a-form-item v-if="ipLimitEnable && client.limitIp > 0 && client.email && mode === 'edit'"
:label="t('pages.inbounds.IPLimitlog')">
<a-textarea v-model:value="clientIpsText" readonly :placeholder="t('pages.inbounds.IPLimitlogDesc')"
:auto-size="{ minRows: 3, maxRows: 8 }" @click="loadClientIps" />
<a-button type="link" size="small" danger @click="clearClientIps">
<template #icon>
<DeleteOutlined />
</template>
{{ t('pages.inbounds.IPLimitlogclear') }}
</a-button>
</a-form-item>
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
<a-select v-model:value="client.flow">
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">
{{ key }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="protocol === Protocols.VLESS" label="Reverse tag">
<a-input v-model:value="client.reverseTag" placeholder="Optional reverse tag" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
</template>
<a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
</a-form-item>
<a-form-item v-if="mode === 'edit' && clientStats" :label="t('usage')">
<a-tag :color="ColorUtils.clientUsageColor(clientStats, trafficDiff)">
{{ SizeFormatter.sizeFormat(clientStats.up) }} /
{{ SizeFormatter.sizeFormat(clientStats.down) }}
({{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }})
</a-tag>
<a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
<RetweetOutlined class="action-icon" @click="resetClientTraffic" />
</a-tooltip>
</a-form-item>
<a-form-item :label="t('pages.client.delayedStart')">
<a-switch v-model:checked="delayedStart" @click="client.expiryTime = 0" />
</a-form-item>
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
<a-input-number v-model:value="delayedExpireDays" :min="0" />
</a-form-item>
<a-form-item v-else>
<template #label>
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
}}</a-tooltip>
</template>
<DateTimePicker v-model:value="expiryDate" />
<a-tag v-if="mode === 'edit' && isExpired" color="red">{{ t('depleted') }}</a-tag>
</a-form-item>
<a-form-item v-if="client.expiryTime !== 0">
<template #label>
<a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
</template>
<a-input-number v-model:value="client.reset" :min="0" />
</a-form-item>
</a-form>
</a-modal>
</template>
<style scoped>
.status-banner {
display: block;
margin-bottom: 10px;
text-align: center;
}
.random-icon,
.action-icon {
margin-left: 4px;
cursor: pointer;
color: var(--ant-primary-color, #1890ff);
}
</style>
@@ -0,0 +1,821 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
EditOutlined,
InfoCircleOutlined,
QrcodeOutlined,
RetweetOutlined,
DeleteOutlined,
EllipsisOutlined,
} from '@ant-design/icons-vue';
import { Modal } from 'ant-design-vue';
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
import InfinityIcon from '@/components/InfinityIcon.vue';
import { useDatepicker } from '@/composables/useDatepicker.js';
const { datepicker } = useDatepicker();
const { t } = useI18n();
// Per-inbound expand-row content. CSS-grid layout (not a nested
// <a-table>) so it sits flush inside the parent's expanded cell.
// No API calls here — events bubble to the parent's modals.
const props = defineProps({
dbInbound: { type: Object, required: true },
isMobile: { type: Boolean, default: false },
trafficDiff: { type: Number, default: 0 },
expireDiff: { type: Number, default: 0 },
onlineClients: { type: Array, default: () => [] },
lastOnlineMap: { type: Object, default: () => ({}) },
isDarkTheme: { type: Boolean, default: false },
pageSize: { type: Number, default: 0 },
});
const emit = defineEmits([
'edit-client',
'qrcode-client',
'info-client',
'reset-traffic-client',
'delete-client',
'delete-clients',
'toggle-enable-client',
]);
const inbound = computed(() => props.dbInbound.toInbound());
const clients = computed(() => inbound.value?.clients || []);
const currentPage = ref(1);
const paginatedClients = computed(() => {
if (!props.pageSize || props.pageSize <= 0) return clients.value;
const start = (currentPage.value - 1) * props.pageSize;
return clients.value.slice(start, start + props.pageSize);
});
watch([clients, () => props.pageSize], () => {
const total = clients.value.length;
const size = props.pageSize > 0 ? props.pageSize : (total || 1);
const maxPage = Math.max(1, Math.ceil(total / size));
if (currentPage.value > maxPage) currentPage.value = maxPage;
});
// === Per-client stats lookup =======================================
const statsMap = computed(() => {
const m = new Map();
for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
return m;
});
function statsFor(email) {
return email ? statsMap.value.get(email) : null;
}
function getUp(email) { return statsFor(email)?.up || 0; }
function getDown(email) { return statsFor(email)?.down || 0; }
function getSum(email) { const s = statsFor(email); return s ? s.up + s.down : 0; }
function getRem(email) {
const s = statsFor(email);
if (!s) return 0;
const r = s.total - s.up - s.down;
return r > 0 ? r : 0;
}
function getAllTime(email) {
const s = statsFor(email);
if (!s) return 0;
// allTime is the cumulative-historical counter; never let it dip
// below up+down (manual edits / partial migrations can push it under).
const current = (s.up || 0) + (s.down || 0);
return s.allTime > current ? s.allTime : current;
}
function isClientDepleted(email) {
const s = statsFor(email);
if (!s) return false;
const total = s.total ?? 0;
const used = (s.up ?? 0) + (s.down ?? 0);
if (total > 0 && used >= total) return true;
const exp = s.expiryTime ?? 0;
if (exp > 0 && Date.now() >= exp) return true;
return false;
}
function isClientOnline(email) {
return !!email && props.onlineClients.includes(email);
}
function lastOnlineLabel(email) {
const ts = props.lastOnlineMap[email];
if (!ts) return '-';
return IntlUtil.formatDate(ts, datepicker.value);
}
function statsProgress(email) {
const s = statsFor(email);
if (!s) return 0;
if (s.total === 0) return 100;
return (100 * (s.down + s.up)) / s.total;
}
function expireProgress(expTime, reset) {
const now = Date.now();
const remainedSec = expTime < 0 ? -expTime / 1000 : (expTime - now) / 1000;
const resetSec = reset * 86400;
if (remainedSec >= resetSec) return 0;
return 100 * (1 - remainedSec / resetSec);
}
function clientStatsColor(email) {
return ColorUtils.clientUsageColor(statsFor(email), props.trafficDiff);
}
function statsExpColor(email) {
// AD-Vue 4 semantic palette mirrors ColorUtils.* so the badge dot
// matches the row's traffic/expiry tags.
const PURPLE = '#722ed1', SUCCESS = '#52c41a', WARN = '#faad14', DANGER = '#ff4d4f';
if (!email) return PURPLE;
const s = statsFor(email);
if (!s) return PURPLE;
const a = ColorUtils.usageColor(s.down + s.up, props.trafficDiff, s.total);
const b = ColorUtils.usageColor(Date.now(), props.expireDiff, s.expiryTime);
if (a === 'red' || b === 'red') return DANGER;
if (a === 'orange' || b === 'orange') return WARN;
if (a === 'green' || b === 'green') return SUCCESS;
return PURPLE;
}
const isRemovable = computed(() => clients.value.length > 1);
function totalGbDisplay(client) {
if (!client.totalGB || client.totalGB <= 0) return '';
return `${Math.round((client.totalGB / 1073741824) * 100) / 100} GB`;
}
const isUnlimitedTotal = (client) => !client.totalGB || client.totalGB <= 0;
function statusBadgeColor(client) {
if (!client.enable) return props.isDarkTheme ? '#2c3950' : '#bcbcbc';
return statsExpColor(client.email);
}
// === Action confirms ==============================================
function confirmReset(client) {
Modal.confirm({
title: `${t('pages.inbounds.resetTraffic')}${client.email}`,
content: t('pages.inbounds.resetTrafficContent'),
okText: t('reset'),
cancelText: t('cancel'),
onOk: () => emit('reset-traffic-client', { dbInbound: props.dbInbound, client }),
});
}
function confirmDelete(client) {
Modal.confirm({
title: `${t('pages.inbounds.deleteClient')}${client.email}`,
content: t('pages.inbounds.deleteClientContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => emit('delete-client', { dbInbound: props.dbInbound, client }),
});
}
// Stable row key for v-for — falls back through email/id/password
// because not every protocol fills the same field.
function rowKey(client) {
return client.email || client.id || client.password || JSON.stringify(client);
}
const selected = ref(new Set());
const allSelected = computed(() =>
clients.value.length > 0 && clients.value.every((c) => selected.value.has(rowKey(c))),
);
const someSelected = computed(() =>
clients.value.some((c) => selected.value.has(rowKey(c))),
);
const selectedCount = computed(() => selected.value.size);
function isSelected(key) {
return selected.value.has(key);
}
function toggleSelect(key, next) {
const s = new Set(selected.value);
if (next) s.add(key); else s.delete(key);
selected.value = s;
}
function selectAll(next) {
if (next) {
selected.value = new Set(clients.value.map(rowKey));
} else {
selected.value = new Set();
}
}
function clearSelection() {
selected.value = new Set();
}
watch(clients, (list) => {
if (selected.value.size === 0) return;
const valid = new Set(list.map(rowKey));
const next = new Set();
for (const k of selected.value) if (valid.has(k)) next.add(k);
if (next.size !== selected.value.size) selected.value = next;
});
function confirmBulkDelete() {
const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
if (picked.length === 0) return;
const total = clients.value.length;
const keepLast = picked.length === total;
const toDelete = keepLast ? picked.slice(0, -1) : picked;
if (toDelete.length === 0) {
Modal.warning({
title: t('pages.inbounds.deleteClient'),
content: 'Inbound must keep at least one client — delete the inbound to remove all.',
okText: t('confirm'),
});
return;
}
Modal.confirm({
title: `${t('pages.inbounds.deleteClient')}${toDelete.length}${keepLast ? ` / ${total}` : ''}`,
content: keepLast
? 'Inbound must keep at least one client — the last selected will remain. Delete the inbound to remove all.'
: t('pages.inbounds.deleteClientContent'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => {
emit('delete-clients', { dbInbound: props.dbInbound, clients: toDelete });
clearSelection();
},
});
}
</script>
<template>
<div class="client-list"
:class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
<div v-if="isRemovable && selectedCount > 0" class="bulk-bar">
<span class="bulk-count">{{ selectedCount }} selected</span>
<a-button size="small" type="link" @click="clearSelection">{{ t('cancel') }}</a-button>
<a-button size="small" danger @click="confirmBulkDelete">
<DeleteOutlined /> {{ t('delete') }}
</a-button>
</div>
<!-- ====================== Desktop: grid table ===================== -->
<template v-if="!isMobile">
<div class="client-row client-list-header">
<div v-if="isRemovable" class="cell cell-select">
<a-checkbox :checked="allSelected" :indeterminate="someSelected && !allSelected"
@change="(e) => selectAll(e.target.checked)" />
</div>
<div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
<div class="cell cell-enable">{{ t('enable') }}</div>
<div class="cell cell-online">{{ t('online') }}</div>
<div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
<div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
<div class="cell cell-remained">{{ t('remained') }}</div>
<div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
<div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
</div>
<div v-for="client in paginatedClients" :key="rowKey(client)" class="client-row"
:class="{ 'is-selected': isSelected(rowKey(client)) }">
<div v-if="isRemovable" class="cell cell-select">
<a-checkbox :checked="isSelected(rowKey(client))"
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
</div>
<div class="cell cell-actions">
<a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
<QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
</a-tooltip>
<a-tooltip :title="t('edit')">
<EditOutlined class="row-icon" @click="emit('edit-client', { dbInbound, client })" />
</a-tooltip>
<a-tooltip :title="t('info')">
<InfoCircleOutlined class="row-icon" @click="emit('info-client', { dbInbound, client })" />
</a-tooltip>
<a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
<RetweetOutlined class="row-icon" @click="confirmReset(client)" />
</a-tooltip>
<a-tooltip v-if="isRemovable" :title="t('delete')">
<DeleteOutlined class="row-icon danger" @click="confirmDelete(client)" />
</a-tooltip>
</div>
<div class="cell cell-enable">
<a-switch :checked="client.enable" size="small"
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
</div>
<div class="cell cell-online">
<a-popover>
<template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(client.email) }}</template>
<a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
<a-tag v-else>{{ t('offline') }}</a-tag>
</a-popover>
</div>
<div class="cell cell-client">
<a-tooltip>
<template #title>
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
<template v-else-if="!client.enable">{{ t('disabled') }}</template>
<template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
<template v-else>{{ t('offline') }}</template>
</template>
<a-badge :color="statusBadgeColor(client)" />
</a-tooltip>
<div class="client-id-stack">
<a-tooltip :title="client.email">
<span class="client-email">{{ client.email }}</span>
</a-tooltip>
<span v-if="client.comment && client.comment.trim()" class="client-comment">
{{ client.comment.length > 50 ? client.comment.substring(0, 47) + '…' : client.comment }}
</span>
</div>
</div>
<div class="cell cell-traffic">
<a-popover>
<template v-if="client.email" #content>
<table cellpadding="2">
<tbody>
<tr>
<td> {{ SizeFormatter.sizeFormat(getUp(client.email)) }}</td>
<td> {{ SizeFormatter.sizeFormat(getDown(client.email)) }}</td>
</tr>
<tr v-if="client.totalGB > 0">
<td>{{ t('remained') }}</td>
<td>{{ SizeFormatter.sizeFormat(getRem(client.email)) }}</td>
</tr>
</tbody>
</table>
</template>
<div class="usage-bar">
<span class="usage-text">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</span>
<a-progress v-if="!client.enable" :stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
:show-info="false" :percent="statsProgress(client.email)" size="small" />
<a-progress v-else-if="client.totalGB > 0" :stroke-color="clientStatsColor(client.email)"
:show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
:percent="statsProgress(client.email)" size="small" />
<a-progress v-else :show-info="false" :percent="100" stroke-color="#722ed1" size="small" />
<span class="usage-text">
<InfinityIcon v-if="isUnlimitedTotal(client)" />
<template v-else>{{ totalGbDisplay(client) }}</template>
</span>
</div>
</a-popover>
</div>
<div class="cell cell-remained">
<a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
<InfinityIcon />
</a-tag>
<a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
{{ SizeFormatter.sizeFormat(getRem(client.email)) }}
</a-tag>
</div>
<div class="cell cell-alltime">
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
</div>
<div class="cell cell-expiry">
<template v-if="client.expiryTime !== 0 && client.reset > 0">
<a-popover>
<template #content>
<span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
<span v-else>{{ IntlUtil.formatDate(client.expiryTime, datepicker) }}</span>
</template>
<div class="usage-bar">
<span class="usage-text">{{ IntlUtil.formatRelativeTime(client.expiryTime) }}</span>
<a-progress :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
:percent="expireProgress(client.expiryTime, client.reset)" size="small" />
<span class="usage-text">{{ client.reset }}d</span>
</div>
</a-popover>
</template>
<a-popover v-else-if="client.expiryTime !== 0">
<template #content>
<span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
<span v-else>{{ IntlUtil.formatDate(client.expiryTime) }}</span>
</template>
<a-tag :style="{ minWidth: '50px', border: 'none' }"
:color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
</a-tag>
</a-popover>
<a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)" :style="{ border: 'none' }"
class="infinite-tag">
<InfinityIcon />
</a-tag>
</div>
</div>
</template>
<!-- ====================== Mobile: card list ======================= -->
<template v-else>
<div v-for="client in paginatedClients" :key="rowKey(client)" class="client-card"
:class="{ 'is-selected': isSelected(rowKey(client)) }">
<div class="client-card-head">
<a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
<a-tooltip>
<template #title>
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
<template v-else-if="!client.enable">{{ t('disabled') }}</template>
<template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
<template v-else>{{ t('offline') }}</template>
</template>
<a-badge :color="statusBadgeColor(client)" />
</a-tooltip>
<a-tooltip :title="client.email">
<span class="client-email">{{ client.email }}</span>
</a-tooltip>
<div class="client-card-actions">
<a-switch :checked="client.enable" size="small"
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
<a-dropdown :trigger="['click']" placement="bottomRight">
<EllipsisOutlined class="row-icon" @click.prevent />
<template #overlay>
<a-menu>
<a-menu-item v-if="dbInbound.hasLink()" @click="emit('qrcode-client', { dbInbound, client })">
<QrcodeOutlined /> {{ t('qrCode') }}
</a-menu-item>
<a-menu-item @click="emit('edit-client', { dbInbound, client })">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item @click="emit('info-client', { dbInbound, client })">
<InfoCircleOutlined /> {{ t('info') }}
</a-menu-item>
<a-menu-item v-if="client.email" @click="confirmReset(client)">
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
</a-menu-item>
<a-menu-item v-if="isRemovable" @click="confirmDelete(client)">
<DeleteOutlined /> <span class="danger">{{ t('delete') }}</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<div v-if="client.comment && client.comment.trim()" class="client-comment-line">
{{ client.comment.length > 80 ? client.comment.substring(0, 77) + '…' : client.comment }}
</div>
<div class="client-card-foot">
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
<a-tag :color="clientStatsColor(client.email)">
{{ SizeFormatter.sizeFormat(getSum(client.email)) }} /
<InfinityIcon v-if="isUnlimitedTotal(client)" />
<template v-else>{{ totalGbDisplay(client) }}</template>
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('remained') }}</span>
<a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
<InfinityIcon />
</a-tag>
<a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
{{ SizeFormatter.sizeFormat(getRem(client.email)) }}
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('online') }}</span>
<a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
<a-tag v-else>{{ t('offline') }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
<a-tag v-if="client.expiryTime > 0" :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
</a-tag>
<a-tag v-else-if="client.expiryTime < 0" color="green">
{{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
</a-tag>
<a-tag v-else color="purple">
<InfinityIcon />
</a-tag>
</div>
</div>
</div>
</template>
<a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"
:page-size="pageSize" :total="clients.length" :show-size-changer="false" size="small"
class="client-list-pagination" />
</div>
</template>
<style scoped>
.client-list {
margin: -8px 0;
font-size: 13px;
}
.bulk-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 16px;
background: rgba(22, 119, 255, 0.08);
border-bottom: 1px solid rgba(22, 119, 255, 0.18);
}
.bulk-count {
font-weight: 500;
font-size: 13px;
}
.is-selected {
background: rgba(22, 119, 255, 0.06);
}
.client-row {
display: grid;
/* Default — no select column (single-client inbounds). The .has-select
* modifier below prepends the 40px checkbox column. */
grid-template-columns:
140px
/* actions */
60px
/* enable */
80px
/* online */
minmax(160px, 2fr)
/* client identity */
minmax(160px, 2fr)
/* traffic */
130px
/* all-time */
130px
/* remained */
140px;
/* expiry */
gap: 12px;
align-items: center;
padding: 8px 16px;
border-top: 1px solid rgba(128, 128, 128, 0.12);
}
.client-list.has-select .client-row {
grid-template-columns:
40px
/* select */
140px
/* actions */
60px
/* enable */
80px
/* online */
minmax(160px, 2fr)
/* client identity */
minmax(160px, 2fr)
/* traffic */
130px
/* all-time */
130px
/* remained */
140px;
/* expiry */
}
.client-row:last-child {
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
}
.client-list-header {
font-weight: 500;
font-size: 12px;
opacity: 0.65;
padding-top: 6px;
padding-bottom: 6px;
border-top: none;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.cell {
min-width: 0;
/* allow grid children to shrink instead of overflowing */
}
.cell-select,
.cell-actions,
.cell-enable,
.cell-online,
.cell-alltime,
.cell-remained {
text-align: center;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
flex-wrap: wrap;
}
.cell-actions {
justify-content: flex-start;
}
.cell-client {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.cell-traffic,
.cell-expiry {
text-align: center;
}
.client-list-header .cell {
text-align: center;
}
.client-list-header .cell-actions,
.client-list-header .cell-client {
text-align: left;
}
/* Action icons */
.row-icon {
font-size: 16px;
cursor: pointer;
padding: 0 2px;
color: inherit;
transition: color 120ms ease;
}
.row-icon:hover {
color: var(--ant-color-primary, #1677ff);
}
.row-icon.danger {
color: #ff4d4f;
}
.danger {
color: #ff4d4f;
}
/* Client identity stack (badge + email + comment) */
.client-id-stack {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
overflow: hidden;
}
.client-email {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
}
.client-comment {
font-size: 11px;
opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
}
/* Traffic / expiry inline bar: text | progress | text */
.usage-bar {
display: grid;
grid-template-columns: minmax(50px, auto) minmax(40px, 1fr) minmax(40px, auto);
align-items: center;
gap: 6px;
}
.usage-text {
font-size: 12px;
white-space: nowrap;
}
.usage-bar :deep(.ant-progress) {
margin: 0;
line-height: 1;
}
.infinite-tag {
min-width: 50px;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Strip AD-Vue's default expanded-cell padding so the desktop grid
* sits flush against the inbound row's left/right edges. */
:deep(.ant-table-expanded-row > .ant-table-cell) {
padding: 0 !important;
}
.client-list-pagination {
display: flex;
justify-content: center;
padding: 10px 16px 4px;
}
/* ===== Mobile card list =========================================== */
.client-list.is-mobile {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
}
.client-card {
border: 1px solid rgba(128, 128, 128, 0.18);
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
:global(body.dark) .client-card {
border-color: rgba(255, 255, 255, 0.1);
}
.client-card-head {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.client-card-head .client-email {
flex: 1;
min-width: 0;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.client-card-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.client-card-actions .row-icon {
font-size: 20px;
padding: 4px;
}
.client-comment-line {
font-size: 11px;
opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.client-card-foot {
display: flex;
flex-direction: column;
gap: 4px;
}
.client-card-foot .stat-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.client-card-foot .stat-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.6;
min-width: 96px;
flex-shrink: 0;
}
.client-card-foot :deep(.ant-tag) {
margin: 0;
}
/* Bigger status badge for thumb-readable state at a glance. */
.client-card-head :deep(.ant-badge-status-dot) {
width: 9px;
height: 9px;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+831
View File
@@ -0,0 +1,831 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
MenuOutlined,
SearchOutlined,
FilterOutlined,
MoreOutlined,
EditOutlined,
QrcodeOutlined,
UserAddOutlined,
UsergroupAddOutlined,
CopyOutlined,
FileDoneOutlined,
ExportOutlined,
ImportOutlined,
ReloadOutlined,
RestOutlined,
RetweetOutlined,
BlockOutlined,
DeleteOutlined,
InfoCircleOutlined,
RightOutlined,
} from '@ant-design/icons-vue';
import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
import { DBInbound } from '@/models/dbinbound.js';
import { Inbound } from '@/models/inbound.js';
import InfinityIcon from '@/components/InfinityIcon.vue';
import ClientRowTable from './ClientRowTable.vue';
import { useDatepicker } from '@/composables/useDatepicker.js';
const { datepicker } = useDatepicker();
const { t } = useI18n();
const props = defineProps({
dbInbounds: { type: Array, required: true },
clientCount: { type: Object, required: true },
onlineClients: { type: Array, required: true },
lastOnlineMap: { type: Object, default: () => ({}) },
expireDiff: { type: Number, default: 0 },
trafficDiff: { type: Number, default: 0 },
pageSize: { type: Number, default: 0 },
isMobile: { type: Boolean, default: false },
isDarkTheme: { type: Boolean, default: false },
subEnable: { type: Boolean, default: false },
// Map node id -> node row, supplied by the parent page so each
// inbound row can render its node name without an extra fetch.
nodesById: { type: Map, default: () => new Map() },
});
const emit = defineEmits([
'refresh',
'add-inbound',
'general-action',
'row-action',
// Per-client events surfaced from the expand-row table.
'edit-client',
'qrcode-client',
'info-client',
'reset-traffic-client',
'delete-client',
'delete-clients',
'toggle-enable-client',
]);
// ============ Toolbar / search & filter =============================
const enableFilter = ref(false);
const searchKey = ref('');
const filterBy = ref('');
// Toggle the filter mode — flip cleans the other input.
function onToggleFilter() {
if (enableFilter.value) searchKey.value = '';
else filterBy.value = '';
}
// ============ Search / filter projection =============================
// Mirrors the legacy logic: when searching, keep inbounds that match
// anywhere (deep search); when filtering, keep inbounds that have at
// least one client in the requested bucket and reduce their settings
// to that bucket.
function projectInbound(dbInbound, predicate) {
const next = new DBInbound(dbInbound);
let settings;
try {
settings = JSON.parse(dbInbound.settings || '{}');
} catch (_e) {
settings = {};
}
if (!Array.isArray(settings.clients)) return next;
const filtered = settings.clients.filter(predicate);
next.settings = Inbound.Settings.fromJson(dbInbound.protocol, { clients: filtered });
next.invalidateCache();
return next;
}
const visibleInbounds = computed(() => {
if (enableFilter.value) {
if (ObjectUtil.isEmpty(filterBy.value)) return [...props.dbInbounds];
const out = [];
for (const dbInbound of props.dbInbounds) {
const c = props.clientCount[dbInbound.id];
if (!c || !c[filterBy.value] || c[filterBy.value].length === 0) continue;
const list = c[filterBy.value];
out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
}
return out;
}
if (ObjectUtil.isEmpty(searchKey.value)) return [...props.dbInbounds];
const out = [];
for (const dbInbound of props.dbInbounds) {
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
}
return out;
});
// ============ Columns =================================================
// `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
// `responsive` array still works on column defs. Computed so column
// labels react to live locale switches.
const hasAnyRemark = computed(() =>
props.dbInbounds.some((i) => typeof i?.remark === 'string' && i.remark.trim() !== ''),
);
const desktopColumns = computed(() => {
const cols = [
{ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 },
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 },
{ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 },
];
if (hasAnyRemark.value) {
cols.push({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 });
}
if (props.nodesById.size > 0) {
cols.push({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 });
}
cols.push(
{ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 },
{ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 },
{ title: t('clients'), key: 'clients', align: 'left', width: 50 },
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 },
{ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 },
{ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 },
);
return cols;
});
const columns = computed(() => desktopColumns.value);
// Mobile expansion state — replaces a-table's expandable() since the
// mobile branch renders a hand-rolled card list rather than a table.
const expandedIds = ref(new Set());
function toggleExpanded(id) {
const next = new Set(expandedIds.value);
if (next.has(id)) next.delete(id);
else next.add(id);
expandedIds.value = next;
}
function isExpanded(id) {
return expandedIds.value.has(id);
}
// ============ Pagination ============================================
function paginationFor(rows) {
const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
return {
pageSize: size,
showSizeChanger: false,
hideOnSinglePage: true,
};
}
// ============ Per-row enable switch =================================
async function onSwitchEnable(dbInbound, next) {
const previous = dbInbound.enable;
dbInbound.enable = next; // optimistic
try {
const formData = new FormData();
formData.append('enable', String(next));
const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData);
if (!msg?.success) dbInbound.enable = previous;
} catch (_e) {
dbInbound.enable = previous;
}
}
// ============ Helpers shared with the templates =====================
// Whether to show the "Switch xray" / qrcode menu entry — same predicate
// as legacy: SS single-user inbounds and WireGuard inbounds expose
// inbound-wide QR codes.
function showQrCodeMenu(dbInbound) {
if (dbInbound.isWireguard) return true;
if (dbInbound.isSS) {
try {
return !dbInbound.toInbound().isSSMultiUser;
} catch (_e) {
return false;
}
}
return false;
}
</script>
<template>
<a-card hoverable>
<template #title>
<a-space direction="horizontal">
<a-button type="primary" @click="emit('add-inbound')">
<template #icon>
<PlusOutlined />
</template>
<template v-if="!isMobile">{{ t('pages.inbounds.addInbound') }}</template>
</a-button>
<a-dropdown :trigger="['click']">
<a-button type="primary">
<template #icon>
<MenuOutlined />
</template>
<template v-if="!isMobile">{{ t('pages.inbounds.generalActions') }}</template>
</a-button>
<template #overlay>
<a-menu @click="(a) => emit('general-action', a.key)">
<a-menu-item key="import">
<ImportOutlined /> {{ t('pages.inbounds.importInbound') }}
</a-menu-item>
<a-menu-item key="export">
<ExportOutlined /> {{ t('pages.inbounds.export') }}
</a-menu-item>
<a-menu-item v-if="subEnable" key="subs">
<ExportOutlined /> {{ t('pages.inbounds.export') }} {{ t('pages.settings.subSettings') }}
</a-menu-item>
<a-menu-item key="resetInbounds">
<ReloadOutlined /> {{ t('pages.inbounds.resetAllTraffic') }}
</a-menu-item>
<a-menu-item key="resetClients">
<FileDoneOutlined /> {{ t('pages.inbounds.resetAllClientTraffics') }}
</a-menu-item>
<a-menu-item key="delDepletedClients" class="danger-item">
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</template>
<a-space direction="vertical" :style="{ width: '100%' }">
<!-- Search / filter toolbar -->
<div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
<a-switch v-model:checked="enableFilter" @change="onToggleFilter">
<template #checkedChildren>
<SearchOutlined />
</template>
<template #unCheckedChildren>
<FilterOutlined />
</template>
</a-switch>
<a-input v-if="!enableFilter" v-model:value="searchKey" :placeholder="t('search')" autofocus
:size="isMobile ? 'small' : 'middle'" :style="{ maxWidth: '300px' }" />
<a-radio-group v-if="enableFilter" v-model:value="filterBy" button-style="solid"
:size="isMobile ? 'small' : 'middle'">
<a-radio-button value="">{{ t('none') }}</a-radio-button>
<a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
<a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
<a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
<a-radio-button value="online">{{ t('online') }}</a-radio-button>
</a-radio-group>
</div>
<!-- ====================== Mobile: card list ======================= -->
<div v-if="isMobile" class="inbound-cards">
<div v-if="visibleInbounds.length === 0" class="card-empty"></div>
<div v-for="record in visibleInbounds" :key="record.id" class="inbound-card">
<!-- Header: chevron (multi-user only) + remark + enable + actions -->
<div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
<RightOutlined v-if="record.isMultiUser()" class="card-expand"
:class="{ 'is-expanded': isExpanded(record.id) }" />
<span class="card-id">#{{ record.id }}</span>
<span class="tag-name">{{ record.remark }}</span>
<div class="card-actions" @click.stop>
<a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
<a-dropdown :trigger="['click']" placement="bottomRight">
<MoreOutlined class="row-action-trigger" @click.prevent />
<template #overlay>
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
<a-menu-item key="edit">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
<QrcodeOutlined /> {{ t('qrCode') }}
</a-menu-item>
<template v-if="record.isMultiUser()">
<a-menu-item key="addClient">
<UserAddOutlined /> {{ t('pages.client.add') }}
</a-menu-item>
<a-menu-item key="addBulkClient">
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
</a-menu-item>
<a-menu-item key="copyClients">
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
</a-menu-item>
<a-menu-item key="resetClients">
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
</a-menu-item>
<a-menu-item key="export">
<ExportOutlined /> {{ t('pages.inbounds.export') }}
</a-menu-item>
<a-menu-item v-if="subEnable" key="subs">
<ExportOutlined /> {{ t('pages.inbounds.export') }} {{ t('pages.settings.subSettings') }}
</a-menu-item>
<a-menu-item key="delDepletedClients" class="danger-item">
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
</a-menu-item>
</template>
<template v-else>
<a-menu-item key="showInfo">
<InfoCircleOutlined /> {{ t('info') }}
</a-menu-item>
</template>
<a-menu-item key="clipboard">
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
</a-menu-item>
<a-menu-item key="resetTraffic">
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
</a-menu-item>
<a-menu-item key="clone">
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
</a-menu-item>
<a-menu-item key="delete" class="danger-item">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<!-- 2-column labelled stat grid: protocol/port/node + traffic/clients/expiry -->
<div class="card-stats">
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
<a-tag color="purple">{{ record.protocol }}</a-tag>
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
</template>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.port') }}</span>
<a-tag>{{ record.port }}</a-tag>
</div>
<div v-if="nodesById.size > 0" class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.node') }}</span>
<a-tag v-if="record.nodeId == null" color="default">
{{ t('pages.inbounds.localPanel') }}
</a-tag>
<a-tag v-else-if="nodesById.get(record.nodeId)"
:color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
{{ nodesById.get(record.nodeId).name }}
</a-tag>
<a-tag v-else color="orange">#{{ record.nodeId }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
<a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
{{ SizeFormatter.sizeFormat(record.up + record.down) }} /
<template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
<InfinityIcon v-else />
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
<a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
</div>
<div v-if="clientCount[record.id]" class="stat-row">
<span class="stat-label">{{ t('clients') }}</span>
<a-tag color="green">{{ clientCount[record.id].clients }}</a-tag>
<a-tag v-if="clientCount[record.id].online.length" color="blue">
{{ clientCount[record.id].online.length }} {{ t('online') }}
</a-tag>
<a-tag v-if="clientCount[record.id].depleted.length" color="red">
{{ clientCount[record.id].depleted.length }} {{ t('depleted') }}
</a-tag>
<a-tag v-if="clientCount[record.id].expiring.length" color="orange">
{{ clientCount[record.id].expiring.length }} {{ t('depletingSoon') }}
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
<a-tag v-if="record.expiryTime > 0"
:color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)">
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
</a-tag>
<a-tag v-else color="purple">
<InfinityIcon />
</a-tag>
</div>
</div>
<!-- Expanded client list (multi-user only) -->
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
:page-size="pageSize"
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
@info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@delete-client="(p) => emit('delete-client', p)"
@delete-clients="(p) => emit('delete-clients', p)"
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
</div>
</div>
</div>
<!-- ====================== Desktop: a-table ======================== -->
<a-table v-else :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
:pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
<!-- Per-inbound client list, expanded by clicking the row's
default expand chevron. Hidden via row-class-name for
non-multi-user inbounds (matches legacy behavior). -->
<template #expandedRowRender="{ record }">
<ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
@edit-client="(p) => emit('edit-client', p)"
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@delete-client="(p) => emit('delete-client', p)"
@delete-clients="(p) => emit('delete-clients', p)"
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
</template>
<template #bodyCell="{ column, record }">
<!-- ============== Action dropdown ============== -->
<template v-if="column.key === 'action'">
<a-dropdown :trigger="['click']">
<MoreOutlined class="row-action-trigger" @click.prevent />
<template #overlay>
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
<a-menu-item key="edit">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
<QrcodeOutlined /> {{ t('qrCode') }}
</a-menu-item>
<template v-if="record.isMultiUser()">
<a-menu-item key="addClient">
<UserAddOutlined /> {{ t('pages.client.add') }}
</a-menu-item>
<a-menu-item key="addBulkClient">
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
</a-menu-item>
<a-menu-item key="copyClients">
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
</a-menu-item>
<a-menu-item key="resetClients">
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
</a-menu-item>
<a-menu-item key="export">
<ExportOutlined /> {{ t('pages.inbounds.export') }}
</a-menu-item>
<a-menu-item v-if="subEnable" key="subs">
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
</a-menu-item>
<a-menu-item key="delDepletedClients" class="danger-item">
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
</a-menu-item>
</template>
<template v-else>
<a-menu-item key="showInfo">
<InfoCircleOutlined /> {{ t('info') }}
</a-menu-item>
</template>
<a-menu-item key="clipboard">
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
</a-menu-item>
<a-menu-item key="resetTraffic">
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
</a-menu-item>
<a-menu-item key="clone">
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
</a-menu-item>
<a-menu-item key="delete" class="danger-item">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<!-- ============== Enable switch (desktop) ============== -->
<template v-else-if="column.key === 'enable'">
<a-switch :checked="record.enable" @change="(next) => onSwitchEnable(record, next)" />
</template>
<!-- ============== Node deployment tag ============== -->
<template v-else-if="column.key === 'node'">
<template v-if="record.nodeId == null">
<a-tag color="default">{{ t('pages.inbounds.localPanel') }}</a-tag>
</template>
<template v-else-if="nodesById.get(record.nodeId)">
<a-tag :color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
{{ nodesById.get(record.nodeId).name }}
</a-tag>
</template>
<template v-else>
<!-- Node row was deleted but inbound still references it. -->
<a-tag color="orange">node #{{ record.nodeId }}</a-tag>
</template>
</template>
<!-- ============== Protocol tags ============== -->
<template v-else-if="column.key === 'protocol'">
<div class="protocol-tags">
<a-tag color="purple">{{ record.protocol }}</a-tag>
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
</template>
</div>
</template>
<!-- ============== Clients tag + popovers ============== -->
<template v-else-if="column.key === 'clients'">
<template v-if="clientCount[record.id]">
<a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag>
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
<template #content>
<div class="client-email-list">
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
</div>
</template>
<a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
</a-popover>
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
<template #content>
<div class="client-email-list">
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
</div>
</template>
<a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
}}</a-tag>
</a-popover>
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
<template #content>
<div class="client-email-list">
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
</div>
</template>
<a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
}}</a-tag>
</a-popover>
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
<template #content>
<div class="client-email-list">
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
</div>
</template>
<a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
</a-popover>
</template>
</template>
<!-- ============== Traffic ============== -->
<template v-else-if="column.key === 'traffic'">
<a-popover>
<template #content>
<table cellpadding="2">
<tbody>
<tr>
<td>↑ {{ SizeFormatter.sizeFormat(record.up) }}</td>
<td>↓ {{ SizeFormatter.sizeFormat(record.down) }}</td>
</tr>
<tr v-if="record.total > 0 && record.up + record.down < record.total">
<td>{{ t('remained') }}</td>
<td>{{ SizeFormatter.sizeFormat(record.total - record.up - record.down) }}</td>
</tr>
</tbody>
</table>
</template>
<a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
{{ SizeFormatter.sizeFormat(record.up + record.down) }} /
<template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
<InfinityIcon v-else />
</a-tag>
</a-popover>
</template>
<!-- ============== All-time inbound traffic ============== -->
<template v-else-if="column.key === 'allTimeInbound'">
<a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
</template>
<!-- ============== Expiry ============== -->
<template v-else-if="column.key === 'expiryTime'">
<a-popover v-if="record.expiryTime > 0">
<template #content>{{ IntlUtil.formatDate(record.expiryTime, datepicker) }}</template>
<a-tag :color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)" style="min-width: 50px">
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
</a-tag>
</a-popover>
<a-tag v-else color="purple">
<InfinityIcon />
</a-tag>
</template>
</template>
</a-table>
</a-space>
</a-card>
</template>
<style scoped>
.filter-bar {
display: flex;
align-items: center;
gap: 8px;
}
.filter-bar.mobile {
display: block;
}
.filter-bar.mobile>* {
margin-bottom: 4px;
}
.protocol-tags {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
}
.row-action-trigger {
font-size: 20px;
cursor: pointer;
}
.danger-item {
color: #ff4d4f;
}
/* Hide the expand chevron on rows whose inbound has no client list
* (HTTP/Mixed/Tunnel/WireGuard single-config). */
:deep(.hide-expand-icon .ant-table-row-expand-icon) {
visibility: hidden;
}
/* Push the expand chevron away from the table's left edge so it has
* a little breathing room instead of being flush against the corner. */
:deep(.ant-table-tbody .ant-table-cell-with-append) {
padding-left: 12px;
}
:deep(.ant-table-row-expand-icon) {
margin-inline-end: 10px;
margin-inline-start: 4px;
}
/* Round the table's outer corners — AD-Vue gives .ant-table the radius
* token, but the inner header strip and footer touch the edges, so clip
* them here. */
:deep(.ant-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.ant-table-container) {
border-radius: 8px;
overflow: hidden;
}
:deep(.ant-table-thead > tr:first-child > *:first-child) {
border-start-start-radius: 8px;
}
:deep(.ant-table-thead > tr:first-child > *:last-child) {
border-start-end-radius: 8px;
}
:deep(.ant-table-tbody > tr:last-child > *:first-child) {
border-end-start-radius: 8px;
}
:deep(.ant-table-tbody > tr:last-child > *:last-child) {
border-end-end-radius: 8px;
}
/* ===== Mobile card list ===========================================
* <768px renders inbounds as a vertical stack of cards via the
* v-if="isMobile" branch above; the desktop <a-table> isn't mounted
* so the legacy table-cell tightening rules went away. */
.inbound-cards {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 4px;
}
.inbound-card {
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 10px;
padding: 12px;
background: rgba(255, 255, 255, 0.02);
display: flex;
flex-direction: column;
gap: 8px;
}
:global(body.dark) .inbound-card {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
}
.card-head {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.card-id {
font-size: 11px;
opacity: 0.6;
}
.tag-name {
font-weight: 600;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.card-expand {
font-size: 12px;
opacity: 0.6;
transition: transform 150ms ease;
flex-shrink: 0;
}
.card-expand.is-expanded {
transform: rotate(90deg);
}
.card-stats {
display: flex;
flex-direction: column;
gap: 6px;
}
.stat-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.stat-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.6;
min-width: 96px;
flex-shrink: 0;
}
.card-stats :deep(.ant-tag) {
margin: 0;
}
.card-clients {
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
.card-empty {
text-align: center;
opacity: 0.4;
padding: 20px 0;
}
@media (max-width: 768px) {
:deep(.ant-card-head) {
padding: 0 12px;
min-height: 44px;
}
:deep(.ant-card-head-title),
:deep(.ant-card-extra) {
padding: 8px 0;
}
:deep(.ant-card-body) {
padding: 8px;
}
.filter-bar.mobile {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.filter-bar.mobile>* {
margin-bottom: 0;
}
.row-action-trigger {
font-size: 22px;
padding: 4px;
}
}
</style>
@@ -0,0 +1,748 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import {
SwapOutlined,
PieChartOutlined,
HistoryOutlined,
BarsOutlined,
TeamOutlined,
} from '@ant-design/icons-vue';
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
import { Inbound } from '@/models/inbound.js';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue';
import CustomStatistic from '@/components/CustomStatistic.vue';
import { useNodeList } from '@/composables/useNodeList.js';
import InboundList from './InboundList.vue';
import InboundFormModal from './InboundFormModal.vue';
import ClientFormModal from './ClientFormModal.vue';
import ClientBulkModal from './ClientBulkModal.vue';
import InboundInfoModal from './InboundInfoModal.vue';
import QrCodeModal from './QrCodeModal.vue';
import TextModal from '@/components/TextModal.vue';
import PromptModal from '@/components/PromptModal.vue';
import { useInbounds } from './useInbounds.js';
import { useWebSocket } from '@/composables/useWebSocket.js';
const { t } = useI18n();
const {
fetched,
dbInbounds,
clientCount,
onlineClients,
totals,
expireDiff,
trafficDiff,
pageSize,
subSettings,
tgBotEnable,
ipLimitEnable,
remarkModel,
lastOnlineMap,
refresh,
fetchDefaultSettings,
applyTrafficEvent,
applyClientStatsEvent,
applyInvalidate,
applyInboundsEvent,
} = useInbounds();
// Live updates over WebSocket — replaces the old 5s polling loop.
// The backend pushes traffic + per-client deltas every ~10s; we merge
// them into the local refs in-place so counters and online badges
// update without re-fetching the whole list.
useWebSocket({
traffic: applyTrafficEvent,
client_stats: applyClientStatsEvent,
invalidate: applyInvalidate,
inbounds: applyInboundsEvent,
});
const { isMobile } = useMediaQuery();
// Node list lives on the central panel; the Inbounds page consumes
// the id→node map for the new "Node" column. Fetched once on mount.
const { byId: nodesById } = useNodeList();
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
onMounted(async () => {
await fetchDefaultSettings();
await refresh();
});
// === Add/Edit modal ===================================================
const formOpen = ref(false);
const formMode = ref('add');
const formDbInbound = ref(null);
// === Client modal (single + bulk) =====================================
const clientOpen = ref(false);
const clientMode = ref('add');
const clientDbInbound = ref(null);
const clientIndex = ref(null);
const bulkOpen = ref(false);
const bulkDbInbound = ref(null);
// === Info / QR-code modals ===========================================
const infoOpen = ref(false);
const infoDbInbound = ref(null);
const infoClientIndex = ref(0);
const qrOpen = ref(false);
const qrDbInbound = ref(null);
const qrClient = ref(null);
// hostOverrideFor returns the node's address for a node-managed inbound,
// or '' when the inbound runs locally. Wired into the QR / Info modals
// and into export-all-links functions so generated share links point at
// the node, not the central panel.
function hostOverrideFor(dbInbound) {
if (!dbInbound || dbInbound.nodeId == null) return '';
return nodesById.value.get(dbInbound.nodeId)?.address || '';
}
const infoNodeAddress = computed(() => hostOverrideFor(infoDbInbound.value));
const qrNodeAddress = computed(() => hostOverrideFor(qrDbInbound.value));
// === Shared text + prompt modal state =================================
const textOpen = ref(false);
const textTitle = ref('');
const textContent = ref('');
const textFileName = ref('');
const promptOpen = ref(false);
const promptTitle = ref('');
const promptOkText = ref('OK');
const promptType = ref('textarea');
const promptInitial = ref('');
const promptLoading = ref(false);
let promptHandler = null;
function openText({ title, content, fileName = '' }) {
textTitle.value = title;
textContent.value = content;
textFileName.value = fileName;
textOpen.value = true;
}
function openPrompt({ title, okText, type = 'textarea', value = '', confirm }) {
promptTitle.value = title;
promptOkText.value = okText || 'OK';
promptType.value = type;
promptInitial.value = value;
promptHandler = confirm;
promptOpen.value = true;
}
async function onPromptConfirm(value) {
if (!promptHandler) { promptOpen.value = false; return; }
promptLoading.value = true;
try {
const ok = await promptHandler(value);
if (ok !== false) promptOpen.value = false;
} finally {
promptLoading.value = false;
}
}
// === Export helpers — mirror legacy txtModal call sites ==============
function exportInboundLinks(dbInbound) {
const projected = checkFallback(dbInbound);
openText({
title: 'Export inbound links',
content: projected.genInboundLinks(remarkModel.value, hostOverrideFor(dbInbound)),
fileName: projected.remark || 'inbound',
});
}
function exportInboundClipboard(dbInbound) {
openText({
title: 'Inbound JSON',
content: JSON.stringify(dbInbound, null, 2),
});
}
function exportInboundSubs(dbInbound) {
const inbound = dbInbound.toInbound();
const clients = inbound?.clients || [];
const subLinks = [];
for (const c of clients) {
if (c.subId && subSettings.value.subURI) {
subLinks.push(subSettings.value.subURI + c.subId);
}
}
openText({
title: 'Export subscription links',
content: [...new Set(subLinks)].join('\n'),
fileName: `${dbInbound.remark || 'inbound'}-Subs`,
});
}
function exportAllLinks() {
const out = [];
for (const ib of dbInbounds.value) {
out.push(ib.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
}
openText({
title: 'Export all inbound links',
content: out.join('\r\n'),
fileName: 'All-Inbounds',
});
}
function exportAllSubs() {
const out = [];
for (const ib of dbInbounds.value) {
const inbound = ib.toInbound();
const clients = inbound?.clients || [];
for (const c of clients) {
if (c.subId && subSettings.value.subURI) {
out.push(subSettings.value.subURI + c.subId);
}
}
}
openText({
title: 'Export all subscription links',
content: [...new Set(out)].join('\r\n'),
fileName: 'All-Inbounds-Subs',
});
}
function importInbound() {
openPrompt({
title: 'Import inbound',
okText: 'Import',
type: 'textarea',
value: '',
confirm: async (value) => {
const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
if (msg?.success) {
await refresh();
return true;
}
return false;
},
});
}
// `checkFallback` mirrors the legacy helper: when an inbound listens
// on a unix-socket fallback (`@<name>`), point the link generator at
// the root inbound that owns the listen address so QRs/links carry
// the externally-reachable host:port and the right TLS state.
function checkFallback(dbInbound) {
// We don't keep parsed Inbounds in state right now (the page works
// off DBInbounds); compute on the fly.
if (!dbInbound.listen?.startsWith?.('@')) return dbInbound;
for (const candidate of dbInbounds.value) {
if (candidate.id === dbInbound.id) continue;
const parsed = candidate.toInbound();
if (!parsed.isTcp) continue;
if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
const fallbacks = parsed.settings.fallbacks || [];
if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
// Build a one-off DBInbound copy with the parent's listen/port +
// copied stream so the link gen sees the public endpoint.
const projected = JSON.parse(JSON.stringify(dbInbound));
projected.listen = candidate.listen;
projected.port = candidate.port;
const inheritedStream = parsed.stream;
const ownInbound = dbInbound.toInbound();
ownInbound.stream.security = inheritedStream.security;
ownInbound.stream.tls = inheritedStream.tls;
ownInbound.stream.externalProxy = inheritedStream.externalProxy;
projected.streamSettings = ownInbound.stream.toString();
// Re-wrap so callers get the same DBInbound shape they had.
return new dbInbound.constructor(projected);
}
return dbInbound;
}
function findClientIndex(dbInbound, client) {
if (!client) return 0;
const inbound = dbInbound.toInbound();
const clients = inbound?.clients || [];
const idx = clients.findIndex((c) => {
if (!c) return false;
switch (dbInbound.protocol) {
case 'trojan':
case 'shadowsocks':
return c.password === client.password && c.email === client.email;
default:
return c.id === client.id && c.email === client.email;
}
});
return idx >= 0 ? idx : 0;
}
function getClientId(protocol, client) {
switch (protocol) {
case 'trojan': return client.password;
case 'shadowsocks': return client.email;
case 'hysteria': return client.auth;
default: return client.id;
}
}
// === Per-client handlers (called from the expand-row table) =========
function onEditClient({ dbInbound, client }) {
clientMode.value = 'edit';
clientDbInbound.value = dbInbound;
clientIndex.value = findClientIndex(dbInbound, client);
clientOpen.value = true;
}
function onQrcodeClient({ dbInbound, client }) {
qrDbInbound.value = checkFallback(dbInbound);
qrClient.value = client || null;
qrOpen.value = true;
}
function onInfoClient({ dbInbound, client }) {
infoDbInbound.value = checkFallback(dbInbound);
infoClientIndex.value = findClientIndex(dbInbound, client);
infoOpen.value = true;
}
async function onResetTrafficClient({ dbInbound, client }) {
const msg = await HttpUtil.post(
`/panel/api/inbounds/${dbInbound.id}/resetClientTraffic/${client.email}`,
);
if (msg?.success) await refresh();
}
async function onDeleteClient({ dbInbound, client }) {
const clientId = getClientId(dbInbound.protocol, client);
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
if (msg?.success) await refresh();
}
async function onDeleteClients({ dbInbound, clients }) {
for (const client of clients) {
const clientId = getClientId(dbInbound.protocol, client);
await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
}
await refresh();
}
async function onToggleEnableClient({ dbInbound, client, next }) {
// Mirror legacy: clone the parsed inbound, flip enable on the matching
// client, and post the whole client back through updateClient. This
// keeps the wire shape identical to the modal save path.
const inbound = dbInbound.toInbound();
const clients = inbound?.clients || [];
const idx = findClientIndex(dbInbound, client);
if (idx < 0 || !clients[idx]) return;
clients[idx].enable = next;
const clientId = getClientId(dbInbound.protocol, clients[idx]);
const msg = await HttpUtil.post(`/panel/api/inbounds/updateClient/${clientId}`, {
id: dbInbound.id,
settings: `{"clients": [${clients[idx].toString()}]}`,
});
if (msg?.success) await refresh();
}
function onAddInbound() {
formMode.value = 'add';
formDbInbound.value = null;
formOpen.value = true;
}
function openEdit(dbInbound) {
formMode.value = 'edit';
formDbInbound.value = dbInbound;
formOpen.value = true;
}
function openAddClient(dbInbound) {
clientMode.value = 'add';
clientDbInbound.value = dbInbound;
clientIndex.value = null;
clientOpen.value = true;
}
function openAddBulkClient(dbInbound) {
bulkDbInbound.value = dbInbound;
bulkOpen.value = true;
}
// Per-row destructive actions go through Modal.confirm (matches legacy).
function confirmDelete(dbInbound) {
Modal.confirm({
title: `Delete inbound "${dbInbound.remark}"?`,
content: 'This removes the inbound and all its clients. This cannot be undone.',
okText: 'Delete',
okType: 'danger',
cancelText: 'Cancel',
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
if (msg?.success) await refresh();
},
});
}
function confirmResetTraffic(dbInbound) {
Modal.confirm({
title: `Reset traffic for "${dbInbound.remark}"?`,
content: 'Resets up/down counters to 0 for this inbound.',
okText: 'Reset',
cancelText: 'Cancel',
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllTraffics`);
if (msg?.success) await refresh();
},
});
}
function confirmDelDepleted(dbInboundId) {
Modal.confirm({
title: 'Delete depleted clients?',
content: 'Removes every client whose traffic is exhausted or whose expiry has passed.',
okText: 'Delete',
okType: 'danger',
cancelText: 'Cancel',
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/delDepletedClients/${dbInboundId}`);
if (msg?.success) await refresh();
},
});
}
// Clone — adds a new inbound with the same protocol+stream+sniffing
// but a fresh remark/port and an empty client list.
function confirmClone(dbInbound) {
Modal.confirm({
title: `Clone inbound "${dbInbound.remark}"?`,
content: 'Creates a copy with a new port and an empty client list.',
okText: 'Clone',
cancelText: 'Cancel',
onOk: async () => {
const baseInbound = dbInbound.toInbound();
const data = {
up: 0,
down: 0,
total: 0,
remark: `${dbInbound.remark} (clone)`,
enable: false,
expiryTime: 0,
listen: '',
port: RandomUtil.randomInteger(10000, 60000),
protocol: baseInbound.protocol,
settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
streamSettings: baseInbound.stream.toString(),
sniffing: baseInbound.sniffing.toString(),
};
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
if (msg?.success) await refresh();
},
});
}
function onGeneralAction(key) {
switch (key) {
case 'import':
importInbound();
break;
case 'export':
exportAllLinks();
break;
case 'subs':
exportAllSubs();
break;
case 'resetInbounds':
Modal.confirm({
title: 'Reset all inbound traffic?',
okText: 'Reset',
cancelText: 'Cancel',
onOk: async () => {
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
if (msg?.success) await refresh();
},
});
break;
case 'resetClients':
Modal.confirm({
title: 'Reset all client traffic across all inbounds?',
okText: 'Reset',
cancelText: 'Cancel',
onOk: async () => {
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllClientTraffics/-1');
if (msg?.success) await refresh();
},
});
break;
case 'delDepletedClients':
confirmDelDepleted(-1);
break;
default:
message.info(`General action "${key}" — coming in a later 5f subphase`);
}
}
function onRowAction({ key, dbInbound }) {
switch (key) {
case 'edit':
openEdit(dbInbound);
break;
case 'addClient':
openAddClient(dbInbound);
break;
case 'addBulkClient':
openAddBulkClient(dbInbound);
break;
case 'showInfo':
infoDbInbound.value = checkFallback(dbInbound);
infoClientIndex.value = findClientIndex(dbInbound, null);
infoOpen.value = true;
break;
case 'qrcode':
qrDbInbound.value = checkFallback(dbInbound);
qrClient.value = null;
qrOpen.value = true;
break;
case 'export':
exportInboundLinks(dbInbound);
break;
case 'subs':
exportInboundSubs(dbInbound);
break;
case 'clipboard':
exportInboundClipboard(dbInbound);
break;
case 'copyClients':
// Copy-clients-from-inbound is a tiny dedicated modal in legacy
// (lets you tick clients to copy across inbounds). Defer to a
// future commit — surface a friendly message for now.
message.info('Copy clients across inbounds — coming soon');
break;
case 'delete':
confirmDelete(dbInbound);
break;
case 'resetTraffic':
confirmResetTraffic(dbInbound);
break;
case 'clone':
confirmClone(dbInbound);
break;
case 'resetClients':
Modal.confirm({
title: `Reset client traffic on "${dbInbound.remark}"?`,
okText: 'Reset',
cancelText: 'Cancel',
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllClientTraffics/${dbInbound.id}`);
if (msg?.success) await refresh();
},
});
break;
case 'delDepletedClients':
confirmDelDepleted(dbInbound.id);
break;
default:
message.info(`Action "${key}" — coming in a later 5f subphase`);
}
}
</script>
<template>
<a-config-provider :theme="antdThemeConfig">
<a-layout class="inbounds-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
<a-layout class="content-shell">
<a-layout-content id="content-layout" class="content-area">
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
<div v-if="!fetched" class="loading-spacer" />
<a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
<!-- Summary statistics card -->
<a-col :span="24">
<a-card size="small" hoverable class="summary-card">
<a-row :gutter="[16, 12]">
<a-col :xs="12" :sm="12" :md="5">
<CustomStatistic :title="t('pages.inbounds.totalDownUp')"
:value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
<template #prefix>
<SwapOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :xs="12" :sm="12" :md="5">
<CustomStatistic :title="t('pages.inbounds.totalUsage')"
:value="SizeFormatter.sizeFormat(totals.up + totals.down)">
<template #prefix>
<PieChartOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :xs="12" :sm="12" :md="5">
<CustomStatistic :title="t('pages.inbounds.allTimeTrafficUsage')"
:value="SizeFormatter.sizeFormat(totals.allTime)">
<template #prefix>
<HistoryOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :xs="12" :sm="12" :md="5">
<CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
<template #prefix>
<BarsOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :xs="24" :sm="24" :md="4">
<CustomStatistic :title="t('clients')" value=" ">
<template #prefix>
<a-space direction="horizontal">
<TeamOutlined />
<a-tag color="green">{{ totals.clients }}</a-tag>
<a-popover v-if="totals.deactive.length" :title="t('disabled')">
<template #content>
<div class="client-email-list">
<div v-for="email in totals.deactive" :key="email">{{ email }}</div>
</div>
</template>
<a-tag>{{ totals.deactive.length }}</a-tag>
</a-popover>
<a-popover v-if="totals.depleted.length" :title="t('depleted')">
<template #content>
<div class="client-email-list">
<div v-for="email in totals.depleted" :key="email">{{ email }}</div>
</div>
</template>
<a-tag color="red">{{ totals.depleted.length }}</a-tag>
</a-popover>
<a-popover v-if="totals.expiring.length" :title="t('depletingSoon')">
<template #content>
<div class="client-email-list">
<div v-for="email in totals.expiring" :key="email">{{ email }}</div>
</div>
</template>
<a-tag color="orange">{{ totals.expiring.length }}</a-tag>
</a-popover>
<a-popover v-if="totals.online.length" :title="t('online')">
<template #content>
<div class="client-email-list">
<div v-for="email in totals.online" :key="email">{{ email }}</div>
</div>
</template>
<a-tag color="blue">{{ totals.online.length }}</a-tag>
</a-popover>
</a-space>
</template>
</CustomStatistic>
</a-col>
</a-row>
</a-card>
</a-col>
<!-- Inbound list toolbar, search/filter, columns, row actions -->
<a-col :span="24">
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
:traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh"
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
@delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
</a-col>
</a-row>
</a-spin>
</a-layout-content>
</a-layout>
<InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" @saved="refresh" />
<ClientFormModal v-model:open="clientOpen" :mode="clientMode" :db-inbound="clientDbInbound"
:client-index="clientIndex" :sub-enable="subSettings.enable" :tg-bot-enable="tgBotEnable"
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
:last-online-map="lastOnlineMap" :node-address="infoNodeAddress" />
<QrCodeModal v-model:open="qrOpen" :db-inbound="qrDbInbound" :client="qrClient" :remark-model="remarkModel"
:node-address="qrNodeAddress" :sub-settings="subSettings" />
<TextModal v-model:open="textOpen" :title="textTitle" :content="textContent" :file-name="textFileName" />
<PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType"
:initial-value="promptInitial" :loading="promptLoading" @confirm="onPromptConfirm" />
</a-layout>
</a-config-provider>
</template>
<style scoped>
.inbounds-page {
--bg-page: #e6e8ec;
--bg-card: #ffffff;
min-height: 100vh;
background: var(--bg-page);
}
.inbounds-page.is-dark {
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.inbounds-page.is-dark.is-ultra {
--bg-page: #050505;
--bg-card: #0c0e12;
}
.inbounds-page :deep(.ant-layout),
.inbounds-page :deep(.ant-layout-content) {
background: transparent;
}
.content-shell {
background: transparent;
}
.content-area {
padding: 24px;
}
@media (max-width: 768px) {
.content-area {
padding: 8px;
}
}
.loading-spacer {
min-height: calc(100vh - 120px);
}
.summary-card {
padding: 16px;
}
@media (max-width: 768px) {
.summary-card {
padding: 8px;
}
}
</style>
<style>
/* AD-Vue popovers teleport their content to <body>, so scoped styles
don't reach them — this block has to be unscoped. */
.client-email-list {
max-height: 280px;
min-width: 160px;
overflow-y: auto;
padding-right: 4px;
}
.client-email-list > div {
padding: 2px 0;
font-size: 12px;
white-space: nowrap;
}
</style>
+126
View File
@@ -0,0 +1,126 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Protocols } from '@/models/inbound.js';
import QrPanel from './QrPanel.vue';
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
dbInbound: { type: Object, default: null },
client: { type: Object, default: null },
remarkModel: { type: String, default: '-ieo' },
nodeAddress: { type: String, default: '' },
subSettings: {
type: Object,
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
},
});
const emit = defineEmits(['update:open']);
const links = ref([]);
const wireguardConfigs = ref([]);
const wireguardLinks = ref([]);
const subLink = ref('');
const subJsonLink = ref('');
const activeKeys = ref([]);
const qrItems = computed(() => {
const items = [];
if (subLink.value) {
items.push({
key: 'sub',
header: t('subscription.title'),
value: subLink.value,
});
}
if (subJsonLink.value) {
items.push({
key: 'sub-json',
header: `${t('subscription.title')} (JSON)`,
value: subJsonLink.value,
});
}
links.value.forEach((link, idx) => {
items.push({
key: `l${idx}`,
header: link.remark || `Link ${idx + 1}`,
value: link.link,
});
});
wireguardConfigs.value.forEach((cfg, idx) => {
items.push({
key: `wc${idx}`,
header: `Peer ${idx + 1} config`,
value: cfg,
downloadName: `peer-${idx + 1}.conf`,
});
if (wireguardLinks.value[idx]) {
items.push({
key: `wl${idx}`,
header: `Peer ${idx + 1} link`,
value: wireguardLinks.value[idx],
});
}
});
return items;
});
watch(() => props.open, (next) => {
if (!next || !props.dbInbound) return;
const inbound = props.dbInbound.toInbound();
if (inbound.protocol === Protocols.WIREGUARD) {
const peerRemark = props.client?.email
? `${props.dbInbound.remark}-${props.client.email}`
: props.dbInbound.remark;
wireguardConfigs.value = inbound.genWireguardConfigs(peerRemark, '-ieo', props.nodeAddress).split('\r\n');
wireguardLinks.value = inbound.genWireguardLinks(peerRemark, '-ieo', props.nodeAddress).split('\r\n');
links.value = [];
} else {
// When a client is provided we generate per-client share links;
// otherwise (single-user SS) fall back to the inbound's settings.
links.value = inbound.genAllLinks(props.dbInbound.remark, props.remarkModel, props.client, props.nodeAddress);
wireguardConfigs.value = [];
wireguardLinks.value = [];
}
const subId = props.client?.subId;
if (props.subSettings?.enable && subId) {
subLink.value = (props.subSettings.subURI || '') + subId;
subJsonLink.value = props.subSettings.subJsonEnable
? (props.subSettings.subJsonURI || '') + subId
: '';
} else {
subLink.value = '';
subJsonLink.value = '';
}
const open = [];
if (subLink.value) open.push('sub');
if (subJsonLink.value) open.push('sub-json');
activeKeys.value = open;
});
function close() {
emit('update:open', false);
}
</script>
<template>
<a-modal :open="open" :title="t('qrCode')" :footer="null" width="420px" @cancel="close">
<template v-if="dbInbound">
<a-collapse v-model:active-key="activeKeys" ghost class="qr-collapse">
<a-collapse-panel v-for="item in qrItems" :key="item.key" :header="item.header">
<QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''" />
</a-collapse-panel>
</a-collapse>
</template>
</a-modal>
</template>
<style scoped>
.qr-collapse :deep(.ant-collapse-content-box) {
padding: 8px 0 0;
}
</style>
+89
View File
@@ -0,0 +1,89 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { ClipboardManager, FileManager } from '@/utils';
const { t } = useI18n();
const props = defineProps({
value: { type: String, required: true },
remark: { type: String, default: '' },
downloadName: { type: String, default: '' },
size: { type: Number, default: 240 },
showQr: { type: Boolean, default: true },
});
async function copy() {
const ok = await ClipboardManager.copyText(props.value);
if (ok) message.success(t('copied'));
}
function download() {
if (!props.downloadName) return;
FileManager.downloadTextFile(props.value, props.downloadName);
}
</script>
<template>
<div class="qr-panel">
<div class="qr-panel-header">
<a-tag color="green" class="qr-remark">{{ remark }}</a-tag>
<a-tooltip :title="t('copy')">
<a-button size="small" @click="copy">
<template #icon>
<CopyOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-if="downloadName" :title="t('download')">
<a-button size="small" @click="download">
<template #icon>
<DownloadOutlined />
</template>
</a-button>
</a-tooltip>
</div>
<div v-if="showQr" class="qr-panel-canvas">
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
:title="t('copy')" @click="copy" />
</div>
</div>
</template>
<style scoped>
.qr-panel {
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.qr-panel-header {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.qr-remark {
margin: 0;
}
.qr-panel-canvas {
display: flex;
justify-content: center;
padding: 6px 0;
}
.qr-panel-canvas .qr-code {
cursor: pointer;
padding: 0 !important;
background: #fff;
border-radius: 4px;
}
</style>
+334
View File
@@ -0,0 +1,334 @@
// Loads the inbound list + sidecar data the page needs (online users,
// last-online-map, default settings) and computes the per-inbound client
// roll-ups the legacy panel surfaces in the popovers.
//
// Live-update model: initial GET on mount, then the WebSocket delta path
// keeps the table fresh — the page subscribes to the server's `traffic`,
// `client_stats`, and `invalidate` events and merges them into local
// refs in-place. The manual refresh button is kept as a fallback.
import { computed, ref, shallowRef } from 'vue';
import { HttpUtil, ObjectUtil } from '@/utils';
import { DBInbound } from '@/models/dbinbound.js';
import { Protocols } from '@/models/inbound.js';
import { setDatepicker } from '@/composables/useDatepicker.js';
export function useInbounds() {
const fetched = ref(false);
const refreshing = ref(false);
// shallowRef because each refresh swaps the array; per-row reactivity is
// unnecessary at the page level (modals work on copies).
const dbInbounds = shallowRef([]);
const clientCount = ref({});
const onlineClients = ref([]);
const lastOnlineMap = ref({});
// Default-settings sidecar fields the table needs for color/expiry math.
const expireDiff = ref(0);
const trafficDiff = ref(0);
const subSettings = ref({
enable: false,
subTitle: '',
subURI: '',
subJsonURI: '',
subJsonEnable: false,
});
const remarkModel = ref('-ieo');
const datepicker = ref('gregorian');
const tgBotEnable = ref(false);
const ipLimitEnable = ref(false);
const pageSize = ref(0);
function isClientOnline(email) {
return onlineClients.value.includes(email);
}
// Roll-up of {clients, active, deactive, depleted, expiring, online,
// comments} for a single inbound. Mirrors getClientCounts in the legacy
// template. Skipped for protocols that don't have multi-user clients
// (HTTP, MIXED, WireGuard) since their settings have no client list.
function rollupClients(dbInbound, inbound) {
const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
const clients = inbound?.clients || [];
const active = [];
const deactive = [];
const depleted = [];
const expiring = [];
const online = [];
const comments = new Map();
const now = Date.now();
if (dbInbound.enable) {
for (const client of clients) {
if (client.comment) comments.set(client.email, client.comment);
if (client.enable) {
active.push(client.email);
if (isClientOnline(client.email)) online.push(client.email);
} else {
deactive.push(client.email);
}
}
for (const stats of clientStats) {
const exhausted = stats.total > 0 && stats.up + stats.down >= stats.total;
const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
if (expired || exhausted) {
depleted.push(stats.email);
} else {
const expiringSoon =
(stats.expiryTime > 0 && stats.expiryTime - now < expireDiff.value) ||
(stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiff.value);
if (expiringSoon) expiring.push(stats.email);
}
}
} else {
for (const client of clients) deactive.push(client.email);
}
return {
clients: clients.length,
active,
deactive,
depleted,
expiring,
online,
comments,
};
}
function setInbounds(rows) {
const next = [];
const counts = {};
for (const row of rows) {
const dbInbound = new DBInbound(row);
const parsed = dbInbound.toInbound();
next.push(dbInbound);
const tracked = [
Protocols.VMESS,
Protocols.VLESS,
Protocols.TROJAN,
Protocols.SHADOWSOCKS,
Protocols.HYSTERIA,
];
if (tracked.includes(row.protocol)) {
if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
counts[row.id] = rollupClients(dbInbound, parsed);
}
}
dbInbounds.value = next;
clientCount.value = counts;
fetched.value = true;
}
async function fetchOnlineUsers() {
const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
if (msg?.success) onlineClients.value = msg.obj || [];
}
async function fetchLastOnlineMap() {
const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
if (msg?.success && msg.obj) lastOnlineMap.value = msg.obj;
}
async function fetchDefaultSettings() {
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
if (!msg?.success) return;
const s = msg.obj || {};
expireDiff.value = (s.expireDiff ?? 0) * 86400000;
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
tgBotEnable.value = !!s.tgBotEnable;
subSettings.value = {
enable: !!s.subEnable,
subTitle: s.subTitle || '',
subURI: s.subURI || '',
subJsonURI: s.subJsonURI || '',
subJsonEnable: !!s.subJsonEnable,
};
pageSize.value = s.pageSize ?? 0;
remarkModel.value = s.remarkModel || '-ieo';
datepicker.value = s.datepicker || 'gregorian';
// Mirror into the global composable so date-pickers in modals can
// pick the right calendar without re-fetching the settings.
setDatepicker(datepicker.value);
ipLimitEnable.value = !!s.ipLimitEnable;
}
// ============ WebSocket live-update merge ===========================
// The xray traffic job and the node traffic sync job each broadcast
// a `traffic` payload every ~10s. We merge it into onlineClients +
// lastOnlineMap; per-inbound counters arrive in the parallel
// client_stats event below.
function applyTrafficEvent(payload) {
if (!payload || typeof payload !== 'object') return;
if (Array.isArray(payload.onlineClients)) {
onlineClients.value = payload.onlineClients;
}
if (payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
// Merge so a subsequent payload that drops a quiet client doesn't
// wipe their last-seen timestamp.
lastOnlineMap.value = { ...lastOnlineMap.value, ...payload.lastOnlineMap };
}
// Recompute per-inbound rollups so the "online" badges in the
// expand-row table flip without waiting for a full refresh.
rebuildClientCount();
}
// The client_stats payload carries absolute traffic counters for the
// clients that had activity in the latest window plus per-inbound
// totals. Both are absolute (not deltas), so we overwrite in place.
function applyClientStatsEvent(payload) {
if (!payload || typeof payload !== 'object') return;
let touched = false;
if (Array.isArray(payload.inbounds) && payload.inbounds.length > 0) {
const byId = new Map();
for (const row of payload.inbounds) {
if (row && row.id != null) byId.set(row.id, row);
}
for (const ib of dbInbounds.value) {
const upd = byId.get(ib.id);
if (!upd) continue;
if (typeof upd.up === 'number') ib.up = upd.up;
if (typeof upd.down === 'number') ib.down = upd.down;
if (typeof upd.allTime === 'number') ib.allTime = upd.allTime;
if (typeof upd.total === 'number') ib.total = upd.total;
if (typeof upd.enable === 'boolean') ib.enable = upd.enable;
touched = true;
}
}
if (Array.isArray(payload.clients) && payload.clients.length > 0) {
const byEmail = new Map();
for (const row of payload.clients) {
if (row && row.email) byEmail.set(row.email, row);
}
for (const ib of dbInbounds.value) {
if (!Array.isArray(ib.clientStats)) continue;
for (let i = 0; i < ib.clientStats.length; i++) {
const stat = ib.clientStats[i];
const upd = byEmail.get(stat.email);
if (!upd) continue;
if (typeof upd.up === 'number') stat.up = upd.up;
if (typeof upd.down === 'number') stat.down = upd.down;
if (typeof upd.total === 'number') stat.total = upd.total;
if (typeof upd.allTime === 'number') stat.allTime = upd.allTime;
if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
touched = true;
}
}
}
if (touched) {
dbInbounds.value = [...dbInbounds.value];
rebuildClientCount();
}
}
// The hub may decide a payload is too large to push directly and emit
// an `invalidate` event with the affected dataType instead. For the
// inbounds page that means "the inbound list changed elsewhere — go
// re-fetch via REST".
function applyInvalidate(payload) {
if (!payload || typeof payload !== 'object') return;
if (payload.type === 'inbounds') {
refresh();
}
}
function applyInboundsEvent(payload) {
if (!Array.isArray(payload)) return;
setInbounds(payload);
}
// Recompute the per-inbound roll-up after any in-place mutation.
// Cheap because rollupClients only iterates a single inbound's
// clients + clientStats arrays.
function rebuildClientCount() {
const counts = {};
const tracked = [
Protocols.VMESS,
Protocols.VLESS,
Protocols.TROJAN,
Protocols.SHADOWSOCKS,
Protocols.HYSTERIA,
];
for (const dbInbound of dbInbounds.value) {
const parsed = dbInbound.toInbound();
if (!tracked.includes(dbInbound.protocol)) continue;
if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
counts[dbInbound.id] = rollupClients(dbInbound, parsed);
}
clientCount.value = counts;
}
async function refresh() {
refreshing.value = true;
try {
const msg = await HttpUtil.get('/panel/api/inbounds/list');
if (!msg?.success) return;
await fetchLastOnlineMap();
await fetchOnlineUsers();
setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
} finally {
// Match legacy: keep the spinning-icon state visible briefly so
// a fast network doesn't make the button feel like it didn't fire.
setTimeout(() => { refreshing.value = false; }, 500);
}
}
// Aggregate totals shown in the dashboard summary card. allTime falls
// back to up+down when the per-inbound counter isn't populated yet.
const totals = computed(() => {
let up = 0;
let down = 0;
let allTime = 0;
let clients = 0;
const deactive = [];
const depleted = [];
const expiring = [];
const online = [];
for (const ib of dbInbounds.value) {
up += ib.up || 0;
down += ib.down || 0;
allTime += ib.allTime || (ib.up + ib.down) || 0;
const c = clientCount.value[ib.id];
if (c) {
clients += c.clients;
deactive.push(...c.deactive);
depleted.push(...c.depleted);
expiring.push(...c.expiring);
online.push(...c.online);
}
}
return { up, down, allTime, clients, deactive, depleted, expiring, online };
});
// ObjectUtil reference is wired at module load — keeping a no-op import
// here so the linter doesn't drop it; the legacy search uses it.
void ObjectUtil;
return {
fetched,
refreshing,
dbInbounds,
clientCount,
onlineClients,
lastOnlineMap,
totals,
expireDiff,
trafficDiff,
subSettings,
remarkModel,
datepicker,
tgBotEnable,
ipLimitEnable,
pageSize,
refresh,
fetchDefaultSettings,
applyTrafficEvent,
applyClientStatsEvent,
applyInvalidate,
applyInboundsEvent,
};
}
+101
View File
@@ -0,0 +1,101 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { DownloadOutlined, UploadOutlined } from '@ant-design/icons-vue';
import { HttpUtil, PromiseUtil } from '@/utils';
const { t } = useI18n();
defineProps({
open: { type: Boolean, default: false },
basePath: { type: String, default: '' },
});
const emit = defineEmits(['update:open', 'busy']);
function close() {
emit('update:open', false);
}
function exportDb() {
// The Go endpoint streams x-ui.db as a download. Setting
// window.location triggers a browser download without leaving
// the page (the Go side responds with Content-Disposition: attachment).
window.location = window.X_UI_BASE_PATH+'panel/api/server/getDb';
}
function importDb() {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.db';
fileInput.addEventListener('change', async (e) => {
const dbFile = e.target.files?.[0];
if (!dbFile) return;
const formData = new FormData();
formData.append('db', dbFile);
close();
emit('busy', { busy: true, tip: t('pages.index.importDatabase') + '…' });
const upload = await HttpUtil.post('/panel/api/server/importDB', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (!upload?.success) {
emit('busy', { busy: false });
return;
}
emit('busy', { busy: true, tip: t('pages.settings.restartPanel') + '…' });
const restart = await HttpUtil.post('/panel/setting/restartPanel');
if (restart?.success) {
await PromiseUtil.sleep(5000);
window.location.reload();
} else {
emit('busy', { busy: false });
}
});
fileInput.click();
}
</script>
<template>
<a-modal :open="open" :title="t('pages.index.backupTitle')" :closable="true" :footer="null" @cancel="close">
<a-list bordered class="backup-list">
<a-list-item class="backup-item">
<a-list-item-meta>
<template #title>{{ t('pages.index.exportDatabase') }}</template>
<template #description>{{ t('pages.index.exportDatabaseDesc') }}</template>
</a-list-item-meta>
<a-button type="primary" @click="exportDb">
<template #icon>
<DownloadOutlined />
</template>
</a-button>
</a-list-item>
<a-list-item class="backup-item">
<a-list-item-meta>
<template #title>{{ t('pages.index.importDatabase') }}</template>
<template #description>{{ t('pages.index.importDatabaseDesc') }}</template>
</a-list-item-meta>
<a-button type="primary" @click="importDb">
<template #icon>
<UploadOutlined />
</template>
</a-button>
</a-list-item>
</a-list>
</a-modal>
</template>
<style scoped>
.backup-list {
width: 100%;
}
.backup-item {
display: flex;
align-items: center;
gap: 16px;
}
</style>
@@ -0,0 +1,106 @@
<script setup>
import { reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import { HttpUtil } from '@/utils';
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
// Populate with the record when editing; null/undefined when adding.
record: { type: Object, default: null },
});
const emit = defineEmits(['update:open', 'saved']);
const form = reactive({ type: 'geosite', alias: '', url: '' });
const saving = ref(false);
const editing = ref(false);
const editId = ref(null);
watch(() => props.open, (next) => {
if (!next) return;
if (props.record) {
editing.value = true;
editId.value = props.record.id;
form.type = props.record.type;
form.alias = props.record.alias;
form.url = props.record.url;
} else {
editing.value = false;
editId.value = null;
form.type = 'geosite';
form.alias = '';
form.url = '';
}
});
function close() {
emit('update:open', false);
}
function validate() {
// Backend expects a filesystem-safe alias; legacy enforces the same regex.
if (!/^[a-z0-9_-]+$/.test(form.alias || '')) {
message.error(t('pages.index.customGeoValidationAlias'));
return false;
}
const u = (form.url || '').trim();
if (!/^https?:\/\//i.test(u)) {
message.error(t('pages.index.customGeoValidationUrl'));
return false;
}
try {
const parsed = new URL(u);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
message.error(t('pages.index.customGeoValidationUrl'));
return false;
}
} catch (_e) {
message.error(t('pages.index.customGeoValidationUrl'));
return false;
}
return true;
}
async function submit() {
if (!validate()) return;
saving.value = true;
try {
const url = editing.value
? `/panel/api/custom-geo/update/${editId.value}`
: '/panel/api/custom-geo/add';
const msg = await HttpUtil.post(url, form);
if (msg?.success) {
emit('saved');
close();
}
} finally {
saving.value = false;
}
}
</script>
<template>
<a-modal :open="open" :title="editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')"
:confirm-loading="saving" :ok-text="t('pages.index.customGeoModalSave')" :cancel-text="t('close')" @ok="submit"
@cancel="close">
<a-form layout="vertical">
<a-form-item :label="t('pages.index.customGeoType')">
<a-select v-model:value="form.type" :disabled="editing">
<a-select-option value="geosite">geosite</a-select-option>
<a-select-option value="geoip">geoip</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="t('pages.index.customGeoAlias')">
<a-input v-model:value="form.alias" :disabled="editing"
:placeholder="t('pages.index.customGeoAliasPlaceholder')" />
</a-form-item>
<a-form-item :label="t('pages.index.customGeoUrl')">
<a-input v-model:value="form.url" placeholder="https://" />
</a-form-item>
</a-form>
</a-modal>
</template>
@@ -0,0 +1,311 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import {
PlusOutlined,
ReloadOutlined,
EditOutlined,
DeleteOutlined,
InboxOutlined,
} from '@ant-design/icons-vue';
import { HttpUtil, ClipboardManager } from '@/utils';
import CustomGeoFormModal from './CustomGeoFormModal.vue';
const { t } = useI18n();
const props = defineProps({
// Re-fetch the list when the parent collapse expands this section.
active: { type: Boolean, default: false },
});
const list = ref([]);
const loading = ref(false);
const updatingAll = ref(false);
const actionId = ref(null);
const formOpen = ref(false);
const editingRecord = ref(null);
// Computed so column titles re-render after a locale swap.
const columns = computed(() => [
{ title: t('pages.index.customGeoAlias'), key: 'alias', width: 200 },
{ title: t('pages.index.customGeoUrl'), key: 'url', ellipsis: true },
{ title: t('pages.index.customGeoExtColumn'), key: 'extDat', width: 220 },
{ title: t('pages.index.customGeoLastUpdated'), key: 'lastUpdatedAt', width: 140 },
{ title: t('pages.index.customGeoActions'), key: 'action', width: 120 },
]);
async function loadList() {
loading.value = true;
try {
const msg = await HttpUtil.get('/panel/api/custom-geo/list');
if (msg?.success && Array.isArray(msg.obj)) list.value = msg.obj;
} finally {
loading.value = false;
}
}
function openAdd() {
editingRecord.value = null;
formOpen.value = true;
}
function openEdit(record) {
editingRecord.value = record;
formOpen.value = true;
}
function extDisplay(record) {
const fn = record.type === 'geoip'
? `geoip_${record.alias}.dat`
: `geosite_${record.alias}.dat`;
return `ext:${fn}:tag`;
}
async function copyExt(record) {
const text = extDisplay(record);
const ok = await ClipboardManager.copyText(text);
if (ok) message.success(`${t('copied')}: ${text}`);
}
function formatTime(ts) {
if (!ts) return '';
const d = new Date(ts * 1000);
if (isNaN(d.getTime())) return String(ts);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// Tiny inline relative-time formatter so we don't pull in moment.
function relativeTime(ts) {
if (!ts) return '';
const diff = Math.floor(Date.now() / 1000) - ts;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
if (diff < 2592000) return `${Math.floor(diff / 86400)} d ago`;
return formatTime(ts);
}
function confirmDelete(record) {
Modal.confirm({
title: t('pages.index.customGeoDelete'),
content: t('pages.index.customGeoDeleteConfirm'),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
if (msg?.success) await loadList();
},
});
}
async function downloadOne(id) {
actionId.value = id;
try {
const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
if (msg?.success) await loadList();
} finally {
actionId.value = null;
}
}
async function updateAll() {
updatingAll.value = true;
try {
const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
const ok = msg?.obj?.succeeded?.length || 0;
const failed = msg?.obj?.failed?.length || 0;
if (msg?.success || ok > 0) {
await loadList();
if (failed > 0) message.warning(`Updated ${ok}, failed ${failed}`);
}
} finally {
updatingAll.value = false;
}
}
// Lazy-load: only fetch when the parent collapse opens this panel.
watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true });
</script>
<template>
<div class="custom-geo-section">
<a-alert type="info" show-icon class="mb-10" :message="t('pages.index.customGeoRoutingHint')" />
<div class="toolbar">
<a-button type="primary" :loading="loading" @click="openAdd">
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.index.customGeoAdd') }}
</a-button>
<a-button :loading="updatingAll" :disabled="!list.length" @click="updateAll">
<template #icon>
<ReloadOutlined />
</template>
{{ t('pages.index.geofilesUpdateAll') }}
</a-button>
<span v-if="list.length" class="custom-geo-count">{{ list.length }}</span>
</div>
<a-table :columns="columns" :data-source="list" :pagination="false" :row-key="(r) => r.id" :loading="loading"
size="small" :scroll="{ x: 760 }">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'alias'">
<div class="custom-geo-alias-cell">
<a-tag :color="record.type === 'geoip' ? 'cyan' : 'purple'" class="custom-geo-type-tag">
{{ record.type }}
</a-tag>
<span class="custom-geo-alias">{{ record.alias }}</span>
</div>
</template>
<template v-else-if="column.key === 'url'">
<a-tooltip placement="topLeft" :title="record.url">
<a :href="record.url" target="_blank" rel="noopener noreferrer" class="custom-geo-url">
{{ record.url }}
</a>
</a-tooltip>
</template>
<template v-else-if="column.key === 'extDat'">
<a-tooltip :title="t('copy')">
<code class="custom-geo-ext-code custom-geo-copyable" @click="copyExt(record)">
{{ extDisplay(record) }}
</code>
</a-tooltip>
</template>
<template v-else-if="column.key === 'lastUpdatedAt'">
<a-tooltip v-if="record.lastUpdatedAt" :title="formatTime(record.lastUpdatedAt)">
<span>{{ relativeTime(record.lastUpdatedAt) }}</span>
</a-tooltip>
<span v-else class="custom-geo-muted">—</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space size="small">
<a-tooltip :title="t('pages.index.customGeoEdit')">
<a-button type="link" size="small" @click="openEdit(record)">
<template #icon>
<EditOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip :title="t('pages.index.customGeoDownload')">
<a-button type="link" size="small" :loading="actionId === record.id" @click="downloadOne(record.id)">
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip :title="t('pages.index.customGeoDelete')">
<a-button type="link" size="small" danger @click="confirmDelete(record)">
<template #icon>
<DeleteOutlined />
</template>
</a-button>
</a-tooltip>
</a-space>
</template>
</template>
<template #emptyText>
<div class="custom-geo-empty">
<InboxOutlined class="custom-geo-empty-icon" />
<div>{{ t('pages.index.customGeoEmpty') }}</div>
</div>
</template>
</a-table>
<CustomGeoFormModal v-model:open="formOpen" :record="editingRecord" @saved="loadList" />
</div>
</template>
<style scoped>
.mb-10 {
margin-bottom: 10px;
}
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.custom-geo-count {
margin-left: 4px;
padding: 2px 8px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.05);
font-size: 12px;
opacity: 0.75;
}
:global(body.dark) .custom-geo-count {
background: rgba(255, 255, 255, 0.08);
}
.custom-geo-alias-cell {
display: flex;
align-items: center;
gap: 6px;
}
.custom-geo-alias {
font-weight: 500;
word-break: break-all;
}
.custom-geo-type-tag {
margin: 0;
}
.custom-geo-url {
word-break: break-all;
}
.custom-geo-ext-code {
cursor: pointer;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.05);
user-select: all;
}
.custom-geo-copyable:hover {
background: rgba(0, 0, 0, 0.1);
}
:global(body.dark) .custom-geo-ext-code {
background: rgba(255, 255, 255, 0.08);
}
:global(body.dark) .custom-geo-copyable:hover {
background: rgba(255, 255, 255, 0.14);
}
.custom-geo-muted {
opacity: 0.5;
}
.custom-geo-empty {
text-align: center;
padding: 18px 0;
opacity: 0.6;
}
.custom-geo-empty-icon {
font-size: 32px;
margin-bottom: 6px;
display: block;
}
</style>
+420
View File
@@ -0,0 +1,420 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
BarsOutlined,
ControlOutlined,
CloudServerOutlined,
CloudDownloadOutlined,
CloudUploadOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
AreaChartOutlined,
GlobalOutlined,
SwapOutlined,
EyeOutlined,
EyeInvisibleOutlined,
} from '@ant-design/icons-vue';
const { t } = useI18n();
import { HttpUtil, SizeFormatter, TimeFormatter } from '@/utils';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import { useStatus } from '@/composables/useStatus.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue';
import CustomStatistic from '@/components/CustomStatistic.vue';
import TextModal from '@/components/TextModal.vue';
import StatusCard from './StatusCard.vue';
import XrayStatusCard from './XrayStatusCard.vue';
import PanelUpdateModal from './PanelUpdateModal.vue';
import LogModal from './LogModal.vue';
import BackupModal from './BackupModal.vue';
import SystemHistoryModal from './SystemHistoryModal.vue';
import XrayLogModal from './XrayLogModal.vue';
import VersionModal from './VersionModal.vue';
const { status, fetched, refresh } = useStatus();
const { isMobile } = useMediaQuery();
// `/panel/setting/defaultSettings` returns ipLimitEnable; the xray
// card hides its log button when access logs are off.
const ipLimitEnable = ref(false);
HttpUtil.post('/panel/setting/defaultSettings').then((msg) => {
if (msg?.success && msg.obj) ipLimitEnable.value = !!msg.obj.ipLimitEnable;
});
// Panel-update info — fetched once on mount, drives both the badge
// in QuickActions and the contents of PanelUpdateModal.
const panelUpdateInfo = ref({ currentVersion: '', latestVersion: '', updateAvailable: false });
onMounted(() => {
HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then((msg) => {
if (msg?.success && msg.obj) panelUpdateInfo.value = msg.obj;
});
});
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
// In production, dist.go injects window.X_UI_CUR_VER at serve time.
// In dev, Vite serves the HTML directly so the global is missing — fall
// back to currentVersion from the panel-update API once it answers.
const displayVersion = computed(
() => panelUpdateInfo.value?.currentVersion || window.X_UI_CUR_VER || '?',
);
// Hide/reveal the public IPv4/IPv6 — same pattern as legacy.
const showIp = ref(false);
// Modal open state.
const logsOpen = ref(false);
const backupOpen = ref(false);
const panelUpdateOpen = ref(false);
const sysHistoryOpen = ref(false);
const xrayLogsOpen = ref(false);
const versionOpen = ref(false);
const configTextOpen = ref(false);
const configText = ref('');
// Page-level loading overlay; modals can request it via @busy.
const loading = ref(false);
const loadingTip = ref(t('loading'));
function setBusy({ busy, tip }) {
loading.value = busy;
if (tip) loadingTip.value = tip;
}
// Xray controls
async function stopXray() {
await HttpUtil.post('/panel/api/server/stopXrayService');
await refresh();
}
async function restartXray() {
await HttpUtil.post('/panel/api/server/restartXrayService');
await refresh();
}
function openSystemHistory() { sysHistoryOpen.value = true; }
function openXrayLogs() { xrayLogsOpen.value = true; }
function openVersionSwitch() { versionOpen.value = true; }
// Legacy "Config" action — fetch the rendered xray config and show
// it as JSON in the shared TextModal (same UX as main).
async function openConfig() {
loading.value = true;
try {
const msg = await HttpUtil.get('/panel/api/server/getConfigJson');
if (!msg?.success) return;
configText.value = JSON.stringify(msg.obj, null, 2);
configTextOpen.value = true;
} finally {
loading.value = false;
}
}
</script>
<template>
<a-config-provider :theme="antdThemeConfig">
<a-layout class="index-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
<a-layout class="content-shell">
<a-layout-content class="content-area">
<a-spin :spinning="loading || !fetched" :delay="200" :tip="loading ? loadingTip : t('loading')" size="large">
<div v-if="!fetched" class="loading-spacer" />
<a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
<a-col :span="24">
<StatusCard :status="status" :is-mobile="isMobile" />
</a-col>
<a-col :xs="24" :lg="12">
<XrayStatusCard :status="status" :is-mobile="isMobile" :ip-limit-enable="ipLimitEnable"
@stop-xray="stopXray" @restart-xray="restartXray" @open-xray-logs="openXrayLogs"
@open-logs="logsOpen = true" @open-version-switch="openVersionSwitch" />
</a-col>
<a-col :xs="24" :lg="12">
<a-card :title="t('menu.link')" hoverable>
<template #actions>
<a-space class="action" @click="logsOpen = true">
<BarsOutlined />
<span v-if="!isMobile">{{ t('pages.index.logs') }}</span>
</a-space>
<a-space class="action" @click="openConfig">
<ControlOutlined />
<span v-if="!isMobile">{{ t('pages.index.config') }}</span>
</a-space>
<a-space class="action" @click="backupOpen = true">
<CloudServerOutlined />
<span v-if="!isMobile">{{ t('pages.index.backupTitle') }}</span>
</a-space>
</template>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card title="3X-UI" hoverable>
<template v-if="panelUpdateInfo.updateAvailable" #extra>
<a-tooltip :title="`${t('pages.index.updatePanel')}: ${panelUpdateInfo.latestVersion}`">
<a-tag color="orange" class="update-tag" @click="panelUpdateOpen = true">
<CloudDownloadOutlined />
{{ panelUpdateInfo.latestVersion }}
<span v-if="!isMobile">{{ t('pages.index.updatePanel') }}</span>
</a-tag>
</a-tooltip>
</template>
<div class="link-tags">
<a href="https://github.com/MHSanaei/3x-ui/releases" target="_blank" rel="noopener noreferrer">
<a-tag color="green">v{{ displayVersion }}</a-tag>
</a>
<a href="https://t.me/XrayUI" target="_blank" rel="noopener noreferrer">
<a-tag color="green">@XrayUI</a-tag>
</a>
<a href="https://github.com/MHSanaei/3x-ui/wiki" target="_blank" rel="noopener noreferrer">
<a-tag color="purple">{{ t('pages.index.documentation') }}</a-tag>
</a>
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :title="t('pages.index.operationHours')" hoverable>
<a-tag :color="status.xray.color">
Xray: {{ TimeFormatter.formatSecond(status.appStats.uptime) }}
</a-tag>
<a-tag color="green">OS: {{ TimeFormatter.formatSecond(status.uptime) }}</a-tag>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :title="t('pages.index.systemLoad')" hoverable>
<template #extra>
<a-tag color="blue" class="history-tag" @click="openSystemHistory">
<AreaChartOutlined />
{{ t('pages.index.systemHistoryTitle') }}
</a-tag>
</template>
<a-tooltip :title="t('pages.index.systemLoadDesc')">
<a-tag color="green">
{{ status.loads[0] }} | {{ status.loads[1] }} | {{ status.loads[2] }}
</a-tag>
</a-tooltip>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :title="t('usage')" hoverable>
<a-tag color="green">
{{ t('pages.index.memory') }}: {{ SizeFormatter.sizeFormat(status.appStats.mem) }}
</a-tag>
<a-tag color="green">
{{ t('pages.index.threads') }}: {{ status.appStats.threads }}
</a-tag>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :title="t('pages.index.overallSpeed')" hoverable>
<a-row :gutter="isMobile ? [8, 8] : 0">
<a-col :span="12">
<CustomStatistic :title="t('pages.index.upload')"
:value="SizeFormatter.sizeFormat(status.netIO.up)">
<template #prefix>
<ArrowUpOutlined />
</template>
<template #suffix>/s</template>
</CustomStatistic>
</a-col>
<a-col :span="12">
<CustomStatistic :title="t('pages.index.download')"
:value="SizeFormatter.sizeFormat(status.netIO.down)">
<template #prefix>
<ArrowDownOutlined />
</template>
<template #suffix>/s</template>
</CustomStatistic>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :title="t('pages.index.totalData')" hoverable>
<a-row :gutter="isMobile ? [8, 8] : 0">
<a-col :span="12">
<CustomStatistic :title="t('pages.index.sent')"
:value="SizeFormatter.sizeFormat(status.netTraffic.sent)">
<template #prefix>
<CloudUploadOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :span="12">
<CustomStatistic :title="t('pages.index.received')"
:value="SizeFormatter.sizeFormat(status.netTraffic.recv)">
<template #prefix>
<CloudDownloadOutlined />
</template>
</CustomStatistic>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :title="t('pages.index.ipAddresses')" hoverable>
<template #extra>
<a-tooltip :title="t('pages.index.toggleIpVisibility')" :placement="isMobile ? 'topRight' : 'top'">
<component :is="showIp ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
@click="showIp = !showIp" />
</a-tooltip>
</template>
<a-row :class="showIp ? 'ip-visible' : 'ip-hidden'" :gutter="isMobile ? [8, 8] : 0">
<a-col :span="isMobile ? 24 : 12">
<CustomStatistic title="IPv4" :value="status.publicIP.ipv4">
<template #prefix>
<GlobalOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :span="isMobile ? 24 : 12">
<CustomStatistic title="IPv6" :value="status.publicIP.ipv6">
<template #prefix>
<GlobalOutlined />
</template>
</CustomStatistic>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :title="t('pages.index.connectionCount')" hoverable>
<a-row :gutter="isMobile ? [8, 8] : 0">
<a-col :span="12">
<CustomStatistic title="TCP" :value="status.tcpCount">
<template #prefix>
<SwapOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :span="12">
<CustomStatistic title="UDP" :value="status.udpCount">
<template #prefix>
<SwapOutlined />
</template>
</CustomStatistic>
</a-col>
</a-row>
</a-card>
</a-col>
</a-row>
</a-spin>
</a-layout-content>
</a-layout>
<PanelUpdateModal v-model:open="panelUpdateOpen" :info="panelUpdateInfo" @busy="setBusy" />
<LogModal v-model:open="logsOpen" />
<BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" />
<SystemHistoryModal v-model:open="sysHistoryOpen" :status="status" />
<XrayLogModal v-model:open="xrayLogsOpen" />
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
<TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
file-name="config.json" />
</a-layout>
</a-config-provider>
</template>
<style scoped>
.index-page {
--bg-page: #e6e8ec;
--bg-card: #ffffff;
min-height: 100vh;
background: var(--bg-page);
}
.index-page.is-dark {
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.index-page.is-dark.is-ultra {
--bg-page: #050505;
--bg-card: #0c0e12;
}
.index-page :deep(.ant-layout),
.index-page :deep(.ant-layout-content) {
background: transparent;
}
.content-shell {
background: transparent;
}
.content-area {
padding: 24px;
}
@media (max-width: 768px) {
.content-area {
padding: 12px;
padding-top: 64px;
}
}
.loading-spacer {
min-height: calc(100vh - 120px);
}
.action {
cursor: pointer;
justify-content: center;
}
.update-tag {
cursor: pointer;
margin: 0;
display: inline-flex;
align-items: center;
gap: 4px;
}
.history-tag {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
margin-inline-end: 0;
}
.link-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.link-tags a {
display: inline-flex;
}
.link-tags :deep(.ant-tag) {
margin-inline-end: 0;
}
.ip-toggle-icon {
cursor: pointer;
font-size: 16px;
}
.ip-hidden :deep(.ant-statistic-content-value) {
filter: blur(6px);
transition: filter 0.2s ease;
}
.ip-visible :deep(.ant-statistic-content-value) {
filter: none;
}
</style>
+355
View File
@@ -0,0 +1,355 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
const { t } = useI18n();
const { isMobile } = useMediaQuery();
const props = defineProps({
open: { type: Boolean, default: false },
});
const emit = defineEmits(['update:open']);
const rows = ref('20');
const level = ref('info');
const syslog = ref(false);
const loading = ref(false);
const logs = ref([]);
const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
const LEVEL_CLASSES = ['level-debug', 'level-info', 'level-notice', 'level-warning', 'level-error'];
// Parses "YYYY-MM-DD HH:MM:SS LEVEL - message". Lines without the
// 3-token header degrade gracefully: the unparsed head becomes the
// level so it still gets color-coded.
function parseLogLine(line) {
const [head, ...rest] = (line || '').split(' - ');
const message = rest.join(' - ');
const parts = head.split(' ');
let date = '';
let time = '';
let levelText;
if (parts.length >= 3) {
[date, time, levelText] = parts;
} else {
levelText = head;
}
const li = LEVELS.indexOf(levelText);
const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
let service = '';
let body = message || '';
if (body.startsWith('XRAY:')) {
service = 'XRAY:';
body = body.slice('XRAY:'.length).trimStart();
} else if (body) {
service = 'X-UI:';
}
return { date, time, levelText, levelClass, service, body };
}
const parsedLogs = computed(() => logs.value.map(parseLogLine));
async function refresh() {
loading.value = true;
try {
const msg = await HttpUtil.post(`/panel/api/server/logs/${rows.value}`, {
level: level.value,
syslog: syslog.value,
});
if (msg?.success) {
logs.value = msg.obj || [];
}
await PromiseUtil.sleep(300);
} finally {
loading.value = false;
}
}
function close() {
emit('update:open', false);
}
function download() {
FileManager.downloadTextFile(logs.value.join('\n'), 'x-ui.log');
}
watch(() => props.open, (next) => { if (next) refresh(); });
watch([rows, level, syslog], () => { if (props.open) refresh(); });
const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
</script>
<template>
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" :class="{ 'logmodal-mobile': isMobile }"
@cancel="close">
<template #title>
{{ t('pages.index.logs') }}
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
</template>
<a-form layout="inline" class="log-toolbar">
<a-form-item>
<a-input-group compact>
<a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
<a-select-option value="10">10</a-select-option>
<a-select-option value="20">20</a-select-option>
<a-select-option value="50">50</a-select-option>
<a-select-option value="100">100</a-select-option>
<a-select-option value="500">500</a-select-option>
</a-select>
<a-select v-model:value="level" size="small" :style="{ width: '95px' }">
<a-select-option value="debug">Debug</a-select-option>
<a-select-option value="info">Info</a-select-option>
<a-select-option value="notice">Notice</a-select-option>
<a-select-option value="warning">Warning</a-select-option>
<a-select-option value="err">Error</a-select-option>
</a-select>
</a-input-group>
</a-form-item>
<a-form-item>
<a-checkbox v-model:checked="syslog">SysLog</a-checkbox>
</a-form-item>
<a-form-item class="download-item">
<a-button type="primary" @click="download">
<template #icon>
<DownloadOutlined />
</template>
</a-button>
</a-form-item>
</a-form>
<div class="log-container" :class="{ 'log-container-mobile': isMobile }">
<div v-if="parsedLogs.length === 0" class="log-empty">No Record...</div>
<template v-else-if="isMobile">
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-card">
<div class="log-card-head">
<span v-if="log.date || log.time" class="log-time">
<span v-if="log.time">{{ log.time }}</span>
<span v-if="log.date" class="log-date">{{ log.date }}</span>
</span>
<span v-if="log.levelText" class="log-level-badge" :class="log.levelClass">
{{ log.levelText }}
</span>
</div>
<div v-if="log.body || log.service" class="log-body">
<b v-if="log.service">{{ log.service }}</b>
<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
</div>
</div>
</template>
<template v-else>
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-line">
<span v-if="log.date || log.time" class="log-stamp">
{{ log.date }}<template v-if="log.date && log.time"> </template>{{ log.time }}
</span>
<span v-if="log.levelText" class="log-level" :class="log.levelClass">
{{ log.levelText }}
</span>
<template v-if="log.body || log.service">
<span> - </span>
<b v-if="log.service">{{ log.service }} </b>
<span>{{ log.body }}</span>
</template>
</div>
</template>
</div>
</a-modal>
</template>
<style scoped>
.reload-icon {
cursor: pointer;
vertical-align: middle;
margin-left: 10px;
}
.log-toolbar {
flex-wrap: wrap;
row-gap: 8px;
}
.log-toolbar .download-item {
margin-left: auto;
}
.log-container {
/* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
below so each level keeps ≥4.5:1 contrast against the container. */
--log-stamp: #3c89e8;
--log-debug: #3c89e8;
--log-info: #008771;
--log-notice: #008771;
--log-warning: #f37b24;
--log-error: #e04141;
--log-unknown: #595959;
--log-divider: rgba(128, 128, 128, 0.18);
margin-top: 12px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
max-height: 60vh;
overflow-y: auto;
border: 1px solid rgba(128, 128, 128, 0.25);
border-radius: 6px;
background: rgba(0, 0, 0, 0.04);
}
.log-stamp {
color: var(--log-stamp);
}
.log-level {
margin-left: 4px;
}
.level-debug {
color: var(--log-debug);
}
.level-info {
color: var(--log-info);
}
.level-notice {
color: var(--log-notice);
}
.level-warning {
color: var(--log-warning);
}
.level-error {
color: var(--log-error);
}
.level-unknown {
color: var(--log-unknown);
}
.log-container-mobile {
padding: 8px;
white-space: normal;
max-height: 70vh;
}
.log-empty {
text-align: center;
opacity: 0.5;
padding: 20px 0;
}
.log-line+.log-line {
margin-top: 2px;
}
.log-card {
border-bottom: 1px solid var(--log-divider);
padding: 8px 0;
}
.log-card:last-child {
border-bottom: 0;
}
.log-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.log-time {
display: inline-flex;
align-items: baseline;
gap: 6px;
font-weight: 600;
font-size: 12px;
letter-spacing: 0.02em;
}
.log-date {
font-size: 10px;
font-weight: 500;
opacity: 0.55;
}
.log-level-badge {
display: inline-block;
font-size: 10px;
line-height: 14px;
padding: 0 6px;
border-radius: 4px;
border: 1px solid currentColor;
letter-spacing: 0.04em;
font-weight: 600;
white-space: nowrap;
background: color-mix(in srgb, currentColor 14%, transparent);
}
.log-body {
font-size: 12px;
word-break: break-word;
}
.log-body-text {
margin-left: 4px;
}
:global(body.dark) .log-container {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.88);
--log-stamp: #6aa6ee;
--log-debug: #6aa6ee;
--log-info: #4ed3a6;
--log-notice: #4ed3a6;
--log-warning: #ffb872;
--log-error: #ff7575;
--log-unknown: #b5b5b5;
--log-divider: rgba(255, 255, 255, 0.1);
}
:global([data-theme="ultra-dark"]) .log-container {
--log-stamp: #7fb6f1;
--log-debug: #7fb6f1;
--log-info: #5fd9b0;
--log-notice: #5fd9b0;
--log-warning: #ffcc88;
--log-error: #ff8a8a;
--log-unknown: #c4c4c4;
--log-divider: rgba(255, 255, 255, 0.12);
}
/* Mobile: pull the modal flush with the screen edges. */
:global(.logmodal-mobile) {
top: 0 !important;
padding-bottom: 0 !important;
max-width: 100vw !important;
}
:global(.logmodal-mobile .ant-modal-content) {
border-radius: 0;
height: 100vh;
}
:global(.logmodal-mobile .ant-modal-body) {
padding: 12px;
}
</style>
@@ -0,0 +1,112 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import { CloudDownloadOutlined } from '@ant-design/icons-vue';
import { HttpUtil, PromiseUtil } from '@/utils';
import axios from 'axios';
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
info: {
type: Object,
default: () => ({ currentVersion: '', latestVersion: '', updateAvailable: false }),
},
});
const emit = defineEmits(['update:open', 'busy']);
function close() {
emit('update:open', false);
}
function updatePanel() {
Modal.confirm({
title: t('pages.index.panelUpdateDialog'),
content: t('pages.index.panelUpdateDialogDesc').replace('#version#', props.info.latestVersion || ''),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
const baseTip = t('pages.index.dontRefresh');
const tip = props.info.latestVersion ? `${baseTip} (${props.info.latestVersion})` : baseTip;
close();
emit('busy', { busy: true, tip });
const msg = await HttpUtil.post('/panel/api/server/updatePanel');
if (!msg?.success) {
emit('busy', { busy: false });
return;
}
// Wait for the running process to exit, then poll the new panel
// until it answers (up to ~90s). Reload as soon as it's back.
await PromiseUtil.sleep(5000);
const deadline = Date.now() + 90_000;
let back = false;
while (Date.now() < deadline) {
try {
const r = await axios.get('/panel/api/server/status', { timeout: 2000 });
if (r?.data?.success) { back = true; break; }
} catch (_) { /* still restarting */ }
await PromiseUtil.sleep(2000);
}
if (back) {
message.success(t('pages.index.panelUpdateStartedPopover'));
await PromiseUtil.sleep(800);
}
window.location.reload();
},
});
}
</script>
<template>
<a-modal :open="open" :title="t('pages.index.updatePanel')" :closable="true" :footer="null" @cancel="close">
<a-alert v-if="info.updateAvailable" type="warning" class="mb-12" :message="t('pages.index.panelUpdateDesc')"
show-icon />
<a-list bordered class="version-list">
<a-list-item class="version-list-item">
<span>{{ t('pages.index.currentPanelVersion') }}</span>
<a-tag color="green">v{{ info.currentVersion || '?' }}</a-tag>
</a-list-item>
<a-list-item v-if="info.updateAvailable" class="version-list-item">
<span>{{ t('pages.index.latestPanelVersion') }}</span>
<a-tag color="purple">{{ info.latestVersion || '-' }}</a-tag>
</a-list-item>
<a-list-item v-else class="version-list-item">
<span>{{ t('pages.index.panelUpToDate') }}</span>
<a-tag color="green">{{ t('pages.index.panelUpToDate') }}</a-tag>
</a-list-item>
</a-list>
<div class="actions-row">
<a-button type="primary" :disabled="!info.updateAvailable" @click="updatePanel">
<template #icon>
<CloudDownloadOutlined />
</template>
{{ t('pages.index.updatePanel') }}
</a-button>
</div>
</a-modal>
</template>
<style scoped>
.mb-12 {
margin-bottom: 12px;
}
.version-list {
width: 100%;
}
.version-list-item {
display: flex;
justify-content: space-between;
}
.actions-row {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
</style>
+96
View File
@@ -0,0 +1,96 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { AreaChartOutlined } from '@ant-design/icons-vue';
import { CPUFormatter, SizeFormatter } from '@/utils';
const { t } = useI18n();
const props = defineProps({
status: { type: Object, required: true },
isMobile: { type: Boolean, default: false },
});
// AD-Vue's default 120px dashboard renders the percent text at ~36px
// which dwarfs the rest of the card. 70 (60 on mobile) plus the
// :deep(.ant-progress-text) override below keep the gauges compact.
const gaugeSize = computed(() => (props.isMobile ? 60 : 70));
// AD-Vue's default unfinished trail (rgba(0,0,0,0.06) /
// rgba(255,255,255,0.08)) is invisible against the light card; a
// neutral mid-gray reads on both themes.
const trailColor = 'rgba(128, 128, 128, 0.25)';
</script>
<template>
<a-card hoverable>
<a-row :gutter="[0, isMobile ? 16 : 0]">
<!-- CPU + Memory -->
<a-col :xs="24" :md="12">
<a-row>
<a-col :span="12" class="text-center">
<a-progress type="dashboard" status="normal" :stroke-color="status.cpu.color" :trail-color="trailColor"
:percent="status.cpu.percent" :width="gaugeSize" />
<div>
<b>{{ t('pages.index.cpu') }}:</b> {{ CPUFormatter.cpuCoreFormat(status.cpuCores) }}
<a-tooltip>
<template #title>
<div><b>{{ t('pages.index.logicalProcessors') }}:</b> {{ status.logicalPro }}</div>
<div><b>{{ t('pages.index.frequency') }}:</b> {{ CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) }}
</div>
</template>
<AreaChartOutlined />
</a-tooltip>
</div>
</a-col>
<a-col :span="12" class="text-center">
<a-progress type="dashboard" status="normal" :stroke-color="status.mem.color" :trail-color="trailColor"
:percent="status.mem.percent" :width="gaugeSize" />
<div>
<b>{{ t('pages.index.memory') }}:</b> {{ SizeFormatter.sizeFormat(status.mem.current) }} /
{{ SizeFormatter.sizeFormat(status.mem.total) }}
</div>
</a-col>
</a-row>
</a-col>
<!-- Swap + Disk -->
<a-col :xs="24" :md="12">
<a-row>
<a-col :span="12" class="text-center">
<a-progress type="dashboard" status="normal" :stroke-color="status.swap.color" :trail-color="trailColor"
:percent="status.swap.percent" :width="gaugeSize" />
<div>
<b>{{ t('pages.index.swap') }}:</b> {{ SizeFormatter.sizeFormat(status.swap.current) }} /
{{ SizeFormatter.sizeFormat(status.swap.total) }}
</div>
</a-col>
<a-col :span="12" class="text-center">
<a-progress type="dashboard" status="normal" :stroke-color="status.disk.color" :trail-color="trailColor"
:percent="status.disk.percent" :width="gaugeSize" />
<div>
<b>{{ t('pages.index.storage') }}:</b> {{ SizeFormatter.sizeFormat(status.disk.current) }} /
{{ SizeFormatter.sizeFormat(status.disk.total) }}
</div>
</a-col>
</a-row>
</a-col>
</a-row>
</a-card>
</template>
<style scoped>
.text-center {
text-align: center;
}
/* Pin the percent number to a label-sized 14px AD-Vue scales it
* from the SVG's intrinsic size, so :width alone leaves it too big. */
:deep(.ant-progress-text) {
font-size: 14px !important;
font-weight: 500;
}
</style>
@@ -0,0 +1,160 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { HttpUtil, SizeFormatter } from '@/utils';
import Sparkline from '@/components/Sparkline.vue';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
const { t } = useI18n();
const { isMobile } = useMediaQuery();
const modalWidth = computed(() => (isMobile.value ? '95vw' : '900px'));
const props = defineProps({
open: { type: Boolean, default: false },
status: { type: Object, required: true },
});
const emit = defineEmits(['update:open']);
// One tab per system metric. The order here drives the tab order in
// the UI; everything else (axis label, tooltip unit, fetch URL) is
// looked up from the active key. Adding another metric is one row.
const metrics = [
{ key: 'cpu', tab: 'CPU', valueMax: 100, unit: '%', stroke: '' },
{ key: 'mem', tab: 'RAM', valueMax: 100, unit: '%', stroke: '#7c4dff' },
{ key: 'netUp', tab: 'Net Up', valueMax: null, unit: 'B/s', stroke: '#1890ff' },
{ key: 'netDown', tab: 'Net Down', valueMax: null, unit: 'B/s', stroke: '#13c2c2' },
{ key: 'online', tab: 'Online', valueMax: null, unit: '', stroke: '#52c41a' },
{ key: 'load1', tab: 'Load 1m', valueMax: null, unit: '', stroke: '#fa8c16' },
{ key: 'load5', tab: 'Load 5m', valueMax: null, unit: '', stroke: '#f5222d' },
{ key: 'load15', tab: 'Load 15m', valueMax: null, unit: '', stroke: '#a0d911' },
];
const activeKey = ref('cpu');
const bucket = ref(2);
const points = ref([]);
const labels = ref([]);
const activeMetric = computed(() => metrics.find((m) => m.key === activeKey.value));
// CPU keeps using the status-card color so the modal visually echoes
// the dot in StatusCard. Non-CPU tabs each get their own constant color.
const strokeColor = computed(() => {
const m = activeMetric.value;
if (m?.stroke) return m.stroke;
return props.status?.cpu?.color || '#008771';
});
function unitFormatter(unit) {
if (unit === 'B/s') {
return (v) => `${SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0))}/s`;
}
if (unit === '%') {
return (v) => `${Number(v).toFixed(1)}%`;
}
// Plain numbers: load averages get two decimals, online client count
// is integer. Heuristic on the unit-less metric key is good enough.
return (v) => {
const n = Number(v) || 0;
if (activeKey.value === 'online') return String(Math.round(n));
return n.toFixed(2);
};
}
const yFormatter = computed(() => unitFormatter(activeMetric.value?.unit ?? ''));
async function fetchBucket() {
const m = activeMetric.value;
if (!m) return;
try {
const url = `/panel/api/server/history/${m.key}/${bucket.value}`;
const msg = await HttpUtil.get(url);
if (msg?.success && Array.isArray(msg.obj)) {
const vals = [];
const labs = [];
for (const p of msg.obj) {
const d = new Date(p.t * 1000);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
labs.push(bucket.value >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
vals.push(Number(p.v) || 0);
}
labels.value = labs;
points.value = vals;
} else {
labels.value = [];
points.value = [];
}
} catch (e) {
console.error('Failed to fetch history bucket', e);
labels.value = [];
points.value = [];
}
}
function close() {
emit('update:open', false);
}
watch(() => props.open, (next) => {
if (next) {
activeKey.value = 'cpu';
fetchBucket();
}
});
watch([activeKey, bucket], () => {
if (props.open) fetchBucket();
});
</script>
<template>
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" @cancel="close">
<template #title>
{{ t('pages.index.systemHistoryTitle') }}
<a-select v-model:value="bucket" size="small" class="bucket-select">
<a-select-option :value="2">2m</a-select-option>
<a-select-option :value="30">30m</a-select-option>
<a-select-option :value="60">1h</a-select-option>
<a-select-option :value="120">2h</a-select-option>
<a-select-option :value="180">3h</a-select-option>
<a-select-option :value="300">5h</a-select-option>
</a-select>
</template>
<a-tabs v-model:active-key="activeKey" size="small" class="history-tabs">
<a-tab-pane v-for="m in metrics" :key="m.key" :tab="m.tab" />
</a-tabs>
<div class="cpu-chart-wrap">
<div class="cpu-chart-meta">
Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
</div>
<Sparkline :data="points" :labels="labels" :vb-width="840" :height="220" :stroke="strokeColor" :stroke-width="2.2"
:show-grid="true" :show-axes="true" :tick-count-x="5" :max-points="points.length || 1" :fill-opacity="0.18"
:marker-radius="3.2" :show-tooltip="true" :value-min="0" :value-max="activeMetric?.valueMax ?? null"
:y-formatter="yFormatter" />
</div>
</a-modal>
</template>
<style scoped>
.bucket-select {
width: 80px;
margin-left: 10px;
}
.history-tabs {
margin-bottom: 4px;
}
.cpu-chart-wrap {
padding: 8px 16px 16px;
}
.cpu-chart-meta {
margin-bottom: 10px;
font-size: 11px;
opacity: 0.65;
}
</style>

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