Compare commits

...

24 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
81 changed files with 3948 additions and 1193 deletions
+2 -2
View File
@@ -43,10 +43,10 @@ actionstop = <iptables> -D <chain> -p <protocol> -j 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
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
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
[Init]
name = default
+1 -1
View File
@@ -1 +1 @@
3.0.0
3.0.1
+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>
+113 -156
View File
@@ -1,20 +1,18 @@
{
"name": "3x-ui-frontend",
"version": "0.0.0",
"version": "0.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "3x-ui-frontend",
"version": "0.0.0",
"version": "0.0.2",
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.2.6",
"axios": "^1.7.9",
"dayjs": "^1.11.20",
"moment": "^2.30.1",
"otpauth": "^9.5.1",
"qrious": "^4.0.2",
"qs": "^6.13.1",
"vue": "^3.5.13",
"vue-i18n": "^11.1.4",
@@ -207,42 +205,6 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/config-array/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz",
@@ -462,18 +424,18 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.128.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz",
"integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==",
"version": "0.129.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
"integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
"integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
"integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
"cpu": [
"arm64"
],
@@ -487,9 +449,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz",
"integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
"integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
"cpu": [
"arm64"
],
@@ -503,9 +465,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz",
"integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
"integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
"cpu": [
"x64"
],
@@ -519,9 +481,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz",
"integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
"integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
"cpu": [
"x64"
],
@@ -535,9 +497,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz",
"integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
"integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
"cpu": [
"arm"
],
@@ -551,9 +513,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz",
"integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
"integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
"cpu": [
"arm64"
],
@@ -567,9 +529,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz",
"integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
"integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
"cpu": [
"arm64"
],
@@ -583,9 +545,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz",
"integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
"integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
"cpu": [
"ppc64"
],
@@ -599,9 +561,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz",
"integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
"integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
"cpu": [
"s390x"
],
@@ -615,9 +577,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz",
"integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
"integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
"cpu": [
"x64"
],
@@ -631,9 +593,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz",
"integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
"integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
"cpu": [
"x64"
],
@@ -647,9 +609,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz",
"integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
"integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
"cpu": [
"arm64"
],
@@ -663,9 +625,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz",
"integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
"integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
"cpu": [
"wasm32"
],
@@ -681,9 +643,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
"integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
"integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
"cpu": [
"arm64"
],
@@ -697,9 +659,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz",
"integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
"integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
"cpu": [
"x64"
],
@@ -968,12 +930,33 @@
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"dev": true
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1306,42 +1289,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/espree": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
@@ -2073,6 +2020,21 @@
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -2318,11 +2280,6 @@
"node": ">=6"
}
},
"node_modules/qrious": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/qrious/-/qrious-4.0.2.tgz",
"integrity": "sha512-xWPJIrK1zu5Ypn898fBp8RHkT/9ibquV2Kv24S/JY9VYEhMBMKur1gHVsOiNUh7PHP9uCgejjpZUHUIXXKoU/g=="
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
@@ -2343,13 +2300,13 @@
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"node_modules/rolldown": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz",
"integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
"integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
"dev": true,
"dependencies": {
"@oxc-project/types": "=0.128.0",
"@rolldown/pluginutils": "1.0.0-rc.18"
"@oxc-project/types": "=0.129.0",
"@rolldown/pluginutils": "1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -2358,27 +2315,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.18",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.18",
"@rolldown/binding-darwin-x64": "1.0.0-rc.18",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.18",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.18",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.18",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.18",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18"
"@rolldown/binding-android-arm64": "1.0.0",
"@rolldown/binding-darwin-arm64": "1.0.0",
"@rolldown/binding-darwin-x64": "1.0.0",
"@rolldown/binding-freebsd-x64": "1.0.0",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
"@rolldown/binding-linux-arm64-gnu": "1.0.0",
"@rolldown/binding-linux-arm64-musl": "1.0.0",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0",
"@rolldown/binding-linux-s390x-gnu": "1.0.0",
"@rolldown/binding-linux-x64-gnu": "1.0.0",
"@rolldown/binding-linux-x64-musl": "1.0.0",
"@rolldown/binding-openharmony-arm64": "1.0.0",
"@rolldown/binding-wasm32-wasi": "1.0.0",
"@rolldown/binding-win32-arm64-msvc": "1.0.0",
"@rolldown/binding-win32-x64-msvc": "1.0.0"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz",
"integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
"integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
"dev": true
},
"node_modules/scroll-into-view-if-needed": {
@@ -2567,15 +2524,15 @@
"dev": true
},
"node_modules/vite": {
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
"integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==",
"version": "8.0.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
"integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
"dev": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.14",
"rolldown": "1.0.0-rc.18",
"rolldown": "1.0.0",
"tinyglobby": "^0.2.16"
},
"bin": {
+1 -3
View File
@@ -1,7 +1,7 @@
{
"name": "3x-ui-frontend",
"private": true,
"version": "0.0.1",
"version": "0.0.2",
"type": "module",
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
"scripts": {
@@ -15,9 +15,7 @@
"ant-design-vue": "^4.2.6",
"axios": "^1.7.9",
"dayjs": "^1.11.20",
"moment": "^2.30.1",
"otpauth": "^9.5.1",
"qrious": "^4.0.2",
"qs": "^6.13.1",
"vue": "^3.5.13",
"vue-i18n": "^11.1.4",
+3 -3
View File
@@ -22,7 +22,7 @@ function readMetaToken() {
// recurse through this same interceptor.
async function fetchCsrfToken() {
try {
const basePath = window.__X_UI_BASE_PATH__;
const basePath = window.X_UI_BASE_PATH;
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
: CSRF_TOKEN_PATH);
@@ -59,7 +59,7 @@ 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__;
const basePath = window.X_UI_BASE_PATH;
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
axios.defaults.baseURL = basePath;
}
@@ -98,7 +98,7 @@ export function setupAxios() {
// 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__ || '/';
const basePath = window.X_UI_BASE_PATH || '/';
window.location.href = `${basePath}login.html`;
} else {
window.location.reload();
+1 -1
View File
@@ -140,7 +140,7 @@ export class WebSocketClient {
#buildUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// basePath comes from window.__X_UI_BASE_PATH__ which is only injected
// 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
+295 -35
View File
@@ -9,28 +9,23 @@ import {
ClusterOutlined,
LogoutOutlined,
CloseOutlined,
MenuFoldOutlined,
MenuOutlined,
ApiOutlined,
} from '@ant-design/icons-vue';
import { currentTheme } from '@/composables/useTheme.js';
import ThemeSwitch from '@/components/ThemeSwitch.vue';
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
const { t } = useI18n();
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const props = defineProps({
// Path prefix (e.g. /custom-base/) the panel is served under. Defaults
// to '' which means tab keys end up as '/panel/...'. Pages pass the
// value the Go backend gave them (in production via a meta tag).
basePath: { type: String, default: '' },
// Current request URI so the matching menu item highlights.
requestUri: { type: String, default: '' },
});
// AD-Vue 4 dropped <a-icon :type="x"> in favor of explicit icon
// imports — keep a small name-to-component map so tab definitions stay
// declarative.
const iconByName = {
dashboard: DashboardOutlined,
user: UserOutlined,
@@ -38,30 +33,27 @@ const iconByName = {
tool: ToolOutlined,
cluster: ClusterOutlined,
logout: LogoutOutlined,
apidocs: ApiOutlined,
};
// basePath comes from Go (`/` by default, `/myprefix/` when configured) so
// these concatenations land on absolute paths. In dev we synthesize the prop
// from a window global which can be empty — force a leading slash so the
// browser doesn't resolve the link relative to the current pathname (which
// would turn /panel/settings + 'panel/...' into /panel/panel/...).
const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
// Labels are i18n-driven so the sidebar matches the locale picked
// in panel settings without a page reload of the sidebar component.
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')) {
@@ -86,14 +78,55 @@ function toggleDrawer() {
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">
<ThemeSwitch />
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
<a-menu-item v-for="tab in tabs" :key="tab.key">
<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>
@@ -101,43 +134,218 @@ function closeDrawer() {
</a-layout-sider>
<a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
:wrap-style="{ padding: 0 }" :style="{ height: '100%' }" @close="closeDrawer">
<ThemeSwitch />
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
<a-menu-item v-for="tab in tabs" :key="tab.key">
: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 class="drawer-handle" type="button" @click="toggleDrawer">
<CloseOutlined v-if="drawerOpen" />
<MenuFoldOutlined v-else />
<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 {
height: 100%;
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: 16px;
left: 16px;
top: 12px;
left: 12px;
z-index: 1100;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border: none;
width: 36px;
height: 36px;
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) {
@@ -145,9 +353,6 @@ function closeDrawer() {
display: inline-flex;
}
/* On mobile the drawer is the menu — hide the inline sider's content
* + the collapse trigger so the sider stops taking layout space and
* leaves no remnant button next to the page. */
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
display: none;
@@ -161,3 +366,58 @@ function closeDrawer() {
}
}
</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>
+6 -6
View File
@@ -235,8 +235,8 @@ function onAntChange(next) {
/* ===== Dark (navy) ======================================================= */
body.dark .persian-datepicker-input {
background: #142340;
border-color: #1f3358;
background: #252526;
border-color: #3c3c3c;
color: rgba(255, 255, 255, 0.88);
}
@@ -251,14 +251,14 @@ body.dark .persian-datepicker-input:focus {
body.dark .vpd-main .vpd-icon-btn {
background: rgba(255, 255, 255, 0.04) !important;
border: 1px solid #1f3358 !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: #1a2c4d;
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),
@@ -266,7 +266,7 @@ body.dark .vpd-wrapper .vpd-content {
}
body.dark .vpd-wrapper .vpd-body {
background: #1a2c4d;
background: #2d2d30;
color: rgba(255, 255, 255, 0.88);
}
@@ -315,7 +315,7 @@ body.dark .vpd-wrapper .vpd-actions button:hover {
body.dark .vpd-wrapper .vpd-addon-list,
body.dark .vpd-wrapper .vpd-addon-list-content {
background: #1a2c4d;
background: #2d2d30;
color: rgba(255, 255, 255, 0.88);
}
-49
View File
@@ -1,49 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { BulbFilled, BulbOutlined } from '@ant-design/icons-vue';
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
const { t } = useI18n();
const BulbIcon = computed(() => (theme.isDark ? BulbFilled : BulbOutlined));
function onDarkChange() {
pauseAnimationsUntilLeave('change-theme');
toggleTheme();
}
function onUltraClick() {
pauseAnimationsUntilLeave('change-theme-ultra');
toggleUltra();
}
</script>
<template>
<a-menu :theme="currentTheme" mode="inline" :selected-keys="[]">
<a-sub-menu>
<template #title>
<span>
<component :is="BulbIcon" />
<span class="theme-label">{{ t('menu.theme') }}</span>
</span>
</template>
<a-menu-item id="change-theme" class="ant-menu-theme-switch">
<span>{{ t('menu.dark') }}</span>
<a-switch :style="{ marginLeft: '2px' }" size="small" :checked="theme.isDark" @change="onDarkChange" />
</a-menu-item>
<a-menu-item v-if="theme.isDark" id="change-theme-ultra" class="ant-menu-theme-switch">
<span>{{ t('menu.ultraDark') }}</span>
<a-checkbox :style="{ marginLeft: '2px' }" :checked="theme.isUltra" @click="onUltraClick" />
</a-menu-item>
</a-sub-menu>
</a-menu>
</template>
<style scoped>
.theme-label {
margin-left: 8px;
}
</style>
@@ -1,25 +0,0 @@
<script setup>
import { theme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
function onDarkChange() {
pauseAnimationsUntilLeave('change-theme');
toggleTheme();
}
function onUltraClick() {
toggleUltra();
}
</script>
<template>
<a-space id="change-theme" direction="vertical" :size="10" :style="{ width: '100%' }">
<a-space direction="horizontal" size="small">
<a-switch size="small" :checked="theme.isDark" @change="onDarkChange" />
<span>Dark</span>
</a-space>
<a-space v-if="theme.isDark" direction="horizontal" size="small">
<a-checkbox :checked="theme.isUltra" @click="onUltraClick" />
<span>Ultra dark</span>
</a-space>
</a-space>
</template>
+16 -16
View File
@@ -27,14 +27,15 @@ 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 navy palette across page/cards/modals so
// the sidebar blends with the rest of the surface; ultra-dark stays
// neutral black on top of darkAlgorithm.
// 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: '#0a1426',
colorBgLayout: '#0a1426',
colorBgContainer: '#142340',
colorBgElevated: '#1a2c4d',
colorBgBase: '#1e1e1e',
colorBgLayout: '#1e1e1e',
colorBgContainer: '#252526',
colorBgElevated: '#2d2d30',
};
const ULTRA_DARK_TOKENS = {
colorBgBase: '#000',
@@ -47,13 +48,12 @@ const ULTRA_DARK_TOKENS = {
// + 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.
// Dark theme uses a refined navy for the sidebar — distinct from the
// neutral ultra-dark and warmer than AD-Vue's stock #001529.
// 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: '#0d1d33',
colorBgTrigger: '#15294a',
colorBgBody: '#000',
colorBgHeader: '#252526',
colorBgTrigger: '#333333',
colorBgBody: '#1e1e1e',
};
const ULTRA_DARK_LAYOUT_TOKENS = {
colorBgHeader: '#0a0a0a',
@@ -61,9 +61,9 @@ const ULTRA_DARK_LAYOUT_TOKENS = {
colorBgBody: '#000',
};
const DARK_MENU_TOKENS = {
colorItemBg: '#0d1d33',
colorSubItemBg: '#08142a',
menuSubMenuBg: '#0d1d33',
colorItemBg: '#252526',
colorSubItemBg: '#1e1e1e',
menuSubMenuBg: '#252526',
};
const ULTRA_DARK_MENU_TOKENS = {
colorItemBg: '#0a0a0a',
+1 -1
View File
@@ -9,7 +9,7 @@ let sharedClient = null;
function getSharedClient() {
if (sharedClient) return sharedClient;
const basePath = (typeof window !== 'undefined' && window.__X_UI_BASE_PATH__) || '';
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
sharedClient = new WebSocketClient(basePath);
return sharedClient;
}
+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');
+3 -3
View File
@@ -1926,13 +1926,13 @@ Outbound.VmessSettings = class extends CommonClass {
}
};
Outbound.VLESSSettings = class extends CommonClass {
constructor(address, port, id, flow, encryption, reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = []) {
constructor(address, port, id, flow, encryption = 'none', reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = []) {
super();
this.address = address;
this.port = port;
this.id = id;
this.flow = flow;
this.encryption = encryption;
this.encryption = encryption || 'none';
this.reverseTag = reverseTag;
this.reverseSniffing = reverseSniffing;
this.testpre = testpre;
@@ -1966,7 +1966,7 @@ Outbound.VLESSSettings = class extends CommonClass {
port: this.port,
id: this.id,
flow: this.flow,
encryption: this.encryption,
encryption: this.encryption || 'none',
};
if (!ObjectUtil.isEmpty(this.reverseTag)) {
const reverseSniffing = this.reverseSniffing ? this.reverseSniffing.toJson() : {};
+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 inbound’s 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 inbound’s 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 client’s credentials. Returns an array of "ip (timestamp)" strings.',
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/clearClientIps/:email',
summary: 'Reset the recorded IP list for a client.',
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/addClient',
summary: 'Add one or more clients to an existing inbound. The settings field is the JSON-encoded settings.clients array of the target inbound.',
body:
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"newuser\\",\\"limitIp\\":0,\\"totalGB\\":0,\\"expiryTime\\":0,\\"enable\\":true,\\"flow\\":\\"\\"}]}"\n}',
},
{
method: 'POST',
path: '/panel/api/inbounds/:id/copyClients',
summary: 'Copy selected clients from one inbound into another. Useful for duplicating user lists across protocols.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Target inbound ID.' },
{ name: 'sourceInboundId', in: 'body', type: 'number', desc: 'Inbound ID to read clients from.' },
{ name: 'clientEmails', in: 'body', type: 'string[]', desc: 'Emails of clients to copy. Empty means all clients.' },
{ name: 'flow', in: 'body', type: 'string', desc: 'Override the flow field on copied clients (e.g. "xtls-rprx-vision"). Empty to keep source flow.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/:id/delClient/:clientId',
summary: 'Delete a client by its UUID/password from a specific inbound.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/updateClient/:clientId',
summary: 'Update a single client without rewriting the whole settings JSON. Send the target inbound payload with the new client values.',
params: [
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
],
body:
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
},
{
method: 'POST',
path: '/panel/api/inbounds/:id/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 client’s upload + download counters. Useful for migrations from external accounting systems.',
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
],
body: '{\n "upload": 1073741824,\n "download": 5368709120\n}',
},
{
method: 'POST',
path: '/panel/api/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 that’s 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 panel’s 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 node’s 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',
};
@@ -53,6 +53,7 @@ const form = reactive({
flow: '',
subId: '',
tgId: 0,
comment: '',
limitIp: 0,
totalGB: 0,
expiryTime: 0, // ms epoch; negative => delayed start days
@@ -85,6 +86,7 @@ watch(() => props.open, (next) => {
form.flow = '';
form.subId = '';
form.tgId = 0;
form.comment = '';
form.limitIp = 0;
form.totalGB = 0;
form.expiryTime = 0;
@@ -135,6 +137,7 @@ function buildClients() {
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
@@ -227,6 +230,10 @@ async function submit() {
<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>
+186 -5
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
EditOutlined,
@@ -31,6 +31,7 @@ const props = defineProps({
onlineClients: { type: Array, default: () => [] },
lastOnlineMap: { type: Object, default: () => ({}) },
isDarkTheme: { type: Boolean, default: false },
pageSize: { type: Number, default: 0 },
});
const emit = defineEmits([
@@ -39,12 +40,27 @@ const emit = defineEmits([
'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();
@@ -162,23 +178,111 @@ function confirmDelete(client) {
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 }">
<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 clients" :key="rowKey(client)" class="client-row">
<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 })" />
@@ -262,6 +366,15 @@ function rowKey(client) {
</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>
@@ -301,8 +414,11 @@ function rowKey(client) {
<!-- ====================== Mobile: card list ======================= -->
<template v-else>
<div v-for="client in clients" :key="rowKey(client)" class="client-card">
<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>
@@ -356,6 +472,15 @@ function rowKey(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>
@@ -380,6 +505,10 @@ function rowKey(client) {
</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>
@@ -389,8 +518,28 @@ function rowKey(client) {
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 */
@@ -404,6 +553,8 @@ function rowKey(client) {
/* traffic */
130px
/* all-time */
130px
/* remained */
140px;
/* expiry */
gap: 12px;
@@ -412,6 +563,28 @@ function rowKey(client) {
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);
}
@@ -432,10 +605,12 @@ function rowKey(client) {
/* allow grid children to shrink instead of overflowing */
}
.cell-select,
.cell-actions,
.cell-enable,
.cell-online,
.cell-alltime {
.cell-alltime,
.cell-remained {
text-align: center;
display: inline-flex;
align-items: center;
@@ -547,6 +722,12 @@ function rowKey(client) {
padding: 0 !important;
}
.client-list-pagination {
display: flex;
justify-content: center;
padding: 10px 16px 4px;
}
/* ===== Mobile card list =========================================== */
.client-list.is-mobile {
display: flex;
@@ -387,6 +387,9 @@ const showSubscriptionTab = computed(
<td>
<a-tag v-if="clientStats && clientSettings.totalGB > 0" :color="statsColor(clientStats)">{{
getRemainingStats() }}</a-tag>
<a-tag v-else-if="!clientSettings.totalGB || clientSettings.totalGB <= 0" color="purple">
<InfinityIcon />
</a-tag>
</td>
<td>
<a-tag v-if="clientSettings.totalGB > 0" :color="clientStats ? statsColor(clientStats) : 'default'">{{
+26 -7
View File
@@ -62,6 +62,7 @@ const emit = defineEmits([
'info-client',
'reset-traffic-client',
'delete-client',
'delete-clients',
'toggle-enable-client',
]);
@@ -121,13 +122,19 @@ const visibleInbounds = computed(() => {
// `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, responsive: ['xs'] },
{ 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 },
{ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 },
];
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 });
}
@@ -400,10 +407,12 @@ function showQrCodeMenu(dbInbound) {
<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>
@@ -419,10 +428,12 @@ function showQrCodeMenu(dbInbound) {
<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" @edit-client="(p) => emit('edit-client', p)"
: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>
@@ -523,27 +534,35 @@ function showQrCodeMenu(dbInbound) {
<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 v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
<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 v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
<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 v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
<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 v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
<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>
+62 -8
View File
@@ -67,7 +67,7 @@ const { isMobile } = useMediaQuery();
// 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 basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
onMounted(async () => {
@@ -322,6 +322,14 @@ async function onDeleteClient({ dbInbound, client }) {
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
@@ -593,9 +601,38 @@ function onRowAction({ key, dbInbound }) {
<a-space direction="horizontal">
<TeamOutlined />
<a-tag color="green">{{ totals.clients }}</a-tag>
<a-tag v-if="totals.deactive.length">{{ totals.deactive.length }}</a-tag>
<a-tag v-if="totals.depleted.length" color="red">{{ totals.depleted.length }}</a-tag>
<a-tag v-if="totals.expiring.length" color="orange">{{ totals.expiring.length }}</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>
@@ -613,7 +650,7 @@ function onRowAction({ key, dbInbound }) {
@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"
@toggle-enable-client="onToggleEnableClient" />
@delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
</a-col>
</a-row>
</a-spin>
@@ -631,7 +668,7 @@ function onRowAction({ key, dbInbound }) {
: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" />
: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"
@@ -650,8 +687,8 @@ function onRowAction({ key, dbInbound }) {
}
.inbounds-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.inbounds-page.is-dark.is-ultra {
@@ -692,3 +729,20 @@ function onRowAction({ key, dbInbound }) {
}
}
</style>
<style>
/* AD-Vue popovers teleport their content to <body>, so scoped styles
don't reach them — this block has to be unscoped. */
.client-email-list {
max-height: 280px;
min-width: 160px;
overflow-y: auto;
padding-right: 4px;
}
.client-email-list > div {
padding: 2px 0;
font-size: 12px;
white-space: nowrap;
}
</style>
+75 -16
View File
@@ -1,26 +1,21 @@
<script setup>
import { ref, watch } from 'vue';
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();
// Light QR-only modal — used for the "qrcode" row action on
// single-user Shadowsocks and WireGuard inbounds. The big info modal
// (InboundInfoModal) is too detailed when the user just wants the
// share link as a QR.
const props = defineProps({
open: { type: Boolean, default: false },
dbInbound: { type: Object, default: null },
client: { type: Object, default: null },
remarkModel: { type: String, default: '-ieo' },
// Address of the node hosting this inbound (empty string for local).
// When set, share/QR links use it as the host instead of the panel's
// origin — node-managed inbounds proxy from the node, not the panel.
nodeAddress: { type: String, default: '' },
subSettings: {
type: Object,
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
},
});
const emit = defineEmits(['update:open']);
@@ -28,6 +23,50 @@ 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;
@@ -46,6 +85,21 @@ watch(() => props.open, (next) => {
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() {
@@ -56,12 +110,17 @@ function close() {
<template>
<a-modal :open="open" :title="t('qrCode')" :footer="null" width="420px" @cancel="close">
<template v-if="dbInbound">
<QrPanel v-for="(link, idx) in links" :key="`l${idx}`" :value="link.link"
:remark="link.remark || `Link ${idx + 1}`" />
<template v-for="(cfg, idx) in wireguardConfigs" :key="`w${idx}`">
<QrPanel :value="cfg" :remark="`Peer ${idx + 1} config`" :download-name="`peer-${idx + 1}.conf`" />
<QrPanel v-if="wireguardLinks[idx]" :value="wireguardLinks[idx]" :remark="`Peer ${idx + 1} link`" />
</template>
<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>
+5 -69
View File
@@ -1,7 +1,5 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import QRious from 'qrious';
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
@@ -9,73 +7,14 @@ import { ClipboardManager, FileManager } from '@/utils';
const { t } = useI18n();
// Renders a single share-link as a clickable QR code + a copy button
// + (optional) a download button. Used per-link inside the inbound
// info modal — the canvas is repainted whenever `value` changes.
const props = defineProps({
// The link or config text to encode + display.
value: { type: String, required: true },
// Header label shown next to the copy button.
remark: { type: String, default: '' },
// Optional download filename — when set, surfaces a download button.
downloadName: { type: String, default: '' },
// Final on-screen QR size in CSS pixels. The canvas drawing buffer
// is rounded down to a multiple of the QR matrix width (so the QR
// fills it edge-to-edge) and CSS then scales the canvas to exactly
// this size — so a denser QR (e.g. WireGuard config) and a sparser
// one (its link) display at identical dimensions.
size: { type: Number, default: 240 },
// Toggle the QR rendering off when callers only want the "row of buttons"
// styling (used when the legacy panel rendered links without QRs).
showQr: { type: Boolean, default: true },
});
const canvas = ref(null);
// Byte-mode capacities (level M) for QR versions 1..40 — used to pick
// the matrix width up front so we can size the canvas as a multiple
// of pixelSize. Without this, QRious renders at floor(size/matrix)
// and centers, leaving a white margin whenever size isn't divisible.
const QR_M_BYTE_CAPACITY = [
14, 26, 42, 62, 84, 106, 122, 152, 180, 213,
251, 287, 331, 362, 412, 450, 504, 560, 624, 666,
711, 779, 857, 911, 997, 1059, 1125, 1190, 1264, 1370,
1452, 1538, 1628, 1722, 1809, 1911, 1989, 2099, 2213, 2331,
];
function pickQrMatrixWidth(value) {
const byteLen = new TextEncoder().encode(value).length;
for (let i = 0; i < QR_M_BYTE_CAPACITY.length; i++) {
if (byteLen <= QR_M_BYTE_CAPACITY[i]) return 17 + 4 * (i + 1);
}
return 17 + 4 * 40; // version 40 (177 modules)
}
function paint() {
if (!props.showQr || !canvas.value || !props.value) return;
// Canvas size = matrixWidth × pixelSize, so the QR fills it edge-to-
// edge. pixelSize is floored against the requested size so the QR
// never grows past the host's expected box.
const matrixWidth = pickQrMatrixWidth(props.value);
const pixelSize = Math.max(1, Math.floor(props.size / matrixWidth));
const exactSize = matrixWidth * pixelSize;
new QRious({
element: canvas.value,
size: exactSize,
value: props.value,
background: 'white',
backgroundAlpha: 1,
foreground: 'black',
padding: 0,
level: 'M',
});
}
onMounted(paint);
watch(() => props.value, paint);
watch(() => props.size, paint);
async function copy() {
const ok = await ClipboardManager.copyText(props.value);
if (ok) message.success(t('copied'));
@@ -107,7 +46,8 @@ function download() {
</a-tooltip>
</div>
<div v-if="showQr" class="qr-panel-canvas">
<canvas ref="canvas" :style="{ width: `${size}px`, height: `${size}px` }" @click="copy" />
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
:title="t('copy')" @click="copy" />
</div>
</div>
</template>
@@ -140,14 +80,10 @@ function download() {
padding: 6px 0;
}
.qr-panel-canvas canvas {
.qr-panel-canvas .qr-code {
cursor: pointer;
display: block;
padding: 0 !important;
background: #fff;
border-radius: 4px;
/* Drawing buffer is matrix-snapped (smaller than display size for
* dense QRs); scale up crisply so dense and sparse QRs share the
* same on-screen footprint without blurring. */
image-rendering: pixelated;
image-rendering: crisp-edges;
}
</style>
+3 -1
View File
@@ -287,6 +287,7 @@ export function useInbounds() {
const deactive = [];
const depleted = [];
const expiring = [];
const online = [];
for (const ib of dbInbounds.value) {
up += ib.up || 0;
down += ib.down || 0;
@@ -297,9 +298,10 @@ export function useInbounds() {
deactive.push(...c.deactive);
depleted.push(...c.depleted);
expiring.push(...c.expiring);
online.push(...c.online);
}
}
return { up, down, allTime, clients, deactive, depleted, expiring };
return { up, down, allTime, clients, deactive, depleted, expiring, online };
});
// ObjectUtil reference is wired at module load — keeping a no-op import
+1 -1
View File
@@ -20,7 +20,7 @@ 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 = '/panel/api/server/getDb';
window.location = window.X_UI_BASE_PATH+'panel/api/server/getDb';
}
function importDb() {
+5 -5
View File
@@ -53,14 +53,14 @@ onMounted(() => {
});
});
const basePath = window.__X_UI_BASE_PATH__ || '';
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 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__ || '?',
() => panelUpdateInfo.value?.currentVersion || window.X_UI_CUR_VER || '?',
);
// Hide/reveal the public IPv4/IPv6 — same pattern as legacy.
@@ -336,8 +336,8 @@ async function openConfig() {
}
.index-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.index-page.is-dark.is-ultra {
+379 -246
View File
@@ -8,11 +8,25 @@ import {
antdThemeConfig,
currentTheme,
theme as themeState,
toggleTheme,
toggleUltra,
pauseAnimationsUntilLeave,
} from '@/composables/useTheme.js';
import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
const { t } = useI18n();
const fetched = ref(false);
const submitting = ref(false);
const twoFactorEnable = ref(false);
const user = reactive({
username: '',
password: '',
twoFactorCode: '',
});
const basePath = window.X_UI_BASE_PATH || '';
const headlineWords = computed(() => [t('pages.login.hello'), t('pages.login.title')]);
const HEADLINE_INTERVAL_MS = 2000;
const headlineIndex = ref(0);
@@ -28,23 +42,9 @@ onBeforeUnmount(() => {
if (headlineTimer != null) window.clearInterval(headlineTimer);
});
const fetched = ref(false);
const submitting = ref(false);
const twoFactorEnable = ref(false);
const user = reactive({
username: '',
password: '',
twoFactorCode: '',
});
const basePath = window.__X_UI_BASE_PATH__ || '';
onMounted(async () => {
const msg = await HttpUtil.post('/getTwoFactorEnable');
if (msg.success) {
twoFactorEnable.value = !!msg.obj;
}
if (msg.success) twoFactorEnable.value = !!msg.obj;
fetched.value = true;
});
@@ -52,9 +52,7 @@ async function login() {
submitting.value = true;
try {
const msg = await HttpUtil.post('/login', user);
if (msg.success) {
window.location.href = basePath + 'panel/';
}
if (msg.success) window.location.href = basePath + 'panel/';
} finally {
submitting.value = false;
}
@@ -64,108 +62,123 @@ const lang = ref(LanguageManager.getLanguage());
function onLangChange(next) {
LanguageManager.setLanguage(next);
}
/* Same Light -> Dark -> Ultra Dark -> Light cycle the sidebar's brand
* button uses, so the login chrome offers a one-click theme toggle
* without the popover ceremony. */
function cycleTheme() {
pauseAnimationsUntilLeave('login-theme-cycle');
if (!themeState.isDark) {
toggleTheme();
if (themeState.isUltra) toggleUltra();
} else if (!themeState.isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
}
</script>
<template>
<a-config-provider :theme="antdThemeConfig">
<a-layout class="login-app" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
<a-layout-content class="login-content">
<div class="waves-header">
<div class="waves-inner-header"></div>
<svg class="waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 24 150 28" preserveAspectRatio="none" shape-rendering="auto">
<defs>
<path id="gentle-wave" d="M-160 44c30 0 58-18 88-18s 58 18 88 18 58-18 88-18 58 18 88 18 v44h-352z" />
</defs>
<g class="parallax">
<use xlink:href="#gentle-wave" x="48" y="0" />
<use xlink:href="#gentle-wave" x="48" y="3" />
<use xlink:href="#gentle-wave" x="48" y="5" />
<use xlink:href="#gentle-wave" x="48" y="7" />
</g>
</svg>
<!-- Floating chrome at top-right: theme cycle (Light/Dark/Ultra)
plus a language picker hidden behind the gear popover. -->
<div class="login-toolbar">
<button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
@click="cycleTheme">
<svg v-if="!themeState.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="!themeState.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>
<a-popover :overlay-class-name="currentTheme" :title="t('pages.settings.language')" placement="bottomRight"
trigger="click">
<template #content>
<a-space direction="vertical" :size="10" class="settings-popover">
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value">
<span :aria-label="l.name">{{ l.icon }}</span>
&nbsp;&nbsp;<span>{{ l.name }}</span>
</a-select-option>
</a-select>
</a-space>
</template>
<a-button shape="circle" class="toolbar-btn" :aria-label="t('menu.settings')">
<template #icon>
<SettingOutlined />
</template>
</a-button>
</a-popover>
</div>
<a-row type="flex" justify="center" align="middle" class="login-row">
<a-col class="login-card">
<div v-if="!fetched" class="login-loading">
<a-spin size="large" />
</div>
<div class="login-wrapper">
<div v-if="!fetched" class="login-loading">
<a-spin size="large" />
</div>
<div v-else>
<div class="login-settings">
<a-popover :overlay-class-name="currentTheme" :title="t('menu.settings')" placement="bottomRight"
trigger="click">
<template #content>
<a-space direction="vertical" :size="10" class="settings-popover">
<ThemeSwitchLogin />
<span>{{ t('pages.settings.language') }}</span>
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
:value="l.value">
<span :aria-label="l.name">{{ l.icon }}</span>
&nbsp;&nbsp;<span>{{ l.name }}</span>
</a-select-option>
</a-select>
</a-space>
<div v-else class="login-card">
<div class="brand">
<span class="brand-name">3X-UI</span>
<span class="brand-accent" aria-hidden="true"></span>
</div>
<h2 class="welcome">
<Transition name="headline" mode="out-in">
<b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
</Transition>
</h2>
<a-form layout="vertical" class="login-form" @submit.prevent="login">
<a-form-item :label="t('username')">
<a-input v-model:value="user.username" autocomplete="username" name="username" size="large"
:placeholder="t('username')" autofocus required>
<template #prefix>
<UserOutlined />
</template>
<a-button shape="circle">
<template #icon>
<SettingOutlined />
</template>
</a-button>
</a-popover>
</div>
</a-input>
</a-form-item>
<a-row justify="center">
<a-col :span="24">
<h2 class="login-title">
<Transition name="headline" mode="out-in">
<b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
</Transition>
</h2>
</a-col>
</a-row>
<a-form-item :label="t('password')">
<a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
size="large" :placeholder="t('password')" required>
<template #prefix>
<LockOutlined />
</template>
</a-input-password>
</a-form-item>
<a-form layout="vertical" @submit.prevent="login">
<a-form-item>
<a-input v-model:value="user.username" autocomplete="username" name="username"
:placeholder="t('username')" autofocus required>
<template #prefix>
<UserOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item v-if="twoFactorEnable" :label="t('twoFactorCode')">
<a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
size="large" :placeholder="t('twoFactorCode')" required>
<template #prefix>
<KeyOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item>
<a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
:placeholder="t('password')" required>
<template #prefix>
<LockOutlined />
</template>
</a-input-password>
</a-form-item>
<a-form-item class="submit-row">
<a-button type="primary" html-type="submit" :loading="submitting" size="large" block>
{{ submitting ? '' : t('login') }}
</a-button>
</a-form-item>
</a-form>
<a-form-item v-if="twoFactorEnable">
<a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
:placeholder="t('twoFactorCode')" required>
<template #prefix>
<KeyOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item>
<a-row justify="center">
<a-button type="primary" html-type="submit" :loading="submitting" block>
{{ submitting ? '' : t('login') }}
</a-button>
</a-row>
</a-form-item>
</a-form>
</div>
</a-col>
</a-row>
</div>
</div>
</a-layout-content>
</a-layout>
</a-config-provider>
@@ -173,59 +186,293 @@ function onLangChange(next) {
<style scoped>
.login-app {
--bg-page: #c7ebe2;
--bg-wave-header: #dbf5ed;
--bg-page: #f5f7fa;
--bg-card: #ffffff;
--color-title: #008771;
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.09);
--wave-fill: rgba(0, 135, 113, 0.12);
--wave-fill-bottom: #c7ebe2;
--color-text: rgba(0, 0, 0, 0.88);
--color-text-subtle: rgba(0, 0, 0, 0.55);
--color-accent: #1677ff;
--color-border: rgba(0, 0, 0, 0.08);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
--blob-1: rgba(99, 102, 241, 0.45);
--blob-2: rgba(236, 72, 153, 0.38);
--blob-3: rgba(20, 184, 166, 0.32);
position: relative;
min-height: 100vh;
overflow: hidden;
background: var(--bg-page);
}
.login-app.is-dark {
--bg-page: #222d42;
--bg-wave-header: #0a1222;
--bg-card: #151f31;
--color-title: rgba(255, 255, 255, 0.92);
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.45);
--wave-fill: #222d42;
--wave-fill-bottom: #222d42;
--bg-page: #1e1e1e;
--bg-card: #252526;
--color-text: rgba(255, 255, 255, 0.92);
--color-text-subtle: rgba(255, 255, 255, 0.55);
--color-accent: #4096ff;
--color-border: rgba(255, 255, 255, 0.08);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 8px 32px rgba(0, 0, 0, 0.4);
--blob-1: rgba(64, 150, 255, 0.40);
--blob-2: rgba(168, 85, 247, 0.34);
--blob-3: rgba(34, 211, 238, 0.22);
}
.login-app.is-dark.is-ultra {
--bg-page: #0f2d32;
--bg-wave-header: #0a2227;
--bg-card: #0c0e12;
--wave-fill: #1f4d52;
--wave-fill-bottom: #0f2d32;
--bg-page: #000;
--bg-card: #141414;
--color-border: rgba(255, 255, 255, 0.06);
--blob-1: rgba(64, 150, 255, 0.22);
--blob-2: rgba(168, 85, 247, 0.18);
--blob-3: rgba(34, 211, 238, 0.12);
}
/* Three blurred blobs slowly drift across the page; ::before and
* ::after carry two of them, the third lives on .login-content::before
* so we can animate it independently. */
.login-app::before,
.login-app::after {
content: '';
position: absolute;
width: 70vw;
height: 70vw;
max-width: 900px;
max-height: 900px;
border-radius: 50%;
filter: blur(90px);
pointer-events: none;
z-index: 0;
will-change: transform;
}
.login-app::before {
top: -25vw;
left: -20vw;
background: radial-gradient(circle, var(--blob-1) 0%, transparent 65%);
animation: blob-drift-a 24s ease-in-out infinite alternate;
}
.login-app::after {
bottom: -25vw;
right: -20vw;
background: radial-gradient(circle, var(--blob-2) 0%, transparent 65%);
animation: blob-drift-b 30s ease-in-out infinite alternate;
}
.login-content::before {
content: '';
position: absolute;
top: 30%;
left: 50%;
width: 50vw;
height: 50vw;
max-width: 700px;
max-height: 700px;
border-radius: 50%;
background: radial-gradient(circle, var(--blob-3) 0%, transparent 65%);
filter: blur(90px);
pointer-events: none;
z-index: 0;
will-change: transform;
animation: blob-drift-c 36s ease-in-out infinite alternate;
}
@keyframes blob-drift-a {
0% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(18vw, 10vh) scale(1.15);
}
100% {
transform: translate(34vw, 22vh) scale(1.25);
}
}
@keyframes blob-drift-b {
0% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(-16vw, -10vh) scale(1.12);
}
100% {
transform: translate(-30vw, -22vh) scale(1.2);
}
}
@keyframes blob-drift-c {
0% {
transform: translate(-50%, -50%) scale(1);
}
50% {
transform: translate(-20%, -20%) scale(1.1);
}
100% {
transform: translate(-80%, -10%) scale(1.05);
}
}
@media (prefers-reduced-motion: reduce) {
.login-app::before,
.login-app::after,
.login-content::before {
animation: none;
}
}
.login-app,
.login-app :deep(.ant-layout-content) {
background: transparent;
}
.login-app {
background: var(--bg-page);
.login-content {
position: relative;
}
.login-content>* {
position: relative;
z-index: 1;
}
.login-toolbar {
position: fixed;
top: 16px;
right: 16px;
z-index: 10;
display: inline-flex;
align-items: center;
gap: 8px;
}
.toolbar-btn {
width: 40px;
height: 40px;
}
.theme-cycle {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--bg-card);
color: var(--color-text);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 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.05);
outline: none;
}
.theme-cycle svg {
width: 18px;
height: 18px;
}
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.login-loading {
text-align: center;
}
.login-card {
width: 100%;
max-width: 400px;
background: var(--bg-card);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 40px 32px 28px;
box-shadow: var(--shadow-card);
}
.login-title {
color: var(--color-title);
@media (max-width: 480px) {
.login-card {
padding: 32px 20px 24px;
}
}
.login-settings {
.brand {
display: flex;
justify-content: flex-end;
flex-direction: column;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.brand-name {
font-size: 28px;
font-weight: 700;
letter-spacing: 1.5px;
color: var(--color-text);
}
.brand-accent {
display: block;
width: 40px;
height: 3px;
border-radius: 2px;
background: var(--color-accent);
}
.welcome {
text-align: center;
color: var(--color-text);
font-size: 32px;
font-weight: 700;
line-height: 1.2;
min-height: 42px;
margin: 12px 0 28px;
letter-spacing: 0.3px;
}
.welcome b {
display: inline-block;
font-weight: inherit;
}
.headline-enter-active,
.headline-leave-active {
transition: opacity 280ms ease, transform 280ms ease;
}
.headline-enter-from {
opacity: 0;
transform: translateY(6px);
}
.headline-leave-to {
opacity: 0;
transform: translateY(-6px);
}
.login-form :deep(.ant-form-item-label > label) {
color: var(--color-text);
font-weight: 500;
}
.submit-row {
margin-bottom: 0;
}
.settings-popover {
min-width: 220px;
}
@@ -233,118 +480,4 @@ function onLangChange(next) {
.lang-select {
width: 100%;
}
.login-content {
position: relative;
}
.login-row {
position: relative;
z-index: 1;
min-height: 100vh;
padding: 24px 0;
}
.login-card {
width: clamp(280px, 90vw, 300px);
border-radius: 2rem;
padding: clamp(2rem, 5vw, 4rem) 1.5rem;
transition: background 0.3s, box-shadow 0.3s;
}
.login-loading {
text-align: center;
padding: 40px 0;
}
.login-title {
text-align: center;
margin-bottom: 32px;
font-size: 2rem;
font-weight: 500;
min-height: 2.5rem;
}
.login-title b {
display: inline-block;
}
.headline-enter-active,
.headline-leave-active {
transition: opacity 0.4s ease, transform 0.4s ease;
}
.headline-enter-from {
opacity: 0;
transform: translateY(-12px);
}
.headline-leave-to {
opacity: 0;
transform: translateY(12px);
}
.waves-header {
position: fixed;
inset: 0 0 auto 0;
width: 100%;
z-index: 0;
pointer-events: none;
background: var(--bg-wave-header);
}
.waves-inner-header {
height: 50vh;
width: 100%;
}
.waves {
position: relative;
display: block;
width: 100%;
height: 15vh;
min-height: 100px;
max-height: 150px;
margin-bottom: -8px;
}
.parallax>use {
fill: var(--wave-fill);
animation: move-forever 25s cubic-bezier(0.55, 0.5, 0.45, 0.5) infinite;
}
.parallax>use:nth-child(1) {
animation-delay: -2s;
animation-duration: 4s;
opacity: 0.2;
}
.parallax>use:nth-child(2) {
animation-delay: -3s;
animation-duration: 7s;
opacity: 0.4;
}
.parallax>use:nth-child(3) {
animation-delay: -4s;
animation-duration: 10s;
opacity: 0.6;
}
.parallax>use:nth-child(4) {
animation-delay: -5s;
animation-duration: 13s;
fill: var(--wave-fill-bottom);
opacity: 1;
}
@keyframes move-forever {
0% {
transform: translate3d(-90px, 0, 0);
}
100% {
transform: translate3d(85px, 0, 0);
}
}
</style>
+3 -3
View File
@@ -39,7 +39,7 @@ useWebSocket({ nodes: applyNodesEvent });
const { isMobile } = useMediaQuery();
const basePath = window.__X_UI_BASE_PATH__ || '';
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
// === Form modal state =================================================
@@ -172,8 +172,8 @@ async function onToggleEnable(node, next) {
}
.nodes-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.nodes-page.is-dark.is-ultra {
+1 -1
View File
@@ -54,7 +54,7 @@ async function sendUpdateUser() {
if (msg?.success) {
// Force re-login at the standard logout path; basePath is handled
// by the Go router so a relative redirect is correct here.
const basePath = window.__X_UI_BASE_PATH__ || '';
const basePath = window.X_UI_BASE_PATH || '';
window.location.replace(`${basePath}logout`);
}
} finally {
+3 -3
View File
@@ -26,7 +26,7 @@ const { t } = useI18n();
const { fetched, spinning, saveDisabled, allSetting, saveAll } = useAllSetting();
const { isMobile } = useMediaQuery();
const basePath = window.__X_UI_BASE_PATH__ || '';
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
// AD-Vue 4's <a-back-top> calls `target()` after mount to find the
@@ -256,8 +256,8 @@ const alertVisible = ref(true);
}
.settings-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.settings-page.is-dark.is-ultra {
+9 -70
View File
@@ -1,24 +1,13 @@
<script setup>
import { nextTick, ref, watch } from 'vue';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import * as OTPAuth from 'otpauth';
import QRious from 'qrious';
import { ClipboardManager } from '@/utils';
const { t } = useI18n();
// Two flavors of this modal:
// • type='set' shows a QR code + manual key + a 6-digit verifier
// (used when enabling 2FA the first time);
// • type='confirm' shows just the 6-digit verifier (used when
// toggling 2FA off and when changing the admin user/password).
//
// Either way the parent supplies a `confirm(success: boolean)`
// callback — we run it with `true` only if the entered code matches
// the live TOTP value, otherwise `false`.
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: '' },
@@ -30,29 +19,10 @@ const props = defineProps({
const emit = defineEmits(['update:open', 'confirm']);
const enteredCode = ref('');
const qrCanvas = ref(null);
const qrValue = ref('');
let totp = null;
// Byte-mode capacities (level L) for QR versions 1..40 — used to pick
// the matrix width up front so the canvas size is an exact multiple of
// pixelSize. Without this, QRious renders at floor(size/matrix) and
// centers, leaving a white margin around the QR.
const QR_L_BYTE_CAPACITY = [
17, 32, 53, 78, 106, 134, 154, 192, 230, 271,
321, 367, 425, 458, 520, 586, 644, 718, 792, 858,
929, 1003, 1091, 1171, 1273, 1367, 1465, 1528, 1628, 1732,
1840, 1952, 2068, 2188, 2303, 2431, 2563, 2699, 2809, 2953,
];
function pickQrMatrixWidth(value) {
const byteLen = new TextEncoder().encode(value).length;
for (let i = 0; i < QR_L_BYTE_CAPACITY.length; i++) {
if (byteLen <= QR_L_BYTE_CAPACITY[i]) return 17 + 4 * (i + 1);
}
return 17 + 4 * 40;
}
function buildTotp() {
totp = new OTPAuth.TOTP({
issuer: '3x-ui',
@@ -62,25 +32,7 @@ function buildTotp() {
period: 30,
secret: props.token,
});
}
async function paintQr() {
await nextTick();
if (!qrCanvas.value || !totp) return;
const value = totp.toString();
const matrixWidth = pickQrMatrixWidth(value);
const pixelSize = Math.max(1, Math.floor(200 / matrixWidth));
const exactSize = matrixWidth * pixelSize;
new QRious({
element: qrCanvas.value,
size: exactSize,
value,
background: 'white',
backgroundAlpha: 1,
foreground: 'black',
padding: 0,
level: 'L',
});
qrValue.value = totp.toString();
}
watch(() => props.open, (next) => {
@@ -88,7 +40,6 @@ watch(() => props.open, (next) => {
enteredCode.value = '';
if (props.token) {
buildTotp();
if (props.type === 'set') paintQr();
}
});
@@ -124,9 +75,8 @@ async function copyToken() {
<a-divider />
<p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
<div class="qr-wrap">
<div class="qr-bg">
<canvas ref="qrCanvas" class="qr-cv" @click="copyToken" />
</div>
<a-qrcode class="qr-code" :value="qrValue" :size="180" type="svg" :bordered="false"
error-level="L" :title="t('copy')" @click="copyToken" />
<span class="qr-token">{{ token }}</span>
</div>
<a-divider />
@@ -154,22 +104,11 @@ async function copyToken() {
gap: 12px;
}
.qr-bg {
width: 180px;
height: 180px;
background: #fff;
padding: 4px;
border-radius: 6px;
}
.qr-cv {
.qr-code {
cursor: pointer;
width: 100% !important;
height: 100% !important;
/* Drawing buffer is matrix-snapped (smaller than display size); scale
* up crisply so the QR fills the box without blurring. */
image-rendering: pixelated;
image-rendering: crisp-edges;
padding: 0 !important;
background: #fff;
border-radius: 6px;
}
.qr-token {
+116 -53
View File
@@ -9,14 +9,15 @@ import {
CopyOutlined,
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import QRious from 'qrious';
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
import {
theme as themeState,
antdThemeConfig,
toggleTheme,
toggleUltra,
pauseAnimationsUntilLeave,
} from '@/composables/useTheme.js';
import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
const { t } = useI18n();
@@ -38,6 +39,8 @@ const lastOnlineMs = Number(subData.lastOnline || 0);
const subUrl = subData.subUrl || '';
const subJsonUrl = subData.subJsonUrl || '';
const subClashUrl = subData.subClashUrl || '';
const subTitle = subData.subTitle || '';
const subSupportUrl = subData.subSupportUrl || '';
const links = Array.isArray(subData.links) ? subData.links : [];
// Panel's "Calendar Type" setting; controls whether expiry / lastOnline
// render in Gregorian or Jalali on this standalone subscription page.
@@ -71,32 +74,23 @@ function onLangChange(next) {
LanguageManager.setLanguage(next);
}
// QR code rendering ===========================================
// Each ref points at a canvas element we paint after mount; QRious
// sizes itself from the element's `size` attribute.
const subQr = ref(null);
const subJsonQr = ref(null);
const subClashQr = ref(null);
function paintQr(canvas, value) {
if (!canvas || !value) return;
new QRious({
element: canvas,
size: 220,
value,
background: 'white',
backgroundAlpha: 1,
foreground: 'black',
padding: 4,
level: 'M',
});
/* Same Light -> Dark -> Ultra Dark -> Light cycle the panel sidebar
* uses, so the standalone subscription page offers a one-click theme
* toggle without the popover ceremony. */
function cycleTheme() {
pauseAnimationsUntilLeave('sub-theme-cycle');
if (!themeState.isDark) {
toggleTheme();
if (themeState.isUltra) toggleUltra();
} else if (!themeState.isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
}
onMounted(() => {
paintQr(subQr.value, subUrl);
paintQr(subJsonQr.value, subJsonUrl);
paintQr(subClashQr.value, subClashUrl);
});
const QR_SIZE = 240;
// Actions =====================================================
async function copy(value) {
@@ -128,7 +122,14 @@ function linkName(link, idx) {
// iOS deep links — taken verbatim from the legacy subpage. Each
// client expects the sub URL in a slightly different param name.
const shadowrocketUrl = computed(() => `sub://${btoa(subUrl)}`);
const shadowrocketUrl = computed(() => {
if (!subUrl) return '';
const separator = subUrl.includes('?') ? '&' : '?';
const rawUrl = subUrl + separator + 'flag=shadowrocket';
const base64Url = encodeURIComponent(btoa(rawUrl));
const remark = encodeURIComponent(subTitle || sId || 'Subscription');
return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
});
const v2boxUrl = computed(() => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`);
const streisandUrl = computed(() => `streisand://import/${encodeURIComponent(subUrl)}`);
const v2raytunUrl = computed(() => subUrl);
@@ -157,26 +158,45 @@ const themeClass = computed(() => ({
</a-space>
</template>
<template #extra>
<a-popover :title="t('menu.settings')" placement="bottomRight" trigger="click">
<template #content>
<a-space direction="vertical" :size="10" class="settings-popover">
<ThemeSwitchLogin />
<span>{{ t('pages.settings.language') }}</span>
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
:value="l.value">
<span :aria-label="l.name">{{ l.icon }}</span>
&nbsp;&nbsp;<span>{{ l.name }}</span>
</a-select-option>
</a-select>
</a-space>
</template>
<a-button shape="circle">
<template #icon>
<SettingOutlined />
<a-space :size="8" align="center">
<button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
@click="cycleTheme">
<svg v-if="!themeState.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="!themeState.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>
<a-popover :title="t('pages.settings.language')" placement="bottomRight" trigger="click">
<template #content>
<a-space direction="vertical" :size="10" class="settings-popover">
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
:value="l.value">
<span :aria-label="l.name">{{ l.icon }}</span>
&nbsp;&nbsp;<span>{{ l.name }}</span>
</a-select-option>
</a-select>
</a-space>
</template>
</a-button>
</a-popover>
<a-button shape="circle">
<template #icon>
<SettingOutlined />
</template>
</a-button>
</a-popover>
</a-space>
</template>
<!-- ============== QR codes ============== -->
@@ -184,7 +204,8 @@ const themeClass = computed(() => ({
<a-col :xs="24" :sm="subJsonUrl || subClashUrl ? 12 : 24" class="qr-col">
<div class="qr-box">
<a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
<canvas ref="subQr" class="qr-canvas" :title="t('copy')" @click="copy(subUrl)" />
<a-qrcode class="qr-code" :value="subUrl" :size="QR_SIZE" type="svg" :bordered="false"
:title="t('copy')" @click="copy(subUrl)" />
</div>
</a-col>
<a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
@@ -192,13 +213,15 @@ const themeClass = computed(() => ({
<a-tag color="purple" class="qr-tag">
{{ t('pages.settings.subSettings') }} JSON
</a-tag>
<canvas ref="subJsonQr" class="qr-canvas" :title="t('copy')" @click="copy(subJsonUrl)" />
<a-qrcode class="qr-code" :value="subJsonUrl" :size="QR_SIZE" type="svg" :bordered="false"
:title="t('copy')" @click="copy(subJsonUrl)" />
</div>
</a-col>
<a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
<div class="qr-box">
<a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
<canvas ref="subClashQr" class="qr-canvas" :title="t('copy')" @click="copy(subClashUrl)" />
<a-qrcode class="qr-code" :value="subClashUrl" :size="QR_SIZE" type="svg" :bordered="false"
:title="t('copy')" @click="copy(subClashUrl)" />
</div>
</a-col>
</a-row>
@@ -299,8 +322,8 @@ const themeClass = computed(() => ({
}
.subscription-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.subscription-page.is-dark.is-ultra {
@@ -336,7 +359,7 @@ const themeClass = computed(() => ({
flex-direction: column;
align-items: center;
gap: 4px;
width: 220px;
width: 240px;
}
.qr-tag {
@@ -345,8 +368,9 @@ const themeClass = computed(() => ({
margin: 0;
}
.qr-canvas {
.qr-code {
cursor: pointer;
padding: 0 !important;
background: #fff;
border-radius: 4px;
}
@@ -463,6 +487,45 @@ const themeClass = computed(() => ({
min-width: 220px;
}
.theme-cycle {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.08);
background: var(--bg-card);
color: rgba(0, 0, 0, 0.65);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 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.05);
outline: none;
}
.theme-cycle svg {
width: 16px;
height: 16px;
}
.is-dark .theme-cycle {
border-color: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
}
.is-dark .theme-cycle:hover,
.is-dark .theme-cycle:focus-visible {
background-color: rgba(64, 150, 255, 0.1);
color: #4096ff;
}
.lang-select {
width: 100%;
}
+124 -2
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
@@ -30,6 +30,30 @@ const STRATEGY_LABELS = {
leastPing: 'Least ping',
};
// Observatory defaults — values that the legacy panel seeded when a
// leastPing balancer first appeared. ProbeURL / interval follow Xray's
// own docs (https://xtls.github.io/config/observatory.html).
const DEFAULT_OBSERVATORY = Object.freeze({
subjectSelector: [],
probeURL: 'https://www.google.com/generate_204',
probeInterval: '1m',
enableConcurrency: true,
});
// BurstObservatory defaults — seeded when a leastLoad balancer is
// configured. Hicloud's generate_204 is the same connectivity probe
// the legacy panel used (https://xtls.github.io/config/burstobservatory.html).
const DEFAULT_BURST_OBSERVATORY = Object.freeze({
subjectSelector: [],
pingConfig: {
destination: 'https://www.google.com/generate_204',
interval: '1m',
connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
timeout: '5s',
sampling: 2,
},
});
const rows = computed(() => {
const list = props.templateSettings?.routing?.balancers || [];
return list.map((b, idx) => ({
@@ -83,6 +107,41 @@ function ensureBalancersArray() {
return props.templateSettings.routing.balancers;
}
// Keep observatory / burstObservatory in sync with the configured
// balancers. leastPing balancers feed Observatory's subjectSelector;
// leastLoad balancers feed BurstObservatory's. When the matching
// strategy disappears we drop the observatory entirely so the rendered
// xray config stays minimal.
function collectSelectors(list) {
const out = new Set();
list.forEach((b) => (b.selector || []).forEach((s) => s && out.add(s)));
return [...out];
}
function syncObservatories() {
const t = props.templateSettings;
if (!t) return;
const balancers = t.routing?.balancers || [];
const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
if (leastPings.length > 0) {
if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
t.observatory.subjectSelector = collectSelectors(leastPings);
} else {
delete t.observatory;
}
const leastLoads = balancers.filter((b) => b.strategy?.type === 'leastLoad');
if (leastLoads.length > 0) {
if (!t.burstObservatory) {
t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
}
t.burstObservatory.subjectSelector = collectSelectors(leastLoads);
} else {
delete t.burstObservatory;
}
}
function buildWireBalancer(form) {
const out = {
tag: form.tag,
@@ -115,6 +174,7 @@ function onConfirm(form) {
}
}
}
syncObservatories();
modalOpen.value = false;
}
@@ -128,7 +188,10 @@ function confirmDelete(idx) {
// 4 leaves the modal open if onOk returns a truthy non-thenable
// (it expects a Promise to await), and splice() returns the array
// of removed items.
onOk: () => { props.templateSettings.routing.balancers.splice(idx, 1); },
onOk: () => {
props.templateSettings.routing.balancers.splice(idx, 1);
syncObservatories();
},
});
}
@@ -139,6 +202,49 @@ const columns = computed(() => [
{ title: 'Selector', key: 'selector', align: 'center' },
{ title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
]);
// === Observatory / BurstObservatory inline editor ====================
// The legacy panel surfaced both top-level observatory blocks here as a
// raw JSON editor so admins could tune probeURL / interval / sampling
// without having to drop into the full xray template tab. We keep that
// affordance but only render it when the matching observatory exists —
// which is itself driven by syncObservatories() above.
const hasObservatory = computed(() => !!props.templateSettings?.observatory);
const hasBurstObservatory = computed(() => !!props.templateSettings?.burstObservatory);
const showObsEditor = computed(() => hasObservatory.value || hasBurstObservatory.value);
const obsView = ref('observatory');
// Keep the radio selection valid as observatories appear/disappear —
// e.g. deleting the last leastPing balancer should flip the editor to
// the burstObservatory pane instead of leaving it pointing at the
// (now-removed) observatory key.
watch(showObsEditor, () => {
if (obsView.value === 'observatory' && !hasObservatory.value && hasBurstObservatory.value) {
obsView.value = 'burstObservatory';
} else if (obsView.value === 'burstObservatory' && !hasBurstObservatory.value && hasObservatory.value) {
obsView.value = 'observatory';
}
}, { immediate: true });
const obsText = computed({
get: () => {
const t = props.templateSettings;
if (!t) return '';
const src = obsView.value === 'observatory' ? t.observatory : t.burstObservatory;
return src ? JSON.stringify(src, null, 2) : '';
},
set: (next) => {
let parsed;
try { parsed = JSON.parse(next); } catch (_e) { return; }
if (!props.templateSettings) return;
if (obsView.value === 'observatory') {
props.templateSettings.observatory = parsed;
} else {
props.templateSettings.burstObservatory = parsed;
}
},
});
</script>
<template>
@@ -192,6 +298,16 @@ const columns = computed(() => [
</template>
</template>
</a-table>
<template v-if="showObsEditor">
<a-divider :style="{ margin: '8px 0' }" />
<a-radio-group v-model:value="obsView" button-style="solid" size="small">
<a-radio-button v-if="hasObservatory" value="observatory">Observatory</a-radio-button>
<a-radio-button v-if="hasBurstObservatory" value="burstObservatory">Burst Observatory</a-radio-button>
</a-radio-group>
<a-textarea v-model:value="obsText" :auto-size="{ minRows: 8, maxRows: 24 }" spellcheck="false"
class="json-editor" />
</template>
</template>
<BalancerFormModal v-model:open="modalOpen" :balancer="editingBalancer" :outbound-tags="outboundTags"
@@ -213,4 +329,10 @@ const columns = computed(() => [
.danger {
color: #ff4d4f;
}
.json-editor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
margin-top: 8px;
}
</style>
+25 -4
View File
@@ -22,6 +22,14 @@ const props = defineProps({
const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
const dnsFieldOmit = Object.freeze(Object.create(null));
function dnsValueEmptyForOmit(v) {
if (v === undefined || v === null) return true;
if (typeof v === 'string') return v.trim() === '';
return false;
}
const enableDNS = computed({
get: () => !!props.templateSettings?.dns,
set: (next) => {
@@ -29,7 +37,6 @@ const enableDNS = computed({
if (next) {
props.templateSettings.dns = {
tag: 'dns_inbound',
clientIp: '',
queryStrategy: 'UseIP',
disableCache: false,
disableFallback: false,
@@ -50,16 +57,30 @@ const enableDNS = computed({
});
function dnsField(field, fallback) {
const omitWhenUnset = fallback === dnsFieldOmit;
return computed({
get: () => props.templateSettings?.dns?.[field] ?? fallback,
get: () => {
const raw = props.templateSettings?.dns?.[field];
if (fallback === dnsFieldOmit) return raw ?? '';
return raw ?? fallback;
},
set: (v) => {
if (props.templateSettings?.dns) props.templateSettings.dns[field] = v;
if (!props.templateSettings?.dns) return;
if (omitWhenUnset) {
if (dnsValueEmptyForOmit(v)) {
if (field in props.templateSettings.dns) delete props.templateSettings.dns[field];
} else {
props.templateSettings.dns[field] = v;
}
} else {
props.templateSettings.dns[field] = v;
}
},
});
}
const dnsTag = dnsField('tag', 'dns_inbound');
const dnsClientIp = dnsField('clientIp', '');
const dnsClientIp = dnsField('clientIp', dnsFieldOmit);
const dnsStrategy = dnsField('queryStrategy', 'UseIP');
const dnsDisableCache = dnsField('disableCache', false);
const dnsDisableFallback = dnsField('disableFallback', false);
+31 -3
View File
@@ -222,6 +222,12 @@ function resetOutbound() {
}
function close() { emit('update:open', false); }
function loadColor(load) {
if (load < 30) return 'green';
if (load < 70) return 'orange';
return 'red';
}
</script>
<template>
@@ -299,9 +305,13 @@ function close() { emit('update:open', false); }
</a-form-item>
<a-form-item v-if="filteredServers.length > 0" label="Server">
<a-select v-model:value="serverId">
<a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id">
{{ s.cityName }} - {{ s.name }} (load: {{ s.load }}%)
<a-select v-model:value="serverId" show-search option-filter-prop="label">
<a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id"
:label="`${s.cityName} ${s.name} ${s.hostname}`">
<span class="server-row">
<span class="server-name">{{ s.cityName }} - {{ s.name }}</span>
<a-tag :color="loadColor(s.load)" class="server-load-tag">{{ s.load }}%</a-tag>
</span>
</a-select-option>
</a-select>
</a-form-item>
@@ -376,4 +386,22 @@ function close() { emit('update:open', false); }
.ml-8 {
margin-left: 8px;
}
.server-row {
display: inline-flex;
align-items: center;
gap: 8px;
width: 100%;
}
.server-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.server-load-tag {
margin-right: 0;
flex-shrink: 0;
}
</style>
@@ -34,7 +34,6 @@ const props = defineProps({
open: { type: Boolean, default: false },
outbound: { type: Object, default: null },
existingTags: { type: Array, default: () => [] },
inboundTags: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:open', 'confirm']);
@@ -318,10 +317,8 @@ function regenerateWgKeys() {
<!-- ============== Loopback ============== -->
<template v-if="isLoopback">
<a-form-item label="Inbound tag">
<a-auto-complete v-model:value="outbound.settings.inboundTag"
:options="inboundTags.map((tag) => ({ value: tag }))"
:filter-option="(input, option) => option.value.toLowerCase().includes(input.toLowerCase())"
placeholder="tag of an existing inbound to re-route into" />
<a-input v-model:value="outbound.settings.inboundTag"
placeholder="inbound tag using in routing rules" />
</a-form-item>
</template>
+166 -45
View File
@@ -16,6 +16,7 @@ import {
LoadingOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
PlayCircleOutlined,
} from '@ant-design/icons-vue';
import { Modal } from 'ant-design-vue';
@@ -25,30 +26,18 @@ import OutboundFormModal from './OutboundFormModal.vue';
const { t } = useI18n();
// Outbounds tab — list + actions over templateSettings.outbounds.
// Mirrors the legacy outbound table layout (identity / address /
// traffic / test result / test button) plus the row action menu
// (set first / edit / reset traffic / delete). Mobile collapses to
// a card list.
const props = defineProps({
templateSettings: { type: Object, default: null },
outboundsTraffic: { type: Array, default: () => [] },
outboundTestStates: { type: Object, default: () => ({}) },
testingAll: { type: Boolean, default: false },
inboundTags: { type: Array, default: () => [] },
isMobile: { type: Boolean, default: false },
});
const inboundTagOptions = computed(() => {
const out = new Set();
for (const ib of props.templateSettings?.inbounds || []) {
if (ib.tag) out.add(ib.tag);
}
for (const t of props.inboundTags || []) out.add(t);
return [...out];
});
const emit = defineEmits(['reset-traffic', 'test', 'test-all', 'show-warp', 'show-nord', 'delete']);
const emit = defineEmits(['reset-traffic', 'test', 'show-warp', 'show-nord', 'delete']);
const testMode = ref('tcp');
// === Modal state ====================================================
const modalOpen = ref(false);
@@ -141,10 +130,13 @@ function outboundAddresses(o) {
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
}
function isUntestable(o) {
return o.protocol === Protocols.Blackhole
function isUntestable(o, mode = testMode.value) {
if (!o) return true;
if (o.protocol === Protocols.Blackhole
|| o.protocol === Protocols.Loopback
|| o.tag === 'blocked';
|| o.tag === 'blocked') return true;
if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true;
return false;
}
function isTesting(idx) {
return !!props.outboundTestStates?.[idx]?.testing;
@@ -156,6 +148,12 @@ function showSecurity(security) {
return security === 'tls' || security === 'reality';
}
function hasBreakdown(r) {
if (!r) return false;
if (r.endpoints?.length) return true;
return !!(r.ttfbMs || r.tlsMs || r.connectMs || r.dnsMs || r.statusCode || r.error);
}
// === Columns ========================================================
// Computed so titles re-render after a locale swap.
const columns = computed(() => [
@@ -163,7 +161,7 @@ const columns = computed(() => [
{ title: 'Tag', key: 'identity', align: 'left', width: 220 },
{ title: t('pages.inbounds.address'), key: 'address', align: 'left', width: 230 },
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 },
{ title: t('check'), key: 'testResult', align: 'left', width: 140 },
{ title: t('pages.xray.latency') !== 'pages.xray.latency' ? t('pages.xray.latency') : 'Latency', key: 'testResult', align: 'left', width: 140 },
{ title: t('check'), key: 'test', align: 'center', width: 80 },
]);
@@ -177,8 +175,8 @@ const rows = computed(() => {
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
<!-- Toolbar -->
<a-row :gutter="[12, 12]" align="middle" justify="space-between">
<a-col :xs="24" :sm="14">
<a-space size="small">
<a-col :xs="24" :sm="12">
<a-space size="small" wrap>
<a-button type="primary" @click="openAdd">
<template #icon>
<PlusOutlined />
@@ -199,15 +197,29 @@ const rows = computed(() => {
</a-button>
</a-space>
</a-col>
<a-col :xs="24" :sm="10" class="toolbar-right">
<a-popconfirm placement="topRight" :ok-text="t('reset')" :cancel-text="t('cancel')"
:title="t('pages.inbounds.resetAllTrafficContent')" @confirm="emit('reset-traffic', '-alltags-')">
<a-button>
<a-col :xs="24" :sm="12" class="toolbar-right">
<a-space size="small" wrap>
<a-tooltip :title="t('pages.xray.testModeHint') !== 'pages.xray.testModeHint' ? t('pages.xray.testModeHint') : 'TCP: fast dial-only probe. HTTP: full request through xray.'">
<a-radio-group v-model:value="testMode" size="small" button-style="solid">
<a-radio-button value="tcp">TCP</a-radio-button>
<a-radio-button value="http">HTTP</a-radio-button>
</a-radio-group>
</a-tooltip>
<a-button type="primary" :loading="testingAll" @click="emit('test-all', testMode)">
<template #icon>
<RetweetOutlined />
<PlayCircleOutlined />
</template>
<span v-if="!isMobile">{{ t('pages.xray.testAll') !== 'pages.xray.testAll' ? t('pages.xray.testAll') : 'Test all' }}</span>
</a-button>
</a-popconfirm>
<a-popconfirm placement="topRight" :ok-text="t('reset')" :cancel-text="t('cancel')"
:title="t('pages.inbounds.resetAllTrafficContent')" @confirm="emit('reset-traffic', '-alltags-')">
<a-button>
<template #icon>
<RetweetOutlined />
</template>
</a-button>
</a-popconfirm>
</a-space>
</a-col>
</a-row>
@@ -262,15 +274,39 @@ const rows = computed(() => {
<span class="traffic-sep" />
<span class="traffic-down">↓ {{ SizeFormatter.sizeFormat(trafficFor(record).down) }}</span>
<span class="card-test">
<span v-if="testResult(index)" :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
<CheckCircleFilled v-if="testResult(index).success" />
<CloseCircleFilled v-else />
<span v-if="testResult(index).success">{{ testResult(index).delay }}&nbsp;ms</span>
<span v-else>failed</span>
</span>
<a-popover v-if="testResult(index)" placement="topRight"
:overlay-class-name="'outbound-test-popover'">
<template #content>
<div class="timing-breakdown">
<div class="td-head" :class="testResult(index).success ? 'ok' : 'fail'">
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
<span v-else>{{ testResult(index).error || 'failed' }}</span>
<span v-if="testResult(index).mode" class="mode-badge">{{ testResult(index).mode.toUpperCase() }}</span>
</div>
<template v-if="hasBreakdown(testResult(index))">
<div v-if="testResult(index).ttfbMs">TTFB: {{ testResult(index).ttfbMs }} ms</div>
<div v-if="testResult(index).tlsMs">TLS: {{ testResult(index).tlsMs }} ms</div>
<div v-if="testResult(index).connectMs">Connect: {{ testResult(index).connectMs }} ms</div>
<div v-if="testResult(index).dnsMs">DNS: {{ testResult(index).dnsMs }} ms</div>
<div v-if="testResult(index).statusCode">HTTP {{ testResult(index).statusCode }}</div>
<div v-for="ep in testResult(index).endpoints || []" :key="ep.address" class="endpoint-row">
<span :class="ep.success ? 'dot-ok' : 'dot-fail'">●</span>
<span class="ep-addr">{{ ep.address }}</span>
<span class="ep-meta">{{ ep.success ? `${ep.delay} ms` : (ep.error || 'failed') }}</span>
</div>
</template>
</div>
</template>
<span :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
<CheckCircleFilled v-if="testResult(index).success" />
<CloseCircleFilled v-else />
<span v-if="testResult(index).success">{{ testResult(index).delay }}&nbsp;ms</span>
<span v-else>failed</span>
</span>
</a-popover>
<LoadingOutlined v-else-if="isTesting(index)" />
<a-button type="primary" shape="circle" size="small" :loading="isTesting(index)"
:disabled="isUntestable(record) || isTesting(index)" @click="emit('test', index)">
:disabled="isUntestable(record, testMode) || isTesting(index)" @click="emit('test', index, testMode)">
<template #icon>
<ThunderboltOutlined />
</template>
@@ -350,22 +386,44 @@ const rows = computed(() => {
</template>
<template v-else-if="column.key === 'testResult'">
<span v-if="testResult(index)" :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
<CheckCircleFilled v-if="testResult(index).success" />
<CloseCircleFilled v-else />
<span v-if="testResult(index).success">{{ testResult(index).delay }}&nbsp;ms</span>
<a-tooltip v-else :title="testResult(index).error">
<span>failed</span>
</a-tooltip>
</span>
<a-popover v-if="testResult(index)" placement="topLeft"
:overlay-class-name="'outbound-test-popover'">
<template #content>
<div class="timing-breakdown">
<div class="td-head" :class="testResult(index).success ? 'ok' : 'fail'">
<span v-if="testResult(index).success">{{ testResult(index).delay }} ms</span>
<span v-else>{{ testResult(index).error || 'failed' }}</span>
<span v-if="testResult(index).mode" class="mode-badge">{{ testResult(index).mode.toUpperCase() }}</span>
</div>
<template v-if="hasBreakdown(testResult(index))">
<div v-if="testResult(index).ttfbMs">TTFB: {{ testResult(index).ttfbMs }} ms</div>
<div v-if="testResult(index).tlsMs">TLS: {{ testResult(index).tlsMs }} ms</div>
<div v-if="testResult(index).connectMs">Connect: {{ testResult(index).connectMs }} ms</div>
<div v-if="testResult(index).dnsMs">DNS: {{ testResult(index).dnsMs }} ms</div>
<div v-if="testResult(index).statusCode">HTTP {{ testResult(index).statusCode }}</div>
<div v-for="ep in testResult(index).endpoints || []" :key="ep.address" class="endpoint-row">
<span :class="ep.success ? 'dot-ok' : 'dot-fail'">●</span>
<span class="ep-addr">{{ ep.address }}</span>
<span class="ep-meta">{{ ep.success ? `${ep.delay} ms` : (ep.error || 'failed') }}</span>
</div>
</template>
</div>
</template>
<span :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
<CheckCircleFilled v-if="testResult(index).success" />
<CloseCircleFilled v-else />
<span v-if="testResult(index).success">{{ testResult(index).delay }}&nbsp;ms</span>
<span v-else>failed</span>
</span>
</a-popover>
<LoadingOutlined v-else-if="isTesting(index)" />
<span v-else class="empty">—</span>
</template>
<template v-else-if="column.key === 'test'">
<a-tooltip :title="t('check')">
<a-tooltip :title="`${t('check')} (${testMode.toUpperCase()})`">
<a-button type="primary" shape="circle" :loading="isTesting(index)"
:disabled="isUntestable(record) || isTesting(index)" @click="emit('test', index)">
:disabled="isUntestable(record, testMode) || isTesting(index)" @click="emit('test', index, testMode)">
<template #icon>
<ThunderboltOutlined />
</template>
@@ -376,7 +434,7 @@ const rows = computed(() => {
</a-table>
<OutboundFormModal v-model:open="modalOpen" :outbound="editingOutbound" :existing-tags="existingTags"
:inbound-tags="inboundTagOptions" @confirm="onConfirm" />
@confirm="onConfirm" />
</a-space>
</template>
@@ -532,3 +590,66 @@ const rows = computed(() => {
color: #ff4d4f;
}
</style>
<style>
.outbound-test-popover .timing-breakdown {
font-size: 12px;
line-height: 1.6;
min-width: 180px;
max-width: 320px;
}
.outbound-test-popover .td-head {
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.outbound-test-popover .td-head.ok {
color: #008771;
}
.outbound-test-popover .td-head.fail {
color: #e04141;
}
.outbound-test-popover .mode-badge {
font-size: 10px;
font-weight: 500;
padding: 0 6px;
border-radius: 8px;
background: rgba(22, 119, 255, 0.12);
color: #1677ff;
margin-left: auto;
}
.outbound-test-popover .endpoint-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
white-space: nowrap;
}
.outbound-test-popover .endpoint-row .ep-addr {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.outbound-test-popover .endpoint-row .ep-meta {
opacity: 0.75;
}
.outbound-test-popover .dot-ok {
color: #008771;
}
.outbound-test-popover .dot-fail {
color: #e04141;
}
</style>
+22 -8
View File
@@ -65,18 +65,32 @@ const editingRule = ref(null);
const editingIndex = ref(null);
const inboundTagOptions = computed(() => {
const out = new Set();
const seen = new Set();
const out = [];
function pushUnique(tag) {
if (!tag) return;
if (seen.has(tag)) return;
seen.add(tag);
out.push(tag);
}
for (const ib of props.templateSettings?.inbounds || []) {
if (ib.tag) out.add(ib.tag);
pushUnique(ib.tag);
}
for (const t of props.inboundTags || []) {
pushUnique(t);
}
for (const t of props.inboundTags || []) out.add(t);
for (const ob of props.templateSettings?.outbounds || []) {
const rt = ob?.reverse?.tag || ob?.settings?.reverse?.tag;
if (rt) out.add(rt);
const rt = ob?.reverse?.tag || ob?.settings?.reverse?.tag || ob?.settings?.inboundTag;
pushUnique(rt);
}
// dnsTag if DNS is configured.
const dt = props.templateSettings?.dns?.tag;
if (dt) out.add(dt);
pushUnique(props.templateSettings?.dns?.tag);
for (const s of props.templateSettings?.dns?.servers || []) {
if (typeof s === 'object' && s?.tag) pushUnique(s.tag);
}
return [...out];
});
+15 -8
View File
@@ -40,21 +40,26 @@ const {
restartResult,
outboundsTraffic,
outboundTestStates,
testingAll,
fetchAll,
resetOutboundsTraffic,
testOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
restartXray,
applyOutboundsEvent,
} = useXraySetting();
// Live outbounds traffic — pushed by xray_traffic_job every ~10s.
useWebSocket({ outbounds: applyOutboundsEvent });
async function onTestOutbound(idx) {
async function onTestOutbound(idx, mode = 'tcp') {
const outbound = templateSettings.value?.outbounds?.[idx];
if (outbound) await testOutbound(idx, outbound);
if (outbound) await testOutbound(idx, outbound, mode);
}
async function onTestAllOutbounds(mode = 'tcp') {
await testAllOutbounds(mode);
}
function onDeleteOutbound(idx) {
@@ -186,7 +191,7 @@ function onRemoveRoutingRules({ prefix }) {
void message;
const { isMobile } = useMediaQuery();
const basePath = window.__X_UI_BASE_PATH__ || '';
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
// See SettingsPage scrollTarget — wrap so `document` is in scope.
@@ -278,8 +283,10 @@ function confirmRestart() {
<UploadOutlined /> <span>{{ t('pages.xray.Outbounds') }}</span>
</template>
<OutboundsTab :template-settings="templateSettings" :outbounds-traffic="outboundsTraffic"
:outbound-test-states="outboundTestStates" :inbound-tags="inboundTags" :is-mobile="isMobile"
@reset-traffic="resetOutboundsTraffic" @test="onTestOutbound" @delete="onDeleteOutbound"
:outbound-test-states="outboundTestStates" :testing-all="testingAll"
:inbound-tags="inboundTags" :is-mobile="isMobile"
@reset-traffic="resetOutboundsTraffic" @test="onTestOutbound"
@test-all="onTestAllOutbounds" @delete="onDeleteOutbound"
@show-warp="showWarp" @show-nord="showNord" />
</a-tab-pane>
@@ -339,8 +346,8 @@ function confirmRestart() {
}
.xray-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
--bg-page: #1e1e1e;
--bg-card: #252526;
}
.xray-page.is-dark.is-ultra {
+39 -40
View File
@@ -1,50 +1,24 @@
// Drives the xray page's fetch / dirty / save lifecycle. The Go side
// returns the live xraySetting (the full JSON config), the inboundTags
// list, and a few sidecar values (clientReverseTags, outboundTestUrl)
// the structured tabs need. We keep the JSON as a string here — pretty-
// printed for the textarea; tabs that want a parsed view can JSON.parse
// it themselves.
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { HttpUtil, PromiseUtil } from '@/utils';
const DIRTY_POLL_MS = 1000;
// Hoists the parsed `templateSettings` alongside the JSON string so
// structured tabs (Basics/Routing/Outbounds/etc.) can mutate fields
// directly while the Advanced (JSON) tab edits the same data as text.
// We keep both in sync with two cooperating watches:
// • mutating templateSettings re-stringifies into xraySetting;
// • editing the JSON text re-parses into templateSettings (only on
// valid JSON — invalid edits leave templateSettings untouched
// so the structured tabs don't blow up while the user types).
let syncing = false;
export function useXraySetting() {
const fetched = ref(false);
const spinning = ref(false);
const saveDisabled = ref(true);
// Holds a user-facing message when fetchAll fails; lets the page
// render an error UI instead of an endless spinner.
const fetchError = ref('');
const xraySetting = ref('');
const oldXraySetting = ref('');
// Parsed mirror — null until first successful fetch / parse.
const templateSettings = ref(null);
const outboundTestUrl = ref('https://www.google.com/generate_204');
const oldOutboundTestUrl = ref('');
const inboundTags = ref([]);
const clientReverseTags = ref([]);
const restartResult = ref('');
// Outbounds tab data — traffic stats + per-row test state. Test
// states are keyed by outbound index (sparse object), each entry
// is `{ testing, result }` where result is the wire response from
// /panel/xray/testOutbound or null while the test is in flight.
const outboundsTraffic = ref([]);
const outboundTestStates = ref({});
@@ -53,7 +27,6 @@ export function useXraySetting() {
const msg = await HttpUtil.post('/panel/xray/');
if (!msg?.success) {
fetchError.value = msg?.msg || 'Failed to load xray config';
// Mark as fetched so the spinner clears and the error UI renders.
fetched.value = true;
return;
}
@@ -79,8 +52,7 @@ export function useXraySetting() {
saveDisabled.value = true;
}
// Structured tabs mutate templateSettings deeply. Re-stringify on
// change so the Advanced JSON view + the dirty-poll see the edits.
watch(
templateSettings,
(next) => {
@@ -95,8 +67,6 @@ export function useXraySetting() {
{ deep: true },
);
// Advanced JSON edits — only refresh templateSettings when the text
// parses, so structured tabs stay readable mid-edit.
watch(xraySetting, (next) => {
if (syncing) return;
try {
@@ -133,21 +103,19 @@ export function useXraySetting() {
if (msg?.success) await fetchOutboundsTraffic();
}
// Merges a WebSocket `outbounds` event into outboundsTraffic in place.
// The xray traffic job pushes the full snapshot every ~10s so the user
// doesn't have to click the (now-removed) refresh button.
function applyOutboundsEvent(payload) {
if (Array.isArray(payload)) outboundsTraffic.value = payload;
}
async function testOutbound(index, outbound) {
async function testOutbound(index, outbound, mode = 'tcp') {
if (!outbound) return null;
if (!outboundTestStates.value[index]) outboundTestStates.value[index] = {};
outboundTestStates.value[index] = { testing: true, result: null };
outboundTestStates.value[index] = { testing: true, result: null, mode };
try {
const msg = await HttpUtil.post('/panel/xray/testOutbound', {
outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettings.value?.outbounds || []),
mode,
});
if (msg?.success) {
outboundTestStates.value[index] = { testing: false, result: msg.obj };
@@ -155,24 +123,53 @@ export function useXraySetting() {
}
outboundTestStates.value[index] = {
testing: false,
result: { success: false, error: msg?.msg || 'Unknown error' },
result: { success: false, error: msg?.msg || 'Unknown error', mode },
};
} catch (e) {
outboundTestStates.value[index] = {
testing: false,
result: { success: false, error: String(e) },
result: { success: false, error: String(e), mode },
};
}
return null;
}
const testingAll = ref(false);
async function testAllOutbounds(mode = 'tcp') {
const list = templateSettings.value?.outbounds || [];
if (list.length === 0 || testingAll.value) return;
testingAll.value = true;
try {
const concurrency = mode === 'tcp' ? 8 : 1;
const queue = list
.map((ob, i) => ({ index: i, outbound: ob }))
.filter(({ outbound }) => {
const tag = outbound?.tag;
const proto = outbound?.protocol;
if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return false;
if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return false;
return true;
});
async function worker() {
while (queue.length > 0) {
const item = queue.shift();
if (!item) break;
await testOutbound(item.index, item.outbound, mode);
}
}
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker());
await Promise.all(workers);
} finally {
testingAll.value = false;
}
}
async function resetToDefault() {
spinning.value = true;
try {
const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
if (msg?.success) {
// Mutate templateSettings — the watch above re-stringifies into
// xraySetting so the Advanced JSON tab and dirty-poll see it.
templateSettings.value = JSON.parse(JSON.stringify(msg.obj));
}
} finally {
@@ -234,11 +231,13 @@ export function useXraySetting() {
restartResult,
outboundsTraffic,
outboundTestStates,
testingAll,
fetchAll,
fetchOutboundsTraffic,
resetOutboundsTraffic,
applyOutboundsEvent,
testOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
restartXray,
+5 -3
View File
@@ -26,6 +26,8 @@ const BASE_MIGRATED_ROUTES = {
'panel/xray/': '/xray.html',
'panel/nodes': '/nodes.html',
'panel/nodes/': '/nodes.html',
'panel/api-docs': '/api-docs.html',
'panel/api-docs/': '/api-docs.html',
};
let cachedBasePath = '/';
@@ -57,7 +59,7 @@ function refreshBasePath() {
}
// `apply: 'serve'` keeps the injection out of `vite build` — dist.go
// already injects __X_UI_BASE_PATH__ at runtime in production.
// already injects webBasePath at runtime in production.
function injectBasePathPlugin() {
return {
name: 'xui-inject-base-path',
@@ -65,7 +67,7 @@ function injectBasePathPlugin() {
transformIndexHtml(html) {
const basePath = refreshBasePath();
const escaped = basePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const tag = `<script>window.__X_UI_BASE_PATH__="${escaped}";</script>`;
const tag = `<script>window.X_UI_BASE_PATH="${escaped}";</script>`;
return html.replace('</head>', `${tag}</head>`);
},
};
@@ -150,6 +152,7 @@ export default defineConfig({
inbounds: path.resolve(__dirname, 'inbounds.html'),
xray: path.resolve(__dirname, 'xray.html'),
nodes: path.resolve(__dirname, 'nodes.html'),
apiDocs: path.resolve(__dirname, 'api-docs.html'),
subpage: path.resolve(__dirname, 'subpage.html'),
},
output: {
@@ -163,7 +166,6 @@ export default defineConfig({
|| id.includes('/node_modules/@vue/')
) return 'vendor-vue';
if (id.includes('dayjs')) return 'vendor-dayjs';
if (id.includes('qrious')) return 'vendor-qrious';
if (id.includes('axios')) return 'vendor-axios';
if (
id.includes('vue3-persian-datetime-picker')
+52 -9
View File
@@ -528,21 +528,24 @@ ssl_cert_issue() {
# Sets global `SSL_HOST` to the chosen domain/IP for Access URL usage
prompt_and_setup_ssl() {
local panel_port="$1"
local web_base_path="$2" # expected without leading slash
local web_base_path="$2"
local server_ip="$3"
local ssl_choice=""
SSL_SCHEME="https"
echo -e "${yellow}Choose SSL certificate setup method:${plain}"
echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)"
echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)"
echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
echo -e "${green}4.${plain} Skip SSL (advanced — behind reverse proxy / SSH tunnel only)"
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
echo -e "${blue}Note:${plain} Option 4 serves the panel over plain HTTP — only safe behind nginx/Caddy or an SSH tunnel."
read -rp "Choose an option (default 2 for IP): " ssl_choice
ssl_choice="${ssl_choice// /}" # Trim whitespace
# Default to 2 (IP cert) if input is empty or invalid (not 1 or 3)
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then
# Default to 2 (IP cert) if input is empty or invalid (not 1, 3 or 4)
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" && "$ssl_choice" != "4" ]]; then
ssl_choice="2"
fi
@@ -653,6 +656,41 @@ prompt_and_setup_ssl() {
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
;;
4)
echo ""
echo -e "${red}⚠ Panel will be installed WITHOUT SSL/TLS.${plain}"
echo -e "${yellow}Login credentials and cookies will travel as plain HTTP.${plain}"
echo -e "${yellow}Only safe when:${plain}"
echo -e "${yellow} • A reverse proxy (nginx, Caddy, Traefik) terminates TLS for you, or${plain}"
echo -e "${yellow} • You access the panel exclusively via SSH tunnel${plain}"
echo ""
SSL_SCHEME="http"
SSL_HOST="${server_ip}"
local bind_local=""
read -rp "Bind the panel to 127.0.0.1 only? (recommended — forces SSH tunnel / reverse-proxy access) [y/N]: " bind_local
if [[ "$bind_local" == "y" || "$bind_local" == "Y" ]]; then
${xui_folder}/x-ui setting -listenIP "127.0.0.1" > /dev/null 2>&1
SSL_HOST="127.0.0.1"
echo -e "${green}✓ Panel bound to 127.0.0.1 only. It is now unreachable from the public internet.${plain}"
echo ""
echo -e "${green}SSH Port Forwarding — open the panel from your local machine via:${plain}"
echo -e " Standard SSH command:"
echo -e " ${yellow}ssh -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
echo -e " If using an SSH key:"
echo -e " ${yellow}ssh -i <sshkeypath> -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
echo -e " Then open in your browser:"
echo -e " ${yellow}http://localhost:2222/${web_base_path}${plain}"
echo ""
echo -e "${yellow}Alternative: point a reverse proxy (nginx/Caddy) at 127.0.0.1:${panel_port} and let it terminate TLS.${plain}"
else
echo -e "${yellow}Panel will listen on all interfaces over plain HTTP. Make sure something else is terminating TLS in front of it.${plain}"
fi
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
echo -e "${green}✓ SSL setup skipped.${plain}"
;;
*)
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
SSL_HOST="${server_ip}"
@@ -716,9 +754,10 @@ config_after_install() {
echo ""
echo -e "${green}═══════════════════════════════════════════${plain}"
echo -e "${green} SSL Certificate Setup (MANDATORY) ${plain}"
echo -e "${green} SSL Certificate Setup (RECOMMENDED) ${plain}"
echo -e "${green}═══════════════════════════════════════════${plain}"
echo -e "${yellow}For security, SSL certificate is required for all panels.${plain}"
echo -e "${yellow}SSL is strongly recommended. Skip only if a reverse proxy${plain}"
echo -e "${yellow}or SSH tunnel handles TLS for you.${plain}"
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
echo ""
@@ -733,10 +772,14 @@ config_after_install() {
echo -e "${green}Password: ${config_password}${plain}"
echo -e "${green}Port: ${config_port}${plain}"
echo -e "${green}WebBasePath: ${config_webBasePath}${plain}"
echo -e "${green}Access URL: https://${SSL_HOST}:${config_port}/${config_webBasePath}${plain}"
echo -e "${green}Access URL: ${SSL_SCHEME}://${SSL_HOST}:${config_port}/${config_webBasePath}${plain}"
echo -e "${green}═══════════════════════════════════════════${plain}"
echo -e "${yellow}⚠ IMPORTANT: Save these credentials securely!${plain}"
echo -e "${yellow}⚠ SSL Certificate: Enabled and configured${plain}"
if [[ "$SSL_SCHEME" == "https" ]]; then
echo -e "${yellow}⚠ SSL Certificate: Enabled and configured${plain}"
else
echo -e "${yellow}⚠ SSL Certificate: Skipped — panel is HTTP-only. Use a reverse proxy or SSH tunnel.${plain}"
fi
else
local config_webBasePath=$(gen_random_string 18)
echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}"
@@ -752,7 +795,7 @@ config_after_install() {
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
echo ""
prompt_and_setup_ssl "${existing_port}" "${config_webBasePath}" "${server_ip}"
echo -e "${green}Access URL: https://${SSL_HOST}:${existing_port}/${config_webBasePath}${plain}"
echo -e "${green}Access URL: ${SSL_SCHEME}://${SSL_HOST}:${existing_port}/${config_webBasePath}${plain}"
else
# If a cert already exists, just show the access URL
echo -e "${green}Access URL: https://${server_ip}:${existing_port}/${config_webBasePath}${plain}"
@@ -785,7 +828,7 @@ config_after_install() {
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
echo ""
prompt_and_setup_ssl "${existing_port}" "${existing_webBasePath}" "${server_ip}"
echo -e "${green}Access URL: https://${SSL_HOST}:${existing_port}/${existing_webBasePath}${plain}"
echo -e "${green}Access URL: ${SSL_SCHEME}://${SSL_HOST}:${existing_port}/${existing_webBasePath}${plain}"
else
echo -e "${green}SSL certificate already configured. No action needed.${plain}"
fi
+3
View File
@@ -61,6 +61,8 @@ func runWebServer() {
}
var subServer *sub.Server
sub.SetDistFS(web.EmbeddedDist())
service.RegisterSubLinkProvider(sub.NewLinkProvider())
subServer = sub.NewServer()
global.SetSubServer(subServer)
err = subServer.Start()
@@ -101,6 +103,7 @@ func runWebServer() {
}
log.Println("Web server restarted successfully.")
sub.SetDistFS(web.EmbeddedDist())
subServer = sub.NewServer()
global.SetSubServer(subServer)
err = subServer.Start()
+16
View File
@@ -0,0 +1,16 @@
package sub
import "embed"
// distFS holds the Vite-built frontend filesystem, injected from main at
// startup. The `web` package owns the //go:embed directive (because dist/
// is at web/dist/), and hands the FS over via SetDistFS so the sub package
// doesn't import web — that would create an import cycle once any
// web/controller handler reuses sub's link-building service.
var distFS embed.FS
// SetDistFS installs the embedded frontend filesystem the sub server uses
// for its info page assets. Must be called before NewServer().Start().
func SetDistFS(fs embed.FS) {
distFS = fs
}
+59
View File
@@ -0,0 +1,59 @@
package sub
import (
"strings"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/web/service"
)
type LinkProvider struct {
settingService service.SettingService
}
func NewLinkProvider() *LinkProvider {
return &LinkProvider{}
}
func (p *LinkProvider) build(host string) *SubService {
showInfo, _ := p.settingService.GetSubShowInfo()
rModel, err := p.settingService.GetRemarkModel()
if err != nil {
rModel = "-ieo"
}
svc := NewSubService(showInfo, rModel)
svc.PrepareForRequest(host)
return svc
}
func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) {
svc := p.build(host)
links, _, _, err := svc.GetSubs(subId, host)
if err != nil {
return nil, err
}
out := make([]string, 0, len(links))
for _, l := range links {
out = append(out, splitLinkLines(l)...)
}
return out, nil
}
func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
svc := p.build(host)
return splitLinkLines(svc.GetLink(inbound, email))
}
func splitLinkLines(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, "\n")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+5 -3
View File
@@ -12,10 +12,10 @@ import (
"os"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util/common"
webpkg "github.com/mhsanaei/3x-ui/v3/web"
"github.com/mhsanaei/3x-ui/v3/web/locale"
"github.com/mhsanaei/3x-ui/v3/web/middleware"
"github.com/mhsanaei/3x-ui/v3/web/network"
@@ -188,7 +188,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
var assetsFS http.FileSystem
if _, err := os.Stat("web/dist/assets"); err == nil {
assetsFS = http.FS(os.DirFS("web/dist/assets"))
} else if subFS, err := fs.Sub(webpkg.EmbeddedDist(), "dist/assets"); err == nil {
} else if subFS, err := fs.Sub(distFS, "dist/assets"); err == nil {
assetsFS = http.FS(subFS)
} else {
logger.Error("sub: failed to mount embedded dist assets:", err)
@@ -313,7 +313,9 @@ func (s *Server) Stop() error {
var err1 error
var err2 error
if s.httpServer != nil {
err1 = s.httpServer.Shutdown(s.ctx)
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
err1 = s.httpServer.Shutdown(shutdownCtx)
}
if s.listener != nil {
err2 = s.listener.Close()
+14 -9
View File
@@ -6,10 +6,10 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
webpkg "github.com/mhsanaei/3x-ui/v3/web"
"github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/gin-gonic/gin"
@@ -127,7 +127,7 @@ func (a *SUBController) subs(c *gin.Context) {
basePath = "/"
}
basePathStr := basePath.(string)
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr)
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
a.serveSubPage(c, basePathStr, page)
return
}
@@ -150,15 +150,20 @@ func (a *SUBController) subs(c *gin.Context) {
// serveSubPage renders web/dist/subpage.html for the current subscription
// request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
// we inject that here, along with window.__X_UI_BASE_PATH__ so the
// we inject that here, along with window.X_UI_BASE_PATH so the
// page's static asset references resolve correctly when the panel runs
// behind a URL prefix.
func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
dist := webpkg.EmbeddedDist()
body, err := dist.ReadFile("dist/subpage.html")
if err != nil {
c.String(http.StatusInternalServerError, "missing embedded subpage")
return
var body []byte
if diskBody, diskErr := os.ReadFile("web/dist/subpage.html"); diskErr == nil {
body = diskBody
} else {
readBody, err := distFS.ReadFile("dist/subpage.html")
if err != nil {
c.String(http.StatusInternalServerError, "missing embedded subpage")
return
}
body = readBody
}
// Vite emits absolute asset URLs (`/assets/...`); when the panel is
@@ -219,7 +224,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
)
escapedBase := jsEscape.Replace(basePath)
inject := []byte(`<script>window.__X_UI_BASE_PATH__="` + escapedBase + `";` +
inject := []byte(`<script>window.X_UI_BASE_PATH="` + escapedBase + `";` +
`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
out := bytes.Replace(body, []byte("</head>"), inject, 1)
+49 -41
View File
@@ -98,7 +98,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
if client.Enable {
hasEnabledClient = true
}
result = append(result, s.getLink(inbound, client.Email))
result = append(result, s.GetLink(inbound, client.Email))
var ct xray.ClientTraffic
ct, clientTraffics = s.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
if ct.LastOnline > lastOnline {
@@ -198,7 +198,11 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
return inbound.Listen, inbound.Port, string(modifiedStream), nil
}
func (s *SubService) getLink(inbound *model.Inbound, email string) string {
// GetLink dispatches to the protocol-specific generator for one (inbound, client)
// pair. Returns "" when the inbound's protocol doesn't produce a subscription URL
// (socks, http, mixed, wireguard, dokodemo, tunnel). The returned string may
// contain multiple `\n`-separated URLs when the inbound has externalProxy set.
func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
switch inbound.Protocol {
case "vmess":
return s.genVmessLink(inbound, email)
@@ -1413,25 +1417,27 @@ func searchHost(headers any) string {
// PageData is a view model for subpage.html
// PageData contains data for rendering the subscription information page.
type PageData struct {
Host string
BasePath string
SId string
Enabled bool
Download string
Upload string
Total string
Used string
Remained string
Expire int64
LastOnline int64
Datepicker string
DownloadByte int64
UploadByte int64
TotalByte int64
SubUrl string
SubJsonUrl string
SubClashUrl string
Result []string
Host string
BasePath string
SId string
Enabled bool
Download string
Upload string
Total string
Used string
Remained string
Expire int64
LastOnline int64
Datepicker string
DownloadByte int64
UploadByte int64
TotalByte int64
SubUrl string
SubJsonUrl string
SubClashUrl string
SubTitle string
SubSupportUrl string
Result []string
}
// ResolveRequest extracts scheme and host info from request/headers consistently.
@@ -1545,7 +1551,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string {
// BuildPageData parses header and prepares the template view model.
// BuildPageData constructs page data for rendering the subscription information page.
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string) PageData {
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string, subTitle string, subSupportUrl string) PageData {
download := common.FormatTraffic(traffic.Down)
upload := common.FormatTraffic(traffic.Up)
total := "∞"
@@ -1563,25 +1569,27 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
}
return PageData{
Host: hostHeader,
BasePath: basePath,
SId: subId,
Enabled: traffic.Enable,
Download: download,
Upload: upload,
Total: total,
Used: used,
Remained: remained,
Expire: traffic.ExpiryTime / 1000,
LastOnline: lastOnline,
Datepicker: datepicker,
DownloadByte: traffic.Down,
UploadByte: traffic.Up,
TotalByte: traffic.Total,
SubUrl: subURL,
SubJsonUrl: subJsonURL,
SubClashUrl: subClashURL,
Result: subs,
Host: hostHeader,
BasePath: basePath,
SId: subId,
Enabled: traffic.Enable,
Download: download,
Upload: upload,
Total: total,
Used: used,
Remained: remained,
Expire: traffic.ExpiryTime / 1000,
LastOnline: lastOnline,
Datepicker: datepicker,
DownloadByte: traffic.Down,
UploadByte: traffic.Up,
TotalByte: traffic.Total,
SubUrl: subURL,
SubJsonUrl: subJsonURL,
SubClashUrl: subClashURL,
SubTitle: subTitle,
SubSupportUrl: subSupportUrl,
Result: subs,
}
}
+7 -47
View File
@@ -15,41 +15,14 @@ import (
"github.com/mhsanaei/3x-ui/v3/web/session"
)
// distFS is filled in once at startup by the web package via SetDistFS.
// It holds the Vite-built frontend (the `dist/<page>.html` files) so
// the panel's HTML routes can serve them in production.
//
// We can't `go:embed` the dist directory directly from this package
// because embed.FS only accepts paths relative to the source file —
// dist/ lives one directory up. The web package owns the embed and
// hands the FS to us through this setter.
var distFS embed.FS
// SetDistFS is called once during server bootstrap by the web package
// to hand off the embedded `dist/` filesystem.
func SetDistFS(fs embed.FS) {
distFS = fs
}
// distPageBuildTime is captured at startup so every served HTML page
// reports a stable Last-Modified header and the browser's conditional
// GETs can hit the 304 path on repeat loads.
var distPageBuildTime = time.Now()
// serveDistPage reads `dist/<name>` from the embedded FS and writes it
// to the response. Two transforms run before send:
//
// 1. `<script>window.__X_UI_BASE_PATH__ = "..."</script>` is injected
// just before </head> so the AppSidebar's link generator sees the
// right prefix.
// 2. Absolute Vite-emitted asset URLs (`/assets/...`) are rewritten
// to include the panel's basePath, so installs running under a
// custom URL prefix (e.g. `/myprefix/`) load the bundle from
// `/myprefix/assets/...` where the static handler actually lives.
//
// The HTML responses are served with no-cache so a panel update
// reaches users on the next reload; the long-hashed JS/CSS files
// under /assets/ stay cacheable indefinitely.
func serveDistPage(c *gin.Context, name string) {
body, err := distFS.ReadFile("dist/" + name)
if err != nil {
@@ -62,21 +35,11 @@ func serveDistPage(c *gin.Context, name string) {
basePath = "/"
}
// Rewrite asset URLs only when basePath isn't the root — for the
// default `/` install, Vite's `/assets/...` already resolves
// correctly and we save the byte churn.
if basePath != "/" {
// Vite emits these three attribute shapes for every entry's
// JS / CSS / modulepreload reference. Anchoring the search to
// the leading attribute name avoids matching unrelated /assets
// substrings inside any inlined script.
body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
}
// Escape just enough that a hostile basePath setting can't break
// out of the JS string literal. The setting is admin-controlled
// but defense-in-depth costs nothing here.
jsEscape := strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
@@ -87,14 +50,6 @@ func serveDistPage(c *gin.Context, name string) {
"&", `&`,
)
escapedBase := jsEscape.Replace(basePath)
escapedVer := jsEscape.Replace(config.GetVersion())
// Embed a CSRF token in the served HTML the same way the legacy
// templates did via `<meta name="csrf-token">`. Without this the
// SPA login page has no way to acquire a token (the existing
// /panel/csrf-token endpoint sits behind checkLogin), and POST
// /login is rejected by CSRFMiddleware. EnsureCSRFToken creates
// a session token on first call even for anonymous visitors.
csrfToken, err := session.EnsureCSRFToken(c)
if err != nil {
logger.Warning("Unable to mint CSRF token for", name+":", err)
@@ -102,8 +57,13 @@ func serveDistPage(c *gin.Context, name string) {
}
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
inject := []byte(`<script>window.__X_UI_BASE_PATH__="` + escapedBase +
`";window.__X_UI_CUR_VER__="` + escapedVer + `";</script>`)
script := `<script>window.X_UI_BASE_PATH="` + escapedBase + `"`
if name != "login.html" {
escapedVer := jsEscape.Replace(config.GetVersion())
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
}
script += `;</script>`
inject := []byte(script)
inject = append(inject, csrfMeta...)
inject = append(inject, []byte(`</head>`)...)
out := bytes.Replace(body, []byte("</head>"), inject, 1)
+56
View File
@@ -3,7 +3,9 @@ package controller
import (
"encoding/json"
"fmt"
"net"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/database/model"
@@ -62,6 +64,8 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.GET("/get/:id", a.getInbound)
g.GET("/getClientTraffics/:email", a.getClientTraffics)
g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById)
g.GET("/getSubLinks/:subId", a.getSubLinks)
g.GET("/getClientLinks/:id/:email", a.getClientLinks)
g.POST("/add", a.addInbound)
g.POST("/del/:id", a.delInbound)
@@ -571,3 +575,55 @@ func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
a.xrayService.SetToNeedRestart()
}
}
// resolveHost mirrors what sub.SubService.ResolveRequest does for the host
// field: prefers X-Forwarded-Host (first entry of any list, port stripped),
// then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the
// controller layer means the service interface stays HTTP-agnostic — service
// methods receive a plain host string instead of a *gin.Context.
func resolveHost(c *gin.Context) string {
if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" {
if i := strings.Index(h, ","); i >= 0 {
h = strings.TrimSpace(h[:i])
}
if hp, _, err := net.SplitHostPort(h); err == nil {
return hp
}
return h
}
if h := c.GetHeader("X-Real-IP"); h != "" {
return h
}
if h, _, err := net.SplitHostPort(c.Request.Host); err == nil {
return h
}
return c.Request.Host
}
// getSubLinks returns every protocol URL produced for the given subscription
// ID — the JSON-array equivalent of /sub/<subId> (no base64 wrap).
func (a *InboundController) getSubLinks(c *gin.Context) {
links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, links, nil)
}
// getClientLinks returns the URL(s) for one client on one inbound — the same
// string the Copy URL button copies in the panel UI. Empty array when the
// protocol has no URL form, or when the email isn't found on the inbound.
func (a *InboundController) getClientLinks(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
links, err := a.inboundService.GetClientLinks(resolveHost(c), id, c.Param("email"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, links, nil)
}
-4
View File
@@ -343,10 +343,6 @@ func (a *ServerController) importDB(c *gin.Context) {
return
}
defer file.Close()
// Always restart Xray before return
defer a.serverService.RestartXrayService()
// lastGetStatusTime removed; no longer needed
// Import it
err = a.serverService.ImportDB(file)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
+4 -1
View File
@@ -199,9 +199,12 @@ func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
// testOutbound tests an outbound configuration and returns the delay/response time.
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
// Optional form "mode": "tcp" for a fast dial-only probe (parallel-safe),
// anything else (default) for a full HTTP probe through a temp xray instance.
func (a *XraySettingController) testOutbound(c *gin.Context) {
outboundJSON := c.PostForm("outbound")
allOutboundsJSON := c.PostForm("allOutbounds")
mode := c.PostForm("mode")
if outboundJSON == "" {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbound parameter is required"))
@@ -211,7 +214,7 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON)
result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON, mode)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
+6
View File
@@ -36,6 +36,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
g.GET("/nodes", a.nodes)
g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings)
g.GET("/api-docs", a.apiDocs)
// SPA pages built by Vite don't have a server-rendered <meta name="csrf-token">,
// so they fetch the session token via this endpoint at startup and replay it
@@ -76,6 +77,11 @@ func (a *XUIController) xraySettings(c *gin.Context) {
serveDistPage(c, "xray.html")
}
// apiDocs renders the in-panel API documentation page.
func (a *XUIController) apiDocs(c *gin.Context) {
serveDistPage(c, "api-docs.html")
}
// csrfToken returns the session CSRF token to authenticated SPA clients.
// The endpoint is GET (a safe method) so it bypasses CSRFMiddleware itself,
// but checkLogin still gates the response — anonymous callers get 401/redirect.
+30 -9
View File
@@ -291,12 +291,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
return inbound, false, common.NewError("Port already exists:", inbound.Port)
}
// pick a tag that won't collide with an existing row. for the common
// case this is the same "inbound-<port>" string the controller already
// set; only when this port already has another inbound on a different
// transport (now possible after the transport-aware port check) does
// this disambiguate with a -tcp/-udp suffix. see #4103.
inbound.Tag, err = s.generateInboundTag(inbound, 0)
inbound.Tag, err = s.resolveInboundTag(inbound, 0)
if err != nil {
return inbound, false, err
}
@@ -636,9 +631,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
oldInbound.Settings = inbound.Settings
oldInbound.StreamSettings = inbound.StreamSettings
oldInbound.Sniffing = inbound.Sniffing
// regenerate tag with collision-aware logic. for this row we pass
// inbound.Id as ignoreId so it doesn't see its own old tag in the db.
oldInbound.Tag, err = s.generateInboundTag(inbound, inbound.Id)
oldInbound.Tag, err = s.resolveInboundTag(inbound, inbound.Id)
if err != nil {
return inbound, false, err
}
@@ -3873,3 +3866,31 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b
return needRestart, db.Save(oldInbound).Error
}
type SubLinkProvider interface {
SubLinksForSubId(host, subId string) ([]string, error)
LinksForClient(host string, inbound *model.Inbound, email string) []string
}
var registeredSubLinkProvider SubLinkProvider
func RegisterSubLinkProvider(p SubLinkProvider) {
registeredSubLinkProvider = p
}
func (s *InboundService) GetSubLinks(host, subId string) ([]string, error) {
if registeredSubLinkProvider == nil {
return nil, common.NewError("sub link provider not registered")
}
return registeredSubLinkProvider.SubLinksForSubId(host, subId)
}
func (s *InboundService) GetClientLinks(host string, id int, email string) ([]string, error) {
inbound, err := s.GetInbound(id)
if err != nil {
return nil, err
}
if registeredSubLinkProvider == nil {
return nil, common.NewError("sub link provider not registered")
}
return registeredSubLinkProvider.LinksForClient(host, inbound, email), nil
}
+7 -2
View File
@@ -25,6 +25,9 @@ func (s *NordService) GetCountries() (string, error) {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", common.NewErrorf("NordVPN API error: %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
if err != nil {
return "", err
@@ -45,6 +48,9 @@ func (s *NordService) GetServers(countryId string) (string, error) {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", common.NewErrorf("NordVPN API error: %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
if err != nil {
return "", err
@@ -97,8 +103,7 @@ func (s *NordService) GetCredentials(token string) (string, error) {
}
req.SetBasicAuth("token", token)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
resp, err := nordHTTPClient.Do(req)
if err != nil {
return "", err
}
+263 -109
View File
@@ -1,13 +1,17 @@
package service
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptrace"
"net/url"
"os"
"strconv"
"sync"
"time"
@@ -15,7 +19,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/util/json_util"
"github.com/mhsanaei/3x-ui/v3/xray"
@@ -26,8 +29,10 @@ import (
// It handles outbound traffic monitoring and statistics.
type OutboundService struct{}
// testSemaphore limits concurrent outbound tests to prevent resource exhaustion.
var testSemaphore sync.Mutex
// httpTestSemaphore serialises HTTP-mode probes (each one spawns a temp xray
// instance, which is too expensive to run in parallel). TCP-mode probes are
// dial-only and don't need the semaphore.
var httpTestSemaphore sync.Mutex
func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
var err error
@@ -117,90 +122,230 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error {
return nil
}
// TestOutboundResult represents the result of testing an outbound
// TestOutboundResult represents the result of testing an outbound.
// Delay/timing fields are in milliseconds. Endpoints is only populated for
// TCP-mode probes; the HTTP-mode timing breakdown lives in DNSMs/ConnectMs/
// TLSMs/TTFBMs (any of these can be 0 if the underlying step was skipped —
// e.g. a non-TLS target leaves TLSMs at 0).
type TestOutboundResult struct {
Success bool `json:"success"`
Delay int64 `json:"delay"` // Delay in milliseconds
Delay int64 `json:"delay"`
Error string `json:"error,omitempty"`
StatusCode int `json:"statusCode,omitempty"`
Mode string `json:"mode,omitempty"`
DNSMs int64 `json:"dnsMs,omitempty"`
ConnectMs int64 `json:"connectMs,omitempty"`
TLSMs int64 `json:"tlsMs,omitempty"`
TTFBMs int64 `json:"ttfbMs,omitempty"`
Endpoints []TestEndpointResult `json:"endpoints,omitempty"`
}
// TestOutbound tests an outbound by creating a temporary xray instance and measuring response time.
// allOutboundsJSON must be a JSON array of all outbounds; they are copied into the test config unchanged.
// Only the test inbound and a route rule (to the tested outbound tag) are added.
func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) {
// TestEndpointResult is one entry in a TCP-mode probe — the per-endpoint
// dial outcome for outbounds that expose multiple servers/peers.
type TestEndpointResult struct {
Address string `json:"address"`
Success bool `json:"success"`
Delay int64 `json:"delay"`
Error string `json:"error,omitempty"`
}
// TestOutbound dispatches to the chosen probe mode:
// - mode="tcp": dial the outbound's host:port directly. No xray spin-up,
// parallel-safe, ~100ms per endpoint. Doesn't validate the proxy
// protocol — only that the remote is reachable on TCP.
// - mode="" or "http": spin a temp xray instance, route a real HTTP
// request through it, return delay + a DNS/Connect/TLS/TTFB breakdown.
// Authoritative but expensive and serialised by httpTestSemaphore.
//
// allOutboundsJSON is only consulted in HTTP mode (it backs
// sockopt.dialerProxy chains during test).
func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
if mode == "tcp" {
return s.testOutboundTCP(outboundJSON)
}
return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON)
}
func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) {
var ob map[string]any
if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
return &TestOutboundResult{Mode: "tcp", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
}
tag, _ := ob["tag"].(string)
protocol, _ := ob["protocol"].(string)
if protocol == "blackhole" || protocol == "freedom" || tag == "blocked" {
return &TestOutboundResult{Mode: "tcp", Success: false, Error: "Outbound has no testable endpoint"}, nil
}
endpoints := extractOutboundEndpoints(ob)
if len(endpoints) == 0 {
return &TestOutboundResult{Mode: "tcp", Success: false, Error: "No testable endpoint"}, nil
}
results := make([]TestEndpointResult, len(endpoints))
var wg sync.WaitGroup
for i := range endpoints {
wg.Add(1)
go func(i int) {
defer wg.Done()
results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second)
}(i)
}
wg.Wait()
var bestDelay int64 = -1
var firstErr string
for _, r := range results {
if r.Success {
if bestDelay < 0 || r.Delay < bestDelay {
bestDelay = r.Delay
}
} else if firstErr == "" {
firstErr = r.Error
}
}
out := &TestOutboundResult{Mode: "tcp", Endpoints: results}
if bestDelay >= 0 {
out.Success = true
out.Delay = bestDelay
} else {
out.Error = firstErr
if out.Error == "" {
out.Error = "All endpoints unreachable"
}
}
return out, nil
}
func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
r := TestEndpointResult{Address: endpoint}
start := time.Now()
conn, err := net.DialTimeout("tcp", endpoint, timeout)
r.Delay = time.Since(start).Milliseconds()
if err != nil {
r.Error = err.Error()
return r
}
conn.Close()
r.Success = true
return r
}
func extractOutboundEndpoints(ob map[string]any) []string {
protocol, _ := ob["protocol"].(string)
settings, _ := ob["settings"].(map[string]any)
if settings == nil {
return nil
}
var out []string
addServer := func(addr any, port any) {
host, _ := addr.(string)
p := numAsInt(port)
if host != "" && p > 0 {
out = append(out, fmt.Sprintf("%s:%d", host, p))
}
}
switch protocol {
case "vmess":
if vnext, ok := settings["vnext"].([]any); ok {
for _, v := range vnext {
if vm, ok := v.(map[string]any); ok {
addServer(vm["address"], vm["port"])
}
}
}
case "vless":
addServer(settings["address"], settings["port"])
case "trojan", "shadowsocks", "http", "socks":
if servers, ok := settings["servers"].([]any); ok {
for _, sv := range servers {
if sm, ok := sv.(map[string]any); ok {
addServer(sm["address"], sm["port"])
}
}
}
case "wireguard":
if peers, ok := settings["peers"].([]any); ok {
for _, p := range peers {
if pm, ok := p.(map[string]any); ok {
if ep, _ := pm["endpoint"].(string); ep != "" {
out = append(out, ep)
}
}
}
}
}
return out
}
func numAsInt(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case int64:
return int(n)
case string:
if i, err := strconv.Atoi(n); err == nil {
return i
}
}
return 0
}
func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) {
if testURL == "" {
testURL = "https://www.google.com/generate_204"
}
// Limit to one concurrent test at a time
if !testSemaphore.TryLock() {
if !httpTestSemaphore.TryLock() {
return &TestOutboundResult{
Mode: "http",
Success: false,
Error: "Another outbound test is already running, please wait",
}, nil
}
defer testSemaphore.Unlock()
defer httpTestSemaphore.Unlock()
// Parse the outbound being tested to get its tag
var testOutbound map[string]any
if err := json.Unmarshal([]byte(outboundJSON), &testOutbound); err != nil {
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Invalid outbound JSON: %v", err),
}, nil
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
}
outboundTag, _ := testOutbound["tag"].(string)
if outboundTag == "" {
return &TestOutboundResult{
Success: false,
Error: "Outbound has no tag",
}, nil
return &TestOutboundResult{Mode: "http", Success: false, Error: "Outbound has no tag"}, nil
}
if protocol, _ := testOutbound["protocol"].(string); protocol == "blackhole" || outboundTag == "blocked" {
return &TestOutboundResult{
Success: false,
Error: "Blocked/blackhole outbound cannot be tested",
}, nil
return &TestOutboundResult{Mode: "http", Success: false, Error: "Blocked/blackhole outbound cannot be tested"}, nil
}
// Use all outbounds when provided; otherwise fall back to single outbound
var allOutbounds []any
if allOutboundsJSON != "" {
if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil {
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err),
}, nil
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err)}, nil
}
}
if len(allOutbounds) == 0 {
allOutbounds = []any{testOutbound}
}
// Find an available port for test inbound
testPort, err := findAvailablePort()
if err != nil {
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Failed to find available port: %v", err),
}, nil
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to find available port: %v", err)}, nil
}
// Copy all outbounds as-is, add only test inbound and route rule
testConfig := s.createTestConfig(outboundTag, allOutbounds, testPort)
// Use a temporary config file so the main config.json is never overwritten
testConfigPath, err := createTestConfigPath()
if err != nil {
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Failed to create test config path: %v", err),
}, nil
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to create test config path: %v", err)}, nil
}
defer os.Remove(testConfigPath) // ensure temp file is removed even if process is not stopped
defer os.Remove(testConfigPath)
// Create temporary xray process with its own config file
testProcess := xray.NewTestProcess(testConfig, testConfigPath)
defer func() {
if testProcess.IsRunning() {
@@ -208,52 +353,24 @@ func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allO
}
}()
// Start the test process
if err := testProcess.Start(); err != nil {
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Failed to start test xray instance: %v", err),
}, nil
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to start test xray instance: %v", err)}, nil
}
// Wait for xray to start listening on the test port
if err := waitForPort(testPort, 3*time.Second); err != nil {
if !testProcess.IsRunning() {
result := testProcess.GetResult()
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Xray process exited: %s", result),
}, nil
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil
}
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Xray failed to start listening: %v", err),
}, nil
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray failed to start listening: %v", err)}, nil
}
// Check if process is still running
if !testProcess.IsRunning() {
result := testProcess.GetResult()
return &TestOutboundResult{
Success: false,
Error: fmt.Sprintf("Xray process exited: %s", result),
}, nil
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil
}
// Test the connection through proxy
delay, statusCode, err := s.testConnection(testPort, testURL)
if err != nil {
return &TestOutboundResult{
Success: false,
Error: err.Error(),
}, nil
}
return &TestOutboundResult{
Success: true,
Delay: delay,
StatusCode: statusCode,
}, nil
return s.testConnection(testPort, testURL)
}
// createTestConfig creates a test config by copying all outbounds unchanged and adding
@@ -329,55 +446,92 @@ func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []an
return cfg
}
// testConnection tests the connection through the proxy and measures delay.
// It performs a warmup request first to establish the SOCKS connection and populate DNS caches,
// then measures the second request for a more accurate latency reading.
func (s *OutboundService) testConnection(proxyPort int, testURL string) (int64, int, error) {
// Create SOCKS5 proxy URL
proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", proxyPort)
// Parse proxy URL
proxyURLParsed, err := url.Parse(proxyURL)
// testConnection runs the actual HTTP probe through the local SOCKS proxy.
// A warmup request seeds xray's DNS cache / handshake; then a fresh
// transport runs the measured request so httptrace sees a real cold
// connection and reports DNS/Connect/TLS/TTFB. Note that DNS and Connect
// reflect *client → SOCKS-on-loopback*, not the remote target — those
// happen inside xray and aren't visible to net/http. TLS and TTFB are
// the meaningful breakdown values for a SOCKS-proxied HTTPS probe.
func (s *OutboundService) testConnection(proxyPort int, testURL string) (*TestOutboundResult, error) {
proxyURLStr := fmt.Sprintf("socks5://127.0.0.1:%d", proxyPort)
proxyURLParsed, err := url.Parse(proxyURLStr)
if err != nil {
return 0, 0, common.NewErrorf("Invalid proxy URL: %v", err)
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid proxy URL: %v", err)}, nil
}
// Create HTTP client with proxy and keep-alive for connection reuse
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURLParsed),
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 1,
IdleConnTimeout: 10 * time.Second,
DisableCompression: true,
},
mkClient := func() *http.Client {
return &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURLParsed),
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 1,
IdleConnTimeout: 1 * time.Second,
DisableCompression: true,
},
}
}
// Warmup request: establishes SOCKS/TLS connection, DNS, and TCP to the target.
// This mirrors real-world usage where connections are reused.
warmupResp, err := client.Get(testURL)
warmup := mkClient()
warmupResp, err := warmup.Get(testURL)
if err != nil {
return 0, 0, common.NewErrorf("Request failed: %v", err)
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Request failed: %v", err)}, nil
}
io.Copy(io.Discard, warmupResp.Body)
warmupResp.Body.Close()
warmup.CloseIdleConnections()
// Measure the actual request on the warm connection
startTime := time.Now()
resp, err := client.Get(testURL)
delay := time.Since(startTime).Milliseconds()
var dnsStart, dnsDone, connectStart, connectDone, tlsStart, tlsDone, firstByte time.Time
trace := &httptrace.ClientTrace{
DNSStart: func(_ httptrace.DNSStartInfo) { dnsStart = time.Now() },
DNSDone: func(_ httptrace.DNSDoneInfo) { dnsDone = time.Now() },
ConnectStart: func(_, _ string) { connectStart = time.Now() },
ConnectDone: func(_, _ string, _ error) { connectDone = time.Now() },
TLSHandshakeStart: func() { tlsStart = time.Now() },
TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { tlsDone = time.Now() },
GotFirstResponseByte: func() { firstByte = time.Now() },
}
client := mkClient()
defer client.CloseIdleConnections()
ctx := httptrace.WithClientTrace(context.Background(), trace)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil)
if err != nil {
return 0, 0, common.NewErrorf("Request failed: %v", err)
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Request build failed: %v", err)}, nil
}
startTime := time.Now()
resp, err := client.Do(req)
delay := time.Since(startTime).Milliseconds()
if err != nil {
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Request failed: %v", err)}, nil
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return delay, resp.StatusCode, nil
out := &TestOutboundResult{
Mode: "http",
Success: true,
Delay: delay,
StatusCode: resp.StatusCode,
}
if !dnsStart.IsZero() && !dnsDone.IsZero() {
out.DNSMs = dnsDone.Sub(dnsStart).Milliseconds()
}
if !connectStart.IsZero() && !connectDone.IsZero() {
out.ConnectMs = connectDone.Sub(connectStart).Milliseconds()
}
if !tlsStart.IsZero() && !tlsDone.IsZero() {
out.TLSMs = tlsDone.Sub(tlsStart).Milliseconds()
}
if !firstByte.IsZero() {
out.TTFBMs = firstByte.Sub(startTime).Milliseconds()
}
return out, nil
}
// waitForPort polls until the given TCP port is accepting connections or the timeout expires.
+49 -4
View File
@@ -118,15 +118,16 @@ func isAnyListen(s string) bool {
// port-only check, this one understands that tcp/443 and udp/443 are
// independent sockets in linux and may coexist on the same address.
//
// node scope: inbounds with different NodeID run on different physical
// machines (local panel xray vs a remote node, or two remote nodes),
// so their sockets can't collide. only candidates with the same NodeID
// participate in the listen/transport overlap check.
//
// the listen-overlap rule (specific addr conflicts with any-addr on the
// same port, both directions) is preserved from the previous check.
func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (bool, error) {
db := database.GetDB()
// pull every candidate on this port; we filter by listen-overlap and
// transport in go to keep the sql plain. the port column is indexed
// in practice by the existing port check, and the candidate set is
// tiny (one per coexisting socket family at most).
var candidates []*model.Inbound
q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
if ignoreId > 0 {
@@ -138,6 +139,9 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
for _, c := range candidates {
if !sameNode(c.NodeID, inbound.NodeID) {
continue
}
if !listenOverlaps(c.Listen, inbound.Listen) {
continue
}
@@ -148,6 +152,21 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
return false, nil
}
// sameNode reports whether two NodeID pointers refer to the same xray
// process. nil/nil means both inbounds run on the local panel; non-nil
// with equal value means they share the same remote node. any mix
// (local vs remote, remote-A vs remote-B) is "different node" and
// can't produce a real socket collision.
func sameNode(a, b *int) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
// baseInboundTag is the historical "inbound-<port>" / "inbound-<listen>:<port>"
// shape. kept exactly so existing routing rules that reference these tags
// keep working after the upgrade.
@@ -220,6 +239,32 @@ func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int
return "", common.NewError("could not pick a unique inbound tag for port:", inbound.Port)
}
// resolveInboundTag chooses a tag for an Add or Update. when the caller
// supplied a non-empty Tag (e.g. the central panel pushed its picked
// tag to a node during a multi-node sync) and that tag is free in the
// local DB, it's used verbatim so the two panels stay in agreement —
// otherwise the node would regenerate (often back to bare
// "inbound-<port>") and the eventual traffic sync-back would try to
// INSERT a row whose tag already exists, hitting the UNIQUE constraint
// on inbounds.tag and rolling the node-side row right back out.
// when Tag is empty (the common UI path) or collides, fall back to the
// transport-aware generateInboundTag.
//
// ignoreId mirrors generateInboundTag: pass 0 on add, the inbound's
// own id on update so a row doesn't see its own current tag as taken.
func (s *InboundService) resolveInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
if inbound.Tag != "" {
taken, err := s.tagExists(inbound.Tag, ignoreId)
if err != nil {
return "", err
}
if !taken {
return inbound.Tag, nil
}
}
return s.generateInboundTag(inbound, ignoreId)
}
func (s *InboundService) tagExists(tag string, ignoreId int) (bool, error) {
db := database.GetDB()
q := db.Model(model.Inbound{}).Where("tag = ?", tag)
+125
View File
@@ -35,6 +35,11 @@ func setupConflictDB(t *testing.T) {
}
func seedInboundConflict(t *testing.T, tag, listen string, port int, protocol model.Protocol, streamSettings, settings string) {
t.Helper()
seedInboundConflictNode(t, tag, listen, port, protocol, streamSettings, settings, nil)
}
func seedInboundConflictNode(t *testing.T, tag, listen string, port int, protocol model.Protocol, streamSettings, settings string, nodeID *int) {
t.Helper()
in := &model.Inbound{
Tag: tag,
@@ -44,12 +49,15 @@ func seedInboundConflict(t *testing.T, tag, listen string, port int, protocol mo
Protocol: protocol,
StreamSettings: streamSettings,
Settings: settings,
NodeID: nodeID,
}
if err := database.GetDB().Create(in).Error; err != nil {
t.Fatalf("seed inbound %s: %v", tag, err)
}
}
func intPtr(v int) *int { return &v }
func TestInboundTransports(t *testing.T) {
cases := []struct {
name string
@@ -345,6 +353,123 @@ func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) {
}
}
// inbounds bound to different nodes run on different physical machines,
// so the same port + transport must be allowed across nodes. covers
// local-vs-remote, remote-A-vs-remote-B, and the still-clashing
// same-node case.
func TestCheckPortConflict_NodeScope(t *testing.T) {
setupConflictDB(t)
seedInboundConflictNode(t, "local-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
seedInboundConflictNode(t, "node1-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, intPtr(1))
svc := &InboundService{}
cases := []struct {
name string
nodeID *int
want bool
}{
{"new local same port + tcp clashes with local", nil, true},
{"new remote on different node from local is fine", intPtr(2), false},
{"new remote on existing node 1 clashes", intPtr(1), true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
candidate := &model.Inbound{
Listen: "0.0.0.0",
Port: 443,
Protocol: model.VLESS,
StreamSettings: `{"network":"tcp"}`,
NodeID: c.nodeID,
}
got, err := svc.checkPortConflict(candidate, 0)
if err != nil {
t.Fatalf("checkPortConflict: %v", err)
}
if got != c.want {
t.Fatalf("got conflict=%v, want %v", got, c.want)
}
})
}
}
// when the caller passes an explicit non-empty Tag that doesn't collide,
// resolveInboundTag returns it verbatim. this is the cross-panel path:
// the central panel picks a tag, pushes the inbound to a node, and the
// node must keep that exact tag so the eventual traffic sync-back can
// match the row by tag. previously the node regenerated and the two
// panels diverged, causing a UNIQUE constraint failure on sync.
func TestResolveInboundTag_RespectsCallerTagWhenFree(t *testing.T) {
setupConflictDB(t)
seedInboundConflictNode(t, "inbound-5000", "0.0.0.0", 5000, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
seedInboundConflictNode(t, "inbound-5000-udp", "0.0.0.0", 5000, model.Hysteria2, ``, ``, nil)
svc := &InboundService{}
pushed := &model.Inbound{
Tag: "inbound-5000-tcp",
Listen: "0.0.0.0",
Port: 5000,
Protocol: model.VLESS,
StreamSettings: `{"network":"tcp"}`,
NodeID: intPtr(1),
}
got, err := svc.resolveInboundTag(pushed, 0)
if err != nil {
t.Fatalf("resolveInboundTag: %v", err)
}
if got != "inbound-5000-tcp" {
t.Fatalf("caller tag must be preserved when free, got %q", got)
}
}
// when the caller leaves Tag empty (the local UI path) resolveInboundTag
// falls back to generateInboundTag, which keeps the historical
// "inbound-<port>" shape so existing routing rules don't change.
func TestResolveInboundTag_GeneratesWhenTagEmpty(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
in := &model.Inbound{
Listen: "0.0.0.0",
Port: 8443,
Protocol: model.VLESS,
}
got, err := svc.resolveInboundTag(in, 0)
if err != nil {
t.Fatalf("resolveInboundTag: %v", err)
}
if got != "inbound-8443" {
t.Fatalf("expected generated inbound-8443, got %q", got)
}
}
// when the caller's Tag collides (e.g. a node that was used standalone
// happens to already own the tag the central panel picked),
// resolveInboundTag falls back to generateInboundTag rather than
// failing — the inbound still lands, just under a slightly different
// tag that the central will pick up via the AddInbound response.
func TestResolveInboundTag_RegeneratesOnCollision(t *testing.T) {
setupConflictDB(t)
seedInboundConflictNode(t, "inbound-5000-tcp", "0.0.0.0", 5000, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
svc := &InboundService{}
pushed := &model.Inbound{
Tag: "inbound-5000-tcp",
Listen: "0.0.0.0",
Port: 5000,
Protocol: model.Hysteria2,
StreamSettings: ``,
Settings: ``,
}
got, err := svc.resolveInboundTag(pushed, 0)
if err != nil {
t.Fatalf("resolveInboundTag: %v", err)
}
if got == "inbound-5000-tcp" {
t.Fatalf("colliding caller tag must be replaced, but resolver kept %q", got)
}
}
// updating an inbound must not see itself as a conflict, that's what
// ignoreId is for.
func TestCheckPortConflict_IgnoreSelfOnUpdate(t *testing.T) {
+9 -3
View File
@@ -976,12 +976,18 @@ func (s *ServerService) ImportDB(file multipart.File) error {
return common.NewErrorf("Invalid or corrupt db file: %v", err)
}
// Stop Xray (ignore error but log)
xrayStopped := true
defer func() {
if xrayStopped {
if errR := s.RestartXrayService(); errR != nil {
logger.Warningf("Failed to restart Xray after DB import error: %v", errR)
}
}
}()
if errStop := s.StopXrayService(); errStop != nil {
logger.Warningf("Failed to stop Xray before DB import: %v", errStop)
}
// Close existing DB to release file locks (especially on Windows)
if errClose := database.CloseDB(); errClose != nil {
logger.Warningf("Failed to close existing DB before replacement: %v", errClose)
}
@@ -1029,7 +1035,7 @@ func (s *ServerService) ImportDB(file multipart.File) error {
s.inboundService.MigrateDB()
// Start Xray
xrayStopped = false
if err = s.RestartXrayService(); err != nil {
return common.NewErrorf("Imported DB but failed to start Xray: %v", err)
}
+47 -18
View File
@@ -21,39 +21,64 @@ type trafficWriteRequest struct {
}
var (
twMu sync.Mutex
twQueue chan *trafficWriteRequest
twCtx context.Context
twCancel context.CancelFunc
twDone chan struct{}
twOnce sync.Once
)
// StartTrafficWriter spins up the serial writer goroutine. Safe to call again
// after StopTrafficWriter — each Start/Stop cycle gets fresh channels. The
// previous sync.Once-based implementation deadlocked after a SIGHUP-driven
// panel restart: Stop killed the consumer goroutine but Once prevented Start
// from spawning a new one, so every later submitTrafficWrite blocked forever
// on <-req.done with no consumer (including the AddTraffic call inside
// XrayService.GetXrayConfig that runs from startTask).
func StartTrafficWriter() {
twOnce.Do(func() {
twQueue = make(chan *trafficWriteRequest, trafficWriterQueueSize)
twCtx, twCancel = context.WithCancel(context.Background())
twDone = make(chan struct{})
go runTrafficWriter()
})
twMu.Lock()
defer twMu.Unlock()
if twQueue != nil {
return
}
queue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
twQueue = queue
twCancel = cancel
twDone = done
go runTrafficWriter(queue, ctx, done)
}
// StopTrafficWriter cancels the writer context and waits for the goroutine to
// drain any pending requests before returning. Resets the package state so a
// subsequent StartTrafficWriter can spawn a fresh consumer.
func StopTrafficWriter() {
if twCancel != nil {
twCancel()
<-twDone
twMu.Lock()
cancel := twCancel
done := twDone
twQueue = nil
twCancel = nil
twDone = nil
twMu.Unlock()
if cancel != nil {
cancel()
}
if done != nil {
<-done
}
}
func runTrafficWriter() {
defer close(twDone)
func runTrafficWriter(queue chan *trafficWriteRequest, ctx context.Context, done chan struct{}) {
defer close(done)
for {
select {
case req := <-twQueue:
case req := <-queue:
req.done <- safeApply(req.apply)
case <-twCtx.Done():
case <-ctx.Done():
for {
select {
case req := <-twQueue:
case req := <-queue:
req.done <- safeApply(req.apply)
default:
return
@@ -74,12 +99,16 @@ func safeApply(fn func() error) (err error) {
}
func submitTrafficWrite(fn func() error) error {
if twQueue == nil {
twMu.Lock()
queue := twQueue
twMu.Unlock()
if queue == nil {
return safeApply(fn)
}
req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)}
select {
case twQueue <- req:
case queue <- req:
case <-time.After(trafficWriterSubmitTimeout):
return errors.New("traffic writer queue full")
}
+1
View File
@@ -97,6 +97,7 @@
"nodes": "النودز",
"settings": "إعدادات البانل",
"xray": "إعدادات Xray",
"apiDocs": "API Docs",
"logout": "تسجيل خروج",
"link": "إدارة"
},
+1
View File
@@ -97,6 +97,7 @@
"nodes": "Nodes",
"settings": "Panel Settings",
"xray": "Xray Configs",
"apiDocs": "API Docs",
"logout": "Log Out",
"link": "Manage"
},
+1
View File
@@ -97,6 +97,7 @@
"nodes": "Nodos",
"settings": "Configuraciones",
"xray": "Ajustes Xray",
"apiDocs": "API Docs",
"logout": "Cerrar Sesión",
"link": "Gestionar"
},
+1
View File
@@ -97,6 +97,7 @@
"nodes": "نودها",
"settings": "تنظیمات پنل",
"xray": "پیکربندی ایکس‌ری",
"apiDocs": "API Docs",
"logout": "خروج",
"link": "مدیریت"
},
+1
View File
@@ -97,6 +97,7 @@
"nodes": "Node",
"settings": "Pengaturan Panel",
"xray": "Konfigurasi Xray",
"apiDocs": "API Docs",
"logout": "Keluar",
"link": "Kelola"
},
+1
View File
@@ -97,6 +97,7 @@
"nodes": "ノード",
"settings": "パネル設定",
"xray": "Xray設定",
"apiDocs": "API Docs",
"logout": "ログアウト",
"link": "リンク管理"
},
+1
View File
@@ -97,6 +97,7 @@
"nodes": "Nós",
"settings": "Panel Settings",
"xray": "Xray Configs",
"apiDocs": "API Docs",
"logout": "Sair",
"link": "Gerenciar"
},
+1
View File
@@ -97,6 +97,7 @@
"nodes": "Узлы",
"settings": "Настройки",
"xray": "Настройки Xray",
"apiDocs": "API Docs",
"logout": "Выход",
"link": "Управление"
},
+1
View File
@@ -97,6 +97,7 @@
"nodes": "Düğümler",
"settings": "Panel Ayarları",
"xray": "Xray Yapılandırmaları",
"apiDocs": "API Docs",
"logout": "Çıkış Yap",
"link": "Yönet"
},
+1
View File
@@ -97,6 +97,7 @@
"nodes": "Вузли",
"settings": "Параметри панелі",
"xray": "Конфігурації Xray",
"apiDocs": "API Docs",
"logout": "Вийти",
"link": "Керувати"
},
+1
View File
@@ -98,6 +98,7 @@
"settings": "Cài đặt bảng điều khiển",
"logout": "Đăng xuất",
"xray": "Cài đặt Xray",
"apiDocs": "API Docs",
"link": "Quản lý"
},
"pages": {
+1
View File
@@ -97,6 +97,7 @@
"nodes": "节点",
"settings": "面板设置",
"xray": "Xray 设置",
"apiDocs": "API Docs",
"logout": "退出登录",
"link": "管理"
},
+1
View File
@@ -97,6 +97,7 @@
"nodes": "節點",
"settings": "面板設定",
"xray": "Xray 設定",
"apiDocs": "API Docs",
"logout": "退出登入",
"link": "管理"
},
+3 -1
View File
@@ -456,7 +456,9 @@ func (s *Server) Stop() error {
var err1 error
var err2 error
if s.httpServer != nil {
err1 = s.httpServer.Shutdown(s.ctx)
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
err1 = s.httpServer.Shutdown(shutdownCtx)
}
if s.listener != nil {
err2 = s.listener.Close()
+1 -1
View File
@@ -13,6 +13,6 @@ start_pre(){
}
reload() {
ebegin "Reloading ${RC_SVCNAME}"
kill -USR1 $pidfile
kill -USR1 $(cat $pidfile)
eend $?
}
+7 -3
View File
@@ -436,7 +436,11 @@ restart() {
}
restart_xray() {
systemctl reload x-ui
if [[ $release == "alpine" ]]; then
rc-service x-ui reload
else
systemctl reload x-ui
fi
LOGI "xray-core Restart signal sent successfully, Please check the log information to confirm whether xray restarted successfully"
sleep 2
show_xray_status
@@ -2109,10 +2113,10 @@ actionstop = <iptables> -D <chain> -p <protocol> -j 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." >> ${iplimit_banned_log_path}
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
echo "\$(date +"%Y/%m/%d %H:%M:%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
[Init]
name = default
+96 -22
View File
@@ -10,6 +10,7 @@ import (
"runtime"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -120,7 +121,8 @@ func NewTestProcess(xrayConfig *Config, configPath string) *Process {
}
type process struct {
cmd *exec.Cmd
cmd *exec.Cmd
done chan struct{}
version string
apiPort int
@@ -139,8 +141,15 @@ type process struct {
logWriter *LogWriter
exitErr error
startTime time.Time
intentionalStop atomic.Bool
}
var (
xrayGracefulStopTimeout = 5 * time.Second
xrayForceStopTimeout = 2 * time.Second
)
// newProcess creates a new internal process struct for Xray.
func newProcess(config *Config) *process {
return &process{
@@ -163,6 +172,13 @@ func (p *process) IsRunning() bool {
if p.cmd == nil || p.cmd.Process == nil {
return false
}
if p.done != nil {
select {
case <-p.done:
return false
default:
}
}
if p.cmd.ProcessState == nil {
return true
}
@@ -326,27 +342,13 @@ func (p *process) Start() (err error) {
}
cmd := exec.Command(GetBinaryPath(), "-c", configPath)
p.cmd = cmd
cmd.Stdout = p.logWriter
cmd.Stderr = p.logWriter
go func() {
err := cmd.Run()
if err != nil {
// On Windows, killing the process results in "exit status 1" which isn't an error for us
if runtime.GOOS == "windows" {
errStr := strings.ToLower(err.Error())
if strings.Contains(errStr, "exit status 1") {
// Suppress noisy log on graceful stop
p.exitErr = err
return
}
}
logger.Error("Failure in running xray-core:", err)
p.exitErr = err
}
}()
err = p.startCommand(cmd)
if err != nil {
return err
}
p.refreshVersion()
p.refreshAPIPort()
@@ -354,11 +356,49 @@ func (p *process) Start() (err error) {
return nil
}
func (p *process) startCommand(cmd *exec.Cmd) error {
p.cmd = cmd
p.done = make(chan struct{})
p.exitErr = nil
p.intentionalStop.Store(false)
if err := cmd.Start(); err != nil {
close(p.done)
p.cmd = nil
return err
}
go p.waitForCommand(cmd)
return nil
}
func (p *process) waitForCommand(cmd *exec.Cmd) {
defer close(p.done)
err := cmd.Wait()
if err == nil || p.intentionalStop.Load() {
return
}
// On Windows, killing the process results in "exit status 1" which isn't an error for us.
if runtime.GOOS == "windows" {
errStr := strings.ToLower(err.Error())
if strings.Contains(errStr, "exit status 1") {
p.exitErr = err
return
}
}
logger.Error("Failure in running xray-core:", err)
p.exitErr = err
}
// Stop terminates the running Xray process.
func (p *process) Stop() error {
if !p.IsRunning() {
return errors.New("xray is not running")
}
p.intentionalStop.Store(true)
// Remove temporary config file used for test runs so main config is never touched
if p.configPath != "" {
@@ -371,9 +411,43 @@ func (p *process) Stop() error {
}
if runtime.GOOS == "windows" {
return p.cmd.Process.Kill()
} else {
return p.cmd.Process.Signal(syscall.SIGTERM)
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return p.waitForExit(xrayForceStopTimeout)
}
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
if errors.Is(err, os.ErrProcessDone) {
return p.waitForExit(xrayForceStopTimeout)
}
return err
}
if err := p.waitForExit(xrayGracefulStopTimeout); err == nil {
return nil
}
logger.Warning("xray-core did not stop after SIGTERM, killing process")
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return p.waitForExit(xrayForceStopTimeout)
}
func (p *process) waitForExit(timeout time.Duration) error {
if p.done == nil {
return nil
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-p.done:
return nil
case <-timer.C:
return common.NewErrorf("timed out waiting for xray-core process to stop after %s", timeout)
}
}
+162
View File
@@ -0,0 +1,162 @@
//go:build !windows
package xray
import (
"os"
"os/exec"
"os/signal"
"path/filepath"
"syscall"
"testing"
"time"
xuilogger "github.com/mhsanaei/3x-ui/v3/logger"
"github.com/op/go-logging"
)
func TestStopWaitsForGracefulExit(t *testing.T) {
initProcessTestLogger(t)
p := startProcessHelper(t, "delayed-term")
start := time.Now()
if err := p.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
if elapsed := time.Since(start); elapsed < 150*time.Millisecond {
t.Fatalf("Stop returned before child exited; elapsed=%s", elapsed)
}
if p.IsRunning() {
t.Fatal("process still reports running after Stop")
}
}
func TestIntentionalStopDoesNotRecordExitError(t *testing.T) {
initProcessTestLogger(t)
p := startProcessHelper(t, "default-term")
if err := p.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
if err := p.GetErr(); err != nil {
t.Fatalf("GetErr after intentional stop = %v, want nil", err)
}
if result := p.GetResult(); result != "" {
t.Fatalf("GetResult after intentional stop = %q, want empty", result)
}
}
func TestStopKillsProcessThatIgnoresSIGTERM(t *testing.T) {
initProcessTestLogger(t)
oldGraceful := xrayGracefulStopTimeout
oldForce := xrayForceStopTimeout
xrayGracefulStopTimeout = 100 * time.Millisecond
xrayForceStopTimeout = 2 * time.Second
t.Cleanup(func() {
xrayGracefulStopTimeout = oldGraceful
xrayForceStopTimeout = oldForce
})
p := startProcessHelper(t, "ignore-term")
if err := p.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
if p.IsRunning() {
t.Fatal("process still reports running after forced stop")
}
}
func initProcessTestLogger(t *testing.T) {
t.Helper()
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
xuilogger.InitLogger(logging.ERROR)
}
func startProcessHelper(t *testing.T, mode string) *process {
t.Helper()
readyPath := filepath.Join(t.TempDir(), "ready")
cmd := exec.Command(os.Args[0], "-test.run=TestXrayProcessHelper", "--", mode)
cmd.Env = append(os.Environ(),
"XRAY_PROCESS_HELPER=1",
"XRAY_PROCESS_READY="+readyPath,
)
p := newProcess(nil)
if err := p.startCommand(cmd); err != nil {
t.Fatalf("start helper process: %v", err)
}
waitForProcessHelperReady(t, readyPath)
t.Cleanup(func() {
if p.IsRunning() {
p.intentionalStop.Store(true)
_ = p.cmd.Process.Kill()
_ = p.waitForExit(2 * time.Second)
}
})
return p
}
func waitForProcessHelperReady(t *testing.T, readyPath string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(readyPath); err == nil {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("helper process did not become ready")
}
func TestXrayProcessHelper(t *testing.T) {
if os.Getenv("XRAY_PROCESS_HELPER") != "1" {
return
}
mode := ""
for i, arg := range os.Args {
if arg == "--" && i+1 < len(os.Args) {
mode = os.Args[i+1]
break
}
}
switch mode {
case "delayed-term":
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM)
markProcessHelperReady(t)
<-sigCh
time.Sleep(200 * time.Millisecond)
os.Exit(0)
case "default-term":
markProcessHelperReady(t)
select {}
case "ignore-term":
signal.Ignore(syscall.SIGTERM)
markProcessHelperReady(t)
select {}
default:
t.Fatalf("unknown helper mode %q", mode)
}
}
func markProcessHelperReady(t *testing.T) {
t.Helper()
readyPath := os.Getenv("XRAY_PROCESS_READY")
if readyPath == "" {
t.Fatal("XRAY_PROCESS_READY is not set")
}
if err := os.WriteFile(readyPath, []byte("ready"), 0644); err != nil {
t.Fatalf("write helper ready file: %v", err)
}
}