Compare commits

...

19 Commits

Author SHA1 Message Date
Sanaei c377dca27c v3.6.0 2026-07-30 03:15:28 +02:00
Sanaei c56f6447a8 chore: refresh dependencies and modernize Go test idioms
Frontend deps: @hookform/resolvers 5.4.3 -> 5.5.7, Storybook 10.5.4 -> 10.5.5
across the four packages we declare, globals 17.7.0 -> 17.8.0, and jsdom
29.1.1 -> 30.0.1. The jsdom major replaces its CSS and selector stack --
@asamuzakjp/css-color 5 -> 6, @asamuzakjp/dom-selector 7 -> 8, undici 7 -> 8,
nwsapi and generational-cache folded into their parents, whatwg-url 17 nested
underneath. Nothing in the Vitest suites reaches those directly and the whole
frontend gate (typecheck, lint, tests, build, Storybook compile) is green.
Panel frontend version to 0.6.0.

Backend deps: mattn/go-sqlite3 1.14.48 -> 1.14.49 and valyala/fasthttp
1.72.0 -> 1.73.0, plus the golang.org/x/exp and genproto/googleapis/rpc
indirect bumps that came with them.

Go tests: modernize -fix output, covering range-over-int, sync.WaitGroup.Go
in place of manual Add/Done pairs, maps.Copy, and Go 1.26 new(expr) for
pointer-to-value in the forwarded-trust table. The storedAs helper is deleted
instead of being left behind a //go:fix inline directive -- keeping it that way
fails govet on the one call site the rewrite did not reach, and every caller now
takes new(...) directly. Behaviour is unchanged.

DnsTab: the hosts-sync effect tested dns while declaring dnsEnabled in its
dependency array. Both carry the same truth value, so this is exhaustive-deps
hygiene rather than a behaviour change.
2026-07-30 03:14:22 +02:00
PathGao 66740b7ef4 fix(frontend): preserve edited server drafts (#6156)
* fix(frontend): preserve edited server drafts

* fix(frontend): retain Xray server projections

* fix(frontend): keep draft controls internal

* fix(frontend): rehydrate saved redacted settings

* fix(frontend): order saved draft hydration

* fix(frontend): preserve draft baselines on security saves

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-30 02:59:53 +02:00
PathGao 8d02ae28f5 fix(frontend): preserve theme body classes (#6157)
* fix(storybook): preserve preview body classes

* fix(frontend): retain theme body classes

* fix(storybook): mirror panel theme attributes

* test(storybook): cover theme switches

* test(storybook): strengthen theme DOM coverage

* fix(frontend): preserve message container classes

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-30 02:59:35 +02:00
PathGao 2c943da3e0 fix(frontend): keep DNS hosts synchronized (#6158)
* fix(frontend): keep DNS hosts synchronized

* fix(frontend): preserve incomplete DNS hosts

* fix(frontend): reset DNS host drafts when disabled

* fix(frontend): clear DNS host drafts when disabled

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-30 02:58:51 +02:00
Sanaei f52c3c4837 perf(clients): make the clients page scale to large panels
The clients page was slow on panels with many clients for two independent
reasons: the server rebuilt the whole picture on every request, and the
browser rebuilt the whole table on every poll.

Server side, ListPaged loaded every client row, every client_inbounds link
and every client_traffics row into Go memory, then filtered, sorted and
paginated in a loop -- on a request the page repeats every five seconds.
Every predicate now runs in SQL and only the requested page's ids are
hydrated, so the cost tracks the page size rather than the client count.
Measured on SQLite with a realistic status mix: the default view at 100k
clients goes from 1,072ms to 64ms. Behaviour is preserved deliberately in
the subtle places -- the cross-panel global-traffic overlay is folded into
the same used-bytes expression the predicates and sort use, LIKE wildcards
are escaped so a search for "a_b" stays literal, and the two different
tiebreak rules the in-memory comparator had are reproduced per sort key.

The summary's per-bucket email lists are capped at 200 with exact counters
beside them. They only back hover popovers, but shipping every match made
the response grow with the panel: at 100k clients it carried ~42k emails,
and the page revalidated all of them through a strict Zod parse every five
seconds. The popover now shows a "+N" chip for the remainder.

Browser side, the page fired three sequential list requests per load and
threw the first two away: the query went out before the persisted sort was
applied, and again before the configured page size was known -- 0 meaning
"one long page" is indistinguishable from "not loaded yet". The page size
is now derived rather than mirrored through an effect, and the previous
visit's value is remembered so the single request goes out at mount instead
of queueing behind /setting/defaultSettings.

Then the per-poll work. Reading isFetching made it a tracked property, so
the refetch interval notified twice per cycle and re-rendered the page even
when structural sharing left the data identical. Xray reports a traffic row
per client whether or not it moved bytes, so the speed map was mostly zeros
and was replaced wholesale every push; zero rows are now dropped and an
unchanged result returns the previous object, which lets React bail out
instead of re-rendering. The five Tooltip-wrapped buttons and the inbound
chips per row do not depend on traffic at all and are now memoised, keyed on
the email because a push replaces the row object of every client whose
counters moved. antd's hashed:false drops 3,311 :where(.css-<hash>) wrappers
and 29% of the generated stylesheet, and a pinned cssVar key stops each of
the eleven page-level ConfigProviders minting its own token scope.

Two callers that only need the mutations, GroupsPage and ClientBulkAddModal,
no longer start the list query -- the groups page had been polling the full
paged list every five seconds for data it never renders.
2026-07-30 02:49:32 +02:00
Sanaei 1e2d6f6081 fix(ci): close the TOCTOU race in the conflict-resolution bot
The resolve-conflicts job runs on issue_comment, a privileged trigger: it
holds GITHUB_TOKEN, the Claude OAuth token and the push PAT, and it checks
out fork code with `gh pr checkout`. The only gate was that the commenter
is the repository owner, which says nothing about the code that ends up in
the workspace. A contributor could force-push to the pull request head
between the owner asking for the merge and the runner fetching it, so the
owner reviews one tree and the job runs another.

Verify before anything is checked out that the head repository was last
pushed to before the triggering comment was written, and refuse the run
otherwise. Pin the head SHA reported by that check and abort if the commit
`gh pr checkout` lands on differs, which closes the remaining window
between the check and the fetch. Require author_association to be OWNER
alongside the existing login comparison.

This also clears CodeQL actions/untrusted-checkout/high, which for
issue_comment triggers demands both an actor/association check and a
comment-vs-head-date check dominating the checkout.
2026-07-29 21:12:28 +02:00
PathGao af5a8e5d40 fix(database): create SQLite backup snapshots online (#6137)
* fix(database): snapshot SQLite backups online

Use SQLite's online backup API for downloadable backups and SQLite migration exports instead of checkpointing then reading the live database file. The regression test validates a backup made while writes continue.

* style(database): group SQLite driver imports

* fix(database): bound online backup retries

Use a single backup step and a bounded connection-acquisition/retry context. Tighten temporary-file cleanup and regression assertions while removing the unused checkpoint helper.

* test(database): cover existing backup destinations

* fix(database): harden SQLite snapshot lifecycle

Sweep interrupted snapshot directories at SQLite startup, keep rollback-journal backups incremental, and make caller-owned cleanup explicit. Reuse one scheduled Telegram snapshot across administrators and make the direct SQLite driver dependency explicit.

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-29 21:04:55 +02:00
PathGao ad288a7ecc fix(sub): honor trustedProxyCIDRs before forwarded URLs (#6135)
* fix(sub): honor trustedProxyCIDRs before forwarded URLs

* fix(sub): avoid unused trust-setting lookups

Skip the trustedProxyCIDRs lookup when no forwarded header can affect a subscription URL. Keep the shipped proxy default in one exported setting constant and document the subscription-link behavior for custom proxy boundaries.

* fix(frontend): meet config text contrast requirements

Keep compact configuration text readable in the light theme and satisfy the Storybook accessibility check.

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-29 21:01:59 +02:00
PathGao ad5f2a28cb fix(xray): synchronize lifecycle state (#6138)
* fix(xray): synchronize lifecycle snapshots

Protect process replacement and result caching with a lifecycle state object, so read paths keep one process snapshot while restarts swap state safely. Bound version probing to prevent a stalled binary from holding the restart lock.

* test(xray): cover concurrent lifecycle reads

Exercise status, result, and traffic reads while the managed process is replaced, so the race detector guards the lifecycle snapshot boundary.

* fix(xray): guard process config snapshots

Synchronize hot-applied config snapshots, keep Telegram reads on one lifecycle snapshot, and strengthen lifecycle timeout and concurrency regression coverage.

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-29 21:00:29 +02:00
PathGao e467b25f03 fix(sub): coalesce external subscription refreshes (#6139)
* fix(sub): coalesce external subscription refreshes

Limit concurrent cache misses to one upstream request per URL and evict the oldest entries once the cache reaches its bounded capacity.

* fix(sub): preserve shared stale refresh results

Release every in-flight waiter on panic or error, carry the leader outcome to waiters, and strengthen cache capacity and stale fallback coverage.

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-29 20:58:00 +02:00
PathGao 03cc80bb9e fix(mtproto): synchronize child-process lifecycle (#6141)
* fix(mtproto): synchronize child-process state

Use lifecycle snapshots around the mtg command, completion signal, and exit error so Wait cannot race status and shutdown reads.

* test(mtproto): cover concurrent process exit

* test(mtproto): cover lifecycle field synchronization

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-29 20:56:26 +02:00
PathGao 863473783d fix(frontend): preserve cancellation and reject invalid query data (#6143)
* fix(frontend): preserve request cancellation and schema failures

* fix(frontend): limit schema failures to query boundaries

* fix(frontend): keep invalid settings recoverable

Keep settings payload validation tolerant so values accepted by the backend remain editable, while paged clients still fail closed. Add an AbortSignal.any fallback and make timeout tests event-driven.

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-29 20:51:40 +02:00
Sanaei c3fa73d5a0 feat(ui): redesign the overview page as a trend-first command deck
Replace the ten-small-cards overview with an action bar, four vitals
tiles carrying 72-sample sparklines seeded from /server/history, a
two-series throughput chart, a TCP/UDP connections chart, and a
grouped system strip (uptime xray|os, panel ram|threads, ip
addresses). StatusCard and XrayStatusCard are deleted; every modal
stays reachable from the action bar, the Xray error message moves
into a tooltip on the state pill, and the panel version text keeps
opening the update modal (the dev-channel switch lives there) even
when no update is available. Live values sit beside the
upload/download and tcp/udp legends, a health sentence appears only
when a vital crosses the shared warn/crit thresholds now exported
from models/status, and load average is left to System History.

The sidebar becomes an auto-collapsed 72px icon rail that expands as
an overlay on hover: rail width, brand-row height and menu paddings
are pinned so nothing shifts during the transition, the collapsed-menu
tooltips are disabled, hover state survives the per-page sidebar
remounts (with a matches(':hover') resync), and the manual collapse
trigger is gone.

Sparkline gains rgb()/rgba() support in its fill gradient, a
showLegend prop so pages stop reaching into its internals, and loses
a dependency-less repaint effect that doubled canvas paints. Chart
tooltips show clock time via the new TimeFormatter.formatClock;
accents come from theme tokens instead of status.cpu.color. Verified
by screenshot at 390/800/1150/1280/1400/1600px in light and dark,
en and fa-IR, plus programmatic geometry checks on the sidebar.
Locale files gain 8 keys and lose 9 dead ones across all 13
languages.
2026-07-29 20:17:37 +02:00
PathGao 87ebcc7a6f feat(ui): tag settings that sit at their shipped default value (#6128)
* feat(ui): tag settings that sit at their shipped default value

A field showing 2096 reads identically whether the install never set
it or the operator saved 2096 — newcomers cannot tell which knobs
they have touched, and after the cleared-port fix (#6121) a port can
never visually return to an unset state. Add a small grey tag next to
numeric settings whose current value equals the shipped default.

The tag deliberately compares values, not provenance: a stored 2096
and a fallback 2096 behave identically, so they read identically, and
the tag reacts live as the user types.

The backing endpoint filters defaultValueMap through the AllSetting
field set, so per-install material (secret, panelGuid, node mTLS
keys) and redacted credential fields never leave the server; a test
pins that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): keep the default tag out of the accessible name, pin the defaults contract

From review, in order of severity:

The badge was rendered inside the element whose id feeds the
control's aria-labelledby, so a visible tag changed every field's
accessible name ('Panel Port Default'). The title text now carries
the id on its own span and the badge sits beside it.

The same default values live in three places: the Go defaultValueMap,
the frontend AllSetting class, and the tag's verdict. A new contract
test parses the Go map's string literals and asserts every shared key
matches the AllSetting class default through the tag's own
comparison — and on first run it caught two real drifts
(tgEnabledEvents / smtpEnabledEvents defaulted to '' in the class but
'login.attempt,cpu.high' on the server), now aligned.

matchesFactoryDefault no longer coerces blank or unparsable defaults
(Number('') is 0; a junk string is not false). The Go tests are
table-driven t.Run subtests and gained the structural invariant:
every returned key is an AllSetting json tag outside the credential
deny-list. The service doc comment now describes the projection
mechanism instead of overclaiming; the i18n key is re-indented and
placed at the head of pages.settings in all 13 locales; the fetch
falls back to {} when validation fails; and smtpPort gets the tag so
plain numeric settings-list fields are covered uniformly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:03:21 +02:00
PathGao 55f0281692 chore(lint): forbid the Number-or-clamp idiom in direct-write settings pages (#6129)
* chore(lint): forbid the Number-or-clamp idiom in direct-write settings pages

Follow-up promised in #6127's review thread: the settings and xray
pages write numeric changes straight into state, so a regressed
handler silently ships the cleared-port bug again. A scoped
no-restricted-syntax rule now rejects Number(...) || N inside an
onChange attribute in those directories, pointing at onNumber().

The one remaining match, the Telegram notify interval, moves onto the
helper with its floor intact: clearing now keeps the stored count
instead of writing 1, and Math.max still clamps typed values. Form
modals that stage values behind Zod keep their deliberate
clear-means-zero semantics; the rule deliberately does not apply
there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(lint): widen the numeric-clamp guard to the shapes that actually drift

From review: the rule matched only the Number-or-literal shape, while
two semantically identical ternary sites already lived inside its own
directories, so 'zero suppressions' reflected the selector's
narrowness rather than a clean subtree. The rule now catches the
ternary typeof form and the nullish-coalescing form too, is anchored
to InputNumber elements so its message can never point a ChangeEvent
handler at a number-typed helper, and documents the extracted-handler
shape it cannot see.

The xray form modals stage values behind Zod like the clients modals
do, so a follow-up config object exempts them explicitly instead of
the comment claiming they were never in scope.

BasicsTab's Happy Eyeballs try-delay — the one genuine direct-write
ternary — moves onto onNumber: clearing keeps the stored delay
instead of writing 0, and 0 stays reachable by typing it. The
Telegram interval gains precision={0} so a typed decimal cannot
compose an @every value its own parser rejects on reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:02:36 +02:00
PathGao bcd71c9296 chore(build): stop shipping production sourcemaps inside the binary (#6131)
* chore(build): stop shipping production sourcemaps inside the binary

Everything under internal/web/dist is embedded into the release
binary via embed.FS, and sourcemap: true put 112 .map files — 18MB,
72% of dist — inside every build users download. Nothing consumes
them there: the panel never references them and npm run dev serves
its own maps regardless of this flag. dist drops from 25MB to 6.7MB;
flip the flag locally when a production bundle needs debugging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(build): gate production sourcemaps behind XUI_SOURCEMAP

From review: hard-coding false made the documented debugging path an
edit to a tracked file, and the XUI_DEBUG serve-from-disk flow lost
maps with no zero-diff way back. XUI_SOURCEMAP=true at build time
restores them; the default stays off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:01:18 +02:00
PathGao 33f72f8f4a fix(api): authenticate GET /panel/api/openapi.json + pin the route registry to the router (#6133)
* test(web): pin the endpoints.ts registry to the actual Gin routes

endpoints.ts is a hand-maintained registry and nothing checked it
against the router: an omitted API route silently vanishes from the
generated OpenAPI docs, and an entry for a removed route documents an
endpoint that 404s. Two new tests construct the real router against a
throwaway DB and diff the /panel/api surface both ways.

The check found one gap on arrival: GET /panel/api/openapi.json — the
endpoint that serves the docs — was itself undocumented. Registered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api)+test: authenticate openapi.json, fold the two route-contract tests into one

Three things from the review, in severity order.

The bot found that GET /panel/api/openapi.json was registered on the
base-path group one line before the /panel/api group installs
checkAPIAuth, so Gin's snapshot of the parent chain meant the whole
admin API surface plus build version was fetchable without a session
— while this very PR was about to document it as auth-required. Move
the registration inside the authed api group. Verified: unauthenticated
it now 404s exactly like server/status (was 200), and a logged-in
session still serves it 200, so the docs page is unaffected.

The existing api_docs_test.go already checked the forward direction by
regex-scanning controller source against a hand-maintained per-file
path switch — which is why it missed this web.go-registered route, and
whose fall-through default silently mis-paths any unlisted controller
file. The new router-based test is a strict superset, so fold in the
extra surface it guarded (/login, /logout, /csrf-token,
/getTwoFactorEnable, /ws) and delete the old test rather than run two.

Harden the endpoints.ts parser: pair each method with the next path
sequentially instead of a brace-crossing regex, and fail loudly when
the parsed count doesn't match the declared method fields. Construct
the server once across both subtests, cancel it, and restore the
previous global on cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:00:05 +02:00
PathGao ea35884390 chore(i18n): delete 230 dead translation keys and guard against new ones (#6132)
* chore(i18n): delete 230 dead translation keys and guard against new ones

The 13 locale files carried 230 keys (11% of the set) that nothing in
the frontend or Go sources references — leftovers of renamed features
(the email notifier reuses tgbot.messages.* for subjects, the old
email.subject*/title* set was orphaned; likewise menu.*, the clients
bulk-copy strings, and the secAlert* family). Nothing detected this:
a missing key falls back to en-US and an unused key fails nothing.

A new test now fails the build when an en-US key has no reference in
frontend/src or internal Go sources (dynamic keys are covered by
harvesting concatenation and template-literal prefixes), and pins
that all 13 locales carry exactly the en-US key set, so parity drift
surfaces at test time instead of as a silent fallback.

Each locale shrinks by the same 230 keys; net -2,900 lines across
the translation set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(i18n): restore the 29 live remarkVars keys, match whole tokens, unmask 9 more

From review: the template-literal harvester required the prefix to end
on a dot, so pages.hosts.remarkVars.desc${token} harvested nothing
and all 29 desc* tooltip keys were wrongly deleted — and the guard
shared the flawed logic, so CI stayed green while the Hosts page
would have shown raw key names in 13 languages. Restored from the
parent commit; the harvester now requires at least one dot but not a
trailing one.

Also from review: references are matched as whole dotted tokens
instead of substrings (a dead key can no longer hide behind a longer
sibling — that unmasked 9 more genuinely dead keys, each verified by
hand before deletion), and the test excludes itself from the scan so
its own prose cannot whitelist a subtree.

Net: -210 keys per locale instead of the previous -230.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:59:25 +02:00
124 changed files with 6297 additions and 4741 deletions
+31 -1
View File
@@ -766,7 +766,7 @@ jobs:
fi
resolve-conflicts:
if: github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, 'resolve pr conflicts') && github.event.comment.user.login == github.repository_owner
if: github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, 'resolve pr conflicts') && github.event.comment.user.login == github.repository_owner && github.event.comment.author_association == 'OWNER'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -774,6 +774,29 @@ jobs:
pull-requests: write
id-token: write
steps:
- name: Refuse a head that moved after the request
id: freshness
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ github.event.issue.number }}
COMMENT_AT: ${{ github.event.comment.created_at }}
run: |
set -euo pipefail
head=$(gh api "repos/${REPO}/pulls/${PR}" --jq '"\(.head.sha) \(.head.repo.pushed_at // "")"')
HEAD_SHA=${head%% *}
HEAD_PUSHED_AT=${head#* }
if [ -z "$HEAD_PUSHED_AT" ]; then
gh pr comment "$PR" --repo "$REPO" --body "The head repository of this pull request is gone, so its branch cannot be verified or merged. Nothing was changed."
echo "::error::The head repository is unavailable; refusing to check it out."
exit 1
fi
if [ "$(date -d "$HEAD_PUSHED_AT" +%s)" -gt "$(date -d "$COMMENT_AT" +%s)" ]; then
gh pr comment "$PR" --repo "$REPO" --body "The head branch was pushed to at ${HEAD_PUSHED_AT}, after this was requested at ${COMMENT_AT}, so the code that would be checked out here is not the code that was reviewed. Nothing was changed. Ask again to act on the current head."
echo "::error::The head moved after the request; refusing to check it out."
exit 1
fi
echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -783,6 +806,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.issue.number }}
PINNED_SHA: ${{ steps.freshness.outputs.sha }}
run: |
set -euo pipefail
hand_back() {
@@ -801,6 +825,12 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
gh pr checkout "$PR"
checked_out=$(git rev-parse HEAD)
if [ "$checked_out" != "$PINNED_SHA" ]; then
gh pr comment "$PR" --body "The head of this pull request moved from \`${PINNED_SHA}\` to \`${checked_out}\` while this run was starting, so nothing was changed."
echo "::error::The head moved from ${PINNED_SHA} to ${checked_out} during the run."
exit 1
fi
git fetch origin "$base"
if git merge --no-commit --no-ff "origin/${base}"; then
git merge --abort 2>/dev/null || true
+1 -1
View File
@@ -19,7 +19,7 @@ browser in full.
| `webBasePath` | `/` | URL path the panel is served under (always normalized to `/…/`). |
| `webCertFile` / `webKeyFile` | _(none)_ | TLS certificate + key. When both are set, the panel serves **HTTPS**. |
| `sessionMaxAge` | `360` | Session lifetime in **minutes** (default 6 hours). |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPs/CIDRs whose forwarded headers (real client IP) are trusted. |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPs/CIDRs whose forwarded headers (real client IP) are trusted. A custom value also controls forwarded host and scheme in subscription links; include the subscription proxy or set `subURI` to override those links. |
| `panelOutbound` | _(none)_ | Route the panel's own egress (update checks, Telegram, geo/sub fetches) through a named Xray outbound. |
After changing the port or base path, the panel URL becomes
+1 -1
View File
@@ -19,7 +19,7 @@ icon: SlidersHorizontal
| `webBasePath` | `/` | مسیر URLی که پنل زیر آن ارائه می‌شود (همیشه به شکل `/…/` نرمال‌سازی می‌شود). |
| `webCertFile` / `webKeyFile` | _(هیچ‌کدام)_ | گواهی + کلید TLS. وقتی هر دو تنظیم شوند، پنل با **HTTPS** ارائه می‌شود. |
| `sessionMaxAge` | `360` | طول عمر نشست بر حسب **دقیقه** (پیش‌فرض ۶ ساعت). |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPها/CIDRهایی که هدرهای فورواردشده‌شان (IP واقعی کلاینت) مورد اعتماد است. |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPها/CIDRهایی که هدرهای فورواردشده‌شان (IP واقعی کلاینت) مورد اعتماد است. مقدار سفارشی همچنین میزبان و طرحِ لینک‌های اشتراک را کنترل می‌کند؛ پراکسی اشتراک را اضافه کنید یا برای بازنویسی این لینک‌ها `subURI` را تنظیم کنید. |
| `panelOutbound` | _(هیچ‌کدام)_ | مسیریابی خروجیِ خود پنل (بررسی به‌روزرسانی‌ها، Telegram، واکشی geo/sub) از طریق یک خروجی Xray با نام مشخص. |
پس از تغییر پورت یا مسیر پایه، آدرس پنل به‌صورت
+1 -1
View File
@@ -19,7 +19,7 @@ icon: SlidersHorizontal
| `webBasePath` | `/` | URL-путь, по которому обслуживается панель (всегда нормализуется к `/…/`). |
| `webCertFile` / `webKeyFile` | _(нет)_ | Сертификат TLS + ключ. Когда заданы оба, панель обслуживается по **HTTPS**. |
| `sessionMaxAge` | `360` | Время жизни сессии в **минутах** (по умолчанию 6 часов). |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IP-адреса/CIDR, чьим переадресованным заголовкам (реальный IP клиента) можно доверять. |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IP-адреса/CIDR, чьим переадресованным заголовкам (реальный IP клиента) можно доверять. Пользовательское значение также управляет пересылаемыми хостом и схемой в ссылках подписки; добавьте прокси подписки или задайте `subURI`, чтобы переопределить эти ссылки. |
| `panelOutbound` | _(нет)_ | Маршрутизация собственного исходящего трафика панели (проверка обновлений, Telegram, запросы geo/подписок) через именованный исходящий канал Xray. |
После изменения порта или базового пути URL панели становится
+1 -1
View File
@@ -15,7 +15,7 @@ icon: SlidersHorizontal
| `webBasePath` | `/` | 面板对外提供服务所使用的 URL 路径(始终规范化为 `/…/`)。 |
| `webCertFile` / `webKeyFile` | _(无)_ | TLS 证书 + 密钥。两者都设置后,面板将以 **HTTPS** 提供服务。 |
| `sessionMaxAge` | `360` | 会话有效期,单位为**分钟**(默认 6 小时)。 |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | 其转发头(真实客户端 IP)受信任的 IP/CIDR。 |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | 其转发头(真实客户端 IP)受信任的 IP/CIDR。自定义值还会控制订阅链接中转发的主机和协议;请将订阅代理加入列表,或设置 `subURI` 覆盖这些链接。 |
| `panelOutbound` | _(无)_ | 通过一个命名的 Xray 出站来路由面板自身的出口流量(更新检查、Telegram、地理/订阅拉取)。 |
更改端口或基础路径后,面板 URL 将变为
+6 -5
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useLayoutEffect } from 'react';
import type { Decorator, Preview } from '@storybook/react-vite';
import { ConfigProvider } from 'antd';
import i18next from 'i18next';
@@ -17,11 +17,12 @@ if (!i18next.isInitialized) {
});
}
const withTheme: Decorator = (Story, context) => {
export const withTheme: Decorator = (Story, context) => {
const dark = context.globals.theme === 'dark';
useEffect(() => {
document.body.setAttribute('class', dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
useLayoutEffect(() => {
document.body.classList.remove('dark', 'light');
document.body.classList.add(dark ? 'dark' : 'light');
document.documentElement.removeAttribute('data-theme');
}, [dark]);
return (
<ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
+33
View File
@@ -53,4 +53,37 @@ export default [
'jsx-a11y/no-autofocus': 'off',
},
},
{
// The settings and xray pages write numeric InputNumber changes straight
// into state, so a null-collapsing handler (`Number(v) || N`, or the
// ternary `typeof v === 'number' ? v : N`) turns a cleared field into a
// stored N — the cleared-port bug, #6121. Handlers here go through
// onNumber() (src/utils/onNumber.ts) instead. Known limit: a handler
// extracted into a variable and passed as onChange={handler} is not
// matched; the inline shapes below are the ones that drift in practice.
files: ['src/pages/settings/**/*.tsx', 'src/pages/xray/**/*.tsx'],
rules: {
'no-restricted-syntax': ['error', {
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="||"] > CallExpression[callee.name="Number"]',
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
}, {
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] ConditionalExpression[test.left.operator="typeof"][alternate.type="Literal"]',
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
}, {
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="??"][right.type="Literal"]',
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
}],
},
},
{
// The xray form modals (OutboundFormModal, BalancerFormModal,
// DnsServerModal, WarpModal, …) stage values behind Zod validation like
// the clients/inbounds modals do, and some of their fields carry a
// deliberate clear-means-zero semantic — the direct-write rule above
// does not apply to them.
files: ['src/pages/xray/**/*Modal.tsx'],
rules: {
'no-restricted-syntax': 'off',
},
},
];
+123 -106
View File
@@ -1,17 +1,17 @@
{
"name": "3x-ui-frontend",
"version": "0.4.3",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "3x-ui-frontend",
"version": "0.4.3",
"version": "0.6.0",
"dependencies": {
"@ant-design/icons": "^6.3.2",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^5.4.3",
"@hookform/resolvers": "^5.5.7",
"@noble/hashes": "^2.2.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query-devtools": "^5.101.4",
@@ -32,10 +32,10 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@storybook/addon-a11y": "^10.5.4",
"@storybook/addon-docs": "^10.5.4",
"@storybook/addon-vitest": "^10.5.4",
"@storybook/react-vite": "^10.5.4",
"@storybook/addon-a11y": "^10.5.5",
"@storybook/addon-docs": "^10.5.5",
"@storybook/addon-vitest": "^10.5.5",
"@storybook/react-vite": "^10.5.5",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.17",
@@ -47,13 +47,13 @@
"eslint": "^10.8.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.7.0",
"globals": "^17.8.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"jsdom": "^30.0.1",
"lint-staged": "^17.2.0",
"msw": "^2.15.0",
"playwright": "^1.62.0",
"storybook": "^10.5.4",
"storybook": "^10.5.5",
"typescript": "6.0.3",
"typescript-eslint": "^8.65.0",
"vite": "8.1.5",
@@ -165,56 +165,58 @@
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
"integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-calc": "^3.2.1",
"@csstools/css-color-parser": "^4.1.9",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz",
"integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"dev": true,
"license": "MIT",
"license": "BlueOak-1.0.0",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1384,9 +1386,9 @@
}
},
"node_modules/@hookform/resolvers": {
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.3.tgz",
"integrity": "sha512-jtS1DEvkT1ql8ImIDQL+UmkhEnDmt3Bp7Yt4q5XQpc0JiQ8b8+I7yT/KQze4gw9Ocmi9xa9ef6zxifizPgdp3A==",
"version": "5.5.7",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.5.7.tgz",
"integrity": "sha512-CyPCYV8/KlXfEXLWj8HHHhVsR/IZ6Ckm3b/a4fWtO/lRnRK1huqncb8LlAWrmpPRsse5glF5aVuDRMHEr3UGag==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
@@ -1413,7 +1415,7 @@
"react-hook-form": "^7.55.0",
"superstruct": ">=0.12.0",
"typanion": "^3.3.2",
"valibot": "^1.0.0 || ^1.0.0-beta.4 || ^1.0.0-rc",
"valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc",
"vest": ">=3.0.0",
"yup": "^1.0.0",
"zod": "^3.25.0 || ^4.0.0"
@@ -3631,9 +3633,9 @@
"license": "MIT"
},
"node_modules/@storybook/addon-a11y": {
"version": "10.5.4",
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.4.tgz",
"integrity": "sha512-UWdZtB7Dh1GjqxTPOrisUNDhDGF5pKGzZdzW9DSSHMBcAOx2dsTmXGzLSFprAz4LTT0OHCtKhxNqKK0JZJ0Y8g==",
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.5.tgz",
"integrity": "sha512-nsMnSRe7pzepXIkUUqI/rL7sp8juXOluyypU4Dz0UuYHhw/cKaxfzuO+3WJN5EtEr/gcnKYb4awxH/duPGavUw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3645,20 +3647,20 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^10.5.4"
"storybook": "^10.5.5"
}
},
"node_modules/@storybook/addon-docs": {
"version": "10.5.4",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.4.tgz",
"integrity": "sha512-2Z/x2pKEmXOCQjmttYzPuQBu9aWeMly8uEs3msrCTBLiHs/F7IlBFnMu0Z+T2Qvk0LEy8O93AlcPSP76aCcKjw==",
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.5.tgz",
"integrity": "sha512-0YpKlimS4XE0kQ8Maa5coeefQxdyDrBHg1wOP3WTPuBe4FolFSCDveR0ge2+vuUBk+fZfn2+l+3Q2jmAWaRGDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
"@storybook/csf-plugin": "10.5.4",
"@storybook/csf-plugin": "10.5.5",
"@storybook/icons": "^2.0.2",
"@storybook/react-dom-shim": "10.5.4",
"@storybook/react-dom-shim": "10.5.5",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
@@ -3669,7 +3671,7 @@
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.5.4"
"storybook": "^10.5.5"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -3678,9 +3680,9 @@
}
},
"node_modules/@storybook/addon-vitest": {
"version": "10.5.4",
"resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.4.tgz",
"integrity": "sha512-5lr81sgrbK3Tjjbstieg/yYj3jdtQYP70ihpOiN1TwogvFATY2/9eMT46FE5ioKtEFcfJRDcgJ2wSlmJKE+kZw==",
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.5.tgz",
"integrity": "sha512-Ymq9ErkSkYiIDuqpJ2+hE5GCQ5J6TCLOWhutqArvwaeAO+HAibM82XNExpJ1/kvPqk9y961GDPkv2W15I88JIw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3695,7 +3697,7 @@
"@vitest/browser": "^3.0.0 || ^4.0.0",
"@vitest/browser-playwright": "^4.0.0",
"@vitest/runner": "^3.0.0 || ^4.0.0",
"storybook": "^10.5.4",
"storybook": "^10.5.5",
"vitest": "^3.0.0 || ^4.0.0"
},
"peerDependenciesMeta": {
@@ -3714,13 +3716,13 @@
}
},
"node_modules/@storybook/builder-vite": {
"version": "10.5.4",
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.4.tgz",
"integrity": "sha512-eXdgow+brSzZrG3CnG18YwxZwEYNAZIh2G5qKwNoZf0uk2ZCPgLRQwtVAcd7BiiEDGnXUHm9pycAS+UiF4F4Mg==",
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.5.tgz",
"integrity": "sha512-dQoJ7gUl8y0z5rV9cE0mz6qTBNmN9R4GOLIZk98rJ8CwduNJOb9eGZXusDzzvnYcp8TnNkqDtyx4tXQSUDInPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/csf-plugin": "10.5.4",
"@storybook/csf-plugin": "10.5.5",
"ts-dedent": "^2.0.0"
},
"funding": {
@@ -3728,14 +3730,14 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^10.5.4",
"storybook": "^10.5.5",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@storybook/csf-plugin": {
"version": "10.5.4",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.4.tgz",
"integrity": "sha512-DSp5Z/eZlRnKq0KrKLJE6uoYf/Ysc+FP0Z5DVTGnOrie+z3tC0lNi9I4RB++EXkJeUDS9/4dxvJZVSWjhLlXxw==",
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.5.tgz",
"integrity": "sha512-/euibhRFqklYCZqUseokojmfYcQpXshVY2QmA1qCuxMz9SzVFD3iSTw+aFLTxpsJGGdcZJk8fnm/rEthLzZ9jA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3748,7 +3750,7 @@
"peerDependencies": {
"esbuild": "*",
"rollup": "*",
"storybook": "^10.5.4",
"storybook": "^10.5.5",
"vite": "*",
"webpack": "*"
},
@@ -3785,14 +3787,14 @@
}
},
"node_modules/@storybook/react": {
"version": "10.5.4",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.4.tgz",
"integrity": "sha512-tOxfVgbYcaVsArN8XTDkJfdsnsnHh1LxjRHVpJ/N+VEkz4FveK/XH3jOLV0YqgrG8yXza7+CteDP4FfPVQY/mw==",
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.5.tgz",
"integrity": "sha512-T2Xj0ey7a9RHU6coYLC0L5lhjcdyhLCs9wNv15FvHvgmrRobkynEV72kq5vGW8tFkahNWI1X9+GZPQ6r8Nm38w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/react-dom-shim": "10.5.4",
"@storybook/react-dom-shim": "10.5.5",
"react-docgen": "^8.0.2",
"react-docgen-typescript": "^2.2.2"
},
@@ -3805,7 +3807,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.5.4",
"storybook": "^10.5.5",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@@ -3821,9 +3823,9 @@
}
},
"node_modules/@storybook/react-dom-shim": {
"version": "10.5.4",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.4.tgz",
"integrity": "sha512-YdlppEOReg8MvTECRNuf79gu2zL83JqKDHIR/65eS0M6y+ue9pkpfjYo7hZVIcyOcRd9npBDXMdt2kC92bCuaA==",
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.5.tgz",
"integrity": "sha512-PIk7N3LLrZIxfNxmkvmQN1d5UQ70XEedT8n0GhBiXnM6XL09xPGB8n8TZXeJBRYluKhDQcAyQeT0/OZmcDVQJg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -3835,7 +3837,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.5.4"
"storybook": "^10.5.5"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -3847,16 +3849,16 @@
}
},
"node_modules/@storybook/react-vite": {
"version": "10.5.4",
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.4.tgz",
"integrity": "sha512-iw7EAA98n30vpf+ZSy2Ll4Ne7oyZ/lS6W22u5Sp5UOI2oAkYuklxIWjRIKGqpY0BHP+GqaGYtUGMZQnBqgul2g==",
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.5.tgz",
"integrity": "sha512-Uy7VV72kVSkw6aDTAPQupXUeZX5LF6e4zqNvTZ+36qxsXAkaFgw7HPEm7L1tsaRfiV+s9anU7UvX47tfJpYGuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0",
"@rollup/pluginutils": "^5.0.2",
"@storybook/builder-vite": "10.5.4",
"@storybook/react": "10.5.4",
"@storybook/builder-vite": "10.5.5",
"@storybook/react": "10.5.5",
"empathic": "^2.0.0",
"magic-string": "^0.30.0",
"react-docgen": "^8.0.2",
@@ -3870,7 +3872,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.5.4",
"storybook": "^10.5.5",
"typescript": ">= 4.9.x",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
},
@@ -7165,7 +7167,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/fast-json-patch": {
@@ -7531,9 +7533,9 @@
}
},
"node_modules/globals": {
"version": "17.7.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
"integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==",
"version": "17.8.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz",
"integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8525,39 +8527,39 @@
}
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@asamuzakjp/css-color": "^6.0.5",
"@asamuzakjp/dom-selector": "^8.3.0",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"tough-cookie": "^6.0.2",
"undici": "^8.9.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"whatwg-url": "^17.1.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
@@ -8575,6 +8577,21 @@
"node": "20 || >=22"
}
},
"node_modules/jsdom/node_modules/whatwg-url": {
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -10420,7 +10437,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -10967,9 +10984,9 @@
}
},
"node_modules/storybook": {
"version": "10.5.4",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.4.tgz",
"integrity": "sha512-bmLxPsxVSPnbeiZqYQpozyNOiJXfk+pf7WfHZflvPkwT6Y+rvYz3Cj/D6H4Kf2jHpuDNiMXBKO3yawLN2OWirg==",
"version": "10.5.5",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.5.tgz",
"integrity": "sha512-UscBIBJDloUeqntukHOhP1a5W/vouePDJbzPSxj466WK801FZtzQiMffMtkjzJiWSuj20wfaYlB2QQKh9aOYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10989,7 +11006,7 @@
"recast": "^0.23.5",
"semver": "^7.7.3",
"use-sync-external-store": "^1.5.0",
"ws": "^8.18.0"
"ws": "^8.21.1"
},
"bin": {
"storybook": "dist/bin/dispatcher.js"
@@ -11760,13 +11777,13 @@
}
},
"node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
"node": ">=22.19.0"
}
},
"node_modules/undici-types": {
+9 -9
View File
@@ -1,7 +1,7 @@
{
"name": "3x-ui-frontend",
"private": true,
"version": "0.4.3",
"version": "0.6.0",
"type": "module",
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
"engines": {
@@ -30,7 +30,7 @@
"@ant-design/icons": "^6.3.2",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^5.4.3",
"@hookform/resolvers": "^5.5.7",
"@noble/hashes": "^2.2.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query-devtools": "^5.101.4",
@@ -51,10 +51,10 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@storybook/addon-a11y": "^10.5.4",
"@storybook/addon-docs": "^10.5.4",
"@storybook/addon-vitest": "^10.5.4",
"@storybook/react-vite": "^10.5.4",
"@storybook/addon-a11y": "^10.5.5",
"@storybook/addon-docs": "^10.5.5",
"@storybook/addon-vitest": "^10.5.5",
"@storybook/react-vite": "^10.5.5",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.17",
@@ -66,13 +66,13 @@
"eslint": "^10.8.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.7.0",
"globals": "^17.8.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"jsdom": "^30.0.1",
"lint-staged": "^17.2.0",
"msw": "^2.15.0",
"playwright": "^1.62.0",
"storybook": "^10.5.4",
"storybook": "^10.5.5",
"typescript": "6.0.3",
"typescript-eslint": "^8.65.0",
"vite": "8.1.5",
+68 -2
View File
@@ -3980,6 +3980,36 @@
}
}
},
"/panel/api/openapi.json": {
"get": {
"tags": [
"Server"
],
"summary": "Serve this API description as an OpenAPI 3 document — the same file that powers the API Docs page. Requires a session or Bearer token like the rest of /panel/api. Useful for generating clients or importing into API tooling.",
"operationId": "get_panel_api_openapi_json",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/server/status": {
"get": {
"tags": [
@@ -5661,7 +5691,7 @@
"tags": [
"Clients"
],
"summary": "Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.",
"summary": "Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters: the *Count fields are exact, while the email arrays beside them stop at 200 entries so the payload does not grow with the panel. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.",
"operationId": "get_panel_api_clients_list_paged",
"parameters": [
{
@@ -5777,12 +5807,18 @@
"summary": {
"total": 2000,
"active": 1850,
"onlineCount": 1,
"depletedCount": 0,
"expiringCount": 0,
"deactiveCount": 150,
"online": [
"alice@example.com"
],
"depleted": [],
"expiring": [],
"deactive": []
"deactive": [
"bob@example.com"
]
}
}
}
@@ -9739,6 +9775,36 @@
}
}
},
"/panel/api/setting/factoryDefaults": {
"post": {
"tags": [
"Settings"
],
"summary": "Return the shipped (factory) default value per browser-safe setting key, so clients can tell a stored value apart from the default it would fall back to. Per-install material (secret, panelGuid, mTLS keys) and credential fields are never included.",
"operationId": "post_panel_api_setting_factoryDefaults",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/setting/update": {
"post": {
"tags": [
+24 -2
View File
@@ -88,6 +88,28 @@ function encodeForm(data: unknown): string {
return parts.join('&');
}
function appendQuery(url: string, query: string): string {
if (query === '') return url;
const hashIndex = url.indexOf('#');
const path = hashIndex === -1 ? url : url.slice(0, hashIndex);
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
const hasQuery = path.includes('?');
const separator = !hasQuery ? '?' : path.endsWith('?') || path.endsWith('&') ? '' : '&';
return `${path}${separator}${query}${hash}`;
}
function requestSignal(options: HttpRequestOptions): AbortSignal | undefined {
if (!options.timeout) return options.signal;
const timeout = AbortSignal.timeout(options.timeout);
if (!options.signal) return timeout;
if (typeof AbortSignal.any === 'function') return AbortSignal.any([options.signal, timeout]);
const controller = new AbortController();
const abort = () => controller.abort();
options.signal.addEventListener('abort', abort, { once: true });
timeout.addEventListener('abort', abort, { once: true });
return controller.signal;
}
async function performFetch(
method: string,
url: string,
@@ -121,8 +143,8 @@ async function performFetch(
}
const query = encodeForm(options.params);
const fullUrl = basePathPrefix + url + (query ? `?${query}` : '');
const signal = options.timeout ? AbortSignal.timeout(options.timeout) : options.signal;
const fullUrl = basePathPrefix + appendQuery(url, query);
const signal = requestSignal(options);
return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
}
+37 -20
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil, Msg } from '@/utils';
@@ -6,8 +6,13 @@ import { parseMsg } from '@/utils/zodValidate';
import { AllSetting } from '@/models/setting';
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
import { keys } from '@/api/queryKeys';
import { useServerDraft } from '@/hooks/useServerDraft';
type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
type SettingSaveResult = {
msg: Msg<unknown>;
saved?: AllSetting;
};
async function fetchAllSetting(): Promise<AllSettingInput | null> {
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
@@ -18,7 +23,6 @@ async function fetchAllSetting(): Promise<AllSettingInput | null> {
export function useAllSettings() {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<AllSetting>(() => new AllSetting());
const [extraSpinning, setExtraSpinning] = useState(false);
const query = useQuery({
@@ -28,41 +32,54 @@ export function useAllSettings() {
});
const server = useMemo(() => new AllSetting(query.data), [query.data]);
useEffect(() => {
if (query.data !== undefined) {
setDraft(new AllSetting(query.data));
}
}, [query.data]);
const { draft, setDraft, isDirty, markSaved } = useServerDraft(
query.data === undefined ? undefined : server,
(setting) => new AllSetting(setting),
(left, right) => left.equals(right),
);
const allSetting = draft ?? server;
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
setDraft((prev) => {
const next = new AllSetting(prev);
const next = new AllSetting(prev ?? server);
Object.assign(next, patch);
return next;
});
}, []);
}, [server, setDraft]);
const saveMut = useMutation({
mutationFn: async (next: SettingSavePayload): Promise<Msg<unknown>> => {
const payload = { ...next };
const body = AllSettingSchema.partial().safeParse(payload);
mutationFn: async ({ payload, saved }: { payload: SettingSavePayload; saved?: AllSetting }): Promise<SettingSaveResult> => {
const next = { ...payload };
const body = AllSettingSchema.partial().safeParse(next);
if (!body.success) {
console.warn('[zod] setting/update body failed validation', body.error.issues);
}
return HttpUtil.post('/panel/api/setting/update', body.success ? { ...payload, ...body.data } : payload);
const msg = await HttpUtil.post('/panel/api/setting/update', body.success ? { ...next, ...body.data } : next);
return { msg, saved };
},
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
onSuccess: ({ msg, saved }) => {
if (!msg?.success) return;
if (saved) markSaved(saved);
queryClient.invalidateQueries({ queryKey: keys.settings.all() });
},
});
const saveAll = useCallback(() => saveMut.mutateAsync({ ...draft }), [saveMut, draft]);
const savePayload = useCallback((payload: SettingSavePayload) => saveMut.mutateAsync(payload), [saveMut]);
const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
const saveAll = useCallback(async () => {
const saved = new AllSetting(allSetting);
return (await saveMut.mutateAsync({ payload: { ...saved }, saved })).msg;
}, [allSetting, saveMut]);
const savePayload = useCallback(
async (payload: SettingSavePayload) => {
const saved = new AllSetting(allSetting);
Object.assign(saved, payload);
return (await saveMut.mutateAsync({ payload, saved })).msg;
},
[allSetting, saveMut],
);
const saveDisabled = !isDirty;
return {
allSetting: draft,
allSetting,
updateSetting,
fetched: query.data !== undefined,
spinning: extraSpinning || saveMut.isPending,
@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { FactoryDefaultsSchema, type FactoryDefaults } from '@/schemas/setting';
import { keys } from '@/api/queryKeys';
async function fetchFactoryDefaults(): Promise<FactoryDefaults> {
const msg = await HttpUtil.post('/panel/api/setting/factoryDefaults', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch factory defaults');
const validated = parseMsg(msg, FactoryDefaultsSchema, 'setting/factoryDefaults');
const parsed = FactoryDefaultsSchema.safeParse(validated.obj);
return parsed.success ? parsed.data : {};
}
export function useFactoryDefaults() {
return useQuery({
queryKey: keys.settings.factoryDefaults(),
queryFn: fetchFactoryDefaults,
staleTime: Infinity,
});
}
+1
View File
@@ -17,6 +17,7 @@ export const keys = {
root: () => ['settings'] as const,
all: () => ['settings', 'all'] as const,
defaults: () => ['settings', 'defaults'] as const,
factoryDefaults: () => ['settings', 'factoryDefaults'] as const,
},
inbounds: {
root: () => ['inbounds'] as const,
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Popover, Progress } from 'antd';
@@ -17,7 +17,11 @@ export interface ClientTrafficCellProps {
compact?: boolean;
}
export default function ClientTrafficCell({
// Every prop is a primitive and the component is pure, so the memo bails out
// whenever a client's counters did not move — which is most of them on most
// pushes. Each skipped instance is one antd Popover (rc-trigger), one Progress,
// a useTranslation subscription and a theme context read, times up to 200 rows.
const ClientTrafficCell = memo(function ClientTrafficCell({
up = 0,
down = 0,
total = 0,
@@ -83,4 +87,6 @@ export default function ClientTrafficCell({
</div>
</Popover>
);
}
});
export default ClientTrafficCell;
@@ -16,6 +16,10 @@ body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold {
color: #874d00;
}
body.light .config-block-text {
color: #595959;
}
.config-block .ant-collapse-extra {
display: flex;
align-items: center;
@@ -0,0 +1,37 @@
import { Tag } from 'antd';
import { useTranslation } from 'react-i18next';
import { useFactoryDefaults } from '@/api/queries/useFactoryDefaults';
/**
* Value semantics on purpose: the tag answers "does this equal the shipped
* default?", not "has the user ever saved this key?" — a stored 2096 and a
* fallback 2096 behave identically, so they read identically.
*/
export function matchesFactoryDefault(current: unknown, factoryDefault: string | undefined): boolean {
if (factoryDefault === undefined) return false;
if (typeof current === 'number') {
const parsed = Number(factoryDefault);
return factoryDefault.trim() !== '' && !Number.isNaN(parsed) && parsed === current;
}
if (typeof current === 'boolean') {
if (factoryDefault !== 'true' && factoryDefault !== 'false') return false;
return (factoryDefault === 'true') === current;
}
if (typeof current === 'string') return factoryDefault === current;
return false;
}
interface DefaultSettingTagProps {
settingKey: string;
value: unknown;
}
export default function DefaultSettingTag({ settingKey, value }: DefaultSettingTagProps) {
const { t } = useTranslation();
const defaults = useFactoryDefaults();
if (!matchesFactoryDefault(value, defaults.data?.[settingKey])) return null;
return <Tag style={{ marginLeft: 8 }}>{t('pages.settings.defaultTag')}</Tag>;
}
@@ -5,6 +5,7 @@ import './SettingListItem.css';
interface SettingListItemProps {
paddings?: 'small' | 'default';
title?: ReactNode;
badge?: ReactNode;
description?: ReactNode;
children?: ReactNode;
control?: ReactNode;
@@ -13,6 +14,7 @@ interface SettingListItemProps {
export default function SettingListItem({
paddings = 'default',
title,
badge,
description,
children,
control,
@@ -28,7 +30,12 @@ export default function SettingListItem({
<Row gutter={[8, 16]} style={{ width: '100%' }}>
<Col xs={24} lg={12}>
<div className="setting-list-meta">
{title && <div className="setting-list-title" id={titleId}>{title}</div>}
{title && (
<div className="setting-list-title">
<span id={titleId}>{title}</span>
{badge}
</div>
)}
{description && <div className="setting-list-description">{description}</div>}
</div>
</Col>
+1
View File
@@ -1,3 +1,4 @@
export { default as InputAddon } from './InputAddon';
export { default as InfinityIcon } from './InfinityIcon';
export { default as SettingListItem } from './SettingListItem';
export { default as DefaultSettingTag } from './DefaultSettingTag';
+17 -9
View File
@@ -48,6 +48,7 @@ interface SparklineProps {
yTickStep?: number;
tickCountX?: number;
showTooltip?: boolean;
showLegend?: boolean;
valueMin?: number;
valueMax?: number | null;
yFormatter?: (v: number) => string;
@@ -80,13 +81,23 @@ interface SparklineView {
extremaPoints: ExtremaResult | null;
}
function hexToRgba(hex: string, alpha: number): string {
let h = hex.trim();
function hexToRgba(color: string, alpha: number): string {
const trimmed = color.trim();
const fn = trimmed.match(/^rgba?\(([^)]+)\)$/i);
if (fn) {
const parts = fn[1].split(/[,/]\s*|\s+/).filter(Boolean).map(Number);
if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) {
const baseAlpha = parts.length > 3 && Number.isFinite(parts[3]) ? parts[3] : 1;
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${baseAlpha * alpha})`;
}
return trimmed;
}
let h = trimmed;
if (h.startsWith('#')) h = h.slice(1);
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
if (h.length !== 6) return hex;
if (h.length !== 6) return trimmed;
const int = Number.parseInt(h, 16);
if (Number.isNaN(int)) return hex;
if (Number.isNaN(int)) return trimmed;
const r = (int >> 16) & 255;
const g = (int >> 8) & 255;
const b = int & 255;
@@ -129,6 +140,7 @@ export default function Sparkline(props: SparklineProps) {
yTickStep = 25,
tickCountX = 4,
showTooltip = false,
showLegend = true,
valueMin = 0,
valueMax = 100,
yFormatter = (v: number) => `${Math.round(v)}%`,
@@ -542,10 +554,6 @@ export default function Sparkline(props: SparklineProps) {
);
}, [points, hasSeries2, hasSeries3, valueMin, valueMax]);
useEffect(() => {
plotRef.current?.redraw(false);
});
useEffect(() => {
const redraw = () => plotRef.current?.redraw(false);
const moBody = new MutationObserver(redraw);
@@ -570,7 +578,7 @@ export default function Sparkline(props: SparklineProps) {
</span>
</div>
)}
{legendItems.length > 0 && (
{showLegend && legendItems.length > 0 && (
<div className="sparkline-legend" aria-hidden="true">
{legendItems.map((s) => (
<span key={s.name} className="extrema-item" style={{ color: s.color }}>● {s.name}</span>
+99 -15
View File
@@ -73,7 +73,9 @@ export interface ClientQueryParams {
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
const DEFAULT_SUMMARY: ClientsSummary = {
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
total: 0, active: 0,
onlineCount: 0, depletedCount: 0, expiringCount: 0, deactiveCount: 0,
online: [], depleted: [], expiring: [], deactive: [],
};
export interface ClientSpeedEntry {
@@ -114,7 +116,50 @@ export function computeClientsSummary(
if (nearExpiry || nearLimit) expiring.push(email);
else active += 1;
}
return { total: stats.length, active, online, depleted, expiring, deactive };
return {
total: stats.length,
active,
onlineCount: online.length,
depletedCount: depleted.length,
expiringCount: expiring.length,
deactiveCount: deactive.length,
online,
depleted,
expiring,
deactive,
};
}
export function sameSpeedMap(
a: Record<string, ClientSpeedEntry>,
b: Record<string, ClientSpeedEntry>,
): boolean {
const aKeys = Object.keys(a);
if (aKeys.length !== Object.keys(b).length) return false;
for (const key of aKeys) {
const left = a[key];
const right = b[key];
if (!right || left.up !== right.up || left.down !== right.down) return false;
}
return true;
}
// The field list computeClientsSummary reads, and deliberately nothing else.
// lastOnline in particular churns for every online client on every push and no
// counter depends on it, so including it here would defeat the comparison.
export function sameSummaryInputs(a: ClientStatRow[], b: ClientStatRow[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const left = a[i];
const right = b[i];
if (left.email !== right.email
|| left.up !== right.up
|| left.down !== right.down
|| left.total !== right.total
|| left.enable !== right.enable
|| left.expiryTime !== right.expiryTime) return false;
}
return true;
}
export function pickClientsSummary(
@@ -155,7 +200,7 @@ async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageRes
const qs = buildQS(params);
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged');
const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged', { strict: true });
if (!validated.obj) throw new Error('Empty clients response');
return validated.obj;
}
@@ -174,17 +219,31 @@ async function fetchDefaults(): Promise<Record<string, unknown>> {
return validated.obj || {};
}
export function useClients() {
export interface UseClientsOptions {
// Callers that only need the mutations — the bulk modals, the groups page —
// pass false. Mounting them used to start a second 5-second poll of the paged
// list whose result they never read, which on a large panel means a full
// summary aggregate every 5 seconds for nothing.
list?: boolean;
}
export function useClients(options: UseClientsOptions = {}) {
const withList = options.list ?? true;
const queryClient = useQueryClient();
const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
// Null until the page has settled on a query. The clients page cannot build
// one until the persisted sort and the panel's configured page size are both
// known, and fetching before then cost three sequential requests per load —
// the first two thrown away (#trace).
const [query, setQueryState] = useState<ClientQueryParams | null>(null);
// setQuery shallow-compares so callers can pass a fresh object every render
// (the common React pattern) without triggering a re-fetch when nothing
// actually changed.
const setQuery = useCallback((next: ClientQueryParams) => {
setQueryState((prev) => {
if (
prev.page === next.page
prev
&& prev.page === next.page
&& prev.pageSize === next.pageSize
&& (prev.search ?? '') === (next.search ?? '')
&& (prev.filter ?? '') === (next.filter ?? '')
@@ -206,8 +265,9 @@ export function useClients() {
}, []);
const listQuery = useQuery({
queryKey: keys.clients.list(query),
queryFn: () => fetchClientPage(query),
queryKey: keys.clients.list(query ?? DEFAULT_QUERY),
queryFn: () => fetchClientPage(query ?? DEFAULT_QUERY),
enabled: withList && query !== null,
staleTime: Infinity,
// List is sorted/paged server-side, so the WS patch can't add new or
// re-sort rows; poll the current page to keep it live (pauses when hidden).
@@ -218,6 +278,7 @@ export function useClients() {
const inboundOptionsQuery = useQuery({
queryKey: keys.inbounds.options(),
queryFn: fetchInboundOptions,
enabled: withList,
staleTime: Infinity,
});
@@ -235,6 +296,7 @@ export function useClients() {
const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
return Array.isArray(validated.obj) ? validated.obj : [];
},
enabled: withList,
staleTime: Infinity,
});
@@ -244,7 +306,11 @@ export function useClients() {
const allGroups = listQuery.data?.groups ?? [];
const fetched = listQuery.data !== undefined || listQuery.isError;
const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
const loading = listQuery.isFetching;
// isFetching is deliberately NOT read here. Touching it makes it a tracked
// property, so the 5s refetchInterval notifies twice per cycle — two whole
// page renders even when structural sharing leaves the data identical, and
// each one bumps rc-table's immutable mark and re-runs every cell renderer.
// Callers that want a spinner for an explicit refresh drive it locally.
// Showing kept-previous data for a new key (filter/sort/page) — drives the
// table overlay so the 5s background poll doesn't flash it.
const transitioning = listQuery.isPlaceholderData;
@@ -277,6 +343,11 @@ export function useClients() {
const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
const pageSize = (defaults.pageSize as number) ?? 0;
// pageSize 0 means "one long page", which is indistinguishable from "the
// settings have not arrived yet" — so callers need this flag to know when the
// configured page size is real. isFetched (not isSuccess) so a failed
// settings request still lets the page fall back and render.
const settingsReady = defaultsQuery.isFetched;
const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
@@ -565,15 +636,23 @@ export function useClients() {
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
}
if (Array.isArray(p.clientTraffics)) {
// Xray reports a row per client whether or not it moved a byte, so most of
// this map used to be zeros. A missing entry and a zero entry render
// identically (isActiveSpeed treats both as inactive), so the zeros are
// dropped and an unchanged result returns the previous object — which lets
// React bail out of the update instead of re-rendering the table.
const next: Record<string, ClientSpeedEntry> = {};
for (const ct of p.clientTraffics) {
if (!ct || !ct.email) continue;
const up = ct.up || 0;
const down = ct.down || 0;
if (up === 0 && down === 0) continue;
next[ct.email] = {
up: (ct.up || 0) / TRAFFIC_POLL_INTERVAL_S,
down: (ct.down || 0) / TRAFFIC_POLL_INTERVAL_S,
up: up / TRAFFIC_POLL_INTERVAL_S,
down: down / TRAFFIC_POLL_INTERVAL_S,
};
}
setClientSpeed(next);
setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
}
}, [queryClient]);
@@ -581,12 +660,17 @@ export function useClients() {
if (!payload || typeof payload !== 'object') return;
const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
if (p.snapshot !== false) setAllClientStats(p.clients);
if (p.snapshot !== false) {
const rows = p.clients;
setAllClientStats((prev) => (sameSummaryInputs(prev, rows) ? prev : rows));
}
const active = queryRef.current;
if (!active) return;
const byEmail = new Map<string, ClientTraffic>();
for (const row of p.clients) {
if (row && row.email) byEmail.set(row.email, row);
}
queryClient.setQueryData<ClientPageResponse>(keys.clients.list(queryRef.current), (prev) => {
queryClient.setQueryData<ClientPageResponse>(keys.clients.list(active), (prev) => {
if (!prev) return prev;
let touched = false;
const next = prev.items.slice();
@@ -624,7 +708,6 @@ export function useClients() {
setQuery,
inbounds,
onlines,
loading,
transitioning,
fetched,
fetchError,
@@ -634,6 +717,7 @@ export function useClients() {
expireDiff,
trafficDiff,
pageSize,
settingsReady,
refresh,
create,
bulkCreate,
+37
View File
@@ -0,0 +1,37 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
export function useServerDraft<T>(server: T | undefined, clone: (value: T) => T, equals: (left: T, right: T) => boolean) {
const cloneRef = useRef(clone);
const equalsRef = useRef(equals);
cloneRef.current = clone;
equalsRef.current = equals;
const [draft, setDraft] = useState<T | undefined>();
const [baseline, setBaseline] = useState<T | undefined>();
const draftRef = useRef(draft);
const baselineRef = useRef(baseline);
draftRef.current = draft;
baselineRef.current = baseline;
useEffect(() => {
if (server === undefined) return;
const currentDraft = draftRef.current;
const currentBaseline = baselineRef.current;
const isDirty = currentDraft !== undefined
&& (currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline));
setBaseline(server);
if (isDirty && !equalsRef.current(currentDraft, server)) return;
setDraft(cloneRef.current(server));
}, [server]);
const markSaved = useCallback((value: T) => {
setBaseline(cloneRef.current(value));
}, []);
const isDirty = useMemo(
() => draft !== undefined && (baseline === undefined || !equalsRef.current(draft, baseline)),
[baseline, draft],
);
return { draft, setDraft, isDirty, markSaved };
}
+24 -4
View File
@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { theme as antdTheme } from 'antd';
import type { ThemeConfig } from 'antd';
@@ -13,14 +13,18 @@ function readBool(key: string, fallback: boolean): boolean {
}
function applyDom(isDark: boolean, isUltra: boolean) {
document.body.setAttribute('class', isDark ? 'dark' : 'light');
document.body.classList.remove('dark', 'light');
document.body.classList.add(isDark ? 'dark' : 'light');
if (isUltra) {
document.documentElement.setAttribute('data-theme', 'ultra-dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
const msg = document.getElementById('message');
if (msg) msg.className = isDark ? 'dark' : 'light';
if (msg) {
msg.classList.remove('dark', 'light');
msg.classList.add(isDark ? 'dark' : 'light');
}
}
// module load so the document is in the right theme before React mounts.
@@ -92,9 +96,24 @@ const LIGHT_BUTTON_TOKENS = {
colorPrimaryActive: '#073ea8',
};
// hashed:false drops the `:where(.css-<hash>)` wrapper antd puts around every
// rule. It costs nothing in specificity — `:where()` contributes zero, so the
// panel's own `.ant-*` overrides still win — and it removes roughly 5,700
// wrappers, 16% of the generated stylesheet, from what the browser has to parse.
//
// cssVar.key pins the CSS-variable scope. Every panel page mounts its own
// ConfigProvider (there is no root one), and without a fixed key each mints a
// fresh useId-derived scope, so navigating re-serialises and re-injects the whole
// token block under a new class instead of reusing the one already in the head.
const SHARED_STYLE_CONFIG = {
hashed: false,
cssVar: { key: 'xui' },
} as const;
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
if (!isDark) {
return {
...SHARED_STYLE_CONFIG,
algorithm: antdTheme.defaultAlgorithm,
token: LIGHT_CONTRAST_TOKENS,
components: {
@@ -104,6 +123,7 @@ export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeCo
};
}
return {
...SHARED_STYLE_CONFIG,
algorithm: antdTheme.darkAlgorithm,
token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
components: {
@@ -142,7 +162,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const [isDark, setIsDark] = useState<boolean>(initialDark);
const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
useEffect(() => {
useLayoutEffect(() => {
applyDom(isDark, isUltra);
localStorage.setItem(STORAGE_DARK, String(isDark));
localStorage.setItem(STORAGE_ULTRA, String(isUltra));
+25 -26
View File
@@ -14,7 +14,6 @@ import {
type OutboundTrafficRow,
} from '@/schemas/xray';
const DIRTY_POLL_MS = 1000;
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
// One HTTP-mode batch request tests this many outbounds through a single
// shared temp xray instance; chunking keeps responses bounded (~30s worst
@@ -22,6 +21,10 @@ const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
// results progressively.
const HTTP_BATCH_CHUNK = 16;
function normalizeOutboundTestUrl(url: string) {
return url || DEFAULT_TEST_URL;
}
export function isUdpOutbound(outbound: unknown): boolean {
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
const p = o?.protocol;
@@ -125,10 +128,11 @@ export function useXraySetting(): UseXraySettingResult {
staleTime: Infinity,
});
const [saveDisabled, setSaveDisabled] = useState(true);
const [xraySetting, setXraySettingState] = useState('');
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
const [savedXraySetting, setSavedXraySetting] = useState('');
const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
const [inboundTags, setInboundTags] = useState<string[]>([]);
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
@@ -139,38 +143,40 @@ export function useXraySetting(): UseXraySettingResult {
const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
const [testingAll, setTestingAll] = useState(false);
const oldXraySettingRef = useRef('');
const oldOutboundTestUrlRef = useRef('');
const syncingRef = useRef(false);
const xraySettingRef = useRef('');
const outboundTestUrlRef = useRef(outboundTestUrl);
const savedXraySettingRef = useRef(savedXraySetting);
const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
const subscriptionOutboundsRef = useRef<unknown[]>([]);
xraySettingRef.current = xraySetting;
outboundTestUrlRef.current = outboundTestUrl;
savedXraySettingRef.current = savedXraySetting;
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
templateSettingsRef.current = templateSettings;
subscriptionOutboundsRef.current = subscriptionOutbounds;
// Seed local editor state from the config query. Runs on first fetch and
// every time the query refetches (e.g. after a successful save).
useEffect(() => {
if (!configQuery.data) return;
const obj = configQuery.data;
const pretty = JSON.stringify(obj.xraySetting, null, 2);
syncingRef.current = true;
setXraySettingState(pretty);
setTemplateSettingsState(obj.xraySetting);
oldXraySettingRef.current = pretty;
syncingRef.current = false;
const nextUrl = normalizeOutboundTestUrl(obj.outboundTestUrl || '');
setInboundTags(obj.inboundTags || []);
setClientReverseTags(obj.clientReverseTags || []);
setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
const isDirty = savedXraySettingRef.current !== xraySettingRef.current
|| savedOutboundTestUrlRef.current !== normalizeOutboundTestUrl(outboundTestUrlRef.current);
if (isDirty) return;
syncingRef.current = true;
setXraySettingState(pretty);
setTemplateSettingsState(obj.xraySetting);
setSavedXraySetting(pretty);
syncingRef.current = false;
setOutboundTestUrlState(nextUrl);
oldOutboundTestUrlRef.current = nextUrl;
setSaveDisabled(true);
setSavedOutboundTestUrl(nextUrl);
}, [configQuery.data]);
const fetched = configQuery.data !== undefined || configQuery.isError;
@@ -220,7 +226,7 @@ export function useXraySetting(): UseXraySettingResult {
const saveMut = useMutation({
mutationFn: async () => {
const sentXraySetting = xraySettingRef.current;
const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current);
const msg = await HttpUtil.post('/panel/api/xray/update', {
xraySetting: sentXraySetting,
outboundTestUrl: sentTestUrl,
@@ -229,9 +235,8 @@ export function useXraySetting(): UseXraySettingResult {
},
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
if (!msg?.success) return;
oldXraySettingRef.current = sentXraySetting;
oldOutboundTestUrlRef.current = sentTestUrl;
setSaveDisabled(true);
setSavedXraySetting(sentXraySetting);
setSavedOutboundTestUrl(sentTestUrl);
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
},
});
@@ -425,14 +430,8 @@ export function useXraySetting(): UseXraySettingResult {
}
}, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
useEffect(() => {
const timer = window.setInterval(() => {
const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
setSaveDisabled(!(dirtyXray || dirtyUrl));
}, DIRTY_POLL_MS);
return () => window.clearInterval(timer);
}, []);
const saveDisabled = savedXraySetting === xraySetting
&& savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
+27 -19
View File
@@ -1,3 +1,10 @@
.ant-sidebar {
flex: 0 0 var(--sider-rail, 72px);
width: var(--sider-rail, 72px);
position: relative;
z-index: 210;
}
.ant-sidebar > .ant-layout-sider {
position: sticky;
top: 0;
@@ -5,6 +12,16 @@
align-self: flex-start;
}
.ant-sidebar > .ant-layout-sider:not(.ant-layout-sider-collapsed) {
box-shadow: 0 0 32px rgba(0, 0, 0, 0.22);
}
.sider-nav .ant-menu-item .anticon,
.sider-nav .ant-menu-submenu-title .anticon,
.sider-utility .ant-menu-item .anticon {
font-size: 16px;
}
.sider-brand,
.drawer-brand {
font-weight: 600;
@@ -18,16 +35,12 @@
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 16px 14px 24px;
height: 58px;
padding: 0 16px 0 24px;
border-bottom: 1px solid var(--ant-color-border-secondary);
user-select: none;
}
.sider-brand-collapsed {
justify-content: center;
font-size: 16px;
padding: 14px 4px;
letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
}
.brand-block {
@@ -37,10 +50,6 @@
line-height: 1.1;
}
.sider-brand-collapsed .brand-block {
flex: 0 0 auto;
}
.brand-actions {
display: inline-flex;
align-items: center;
@@ -246,11 +255,6 @@
outline: none;
}
.sider-version.is-collapsed {
justify-content: center;
padding: 8px 0;
}
.drawer-footer {
flex: 0 0 auto;
padding: 8px 8px 12px;
@@ -261,8 +265,7 @@
display: inline-flex;
}
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children,
.ant-sidebar > .ant-layout-sider .ant-layout-sider-trigger {
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
display: none;
}
@@ -272,6 +275,11 @@
min-width: 0 !important;
width: 0 !important;
}
.ant-sidebar {
flex: 0 0 0;
width: 0;
}
}
body.dark .ant-drawer-content,
+38 -31
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ComponentType } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ComponentType, CSSProperties } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Drawer, Layout, Menu } from 'antd';
@@ -39,11 +39,14 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { useAllSettings } from '@/api/queries/useAllSettings';
import './AppSidebar.css';
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const DONATE_URL = 'https://donate.sanaei.dev/';
const DOCS_URL = 'https://docs.sanaei.dev/';
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
const LOGOUT_KEY = '__logout__';
const RAIL_WIDTH = 72;
const railStyle = { '--sider-rail': `${RAIL_WIDTH}px` } as CSSProperties;
let hoveredAcrossRemounts = false;
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound' | 'routing';
@@ -62,14 +65,6 @@ const iconByName: Record<IconName, ComponentType> = {
routing: SwapOutlined,
};
function readCollapsed(): boolean {
try {
return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
} catch {
return false;
}
}
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
return (
<a
@@ -108,7 +103,7 @@ function VersionBadge({ version, collapsed }: { version: string; collapsed?: boo
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className={`sider-version${collapsed ? ' is-collapsed' : ''}`}
className="sider-version"
aria-label={`GitHub ${label}`}
title={label}
>
@@ -148,8 +143,23 @@ export default function AppSidebar() {
const { allSetting } = useAllSettings();
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
const [drawerOpen, setDrawerOpen] = useState(false);
const railCollapsed = !hovered;
const rootRef = useRef<HTMLDivElement>(null);
const updateHovered = useCallback((value: boolean) => {
hoveredAcrossRemounts = value;
setHovered(value);
}, []);
useEffect(() => {
const timer = window.setTimeout(() => {
const el = rootRef.current;
if (el) updateHovered(el.matches(':hover'));
}, 150);
return () => window.clearTimeout(timer);
}, [updateHovered]);
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
const panelVersion = window.X_UI_CUR_VER || '';
@@ -218,7 +228,7 @@ export default function AppSidebar() {
if (tab.key === '/xray') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
}
return { key: tab.key, icon: <Icon />, label: tab.title };
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
}),
[settingsChildren, xrayChildren]);
@@ -235,13 +245,6 @@ export default function AppSidebar() {
openLink(String(key));
}, [openLink]);
const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
if (type === 'clickTrigger') {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
setCollapsed(isCollapsed);
}
}, []);
const cycleTheme = useCallback((id: string) => {
pauseAnimationsUntilLeave(id);
if (!isDark) {
@@ -256,20 +259,24 @@ export default function AppSidebar() {
}, [isDark, isUltra, toggleTheme, toggleUltra]);
return (
<div className="ant-sidebar">
<div
ref={rootRef}
className="ant-sidebar"
style={railStyle}
onMouseEnter={() => updateHovered(true)}
onMouseLeave={() => updateHovered(false)}
>
<Layout.Sider
theme={currentTheme}
width={220}
collapsible
collapsed={collapsed}
breakpoint="md"
onCollapse={onSiderCollapse}
collapsedWidth={RAIL_WIDTH}
collapsed={railCollapsed}
>
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
<div className="sider-brand">
<div className="brand-block">
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
<span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span>
</div>
{!collapsed && (
{!railCollapsed && (
<div className="brand-actions">
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
@@ -287,7 +294,7 @@ export default function AppSidebar() {
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
openKeys={collapsed ? undefined : openKeys}
openKeys={railCollapsed ? undefined : openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
className="sider-nav"
items={toMenuItems(navItems)}
@@ -302,7 +309,7 @@ export default function AppSidebar() {
onClick={onMenuClick}
/>
<div className="sider-footer">
<VersionBadge version={panelVersion} collapsed={collapsed} />
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
</div>
</Layout.Sider>
+2 -2
View File
@@ -91,7 +91,7 @@ export class AllSetting {
ldapDefaultTotalGB = 0;
ldapDefaultExpiryDays = 0;
ldapDefaultLimitIP = 0;
tgEnabledEvents = '';
tgEnabledEvents = 'login.attempt,cpu.high';
smtpEnable = false;
smtpHost = '';
smtpPort = 587;
@@ -101,7 +101,7 @@ export class AllSetting {
smtpFromName = '';
smtpTo = '';
smtpEncryptionType = 'starttls';
smtpEnabledEvents = '';
smtpEnabledEvents = 'login.attempt,cpu.high';
smtpCpu = 80;
smtpMemory = 80;
outboundDownThreshold = 3;
+8 -3
View File
@@ -1,5 +1,10 @@
import { NumberFormatter } from '@/utils';
export const USAGE_WARN_PERCENT = 80;
export const USAGE_CRIT_PERCENT = 90;
export const USAGE_WARN_COLOR = '#faad14';
export const USAGE_CRIT_COLOR = '#ff4d4f';
export class CurTotal {
current: number;
total: number;
@@ -16,9 +21,9 @@ export class CurTotal {
get color(): string {
const p = this.percent;
if (p < 80) return '#1677ff';
if (p < 90) return '#faad14';
return '#ff4d4f';
if (p < USAGE_WARN_PERCENT) return '#1677ff';
if (p < USAGE_CRIT_PERCENT) return USAGE_WARN_COLOR;
return USAGE_CRIT_COLOR;
}
}
+12 -2
View File
@@ -254,6 +254,11 @@ export const sections: readonly Section[] = [
description:
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
endpoints: [
{
method: 'GET',
path: '/panel/api/openapi.json',
summary: 'Serve this API description as an OpenAPI 3 document — the same file that powers the API Docs page. Requires a session or Bearer token like the rest of /panel/api. Useful for generating clients or importing into API tooling.',
},
{
method: 'GET',
path: '/panel/api/server/status',
@@ -559,7 +564,7 @@ export const sections: readonly Section[] = [
{
method: 'GET',
path: '/panel/api/clients/list/paged',
summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.',
summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters: the *Count fields are exact, while the email arrays beside them stop at 200 entries so the payload does not grow with the panel. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.',
params: [
{ name: 'page', in: 'query', type: 'number', desc: '1-indexed page number. Defaults to 1.' },
{ name: 'pageSize', in: 'query', type: 'number', desc: 'Rows per page. Defaults to 25, capped at 200.' },
@@ -570,7 +575,7 @@ export const sections: readonly Section[] = [
{ name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' },
],
response:
'{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": []\n }\n }\n}',
'{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "onlineCount": 1,\n "depletedCount": 0,\n "expiringCount": 0,\n "deactiveCount": 150,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": ["bob@example.com"]\n }\n }\n}',
},
{
method: 'GET',
@@ -1164,6 +1169,11 @@ export const sections: readonly Section[] = [
path: '/panel/api/setting/defaultSettings',
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
},
{
method: 'POST',
path: '/panel/api/setting/factoryDefaults',
summary: 'Return the shipped (factory) default value per browser-safe setting key, so clients can tell a stored value apart from the default it would fall back to. Per-install material (secret, panelGuid, mTLS keys) and credential fields are never included.',
},
{
method: 'POST',
path: '/panel/api/setting/update',
@@ -56,7 +56,7 @@ export default function ClientBulkAddModal({
}: ClientBulkAddModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const { bulkCreate } = useClients();
const { bulkCreate } = useClients({ list: false });
const methods = useForm<ClientBulkAddFormValues>({ defaultValues: EMPTY });
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
@@ -16,6 +16,13 @@
white-space: nowrap;
}
.client-email-more {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--ant-color-border-secondary, rgba(128, 128, 128, 0.2));
opacity: 0.65;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
+127 -95
View File
@@ -1,4 +1,4 @@
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
@@ -16,7 +16,6 @@ import {
Result,
Row,
Select,
Space,
Spin,
Statistic,
Switch,
@@ -80,12 +79,14 @@ const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
import { ClientInboundChips, ClientRowActions } from './RowCells';
import { emptyFilters, activeFilterCount } from './filters';
import type { ClientFilters } from './filters';
import './ClientsPage.css';
const FILTER_STATE_KEY = 'clientsFilterState';
const DISABLED_PAGE_SIZE = 200;
const DEFAULT_TABLE_PAGE_SIZE = 25;
function UngroupIcon() {
return (
@@ -126,12 +127,29 @@ function UngroupIcon() {
);
}
// The server sends exact counters but caps the email arrays behind them, so a
// panel with thousands of depleted clients neither ships nor renders them all.
// The trailing chip reports what the popover left out.
function ClientEmailList({ emails, total }: { emails: string[]; total: number }) {
const hidden = total - emails.length;
return (
<div className="client-email-list">
{emails.map((e) => <div key={e}>{e}</div>)}
{hidden > 0 && <div className="client-email-more">+{hidden}</div>}
</div>
);
}
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
interface PersistedFilterState {
searchKey: string;
filters: ClientFilters;
sort: string;
// The page size resolved on the previous visit. Without it the first list
// request has to wait for /setting/defaultSettings just to learn how many rows
// to ask for, which serialises two round trips on every load.
pageSize: number | null;
}
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
@@ -147,6 +165,9 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
tunnel: 'orange',
};
const INBOUND_CHIP_LIMIT = 1;
// A shared empty array keeps the memoised chip cell from seeing a fresh prop for
// every unattached client on every render.
const EMPTY_INBOUND_IDS: number[] = [];
function readFilterState(): PersistedFilterState {
try {
@@ -164,9 +185,10 @@ function readFilterState(): PersistedFilterState {
groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
},
sort: typeof raw.sort === 'string' ? raw.sort : '',
pageSize: typeof raw.pageSize === 'number' && raw.pageSize > 0 ? raw.pageSize : null,
};
} catch {
return { searchKey: '', filters: emptyFilters(), sort: '' };
return { searchKey: '', filters: emptyFilters(), sort: '', pageSize: null };
}
}
@@ -208,8 +230,8 @@ export default function ClientsPage() {
summary,
allGroups,
setQuery,
inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
tgBotEnable, expireDiff, trafficDiff, pageSize,
inbounds, onlines, transitioning, fetched, fetchError, subSettings,
tgBotEnable, expireDiff, trafficDiff, pageSize, settingsReady,
create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
clientSpeed,
@@ -265,14 +287,31 @@ export default function ClientsPage() {
const [sortColumn, setSortColumn] = useState<string | null>(initialSort.column);
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(initialSort.order);
const [currentPage, setCurrentPage] = useState(1);
const [tablePageSize, setTablePageSize] = useState(25);
// Derived, not mirrored into state by an effect: an effect lags one render
// behind the settings arriving, and that lag is what made the page fetch the
// list once with the placeholder size and again with the real one.
const [pageSizeChoice, setPageSizeChoice] = useState<number | null>(null);
const settingsPageSize = settingsReady ? (pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE) : null;
// Last visit's resolved size stands in until the settings land, so the list
// request goes out with the page mount instead of queueing behind them. If the
// admin has since changed the setting the authoritative value replaces it and
// costs one refetch — only on the load that follows the change. Null means
// nothing is known yet, which is the one case worth waiting for.
const resolvedPageSize = pageSizeChoice ?? settingsPageSize ?? initial.pageSize;
const tablePageSize = resolvedPageSize ?? DEFAULT_TABLE_PAGE_SIZE;
// debouncedSearch lags behind the input so we don't spam the server on every
// keystroke; the search box still feels instant locally.
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
useEffect(() => {
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters, sort: sortValueFor(sortColumn, sortOrder) }));
}, [searchKey, filters, sortColumn, sortOrder]);
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
searchKey,
filters,
sort: sortValueFor(sortColumn, sortOrder),
// Only ever persist a size we actually resolved, never the render fallback.
pageSize: resolvedPageSize,
}));
}, [searchKey, filters, sortColumn, sortOrder, resolvedPageSize]);
useEffect(() => {
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
@@ -303,6 +342,10 @@ export default function ClientsPage() {
}, [filters.nodeIds, filters.inboundIds, inbounds]);
useEffect(() => {
// With no remembered size and no settings yet, any query we build would be a
// guess, and issuing it costs a full server round trip that is thrown away as
// soon as the real size arrives.
if (resolvedPageSize === null) return;
setQuery({
page: currentPage,
pageSize: tablePageSize,
@@ -321,13 +364,21 @@ export default function ClientsPage() {
sort: sortColumn || undefined,
order: sortOrder || undefined,
});
}, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
}, [setQuery, resolvedPageSize, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
const activeCount = activeFilterCount(filters);
useEffect(() => {
setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
}, [pageSize]);
// Row handlers take an email and look the row up here at call time. Keying
// them on the record object instead would defeat the memoised cells: every
// traffic push replaces the row object of every client whose counters moved,
// so the memo would miss on exactly the rows that are busy. Reading through
// the ref also means a modal opened mid-poll shows current usage.
const rowsByEmail = useRef(new Map<string, ClientRecord>());
rowsByEmail.current = useMemo(() => {
const map = new Map<string, ClientRecord>();
for (const c of clients) map.set(c.email, c);
return map;
}, [clients]);
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
const inboundsById = useMemo(() => {
@@ -454,7 +505,9 @@ export default function ClientsPage() {
setFormOpen(true);
}
async function onEdit(row: ClientRecord) {
const onEdit = useCallback(async (email: string) => {
const row = rowsByEmail.current.get(email);
if (!row) return;
setFormMode('edit');
// Paged list omits per-client secrets to keep the row payload tiny;
// edit needs them, so fetch the full record first.
@@ -465,9 +518,11 @@ export default function ClientsPage() {
setEditingAttachedIds([...ids]);
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
setFormOpen(true);
}
}, [hydrate]);
function onDelete(row: ClientRecord) {
const onDelete = useCallback((email: string) => {
const row = rowsByEmail.current.get(email);
if (!row) return;
modal.confirm({
title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
content: t('pages.clients.deleteConfirmContent'),
@@ -479,9 +534,10 @@ export default function ClientsPage() {
if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
},
});
}
}, [modal, t, remove, messageApi]);
function onResetTraffic(row: ClientRecord) {
const onResetTraffic = useCallback((email: string) => {
const row = rowsByEmail.current.get(email);
if (!row?.email) {
messageApi.warning(t('pages.clients.resetNotPossible'));
return;
@@ -496,19 +552,33 @@ export default function ClientsPage() {
if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
},
});
}
}, [modal, t, resetTraffic, messageApi]);
async function onShowInfo(row: ClientRecord) {
const onShowInfo = useCallback(async (email: string) => {
const row = rowsByEmail.current.get(email);
if (!row) return;
const full = await hydrate(row.email);
setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
setInfoOpen(true);
}
}, [hydrate]);
async function onShowQr(row: ClientRecord) {
const onShowQr = useCallback(async (email: string) => {
const row = rowsByEmail.current.get(email);
if (!row) return;
const full = await hydrate(row.email);
setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
setQrOpen(true);
}
}, [hydrate]);
const [refreshing, setRefreshing] = useState(false);
const onRefreshClick = useCallback(async () => {
setRefreshing(true);
try {
await refresh();
} finally {
setRefreshing(false);
}
}, [refresh]);
const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
setTextTitle(opts.title);
@@ -743,7 +813,7 @@ export default function ClientsPage() {
const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
if (pag?.current) setCurrentPage(pag.current);
if (pag?.pageSize) setTablePageSize(pag.pageSize);
if (pag?.pageSize) setPageSizeChoice(pag.pageSize);
};
const columns = useMemo<ColumnsType<ClientRecord>>(() => [
@@ -752,23 +822,14 @@ export default function ClientsPage() {
key: 'actions',
width: 200,
render: (_v, record) => (
<Space size={4}>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} onClick={() => onShowQr(record)} />
</Tooltip>
<Tooltip title={t('pages.clients.clientInfo')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} aria-label={t('pages.clients.clientInfo')} onClick={() => onShowInfo(record)} />
</Tooltip>
<Tooltip title={t('pages.inbounds.resetTraffic')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} aria-label={t('pages.inbounds.resetTraffic')} onClick={() => onResetTraffic(record)} />
</Tooltip>
<Tooltip title={t('edit')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
<ClientRowActions
email={record.email}
onShowQr={onShowQr}
onShowInfo={onShowInfo}
onResetTraffic={onResetTraffic}
onEdit={onEdit}
onDelete={onDelete}
/>
),
},
{
@@ -850,42 +911,13 @@ export default function ClientsPage() {
key: 'inboundIds',
width: 170,
render: (_v, record) => {
const ids = record.inboundIds || [];
if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
const overflow = ids.slice(INBOUND_CHIP_LIMIT);
const chip = (id: number, compact: boolean) => {
const ib = inboundsById[id];
const proto = (ib?.protocol || '').toLowerCase();
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
const compactLabel = formatInboundLabel(ib?.tag, ib?.remark);
return (
<Tooltip key={id} title={inboundLabel(id)}>
<Tag color={color} style={{ margin: 2 }}>
{compact ? compactLabel : inboundLabel(id)}
</Tag>
</Tooltip>
);
};
return (
<>
{visible.map((id) => chip(id, true))}
{overflow.length > 0 && (
<Popover
trigger="click"
placement="bottomRight"
content={
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
{overflow.map((id) => chip(id, false))}
</div>
}
>
<Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
+{overflow.length}
</Tag>
</Popover>
)}
</>
<ClientInboundChips
ids={record.inboundIds || EMPTY_INBOUND_IDS}
inboundsById={inboundsById}
protocolColors={INBOUND_PROTOCOL_COLORS}
chipLimit={INBOUND_CHIP_LIMIT}
/>
);
},
},
@@ -994,7 +1026,7 @@ export default function ClientsPage() {
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError}
extra={<Button type="primary" loading={loading} onClick={refresh}>{t('refresh')}</Button>}
extra={<Button type="primary" loading={refreshing} onClick={onRefreshClick}>{t('refresh')}</Button>}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
@@ -1007,37 +1039,37 @@ export default function ClientsPage() {
<Col xs={12} sm={8} md={4}>
<Popover
title={t('online')}
open={summary.online.length ? undefined : false}
content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
open={summary.onlineCount ? undefined : false}
content={<ClientEmailList emails={summary.online} total={summary.onlineCount} />}
>
<Statistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
<Statistic title={t('online')} value={String(summary.onlineCount)} prefix={<span className="dot dot-blue" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('depleted')}
open={summary.depleted.length ? undefined : false}
content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
open={summary.depletedCount ? undefined : false}
content={<ClientEmailList emails={summary.depleted} total={summary.depletedCount} />}
>
<Statistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
<Statistic title={t('depleted')} value={String(summary.depletedCount)} prefix={<span className="dot dot-red" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('depletingSoon')}
open={summary.expiring.length ? undefined : false}
content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
open={summary.expiringCount ? undefined : false}
content={<ClientEmailList emails={summary.expiring} total={summary.expiringCount} />}
>
<Statistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
<Statistic title={t('depletingSoon')} value={String(summary.expiringCount)} prefix={<span className="dot dot-orange" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('disabled')}
open={summary.deactive.length ? undefined : false}
content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
open={summary.deactiveCount ? undefined : false}
content={<ClientEmailList emails={summary.deactive} total={summary.deactiveCount} />}
>
<Statistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
<Statistic title={t('disabled')} value={String(summary.deactiveCount)} prefix={<span className="dot dot-gray" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
@@ -1364,7 +1396,7 @@ export default function ClientsPage() {
showTotal={(n) => `${n}`}
onChange={(p, s) => {
setCurrentPage(p);
if (s && s !== tablePageSize) setTablePageSize(s);
if (s && s !== tablePageSize) setPageSizeChoice(s);
}}
/>
</div>
@@ -1391,8 +1423,8 @@ export default function ClientsPage() {
role="button"
tabIndex={0}
aria-label={t('pages.clients.clientInfo')}
onClick={() => onShowInfo(row)}
onKeyDown={activateOnKey(() => onShowInfo(row))}
onClick={() => onShowInfo(row.email)}
onKeyDown={activateOnKey(() => onShowInfo(row.email))}
/>
</Tooltip>
<Switch
@@ -1409,23 +1441,23 @@ export default function ClientsPage() {
{
key: 'qr',
label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
onClick: () => onShowQr(row),
onClick: () => onShowQr(row.email),
},
{
key: 'reset',
label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
onClick: () => onResetTraffic(row),
onClick: () => onResetTraffic(row.email),
},
{
key: 'edit',
label: <><EditOutlined /> {t('edit')}</>,
onClick: () => onEdit(row),
onClick: () => onEdit(row.email),
},
{
key: 'delete',
danger: true,
label: <><DeleteOutlined /> {t('delete')}</>,
onClick: () => onDelete(row),
onClick: () => onDelete(row.email),
},
],
}}
+154
View File
@@ -0,0 +1,154 @@
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Popover, Space, Tag, Tooltip } from 'antd';
import {
DeleteOutlined,
EditOutlined,
InfoCircleOutlined,
QrcodeOutlined,
RetweetOutlined,
} from '@ant-design/icons';
import { formatInboundLabel } from '@/lib/inbounds/label';
import type { InboundOption } from '@/hooks/useClients';
const ICON_BUTTON_STYLE = { fontSize: 16 } as const;
interface ClientRowActionsProps {
email: string;
onShowQr: (email: string) => void;
onShowInfo: (email: string) => void;
onResetTraffic: (email: string) => void;
onEdit: (email: string) => void;
onDelete: (email: string) => void;
}
// Five Tooltip-wrapped buttons per row, none of which depend on traffic. Left
// inline they re-ran rc-tooltip's alignment machinery for every visible row on
// every traffic push — 125 Tooltips on a 25-row page, five seconds apart.
// Keyed on the email rather than the row object, because a push replaces the row
// object of every client whose counters moved; the page resolves the live row.
export const ClientRowActions = memo(function ClientRowActions({
email,
onShowQr,
onShowInfo,
onResetTraffic,
onEdit,
onDelete,
}: ClientRowActionsProps) {
const { t } = useTranslation();
return (
<Space size={4}>
<Tooltip title={t('pages.clients.qrCode')}>
<Button
size="small"
type="text"
style={ICON_BUTTON_STYLE}
icon={<QrcodeOutlined />}
aria-label={t('pages.clients.qrCode')}
onClick={() => onShowQr(email)}
/>
</Tooltip>
<Tooltip title={t('pages.clients.clientInfo')}>
<Button
size="small"
type="text"
style={ICON_BUTTON_STYLE}
icon={<InfoCircleOutlined />}
aria-label={t('pages.clients.clientInfo')}
onClick={() => onShowInfo(email)}
/>
</Tooltip>
<Tooltip title={t('pages.inbounds.resetTraffic')}>
<Button
size="small"
type="text"
style={ICON_BUTTON_STYLE}
icon={<RetweetOutlined />}
aria-label={t('pages.inbounds.resetTraffic')}
onClick={() => onResetTraffic(email)}
/>
</Tooltip>
<Tooltip title={t('edit')}>
<Button
size="small"
type="text"
style={ICON_BUTTON_STYLE}
icon={<EditOutlined />}
aria-label={t('edit')}
onClick={() => onEdit(email)}
/>
</Tooltip>
<Tooltip title={t('delete')}>
<Button
size="small"
type="text"
danger
style={ICON_BUTTON_STYLE}
icon={<DeleteOutlined />}
aria-label={t('delete')}
onClick={() => onDelete(email)}
/>
</Tooltip>
</Space>
);
});
const CHIP_STYLE = { margin: 2 } as const;
const OVERFLOW_CHIP_STYLE = { margin: 2, cursor: 'pointer' } as const;
const OVERFLOW_LIST_STYLE = {
display: 'flex',
flexDirection: 'column' as const,
gap: 4,
maxWidth: 280,
maxHeight: 280,
overflowY: 'auto' as const,
};
interface ClientInboundChipsProps {
ids: number[];
inboundsById: Record<number, InboundOption>;
protocolColors: Record<string, string>;
chipLimit: number;
}
// Attachments never change on a traffic push either, so the same memoisation
// applies: one Tooltip per visible chip plus a Popover for the overflow.
export const ClientInboundChips = memo(function ClientInboundChips({
ids,
inboundsById,
protocolColors,
chipLimit,
}: ClientInboundChipsProps) {
if (ids.length === 0) return <span className="cell-empty">—</span>;
const label = (id: number) => {
const ib = inboundsById[id];
return formatInboundLabel(ib?.tag, ib?.remark);
};
const chip = (id: number) => {
const proto = (inboundsById[id]?.protocol || '').toLowerCase();
return (
<Tooltip key={id} title={label(id)}>
<Tag color={protocolColors[proto] ?? 'default'} style={CHIP_STYLE}>{label(id)}</Tag>
</Tooltip>
);
};
const visible = ids.slice(0, chipLimit);
const overflow = ids.slice(chipLimit);
return (
<>
{visible.map(chip)}
{overflow.length > 0 && (
<Popover
trigger="click"
placement="bottomRight"
content={<div style={OVERFLOW_LIST_STYLE}>{overflow.map(chip)}</div>}
>
<Tag color="default" style={OVERFLOW_CHIP_STYLE}>+{overflow.length}</Tag>
</Popover>
)}
</>
);
});
+1 -1
View File
@@ -93,7 +93,7 @@ export default function GroupsPage() {
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const queryClient = useQueryClient();
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients();
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({ list: false });
const groupsQuery = useQuery({
queryKey: keys.clients.groups(),
@@ -0,0 +1,74 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, theme } from 'antd';
import { Sparkline } from '@/components/viz';
import type { Status } from '@/models/status';
interface ConnectionsCardProps {
status: Status;
tcp: number[];
udp: number[];
labels: string[];
isMobile: boolean;
}
export default function ConnectionsCard({ status, tcp, udp, labels, isMobile }: ConnectionsCardProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const accent = token.colorPrimary;
const udpColor = token.colorTextTertiary;
const referenceLines = useMemo(
() => [
{ y: status.udpCount, color: udpColor, dash: '2 4' },
{ y: status.tcpCount, color: accent, dash: '2 4' },
],
[status.tcpCount, status.udpCount, accent, udpColor],
);
return (
<Card hoverable styles={{ body: { padding: 0 } }}>
<div className="ov-wide-head ov-wide-head-stack">
<div className="ov-kicker">{t('pages.index.connectionCount')}</div>
<div className="ov-conn-total">
<span className="ov-tile-number">{status.tcpCount + status.udpCount}</span>
<span className="ov-tile-unit">{t('pages.index.openSockets')}</span>
</div>
</div>
<div className="ov-conn-legend">
<div className="ov-legend-label">
<span className="ov-swatch" style={{ background: accent }} />
TCP
<span className="ov-legend-num">{status.tcpCount.toLocaleString()}</span>
</div>
<div className="ov-legend-label">
<span className="ov-swatch" style={{ background: udpColor }} />
UDP
<span className="ov-legend-num">{status.udpCount.toLocaleString()}</span>
</div>
</div>
<div className="ov-wide-chart">
<Sparkline
data={tcp}
data2={udp}
labels={labels}
height={isMobile ? 120 : 170}
strokeWidth={1.5}
fillOpacity={0.24}
showTooltip
showLegend={false}
valueMax={null}
stroke={accent}
stroke2={udpColor}
name1="TCP"
name2="UDP"
yFormatter={(v) => Math.round(v).toLocaleString()}
referenceLines={referenceLines}
/>
</div>
</Card>
);
}
+448 -29
View File
@@ -1,51 +1,470 @@
/* Overview page — trend-first layout. Every colour comes from the AntD theme
tokens so light / dark / ultra-dark keep working and the sidebar is untouched.
Set --ov-accent once here if you want a fixed accent instead of the primary. */
.index-page {
--ov-accent: var(--ant-color-primary);
--ov-line: var(--ant-color-border);
--ov-label: var(--ant-color-text-secondary);
--ov-faint: var(--ant-color-text-tertiary);
--ov-gap: 12px;
--ov-pad: 20px;
}
@media (max-width: 768px) {
.index-page .content-area {
padding: 12px;
padding-top: 64px;
}
.index-page {
--ov-gap: 8px;
--ov-pad: 14px;
}
}
.index-page .action {
cursor: pointer;
justify-content: center;
max-width: 100%;
flex-wrap: nowrap;
.ov-page {
display: flex;
flex-direction: column;
gap: var(--ov-gap);
}
.index-page .action > span:not(.anticon) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
/* — action bar — */
.ov-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.index-page .action-update {
color: var(--ant-color-warning);
font-weight: 600;
}
.index-page .action-update .anticon {
color: var(--ant-color-warning);
}
.index-page .history-tag {
cursor: pointer;
.ov-state {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 8px;
padding: 4px 12px;
border: 1px solid var(--ov-line);
border-radius: 999px;
font-size: 13px;
color: var(--ant-color-text);
}
.ov-state-dot {
position: relative;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
flex: none;
}
.ov-state[data-state='running'] .ov-state-dot::after {
content: '';
position: absolute;
inset: -1px;
border-radius: 50%;
border: 1px solid currentColor;
animation: ovPulse 1.6s infinite ease-out;
}
@keyframes ovPulse {
0% { transform: scale(0.9); opacity: 0.5; }
100% { transform: scale(2.4); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ov-state[data-state='running'] .ov-state-dot::after {
animation: none;
}
}
.ov-state-version,
.ov-panel-version {
padding: 0;
border: 0;
background: transparent;
font: inherit;
cursor: pointer;
color: var(--ov-label);
transition: color 0.2s;
}
.ov-state-version:hover,
.ov-state-version:focus-visible,
.ov-panel-version:hover,
.ov-panel-version:focus-visible {
color: var(--ant-color-primary);
}
.ov-panel-version {
font-size: 12px;
color: var(--ov-faint);
}
.ov-update-tag {
cursor: pointer;
margin-inline-end: 0;
}
.index-page .ip-toggle-icon {
cursor: pointer;
font-size: 16px;
.ov-error-detail {
white-space: pre-wrap;
word-break: break-word;
}
.index-page .ip-hidden .ant-statistic-content-value {
filter: blur(6px);
.ov-bar-actions {
margin-inline-start: auto;
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.ov-bar-actions {
margin-inline-start: 0;
width: 100%;
justify-content: space-between;
}
}
.ov-bar-sep {
width: 1px;
height: 20px;
background: var(--ov-line);
margin: 0 4px;
}
.ov-health {
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
}
.ov-health-mark {
width: 14px;
height: 1px;
background: currentColor;
flex: none;
}
.ov-rule {
height: 1px;
border: 0;
margin: 0;
background: linear-gradient(
to right,
transparent,
var(--ov-line) 48px,
var(--ov-line) calc(100% - 48px),
transparent
);
}
/* — shared type — */
.ov-kicker {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ov-label);
}
.ov-kicker-icon {
display: flex;
align-items: center;
gap: 7px;
}
.ov-sub {
font-size: 12.5px;
margin-top: 4px;
color: var(--ov-faint);
}
.ov-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* — vitals tiles — */
.ov-vitals {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--ov-gap);
}
@media (max-width: 1100px) {
.ov-vitals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.ov-vitals { grid-template-columns: minmax(0, 1fr); }
}
.ov-tile {
overflow: hidden;
}
.ov-tile-head {
display: flex;
align-items: center;
gap: 8px;
padding: 14px var(--ov-pad) 0;
color: var(--ov-accent);
}
.ov-tile-icon {
display: inline-flex;
font-size: 15px;
}
.ov-tile-value {
display: flex;
align-items: baseline;
gap: 4px;
padding: 12px var(--ov-pad) 0;
}
.ov-tile-number {
font-size: 34px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.02em;
color: var(--ant-color-text);
font-variant-numeric: tabular-nums;
}
.ov-tile-unit {
font-size: 14px;
color: var(--ov-label);
}
.ov-tile-detail {
padding: 5px var(--ov-pad) 0;
font-size: 12px;
color: var(--ov-label);
}
.ov-tile-foot {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 14px var(--ov-pad) 0;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ov-faint);
}
.ov-tile-chart {
margin-top: 6px;
}
/* — throughput + connections — */
.ov-mid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: var(--ov-gap);
}
@media (max-width: 1100px) {
.ov-mid { grid-template-columns: minmax(0, 1fr); }
}
.ov-wide-head {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 16px;
padding: var(--ov-pad) var(--ov-pad) 0;
}
.ov-wide-head-stack {
flex-direction: column;
gap: 0;
}
.ov-wide-legend {
margin-inline-start: auto;
display: flex;
gap: 22px;
text-align: end;
}
.ov-legend-label {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
font-size: 11px;
color: var(--ov-label);
}
.ov-legend-num {
font-size: 13px;
font-weight: 600;
color: var(--ant-color-text);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ov-conn-total {
display: flex;
align-items: baseline;
gap: 6px;
margin-top: 12px;
}
.ov-conn-legend {
display: flex;
gap: 16px;
padding: 16px var(--ov-pad) 0;
}
.ov-conn-legend > div {
flex: 1 1 0;
}
.ov-conn-legend .ov-legend-label {
justify-content: flex-start;
}
.ov-swatch {
width: 14px;
height: 2px;
flex: none;
}
.ov-wide-chart {
padding: 12px 8px 0;
}
.ov-wide-foot {
display: flex;
gap: 16px;
margin: 12px var(--ov-pad) 0;
padding: 14px 0 var(--ov-pad);
border-top: 1px solid var(--ov-line);
}
.ov-wide-foot > div {
flex: 1 1 0;
}
.ov-foot-sep {
width: 1px;
background: var(--ov-line);
}
.ov-foot-value {
font-size: 18px;
font-weight: 600;
margin-top: 4px;
color: var(--ant-color-text);
font-variant-numeric: tabular-nums;
}
.ov-foot-part {
display: inline-block;
white-space: nowrap;
}
@media (max-width: 560px) {
.ov-wide-foot {
flex-direction: column;
gap: 10px;
}
.ov-wide-foot .ov-foot-sep {
display: none;
}
}
/* — system strip — */
/* tracks follow SystemStrip's cell order:
uptime (xray | os) · panel (memory | threads) · ip addresses */
.ov-strip-grid {
display: grid;
grid-template-columns:
minmax(max-content, 1.2fr) minmax(max-content, 1.2fr) minmax(0, 1.6fr);
gap: 16px;
padding: var(--ov-pad);
}
@media (max-width: 1439px) {
.ov-strip-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.ov-strip-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.ov-strip-grid { grid-template-columns: minmax(0, 1fr); }
}
@media (min-width: 1440px) {
.ov-strip-cell + .ov-strip-cell {
border-inline-start: 1px solid var(--ov-line);
padding-inline-start: 16px;
}
}
.ov-strip-value {
font-size: 19px;
font-weight: 600;
margin-top: 6px;
color: var(--ant-color-text);
font-variant-numeric: tabular-nums;
}
.ov-strip-split {
display: flex;
align-items: stretch;
gap: 14px;
}
.ov-strip-split-sep {
width: 1px;
background: var(--ov-line);
margin-top: 8px;
}
.ov-strip-sub {
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-top: 8px;
color: var(--ov-faint);
}
.ov-strip-sub + .ov-strip-value {
margin-top: 2px;
}
.ov-ip {
margin-top: 7px;
font-size: 13px;
overflow-wrap: anywhere;
transition: filter 0.2s ease;
}
.index-page .ip-visible .ant-statistic-content-value {
filter: none;
.ov-ip-v6 {
margin-top: 3px;
color: var(--ov-label);
}
/* — preserved from the previous overview — */
.index-page .ip-toggle-icon {
cursor: pointer;
font-size: 15px;
margin-inline-start: auto;
}
.index-page .ip-hidden {
filter: blur(6px);
}
+124 -309
View File
@@ -1,53 +1,29 @@
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, ConfigProvider, Layout, Modal, Result, Spin, message } from 'antd';
import {
Button,
Card,
Col,
ConfigProvider,
Layout,
message,
Modal,
Result,
Row,
Space,
Spin,
Statistic,
Tag,
Tooltip,
} from 'antd';
import {
BarsOutlined,
ControlOutlined,
CloudServerOutlined,
CloudDownloadOutlined,
CloudUploadOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
AreaChartOutlined,
GlobalOutlined,
SwapOutlined,
EyeOutlined,
EyeInvisibleOutlined,
ThunderboltOutlined,
DesktopOutlined,
DatabaseOutlined,
ForkOutlined,
CopyOutlined,
TelegramFilled,
CloudDownloadOutlined,
DashboardOutlined,
DatabaseOutlined,
HddOutlined,
SwapOutlined,
} from '@ant-design/icons';
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version';
import { activateOnKey } from '@/utils/a11y';
import { HttpUtil, CPUFormatter, SizeFormatter, ClipboardManager, FileManager } from '@/utils';
import { USAGE_CRIT_COLOR, USAGE_CRIT_PERCENT, USAGE_WARN_COLOR, USAGE_WARN_PERCENT } from '@/models/status';
import { useTheme } from '@/hooks/useTheme';
import { useStatusQuery } from '@/api/queries/useStatusQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import AppSidebar from '@/layouts/AppSidebar';
import { LazyMount } from '@/components/utility';
import { setMessageInstance } from '@/utils/messageBus';
import StatusCard from './StatusCard';
import XrayStatusCard from './XrayStatusCard';
import OverviewActionBar from './OverviewActionBar';
import VitalTile from './VitalTile';
import ThroughputCard from './ThroughputCard';
import ConnectionsCard from './ConnectionsCard';
import SystemStrip from './SystemStrip';
import { mean, peak, useOverviewHistory } from './useOverviewHistory';
import type { PanelUpdateInfo } from './PanelUpdateModal';
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
@@ -90,6 +66,8 @@ export default function IndexPage() {
const [loading, setLoading] = useState(false);
const [loadingTip, setLoadingTip] = useState(t('loading'));
const history = useOverviewHistory(status, fetched && !fetchError);
useEffect(() => {
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
'/panel/api/setting/defaultSettings',
@@ -127,10 +105,6 @@ export default function IndexPage() {
await refresh();
}, [refresh]);
function openPanelVersion() {
setPanelUpdateOpen(true);
}
async function handleChannelChange(dev: boolean) {
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
if (!res?.success) return;
@@ -139,10 +113,6 @@ export default function IndexPage() {
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
}
function openTelegram() {
window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
}
async function openConfig() {
setLoading(true);
try {
@@ -165,6 +135,23 @@ export default function IndexPage() {
}
const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
const totalDisk = status.disk.total;
const freeDisk = Math.max(0, totalDisk - status.disk.current);
const health = useMemo(() => {
const items = [
{ name: t('pages.index.cpu'), value: status.cpu.percent },
{ name: t('pages.index.memory'), value: status.mem.percent },
{ name: t('pages.index.swap'), value: status.swap.percent },
{ name: t('pages.index.storage'), value: status.disk.percent },
];
const list = (xs: typeof items) => xs.map((i) => `${i.name} ${i.value.toFixed(0)}%`).join(', ');
const crit = items.filter((i) => i.value >= USAGE_CRIT_PERCENT);
if (crit.length) return { text: t('pages.index.healthCritical', { list: list(crit) }), color: USAGE_CRIT_COLOR };
const warm = items.filter((i) => i.value >= USAGE_WARN_PERCENT);
if (warm.length) return { text: t('pages.index.healthWarm', { list: list(warm) }), color: USAGE_WARN_COLOR };
return null;
}, [status, t]);
return (
<ConfigProvider theme={antdThemeConfig}>
@@ -190,277 +177,105 @@ export default function IndexPage() {
extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, 12]}>
<Col span={24}>
<StatusCard status={status} isMobile={isMobile} />
</Col>
<div className="ov-page">
<OverviewActionBar
status={status}
isMobile={isMobile}
accessLogEnable={accessLogEnable}
panelVersion={displayVersion}
latestVersion={panelUpdateInfo.latestVersion}
updateAvailable={panelUpdateInfo.updateAvailable}
onStopXray={stopXray}
onRestartXray={restartXray}
onOpenLogs={() => setLogsOpen(true)}
onOpenXrayLogs={() => setXrayLogsOpen(true)}
onOpenConfig={openConfig}
onOpenBackup={() => setBackupOpen(true)}
onOpenSystemHistory={() => setSysHistoryOpen(true)}
onOpenXrayMetrics={() => setXrayMetricsOpen(true)}
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
onOpenVersionSwitch={() => setVersionOpen(true)}
/>
<Col xs={24} lg={12}>
<XrayStatusCard
status={status}
{health && (
<div className="ov-health" style={{ color: health.color }}>
<span className="ov-health-mark" />
{health.text}
</div>
)}
<hr className="ov-rule" />
<div className="ov-vitals">
<VitalTile
icon={<DashboardOutlined />}
label={t('pages.index.cpu')}
percent={status.cpu.percent}
statusColor={status.cpu.color}
detail={`${CPUFormatter.cpuCoreFormat(status.cpuCores)} / ${status.logicalPro}T · ${CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.cpu).toFixed(0)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.cpu).toFixed(0)}%`}
data={history.series.cpu}
isMobile={isMobile}
accessLogEnable={accessLogEnable}
onStopXray={stopXray}
onRestartXray={restartXray}
onOpenXrayLogs={() => setXrayLogsOpen(true)}
onOpenLogs={() => setLogsOpen(true)}
onOpenVersionSwitch={() => setVersionOpen(true)}
/>
</Col>
<Col xs={24} lg={12}>
<Card
title={t('menu.link')}
hoverable
actions={[
<Space className="action" key="logs" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={() => setLogsOpen(true)} onKeyDown={activateOnKey(() => setLogsOpen(true))}>
<BarsOutlined />
{!isMobile && <span>{t('pages.index.logs')}</span>}
</Space>,
<Space className="action" key="config" role="button" tabIndex={0} aria-label={t('pages.index.config')} onClick={openConfig} onKeyDown={activateOnKey(openConfig)}>
<ControlOutlined />
{!isMobile && <span>{t('pages.index.config')}</span>}
</Space>,
<Space className="action" key="backup" role="button" tabIndex={0} aria-label={t('pages.index.backupTitle')} onClick={() => setBackupOpen(true)} onKeyDown={activateOnKey(() => setBackupOpen(true))}>
<CloudServerOutlined />
{!isMobile && <span>{t('pages.index.backupTitle')}</span>}
</Space>,
]}
<VitalTile
icon={<DatabaseOutlined />}
label={t('pages.index.memory')}
percent={status.mem.percent}
statusColor={status.mem.color}
detail={`${SizeFormatter.sizeFormat(status.mem.current)} / ${SizeFormatter.sizeFormat(status.mem.total)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.mem).toFixed(0)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.mem).toFixed(0)}%`}
data={history.series.mem}
isMobile={isMobile}
/>
</Col>
<Col xs={24} lg={12}>
<Card
title={
<Space>
<span>3X-UI</span>
{isMobile && displayVersion && (
<Tag color={panelUpdateInfo.updateAvailable ? 'orange' : 'green'}>
{panelUpdateInfo.updateAvailable
? formatPanelVersion(panelUpdateInfo.latestVersion)
: formatPanelVersion(displayVersion)}
</Tag>
)}
</Space>
}
hoverable
actions={[
<Space className="action" key="tg" role="button" tabIndex={0} aria-label="@XrayUI" onClick={openTelegram} onKeyDown={activateOnKey(openTelegram)}>
<TelegramFilled aria-hidden="true" />
{!isMobile && <span>@XrayUI</span>}
</Space>,
<Space
key="panel-version"
className={`action ${panelUpdateInfo.updateAvailable ? 'action-update' : ''}`}
role="button"
tabIndex={0}
aria-label={t('pages.index.updatePanel')}
onClick={openPanelVersion}
onKeyDown={activateOnKey(openPanelVersion)}
>
<CloudDownloadOutlined />
{!isMobile && (
<span>
{panelUpdateInfo.updateAvailable
? `${t('update')} ${formatPanelVersion(panelUpdateInfo.latestVersion)}`
: formatPanelVersion(displayVersion)}
</span>
)}
</Space>,
]}
<VitalTile
icon={<SwapOutlined />}
label={t('pages.index.swap')}
percent={status.swap.percent}
statusColor={status.swap.color}
detail={`${SizeFormatter.sizeFormat(status.swap.current)} / ${SizeFormatter.sizeFormat(status.swap.total)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.swap).toFixed(1)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.swap).toFixed(0)}%`}
data={history.series.swap}
isMobile={isMobile}
/>
</Col>
<Col xs={24} lg={12}>
<Card
title={t('pages.index.charts')}
hoverable
actions={[
<Space
className="action"
key="sys-history"
role="button"
tabIndex={0}
aria-label={t('pages.index.systemHistoryTitle')}
onClick={() => setSysHistoryOpen(true)}
onKeyDown={activateOnKey(() => setSysHistoryOpen(true))}
>
<AreaChartOutlined />
{!isMobile && <span>{t('pages.index.systemHistoryTitle')}</span>}
</Space>,
<Space
className="action"
key="xray-metrics"
role="button"
tabIndex={0}
aria-label={t('pages.index.xrayMetricsTitle')}
onClick={() => setXrayMetricsOpen(true)}
onKeyDown={activateOnKey(() => setXrayMetricsOpen(true))}
>
<AreaChartOutlined />
{!isMobile && <span>{t('pages.index.xrayMetricsTitle')}</span>}
</Space>,
]}
<VitalTile
icon={<HddOutlined />}
label={t('pages.index.storage')}
percent={status.disk.percent}
statusColor={status.disk.color}
detail={`${SizeFormatter.sizeFormat(status.disk.current)} / ${SizeFormatter.sizeFormat(totalDisk)}`}
footLeft={`${t('pages.index.free')} ${SizeFormatter.sizeFormat(freeDisk)}`}
footRight={`${t('pages.index.avg')} ${mean(history.series.diskUsage).toFixed(1)}%`}
data={history.series.diskUsage}
isMobile={isMobile}
/>
</Col>
</div>
<Col xs={24} lg={12}>
<Card title={t('pages.index.operationHours')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title="Xray"
value={TimeFormatter.formatSecond(status.appStats.uptime)}
prefix={<ThunderboltOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title="OS"
value={TimeFormatter.formatSecond(status.uptime)}
prefix={<DesktopOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<div className="ov-mid">
<ThroughputCard
status={status}
up={history.series.netUp}
down={history.series.netDown}
labels={history.labels}
isMobile={isMobile}
/>
<ConnectionsCard
status={status}
tcp={history.series.tcpCount}
udp={history.series.udpCount}
labels={history.labels}
isMobile={isMobile}
/>
</div>
<Col xs={24} lg={12}>
<Card title={t('usage')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title={t('pages.index.memory')}
value={SizeFormatter.sizeFormat(status.appStats.mem)}
prefix={<DatabaseOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title={t('pages.index.threads')}
value={status.appStats.threads}
prefix={<ForkOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={t('pages.index.overallSpeed')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title={t('pages.index.upload')}
value={SizeFormatter.sizeFormat(status.netIO.up)}
prefix={<ArrowUpOutlined />}
suffix="/s"
/>
</Col>
<Col span={12}>
<Statistic
title={t('pages.index.download')}
value={SizeFormatter.sizeFormat(status.netIO.down)}
prefix={<ArrowDownOutlined />}
suffix="/s"
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={t('pages.index.totalData')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title={t('pages.index.sent')}
value={SizeFormatter.sizeFormat(status.netTraffic.sent)}
prefix={<CloudUploadOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title={t('pages.index.received')}
value={SizeFormatter.sizeFormat(status.netTraffic.recv)}
prefix={<CloudDownloadOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title={t('pages.index.ipAddresses')}
hoverable
extra={
<Tooltip
title={t('pages.index.toggleIpVisibility')}
placement={isMobile ? 'topRight' : 'top'}
>
{showIp ? (
<EyeOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowIp(false)}
onKeyDown={activateOnKey(() => setShowIp(false))}
/>
) : (
<EyeInvisibleOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowIp(true)}
onKeyDown={activateOnKey(() => setShowIp(true))}
/>
)}
</Tooltip>
}
>
<Row className={showIp ? 'ip-visible' : 'ip-hidden'} gutter={isMobile ? [8, 8] : 0}>
<Col span={isMobile ? 24 : 12}>
<Statistic
title="IPv4"
value={status.publicIP.ipv4}
prefix={<GlobalOutlined />}
/>
</Col>
<Col span={isMobile ? 24 : 12}>
<Statistic
title="IPv6"
value={status.publicIP.ipv6}
prefix={<GlobalOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={t('pages.index.connectionCount')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title="TCP"
value={status.tcpCount}
prefix={<SwapOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title="UDP"
value={status.udpCount}
prefix={<SwapOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
</Row>
<SystemStrip
status={status}
showIp={showIp}
onToggleIp={() => setShowIp((v) => !v)}
/>
</div>
)}
</Spin>
</Layout.Content>
@@ -0,0 +1,163 @@
import { Fragment } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Tag, Tooltip } from 'antd';
import {
ArrowUpOutlined,
AreaChartOutlined,
BarsOutlined,
CloudDownloadOutlined,
CloudServerOutlined,
ControlOutlined,
FileTextOutlined,
PoweroffOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { formatPanelVersion } from '@/lib/panel-version';
import type { Status } from '@/models/status';
interface OverviewActionBarProps {
status: Status;
isMobile: boolean;
accessLogEnable: boolean;
panelVersion: string;
latestVersion: string;
updateAvailable: boolean;
onStopXray: () => void;
onRestartXray: () => void;
onOpenLogs: () => void;
onOpenXrayLogs: () => void;
onOpenConfig: () => void;
onOpenBackup: () => void;
onOpenSystemHistory: () => void;
onOpenXrayMetrics: () => void;
onOpenPanelUpdate: () => void;
onOpenVersionSwitch: () => void;
}
interface BarAction {
key: string;
icon: ReactNode;
text: string;
onClick: () => void;
primary?: boolean;
}
const XRAY_STATE_KEYS: Record<string, string> = {
running: 'pages.index.xrayStatusRunning',
stop: 'pages.index.xrayStatusStop',
error: 'pages.index.xrayStatusError',
};
export default function OverviewActionBar({
status,
isMobile,
accessLogEnable,
panelVersion,
latestVersion,
updateAvailable,
onStopXray,
onRestartXray,
onOpenLogs,
onOpenXrayLogs,
onOpenConfig,
onOpenBackup,
onOpenSystemHistory,
onOpenXrayMetrics,
onOpenPanelUpdate,
onOpenVersionSwitch,
}: OverviewActionBarProps) {
const { t } = useTranslation();
const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
const hasVersion = !!status.xray.version && status.xray.version !== 'Unknown';
const size = isMobile ? ('small' as const) : ('middle' as const);
const actionGroups: BarAction[][] = [
[
{ key: 'restart', icon: <ReloadOutlined />, text: t('pages.index.restartXray'), onClick: onRestartXray, primary: true },
{ key: 'stop', icon: <PoweroffOutlined />, text: t('pages.index.stopXray'), onClick: onStopXray },
],
[
{ key: 'logs', icon: <BarsOutlined />, text: t('pages.index.logs'), onClick: onOpenLogs },
...(accessLogEnable
? [{ key: 'accessLogs', icon: <FileTextOutlined />, text: t('pages.index.accessLogs'), onClick: onOpenXrayLogs }]
: []),
{ key: 'config', icon: <ControlOutlined />, text: t('pages.index.config'), onClick: onOpenConfig },
{ key: 'backup', icon: <CloudServerOutlined />, text: t('pages.index.backupTitle'), onClick: onOpenBackup },
],
[
{ key: 'history', icon: <AreaChartOutlined />, text: t('pages.index.systemHistoryTitle'), onClick: onOpenSystemHistory },
{ key: 'metrics', icon: <ArrowUpOutlined />, text: t('pages.index.xrayMetricsTitle'), onClick: onOpenXrayMetrics },
],
];
const statePill = (
<span className="ov-state" data-state={status.xray.state}>
<span className="ov-state-dot" style={{ color: status.xray.color }} />
<span>{`${t('pages.index.xrayStatus')} · ${stateText}`}</span>
{hasVersion && (
<Tooltip title={t('pages.index.xraySwitch')}>
<button
type="button"
className="ov-state-version"
onClick={onOpenVersionSwitch}
>
{`v${status.xray.version}`}
</button>
</Tooltip>
)}
</span>
);
return (
<div className="ov-bar">
{status.xray.state === 'error' && status.xray.errorMsg ? (
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
{statePill}
</Tooltip>
) : (
statePill
)}
{updateAvailable ? (
<Tag
className="ov-update-tag"
color="warning"
icon={<CloudDownloadOutlined />}
onClick={onOpenPanelUpdate}
>
{`${t('update')} ${formatPanelVersion(latestVersion)}`}
</Tag>
) : (
<Tooltip title={t('pages.index.updatePanel')}>
<button type="button" className="ov-panel-version ov-mono" onClick={onOpenPanelUpdate}>
{formatPanelVersion(panelVersion)}
</button>
</Tooltip>
)}
<div className="ov-bar-actions">
{actionGroups.map((group, groupIndex) => (
<Fragment key={group[0].key}>
{groupIndex > 0 && <span className="ov-bar-sep" />}
{group.map((action) => (
<Button
key={action.key}
type={action.primary ? undefined : 'text'}
color={action.primary ? 'primary' : undefined}
variant={action.primary ? 'outlined' : undefined}
size={size}
icon={action.icon}
aria-label={action.text}
onClick={action.onClick}
>
{isMobile ? undefined : action.text}
</Button>
))}
</Fragment>
))}
</div>
</div>
);
}
-9
View File
@@ -1,9 +0,0 @@
.status-card .text-center {
text-align: center;
}
.status-card .ant-progress-text,
.status-card .ant-progress-indicator {
font-size: 12px !important;
font-weight: 500;
}
-115
View File
@@ -1,115 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Card, Col, Progress, Row, Tooltip } from 'antd';
import { AreaChartOutlined } from '@ant-design/icons';
import { CPUFormatter, SizeFormatter } from '@/utils';
import { useTheme } from '@/hooks/useTheme';
import type { Status } from '@/models/status';
import './StatusCard.css';
interface StatusCardProps {
status: Status;
isMobile: boolean;
}
export default function StatusCard({ status, isMobile }: StatusCardProps) {
const { t } = useTranslation();
const { isDark, isUltra } = useTheme();
const gaugeSize = isMobile ? 60 : 90;
const strokeWidth = isMobile ? 7 : 5;
const railColor = isDark
? isUltra ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.16)'
: 'rgba(0, 0, 0, 0.08)';
return (
<Card hoverable className="status-card">
<Row gutter={[0, isMobile ? 16 : 0]}>
<Col xs={24} md={12}>
<Row>
<Col span={12} className="text-center">
<Progress
type="dashboard"
status="normal"
strokeColor={status.cpu.color}
railColor={railColor}
strokeWidth={strokeWidth}
percent={status.cpu.percent}
size={gaugeSize}
/>
<div>
<b>{t('pages.index.cpu')}:</b> {CPUFormatter.cpuCoreFormat(status.cpuCores)}
<Tooltip
title={
<>
<div>
<b>{t('pages.index.logicalProcessors')}:</b> {status.logicalPro}
</div>
<div>
<b>{t('pages.index.frequency')}:</b>{' '}
{CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}
</div>
</>
}
>
<AreaChartOutlined />
</Tooltip>
</div>
</Col>
<Col span={12} className="text-center">
<Progress
type="dashboard"
status="normal"
strokeColor={status.mem.color}
railColor={railColor}
strokeWidth={strokeWidth}
percent={status.mem.percent}
size={gaugeSize}
/>
<div>
<b>{t('pages.index.memory')}:</b> {SizeFormatter.sizeFormat(status.mem.current)} /{' '}
{SizeFormatter.sizeFormat(status.mem.total)}
</div>
</Col>
</Row>
</Col>
<Col xs={24} md={12}>
<Row>
<Col span={12} className="text-center">
<Progress
type="dashboard"
status="normal"
strokeColor={status.swap.color}
railColor={railColor}
strokeWidth={strokeWidth}
percent={status.swap.percent}
size={gaugeSize}
/>
<div>
<b>{t('pages.index.swap')}:</b> {SizeFormatter.sizeFormat(status.swap.current)} /{' '}
{SizeFormatter.sizeFormat(status.swap.total)}
</div>
</Col>
<Col span={12} className="text-center">
<Progress
type="dashboard"
status="normal"
strokeColor={status.disk.color}
railColor={railColor}
strokeWidth={strokeWidth}
percent={status.disk.percent}
size={gaugeSize}
/>
<div>
<b>{t('pages.index.storage')}:</b> {SizeFormatter.sizeFormat(status.disk.current)} /{' '}
{SizeFormatter.sizeFormat(status.disk.total)}
</div>
</Col>
</Row>
</Col>
</Row>
</Card>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useTranslation } from 'react-i18next';
import { Card, Tooltip } from 'antd';
import {
ClockCircleOutlined,
DatabaseOutlined,
EyeInvisibleOutlined,
EyeOutlined,
GlobalOutlined,
} from '@ant-design/icons';
import { SizeFormatter, TimeFormatter } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import type { Status } from '@/models/status';
interface SystemStripProps {
status: Status;
showIp: boolean;
onToggleIp: () => void;
}
export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripProps) {
const { t } = useTranslation();
return (
<Card hoverable styles={{ body: { padding: 0 } }}>
<div className="ov-strip-grid">
<div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon">
<ClockCircleOutlined />
{t('pages.index.uptime')}
</div>
<div className="ov-strip-split">
<div>
<div className="ov-strip-sub">Xray</div>
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.appStats.uptime)}</div>
</div>
<span className="ov-strip-split-sep" />
<div>
<div className="ov-strip-sub">OS</div>
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
</div>
</div>
</div>
<div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon">
<DatabaseOutlined />
{t('pages.index.panel')}
</div>
<div className="ov-strip-split">
<div>
<div className="ov-strip-sub">{t('pages.index.memory')}</div>
<div className="ov-strip-value">{SizeFormatter.sizeFormat(status.appStats.mem)}</div>
</div>
<span className="ov-strip-split-sep" />
<div>
<div className="ov-strip-sub">{t('pages.index.threads')}</div>
<div className="ov-strip-value">{status.appStats.threads}</div>
</div>
</div>
</div>
<div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon">
<GlobalOutlined />
{t('pages.index.ipAddresses')}
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{showIp ? (
<EyeOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={onToggleIp}
onKeyDown={activateOnKey(onToggleIp)}
/>
) : (
<EyeInvisibleOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={onToggleIp}
onKeyDown={activateOnKey(onToggleIp)}
/>
)}
</Tooltip>
</div>
<div className={`ov-ip${showIp ? '' : ' ip-hidden'}`}>
<div className="ov-mono">{status.publicIP.ipv4}</div>
<div className="ov-mono ov-ip-v6">{status.publicIP.ipv6}</div>
</div>
</div>
</div>
</Card>
);
}
@@ -0,0 +1,97 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, theme } from 'antd';
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons';
import { SizeFormatter } from '@/utils';
import { Sparkline } from '@/components/viz';
import type { Status } from '@/models/status';
import { mean, peak } from './useOverviewHistory';
interface ThroughputCardProps {
status: Status;
up: number[];
down: number[];
labels: string[];
isMobile: boolean;
}
export default function ThroughputCard({ status, up, down, labels, isMobile }: ThroughputCardProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const accent = token.colorPrimary;
const downColor = token.colorTextTertiary;
const referenceLines = useMemo(
() => [
{ y: status.netIO.down, color: downColor, dash: '2 4' },
{ y: status.netIO.up, color: accent, dash: '2 4' },
],
[status.netIO.up, status.netIO.down, accent, downColor],
);
return (
<Card hoverable styles={{ body: { padding: 0 } }}>
<div className="ov-wide-head">
<div>
<div className="ov-kicker">{t('pages.index.overallSpeed')}</div>
<div className="ov-sub">
{`${t('pages.index.throughputSub')} · ${t('pages.index.peak')} ${SizeFormatter.speedFormat(peak(down))}`}
</div>
</div>
<div className="ov-wide-legend">
<div className="ov-legend-label">
<ArrowUpOutlined style={{ color: accent }} />
{t('pages.index.upload')}
<span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.up)}</span>
</div>
<div className="ov-legend-label">
<ArrowDownOutlined style={{ color: downColor }} />
{t('pages.index.download')}
<span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.down)}</span>
</div>
</div>
</div>
<div className="ov-wide-chart">
<Sparkline
data={up}
data2={down}
labels={labels}
height={isMobile ? 140 : 186}
strokeWidth={1.75}
fillOpacity={0.24}
showTooltip
showLegend={false}
valueMax={null}
stroke={accent}
stroke2={downColor}
name1={t('pages.index.upload')}
name2={t('pages.index.download')}
yFormatter={SizeFormatter.speedFormat}
referenceLines={referenceLines}
/>
</div>
<div className="ov-wide-foot">
<div>
<div className="ov-kicker">{t('pages.index.sent')}</div>
<div className="ov-foot-value">{SizeFormatter.sizeFormat(status.netTraffic.sent)}</div>
</div>
<span className="ov-foot-sep" />
<div>
<div className="ov-kicker">{t('pages.index.received')}</div>
<div className="ov-foot-value">{SizeFormatter.sizeFormat(status.netTraffic.recv)}</div>
</div>
<span className="ov-foot-sep" />
<div>
<div className="ov-kicker">{t('pages.index.avgWindow')}</div>
<div className="ov-foot-value">
<span className="ov-foot-part">{`↑ ${SizeFormatter.speedFormat(mean(up))}`}</span>{' '}
<span className="ov-foot-part">{`↓ ${SizeFormatter.speedFormat(mean(down))}`}</span>
</div>
</div>
</div>
</Card>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { Card, theme } from 'antd';
import { Sparkline } from '@/components/viz';
import { mean, peak } from './useOverviewHistory';
interface VitalTileProps {
icon: ReactNode;
label: string;
percent: number;
statusColor: string;
detail: string;
footLeft: string;
footRight: string;
data: number[];
isMobile: boolean;
}
export default function VitalTile({
icon,
label,
percent,
statusColor,
detail,
footLeft,
footRight,
data,
isMobile,
}: VitalTileProps) {
const { token } = theme.useToken();
const meanColor = token.colorTextTertiary;
const referenceLines = useMemo(
() => (data.length > 1 ? [{ y: mean(data), dash: '3 4', color: meanColor }] : []),
[data, meanColor],
);
return (
<Card hoverable className="ov-tile" styles={{ body: { padding: 0 } }}>
<div className="ov-tile-head">
<span className="ov-tile-icon">{icon}</span>
<span className="ov-kicker">{label}</span>
</div>
<div className="ov-tile-value">
<span className="ov-tile-number">{percent.toFixed(1)}</span>
<span className="ov-tile-unit">%</span>
</div>
<div className="ov-tile-detail">{detail}</div>
<div className="ov-tile-foot">
<span>{footLeft}</span>
<span>{footRight}</span>
</div>
<div className="ov-tile-chart">
<Sparkline
data={data}
height={isMobile ? 48 : 62}
strokeWidth={1.5}
fillOpacity={0.3}
showGrid={false}
showMarker={false}
valueMax={peak(data) > 0 ? null : 100}
stroke={statusColor}
referenceLines={referenceLines}
yFormatter={(v) => `${v.toFixed(0)}%`}
name1={label}
/>
</div>
</Card>
);
}
@@ -1,14 +0,0 @@
.xray-status-card .action {
cursor: pointer;
justify-content: center;
}
.error-line {
display: block;
max-width: 400px;
white-space: pre-wrap;
}
.cursor-pointer {
cursor: pointer;
}
-123
View File
@@ -1,123 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge, Card, Col, Popover, Row, Space, Tag } from 'antd';
import {
BarsOutlined,
PoweroffOutlined,
ReloadOutlined,
ToolOutlined,
} from '@ant-design/icons';
import type { Status } from '@/models/status';
import { activateOnKey } from '@/utils/a11y';
import './XrayStatusCard.css';
interface XrayStatusCardProps {
status: Status;
isMobile: boolean;
accessLogEnable: boolean;
onStopXray: () => void;
onRestartXray: () => void;
onOpenLogs: () => void;
onOpenXrayLogs: () => void;
onOpenVersionSwitch: () => void;
}
const XRAY_STATE_KEYS: Record<string, string> = {
running: 'pages.index.xrayStatusRunning',
stop: 'pages.index.xrayStatusStop',
error: 'pages.index.xrayStatusError',
};
export default function XrayStatusCard({
status,
isMobile,
accessLogEnable,
onStopXray,
onRestartXray,
onOpenLogs,
onOpenXrayLogs,
onOpenVersionSwitch,
}: XrayStatusCardProps) {
const { t } = useTranslation();
const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
const title = (
<Space>
<span>{t('pages.index.xrayStatus')}</span>
{isMobile && status.xray.version && status.xray.version !== 'Unknown' && (
<Tag color="green">v{status.xray.version}</Tag>
)}
</Space>
);
const errorLines = useMemo(
() => (status.xray.errorMsg || '').split('\n'),
[status.xray.errorMsg],
);
const extra =
status.xray.state !== 'error' ? (
<Badge status="processing" text={stateText} color={status.xray.color} />
) : (
<Popover
title={
<Row align="middle" justify="space-between">
<Col>
<span>{t('pages.index.xrayStatusError')}</span>
</Col>
<Col>
<BarsOutlined className="cursor-pointer" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={onOpenLogs} onKeyDown={activateOnKey(onOpenLogs)} />
</Col>
</Row>
}
content={
<>
{errorLines.map((line, i) => (
<span key={i} className="error-line">
{line}
</span>
))}
</>
}
>
<Badge status="processing" text={stateText} color={status.xray.color} />
</Popover>
);
const actions = [
// the xray log viewer reads the access log file, so the button only makes
// sense when one is configured (unlike IP limit, which no longer needs it)
...(accessLogEnable
? [
<Space className="action" key="xraylogs" role="button" tabIndex={0} aria-label={t('pages.index.accessLogs')} onClick={onOpenXrayLogs} onKeyDown={activateOnKey(onOpenXrayLogs)}>
<BarsOutlined />
{!isMobile && <span>{t('pages.index.accessLogs')}</span>}
</Space>,
]
: []),
<Space className="action" key="stop" role="button" tabIndex={0} aria-label={t('pages.index.stopXray')} onClick={onStopXray} onKeyDown={activateOnKey(onStopXray)}>
<PoweroffOutlined />
{!isMobile && <span>{t('pages.index.stopXray')}</span>}
</Space>,
<Space className="action" key="restart" role="button" tabIndex={0} aria-label={t('pages.index.restartXray')} onClick={onRestartXray} onKeyDown={activateOnKey(onRestartXray)}>
<ReloadOutlined />
{!isMobile && <span>{t('pages.index.restartXray')}</span>}
</Space>,
<Space className="action" key="switch" role="button" tabIndex={0} aria-label={t('pages.index.xraySwitch')} onClick={onOpenVersionSwitch} onKeyDown={activateOnKey(onOpenVersionSwitch)}>
<ToolOutlined />
{!isMobile && (
<span>
{status.xray.version && status.xray.version !== 'Unknown'
? `v${status.xray.version}`
: t('pages.index.xraySwitch')}
</span>
)}
</Space>,
];
return (
<Card hoverable title={title} extra={extra} actions={actions} className="xray-status-card" />
);
}
@@ -0,0 +1,135 @@
import { useEffect, useMemo, useState } from 'react';
import { HttpUtil, TimeFormatter } from '@/utils';
import type { Status } from '@/models/status';
const OVERVIEW_WINDOW = 72;
const SEED_BUCKET_SECONDS = 2;
const SERIES_KEYS = ['cpu', 'mem', 'swap', 'diskUsage', 'netUp', 'netDown', 'tcpCount', 'udpCount'] as const;
export type OverviewSeriesKey = (typeof SERIES_KEYS)[number];
export interface OverviewHistory {
series: Record<OverviewSeriesKey, number[]>;
labels: string[];
}
interface HistoryPoint {
t: number;
v: number;
}
interface HistoryWindow {
series: Record<OverviewSeriesKey, number[]>;
times: number[];
}
function emptySeries(): Record<OverviewSeriesKey, number[]> {
return Object.fromEntries(SERIES_KEYS.map((key) => [key, [] as number[]])) as Record<OverviewSeriesKey, number[]>;
}
function emptyWindow(): HistoryWindow {
return { series: emptySeries(), times: [] };
}
function sampleOf(status: Status): Record<OverviewSeriesKey, number> {
return {
cpu: status.cpu.percent,
mem: status.mem.percent,
swap: status.swap.percent,
diskUsage: status.disk.percent,
netUp: status.netIO.up,
netDown: status.netIO.down,
tcpCount: status.tcpCount,
udpCount: status.udpCount,
};
}
function tailWindow<T>(values: T[]): T[] {
return values.slice(-OVERVIEW_WINDOW);
}
export function mean(values: number[]): number {
if (values.length === 0) return 0;
let total = 0;
for (const v of values) total += v;
return total / values.length;
}
export function peak(values: number[]): number {
let max = 0;
for (const v of values) if (v > max) max = v;
return max;
}
/* the seed bucket must be in the backend's allowedHistoryBuckets whitelist;
2s is the smallest and matches the status poll cadence */
export function useOverviewHistory(status: Status, hasData: boolean): OverviewHistory {
const [trend, setTrend] = useState<HistoryWindow>(emptyWindow);
useEffect(() => {
let cancelled = false;
const seed = async () => {
const responses = new Map<OverviewSeriesKey, HistoryPoint[]>();
await Promise.all(
SERIES_KEYS.map(async (key) => {
const msg = await HttpUtil.get<HistoryPoint[]>(
`/panel/api/server/history/${key}/${SEED_BUCKET_SECONDS}`,
undefined,
{ silent: true },
);
if (msg?.success && Array.isArray(msg.obj)) responses.set(key, msg.obj);
}),
);
if (cancelled || responses.size === 0) return;
let axis: HistoryPoint[] = [];
for (const points of responses.values()) {
if (points.length > axis.length) axis = points;
}
axis = tailWindow(axis);
if (axis.length === 0) return;
const seedTimes = axis.map((p) => Number(p.t) || 0);
const seedSeries = emptySeries();
for (const key of SERIES_KEYS) {
const byTs = new Map<number, number>();
for (const p of responses.get(key) ?? []) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
seedSeries[key] = seedTimes.map((ts) => byTs.get(ts) ?? 0);
}
setTrend((prev) => {
const merged = emptyWindow();
merged.times = tailWindow(seedTimes.concat(prev.times));
for (const key of SERIES_KEYS) {
merged.series[key] = tailWindow(seedSeries[key].concat(prev.series[key]));
}
return merged;
});
};
seed().catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!hasData) return;
setTrend((prev) => {
const point = sampleOf(status);
const next = emptyWindow();
next.times = tailWindow(prev.times.concat(Math.floor(Date.now() / 1000)));
for (const key of SERIES_KEYS) {
next.series[key] = tailWindow(prev.series[key].concat(point[key]));
}
return next;
});
}, [status, hasData]);
const labels = useMemo(() => trend.times.map(TimeFormatter.formatClock), [trend.times]);
return useMemo(() => ({ series: trend.series, labels }), [trend.series, labels]);
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
@@ -63,7 +63,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
onChange={(e) => updateSetting({ smtpHost: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} description={t('pages.settings.smtpPortDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} badge={<DefaultSettingTag settingKey="smtpPort" value={allSetting.smtpPort} />} description={t('pages.settings.smtpPortDesc')}>
<InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ smtpPort: v }))} />
</SettingListItem>
+10 -10
View File
@@ -18,7 +18,7 @@ import {
import type { AllSetting } from '@/models/setting';
import { HttpUtil, LanguageManager } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath } from './uriPath';
@@ -169,7 +169,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
<Input value={allSetting.webDomain} onChange={(e) => updateSetting({ webDomain: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} description={t('pages.settings.panelPortDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} badge={<DefaultSettingTag settingKey="webPort" value={allSetting.webPort} />} description={t('pages.settings.panelPortDesc')}>
<InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ webPort: v }))} />
</SettingListItem>
@@ -178,7 +178,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
<Input value={allSetting.webBasePath} onChange={(e) => updateSetting({ webBasePath: sanitizePath(e.target.value) })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} description={t('pages.settings.sessionMaxAgeDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} badge={<DefaultSettingTag settingKey="sessionMaxAge" value={allSetting.sessionMaxAge} />} description={t('pages.settings.sessionMaxAgeDesc')}>
<InputNumber value={allSetting.sessionMaxAge} min={60} max={525600} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))} />
</SettingListItem>
@@ -207,7 +207,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} badge={<DefaultSettingTag settingKey="pageSize" value={allSetting.pageSize} />} description={t('pages.settings.pageSizeDesc')}>
<InputNumber value={allSetting.pageSize} min={0} max={1000} step={5} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ pageSize: v }))} />
</SettingListItem>
@@ -233,11 +233,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} badge={<DefaultSettingTag settingKey="expireDiff" value={allSetting.expireDiff} />} description={t('pages.settings.expireTimeDiffDesc')}>
<InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ expireDiff: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} badge={<DefaultSettingTag settingKey="trafficDiff" value={allSetting.trafficDiff} />} description={t('pages.settings.trafficDiffDesc')}>
<InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ trafficDiff: v }))} />
</SettingListItem>
@@ -307,7 +307,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
<SettingListItem paddings="small" title={t('pages.settings.ldap.host')}>
<Input value={allSetting.ldapHost} onChange={(e) => updateSetting({ ldapHost: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.port')}>
<SettingListItem paddings="small" title={t('pages.settings.ldap.port')} badge={<DefaultSettingTag settingKey="ldapPort" value={allSetting.ldapPort} />}>
<InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapPort: v }))} />
</SettingListItem>
@@ -386,15 +386,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoDelete')}>
<Switch checked={allSetting.ldapAutoDelete} onChange={(v) => updateSetting({ ldapAutoDelete: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')}>
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')} badge={<DefaultSettingTag settingKey="ldapDefaultTotalGB" value={allSetting.ldapDefaultTotalGB} />}>
<InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')}>
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')} badge={<DefaultSettingTag settingKey="ldapDefaultExpiryDays" value={allSetting.ldapDefaultExpiryDays} />}>
<InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')}>
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')} badge={<DefaultSettingTag settingKey="ldapDefaultLimitIP" value={allSetting.ldapDefaultLimitIP} />}>
<InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))} />
</SettingListItem>
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import type { AllSetting } from '@/models/setting';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { RemarkTemplateField } from '@/components/form';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
@@ -56,7 +56,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
<SettingListItem paddings="small" title={t('pages.settings.subDomain')} description={t('pages.settings.subDomainDesc')}>
<Input value={allSetting.subDomain} onChange={(e) => updateSetting({ subDomain: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subPort')} description={t('pages.settings.subPortDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.subPort')} badge={<DefaultSettingTag settingKey="subPort" value={allSetting.subPort} />} description={t('pages.settings.subPortDesc')}>
<InputNumber value={allSetting.subPort} min={1} max={65535} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ subPort: v }))} />
</SettingListItem>
@@ -105,7 +105,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} badge={<DefaultSettingTag settingKey="subUpdates" value={allSetting.subUpdates} />} description={t('pages.settings.subUpdatesDesc')}>
<InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ subUpdates: v }))} />
</SettingListItem>
+3 -1
View File
@@ -4,6 +4,7 @@ import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from '
import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { LanguageManager } from '@/utils';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
@@ -122,9 +123,10 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
<Space.Compact style={{ width: '100%' }}>
<InputNumber
min={1}
precision={0}
style={{ width: '50%' }}
value={state.num}
onChange={(v) => update({ num: Math.max(1, Number(v) || 1) })}
onChange={onNumber((v) => update({ num: Math.max(1, v) }))}
aria-label={t('pages.settings.notifyTime.interval')}
/>
<Select<Unit>
+4 -3
View File
@@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { onNumber } from '@/utils/onNumber';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
import {
@@ -216,10 +217,10 @@ export default function BasicsTab({
style={{ width: '100%' }}
value={directHappyEyeballs.tryDelayMs}
placeholder="150"
onChange={(v) => setDirectHappyEyeballs({
onChange={onNumber((v) => setDirectHappyEyeballs({
...directHappyEyeballs,
tryDelayMs: typeof v === 'number' ? v : 0,
})}
tryDelayMs: v,
}))}
/>
}
/>
+26 -23
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
import {
@@ -42,6 +42,23 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
const dnsEnabled = !!dns;
const sourceHosts = dns?.hosts;
const incomingHosts = JSON.stringify(sourceHosts ?? {});
const lastWrittenHostsRef = useRef<string | null>(null);
useEffect(() => {
if (!dnsEnabled) {
lastWrittenHostsRef.current = '{}';
setHostsList([]);
return;
}
if (incomingHosts === lastWrittenHostsRef.current) return;
lastWrittenHostsRef.current = incomingHosts;
setHostsList(Object.entries(sourceHosts ?? {}).map(([domain, values]) => ({
domain,
values: Array.isArray(values) ? [...values] : [String(values)],
})));
}, [dnsEnabled, incomingHosts, sourceHosts]);
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
@@ -79,32 +96,18 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
});
}
useEffect(() => {
if (!dns) {
setHostsList([]);
return;
}
const src = dns.hosts || {};
setHostsList(
Object.entries(src).map(([domain, val]) => ({
domain,
values: Array.isArray(val) ? [...val] : [String(val)],
})),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dnsEnabled]);
function syncHosts(next: HostRow[]) {
const obj: Record<string, string | string[]> = {};
for (const row of next) {
if (!row.domain) continue;
const vals = (row.values || []).filter(Boolean);
if (vals.length === 0) continue;
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
}
lastWrittenHostsRef.current = JSON.stringify(obj);
setHostsList(next);
mutate((tt) => {
if (!tt.dns) return;
const obj: Record<string, string | string[]> = {};
for (const row of next) {
if (!row.domain) continue;
const vals = (row.values || []).filter(Boolean);
if (vals.length === 0) continue;
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
}
if (Object.keys(obj).length > 0) {
(tt.dns as DnsConfig).hosts = obj;
} else if ('hosts' in (tt.dns as DnsConfig)) {
+6
View File
@@ -68,9 +68,15 @@ export const InboundOptionSchema = z.object({
export const InboundOptionsSchema = z.array(InboundOptionSchema);
// The *Count fields are exact; the email arrays stop at the server's cap and
// only feed the hover popovers, so never derive a counter from their length.
export const ClientsSummarySchema = z.object({
total: z.number(),
active: z.number(),
onlineCount: z.number().optional().default(0),
depletedCount: z.number().optional().default(0),
expiringCount: z.number().optional().default(0),
deactiveCount: z.number().optional().default(0),
online: nullableStringArray,
depleted: nullableStringArray,
expiring: nullableStringArray,
+4
View File
@@ -103,3 +103,7 @@ export const AllSettingSchema = z.object({
}).loose();
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
export const FactoryDefaultsSchema = z.record(z.string(), z.string());
export type FactoryDefaults = z.infer<typeof FactoryDefaultsSchema>;
@@ -0,0 +1,127 @@
import type { ReactNode } from 'react';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useClients } from '@/hooks/useClients';
import { makeTestQueryClient } from '@/test/test-utils';
import { HttpUtil, Msg } from '@/utils';
afterEach(() => {
vi.restoreAllMocks();
});
const emptyPage = {
items: [],
total: 0,
filtered: 0,
page: 1,
pageSize: 25,
groups: [],
summary: {
total: 0,
active: 0,
onlineCount: 0,
depletedCount: 0,
expiringCount: 0,
deactiveCount: 0,
online: [],
depleted: [],
expiring: [],
deactive: [],
},
};
function mockPanel(defaults: Record<string, unknown>) {
const pagedUrls: string[] = [];
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
if (url.includes('/clients/list/paged')) {
pagedUrls.push(url);
return new Msg(true, '', emptyPage);
}
if (url.includes('/inbounds/options')) return new Msg(true, '', []);
return new Msg(true, '', null);
});
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url: string) => {
if (url.includes('/setting/defaultSettings')) return new Msg(true, '', defaults);
if (url.includes('/clients/onlines')) return new Msg(true, '', []);
return new Msg(true, '', null);
});
return pagedUrls;
}
function wrapperFor() {
const queryClient = makeTestQueryClient();
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
describe('useClients query gating', () => {
it('does not fetch the list until the page supplies a query', async () => {
const pagedUrls = mockPanel({ pageSize: 25 });
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
await waitFor(() => expect(result.current.settingsReady).toBe(true));
// The page has not called setQuery yet, so nothing should have gone out —
// this is what used to cost a thrown-away round trip on every page load.
expect(pagedUrls).toEqual([]);
expect(result.current.fetched).toBe(false);
});
it('issues exactly one request for a page load that settles on one query', async () => {
const pagedUrls = mockPanel({ pageSize: 50 });
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
await waitFor(() => expect(result.current.settingsReady).toBe(true));
act(() => {
result.current.setQuery({ page: 1, pageSize: 50, sort: 'createdAt', order: 'ascend' });
});
await waitFor(() => expect(result.current.fetched).toBe(true));
expect(pagedUrls).toHaveLength(1);
expect(pagedUrls[0]).toContain('pageSize=50');
expect(pagedUrls[0]).toContain('sort=createdAt');
});
it('fetches as soon as a query arrives, without waiting for the settings', async () => {
// The page remembers the previous visit's page size in localStorage, so on a
// return visit it can supply a query on the first render. The hook must not
// hold that back behind /setting/defaultSettings, or the two round trips
// serialise and the list lands ~160ms later than it needs to.
const pagedUrls = mockPanel({ pageSize: 25 });
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
act(() => {
result.current.setQuery({ page: 1, pageSize: 25, sort: 'createdAt', order: 'ascend' });
});
await waitFor(() => expect(pagedUrls).toHaveLength(1));
});
it('reports settingsReady even when the settings request fails, so the page can still render', async () => {
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', emptyPage));
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(false, 'boom', null));
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
await waitFor(() => expect(result.current.settingsReady).toBe(true));
});
it('skips the list, options and onlines queries for mutation-only callers', async () => {
const pagedUrls = mockPanel({ pageSize: 25 });
const postSpy = vi.mocked(HttpUtil.post);
const { result } = renderHook(() => useClients({ list: false }), { wrapper: wrapperFor() });
await waitFor(() => expect(result.current.settingsReady).toBe(true));
act(() => {
result.current.setQuery({ page: 1, pageSize: 25, sort: 'createdAt', order: 'ascend' });
});
await waitFor(() => expect(result.current.settingsReady).toBe(true));
expect(pagedUrls).toEqual([]);
// subSettings still needs defaultSettings; onlines must not be polled.
const posted = postSpy.mock.calls.map((c) => String(c[0]));
expect(posted.some((u) => u.includes('/setting/defaultSettings'))).toBe(true);
expect(posted.some((u) => u.includes('/clients/onlines'))).toBe(false);
expect(vi.mocked(HttpUtil.get).mock.calls.map((c) => String(c[0]))).toEqual([]);
});
});
@@ -0,0 +1,117 @@
import { useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { ClientInboundChips, ClientRowActions } from '@/pages/clients/RowCells';
import type { InboundOption } from '@/hooks/useClients';
const PROTOCOL_COLORS = { vless: 'blue', trojan: 'volcano' };
// Counts how often the cell reads the inbound map, which happens once per chip
// per render. A traffic push re-renders the row, so if the cell is not memoised
// this climbs every five seconds for every visible row.
function countingInboundMap(source: Record<number, InboundOption>) {
const reads = { count: 0 };
const proxy = new Proxy(source, {
get(target, key) {
if (typeof key === 'string' && /^\d+$/.test(key)) reads.count += 1;
return target[key as unknown as number];
},
});
return { proxy, reads };
}
const INBOUNDS: Record<number, InboundOption> = {
1: { id: 1, tag: 'in-vless', remark: 'DE', protocol: 'vless' },
2: { id: 2, tag: 'in-trojan', remark: 'NL', protocol: 'trojan' },
};
function Harness({ children }: { children: (bump: () => void) => React.ReactNode }) {
const [, setTick] = useState(0);
return <>{children(() => setTick((n) => n + 1))}</>;
}
describe('clients table row cells', () => {
it('does not re-render the inbound chips when the row re-renders with the same attachments', async () => {
const { proxy, reads } = countingInboundMap(INBOUNDS);
const ids = [1, 2];
let bump: () => void = () => {};
render(
<Harness>
{(doBump) => {
bump = doBump;
return (
<ClientInboundChips ids={ids} inboundsById={proxy} protocolColors={PROTOCOL_COLORS} chipLimit={1} />
);
}}
</Harness>,
);
const afterFirstRender = reads.count;
expect(afterFirstRender).toBeGreaterThan(0);
// Three simulated traffic pushes: the parent re-renders, the props do not change.
for (let i = 0; i < 3; i++) bump();
await Promise.resolve();
expect(reads.count).toBe(afterFirstRender);
});
it('re-renders the chips when the attachments actually change', async () => {
const { proxy, reads } = countingInboundMap(INBOUNDS);
function Swapper() {
const [ids, setIds] = useState<number[]>([1]);
return (
<>
<button type="button" onClick={() => setIds([1, 2])}>swap</button>
<ClientInboundChips ids={ids} inboundsById={proxy} protocolColors={PROTOCOL_COLORS} chipLimit={1} />
</>
);
}
render(<Swapper />);
const before = reads.count;
await userEvent.click(screen.getByRole('button', { name: 'swap' }));
expect(reads.count).toBeGreaterThan(before);
});
it('keeps the row actions wired to the right client across re-renders', async () => {
const onShowQr = vi.fn();
const onEdit = vi.fn();
const noop = vi.fn();
let bump: () => void = () => {};
render(
<Harness>
{(doBump) => {
bump = doBump;
return (
<ClientRowActions
email="alice@x"
onShowQr={onShowQr}
onShowInfo={noop}
onResetTraffic={noop}
onEdit={onEdit}
onDelete={noop}
/>
);
}}
</Harness>,
);
for (let i = 0; i < 3; i++) bump();
// Queried by position rather than label: the suite loads the real en-US
// bundle, so the aria-labels are translated strings, not keys. Order is
// QR, info, reset traffic, edit, delete.
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(5);
await userEvent.click(buttons[0]);
await userEvent.click(buttons[3]);
expect(onShowQr).toHaveBeenCalledExactlyOnceWith('alice@x');
expect(onEdit).toHaveBeenCalledExactlyOnceWith('alice@x');
});
});
+58 -2
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { computeClientsSummary, pickClientsSummary } from '@/hooks/useClients';
import { computeClientsSummary, pickClientsSummary, sameSpeedMap, sameSummaryInputs } from '@/hooks/useClients';
import type { ClientTraffic, ClientsSummary } from '@/schemas/client';
// Parity with web/service/client.go buildClientsSummary: the same client must
@@ -42,6 +42,24 @@ describe('computeClientsSummary', () => {
expect(s.active).toBe(2); // online@x + offline@x
});
it('reports a counter alongside every bucket list', () => {
const stats: Row[] = [
row({ email: 'online@x', enable: true }),
row({ email: 'disabled@x', enable: false }),
row({ email: 'exhausted@x', enable: true, total: 1 * GB, up: 1 * GB }),
row({ email: 'nearlimit@x', enable: true, total: 10 * GB, up: 9.9 * GB }),
];
const s = computeClientsSummary(stats, new Set(['online@x']), 3 * DAY, 1 * GB);
// The server caps its lists but never its counters; the live recompute has
// both, so the summary card reads the same either way.
expect(s.onlineCount).toBe(s.online.length);
expect(s.depletedCount).toBe(s.depleted.length);
expect(s.expiringCount).toBe(s.expiring.length);
expect(s.deactiveCount).toBe(s.deactive.length);
expect(s.active + s.depletedCount + s.expiringCount + s.deactiveCount).toBe(s.total);
});
it('depleted wins over disabled and over online', () => {
const stats: Row[] = [
row({ email: 'a@x', enable: false, total: 1 * GB, up: 2 * GB }),
@@ -63,7 +81,9 @@ describe('computeClientsSummary', () => {
describe('pickClientsSummary', () => {
const serverSummary: ClientsSummary = {
total: 67, active: 58, online: [], depleted: [], expiring: [], deactive: [],
total: 67, active: 58,
onlineCount: 0, depletedCount: 4, expiringCount: 3, deactiveCount: 2,
online: [], depleted: [], expiring: [], deactive: [],
};
it('keeps the server summary when the snapshot is short of the server total (#6102)', () => {
@@ -84,3 +104,39 @@ describe('pickClientsSummary', () => {
expect(s).toEqual(serverSummary);
});
});
describe('websocket payload identity preservation', () => {
const speed = (up: number, down: number) => ({ up, down });
it('treats an unchanged speed map as unchanged', () => {
const a = { 'a@x': speed(1, 2), 'b@x': speed(3, 4) };
expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 4) })).toBe(true);
expect(sameSpeedMap(a, { 'a@x': speed(1, 2) })).toBe(false);
expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'b@x': speed(3, 5) })).toBe(false);
expect(sameSpeedMap(a, { 'a@x': speed(1, 2), 'c@x': speed(3, 4) })).toBe(false);
expect(sameSpeedMap({}, {})).toBe(true);
});
it('compares exactly the fields the summary reads, and ignores lastOnline', () => {
const base: Row[] = [row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99 })];
// lastOnline moves for every online client on every push and no counter
// depends on it, so it must not force a new snapshot.
const onlyLastOnlineMoved: Row[] = [
row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, lastOnline: 12345 }),
];
expect(sameSummaryInputs(base, onlyLastOnlineMoved)).toBe(true);
for (const changed of [
row({ email: 'b@x', up: 1, down: 2, total: 10, expiryTime: 99 }),
row({ email: 'a@x', up: 2, down: 2, total: 10, expiryTime: 99 }),
row({ email: 'a@x', up: 1, down: 3, total: 10, expiryTime: 99 }),
row({ email: 'a@x', up: 1, down: 2, total: 11, expiryTime: 99 }),
row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 100 }),
row({ email: 'a@x', up: 1, down: 2, total: 10, expiryTime: 99, enable: false }),
]) {
expect(sameSummaryInputs(base, [changed])).toBe(false);
}
expect(sameSummaryInputs(base, [])).toBe(false);
});
});
@@ -0,0 +1,64 @@
import { screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { keys } from '@/api/queryKeys';
import DefaultSettingTag, { matchesFactoryDefault } from '@/components/ui/DefaultSettingTag';
import { makeTestQueryClient, renderWithProviders } from './test-utils';
function clientWithDefaults(defaults: Record<string, string>) {
const queryClient = makeTestQueryClient();
queryClient.setQueryData(keys.settings.factoryDefaults(), defaults);
return queryClient;
}
describe('matchesFactoryDefault', () => {
it('compares by value with type-aware coercion', () => {
expect(matchesFactoryDefault(2096, '2096')).toBe(true);
expect(matchesFactoryDefault(8443, '2096')).toBe(false);
expect(matchesFactoryDefault(true, 'true')).toBe(true);
expect(matchesFactoryDefault(false, 'true')).toBe(false);
expect(matchesFactoryDefault('/sub/', '/sub/')).toBe(true);
expect(matchesFactoryDefault('/other/', '/sub/')).toBe(false);
});
it('never matches when the key has no shipped default', () => {
expect(matchesFactoryDefault(2096, undefined)).toBe(false);
});
it('rejects blank or unparsable defaults instead of coercing them', () => {
expect(matchesFactoryDefault(0, '')).toBe(false);
expect(matchesFactoryDefault(0, ' ')).toBe(false);
expect(matchesFactoryDefault(0, 'none')).toBe(false);
expect(matchesFactoryDefault(false, '')).toBe(false);
expect(matchesFactoryDefault(false, 'no')).toBe(false);
});
});
describe('DefaultSettingTag', () => {
it('shows the tag when the current value equals the shipped default, however it got there', () => {
renderWithProviders(
<DefaultSettingTag settingKey="subPort" value={2096} />,
{ queryClient: clientWithDefaults({ subPort: '2096' }) },
);
expect(screen.getByText('Default')).toBeDefined();
});
it('renders nothing when the value differs from the default', () => {
renderWithProviders(
<DefaultSettingTag settingKey="subPort" value={8443} />,
{ queryClient: clientWithDefaults({ subPort: '2096' }) },
);
expect(screen.queryByText('Default')).toBeNull();
});
it('renders nothing while defaults are unknown', () => {
renderWithProviders(
<DefaultSettingTag settingKey="subPort" value={2096} />,
{ queryClient: makeTestQueryClient() },
);
expect(screen.queryByText('Default')).toBeNull();
});
});
+107
View File
@@ -0,0 +1,107 @@
import { useState } from 'react';
import { describe, expect, it } from 'vitest';
import { fireEvent, screen } from '@testing-library/react';
import DnsTab from '@/pages/xray/dns/DnsTab';
import type { SetTemplate, XraySettingsValue } from '@/hooks/useXraySetting';
import { renderWithProviders } from './test-utils';
function withHosts(hosts: Record<string, string>): XraySettingsValue {
return {
dns: {
hosts,
servers: [],
},
} as unknown as XraySettingsValue;
}
describe('DnsTab', () => {
it('keeps an empty row after adding a host', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
}
renderWithProviders(
<Harness />,
);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
fireEvent.click(screen.getByRole('button', { name: /Add Host$/ }));
expect(screen.getAllByLabelText('Domain (e.g. domain:example.com)')).toHaveLength(2);
});
it('keeps a row visible while its domain is incomplete', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
}
renderWithProviders(<Harness />);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), { target: { value: '' } });
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('');
});
it('shows hosts from an externally refreshed configuration', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return (
<>
<button type="button" onClick={() => setTemplateSettings(withHosts({ 'second.example': '2.2.2.2' }))}>
Refresh hosts
</button>
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
</>
);
}
renderWithProviders(<Harness />);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('first.example');
fireEvent.click(screen.getByRole('button', { name: 'Refresh hosts' }));
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('second.example');
});
it('clears an incomplete host draft when DNS is disabled', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return (
<>
<button type="button" onClick={() => setTemplateSettings({})}>Disable DNS</button>
<button type="button" onClick={() => setTemplateSettings(withHosts({}))}>Enable DNS</button>
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
</>
);
}
renderWithProviders(<Harness />);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: 'Disable DNS' }));
fireEvent.click(screen.getByRole('button', { name: 'Enable DNS' }));
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
expect(screen.queryByLabelText('Domain (e.g. domain:example.com)')).toBeNull();
});
});
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { matchesFactoryDefault } from '@/components/ui/DefaultSettingTag';
import { AllSetting } from '@/models/setting';
/*
* Contract test between the three homes of a setting's default value: the Go
* defaultValueMap (authoritative, served by /setting/factoryDefaults), the
* frontend AllSetting class defaults (what the form shows when the API omits
* a value), and the tag's own comparison. If someone bumps a default on one
* side only, the Default tag would start calling a different value "Default"
* than the one the form displays — this test fails instead.
*/
function goDefaultLiterals(): Record<string, string> {
const source = readFileSync(
resolve(process.cwd(), '..', 'internal', 'web', 'service', 'setting.go'),
'utf8',
);
const start = source.indexOf('var defaultValueMap = map[string]string{');
const end = source.indexOf('\n}', start);
const block = source.slice(start, end);
const literals: Record<string, string> = {};
for (const match of block.matchAll(/"([A-Za-z0-9]+)":\s+"((?:[^"\\]|\\.)*)"\s*,/g)) {
literals[match[1]] = JSON.parse(`"${match[2]}"`);
}
return literals;
}
describe('factory defaults contract', () => {
const goDefaults = goDefaultLiterals();
const frontend = new AllSetting() as unknown as Record<string, unknown>;
const sharedKeys = Object.keys(goDefaults).filter((key) => {
const value = frontend[key];
return typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string';
});
it('parses a plausible slice of the Go map', () => {
expect(goDefaults.webPort).toBe('2053');
expect(goDefaults.subPort).toBe('2096');
expect(sharedKeys.length).toBeGreaterThan(20);
});
it.each(sharedKeys)('frontend default for %s matches the shipped default', (key) => {
expect(
matchesFactoryDefault(frontend[key], goDefaults[key]),
`AllSetting.${key} = ${JSON.stringify(frontend[key])} vs defaultValueMap ${JSON.stringify(goDefaults[key])}`,
).toBe(true);
});
});
+83
View File
@@ -193,4 +193,87 @@ describe('http-init fetch wrapper', () => {
expect(initOf().signal).toBeInstanceOf(AbortSignal);
});
it('preserves a caller cancellation signal when a timeout is set', async () => {
http.setupHttp();
fetchMock.mockResolvedValue(okEnvelope());
const controller = new AbortController();
await http.httpRequest('GET', '/x', undefined, { timeout: 1_000, signal: controller.signal });
controller.abort();
expect(initOf().signal?.aborted).toBe(true);
});
it('preserves both cancellation paths when AbortSignal.any is unavailable', async () => {
const timeout = AbortSignal.timeout.bind(AbortSignal);
vi.resetModules();
vi.stubGlobal('AbortSignal', { timeout });
http = await import('@/api/http-init');
http.setupHttp();
fetchMock.mockResolvedValue(okEnvelope());
const controller = new AbortController();
await http.httpRequest('GET', '/x', undefined, { timeout: 1_000, signal: controller.signal });
controller.abort();
expect(initOf().signal?.aborted).toBe(true);
});
it('times out when AbortSignal.any is unavailable', async () => {
const timeout = AbortSignal.timeout.bind(AbortSignal);
vi.resetModules();
vi.stubGlobal('AbortSignal', { timeout });
http = await import('@/api/http-init');
http.setupHttp();
fetchMock.mockResolvedValue(okEnvelope());
const controller = new AbortController();
await http.httpRequest('GET', '/x', undefined, { timeout: 20, signal: controller.signal });
const signal = initOf().signal as AbortSignal;
await new Promise<void>((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }));
expect(signal.aborted).toBe(true);
});
it('aborts on the timeout when a caller signal is present', async () => {
http.setupHttp();
fetchMock.mockResolvedValue(okEnvelope());
const controller = new AbortController();
await http.httpRequest('GET', '/x', undefined, { timeout: 20, signal: controller.signal });
const signal = initOf().signal as AbortSignal;
await new Promise<void>((resolve) => {
if (signal.aborted) {
resolve();
return;
}
signal.addEventListener('abort', () => resolve(), { once: true });
});
expect(signal.aborted).toBe(true);
});
it.each([
['/x?keep=1', '/x?keep=1&added=yes'],
['/x?', '/x?added=yes'],
['/x#frag', '/x?added=yes#frag'],
['/x?keep=1#frag', '/x?keep=1&added=yes#frag'],
])('appends encoded params to %s', async (url, expected) => {
http.setupHttp();
fetchMock.mockResolvedValue(okEnvelope());
await http.httpRequest('GET', url, undefined, { params: { added: 'yes' } });
expect(urlOf()).toBe(expected);
});
it('preserves the URL when no params are supplied', async () => {
http.setupHttp();
fetchMock.mockResolvedValue(okEnvelope());
await http.httpRequest('GET', '/x?keep=1#frag');
expect(urlOf()).toBe('/x?keep=1#frag');
});
});
+91
View File
@@ -0,0 +1,91 @@
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
/*
* Guards the 13-locale translation set two ways: every key in en-US must be
* referenced somewhere in the frontend or Go sources (dead keys accumulate
* silently — this test deleted over two hundred of them when it was
* introduced), and every locale must carry exactly the en-US key set
* (missing keys fall back to en-US at runtime, so nothing else fails the
* build when a translation is forgotten).
*
* References are matched as whole dotted tokens, not substrings, so a dead
* key cannot hide behind a longer live sibling. Dynamically built keys are
* covered by harvesting the string-literal prefixes that appear next to
* concatenation or template-literal interpolation; the prefix needs at
* least one dot but need not end on one, so a literal that stops mid-leaf
* right before the interpolation still keeps its subtree alive. This file
* excludes itself from the scan so the prose above cannot whitelist
* anything.
*/
const repoRoot = resolve(process.cwd(), '..');
const translationDir = join(repoRoot, 'internal', 'web', 'translation');
const selfPath = fileURLToPath(import.meta.url);
function flattenKeys(obj: Record<string, unknown>, prefix = ''): string[] {
const keys: string[] = [];
for (const [k, v] of Object.entries(obj)) {
const key = `${prefix}${k}`;
if (v !== null && typeof v === 'object') {
keys.push(...flattenKeys(v as Record<string, unknown>, `${key}.`));
} else {
keys.push(key);
}
}
return keys;
}
function collectSources(dir: string, exts: string[], out: string[]): void {
for (const entry of readdirSync(dir)) {
if (['node_modules', 'dist', 'generated', '.git', '.local', 'storybook-static'].includes(entry)) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
collectSources(full, exts, out);
} else if (exts.some((ext) => entry.endsWith(ext)) && resolve(full) !== selfPath) {
out.push(readFileSync(full, 'utf8'));
}
}
}
describe('i18n keys', () => {
const enUS = JSON.parse(readFileSync(join(translationDir, 'en-US.json'), 'utf8'));
const enKeys = flattenKeys(enUS);
const sources: string[] = [];
collectSources(join(repoRoot, 'frontend', 'src'), ['.ts', '.tsx'], sources);
collectSources(join(repoRoot, 'internal'), ['.go'], sources);
const blob = sources.join('\n');
const tokens = new Set(blob.match(/[A-Za-z][A-Za-z0-9_.]*/g) ?? []);
const prefixes: string[] = [];
for (const match of blob.matchAll(/['"`]([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\.?)['"`]\s*\+/g)) {
prefixes.push(match[1]);
}
for (const match of blob.matchAll(/[`']([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\.?)\$\{/g)) {
prefixes.push(match[1]);
}
it('every en-US key is referenced by the frontend or Go sources', () => {
const dead = enKeys.filter(
(key) => !tokens.has(key) && !prefixes.some((p) => key.startsWith(p)),
);
expect(dead, `dead i18n keys (delete from all 13 locales):\n ${dead.join('\n ')}`).toEqual([]);
});
it('every locale carries exactly the en-US key set', () => {
const enSet = new Set(enKeys);
for (const file of readdirSync(translationDir)) {
if (!file.endsWith('.json') || file === 'en-US.json') continue;
const keys = new Set(flattenKeys(JSON.parse(readFileSync(join(translationDir, file), 'utf8'))));
const missing = enKeys.filter((k) => !keys.has(k));
const orphans = [...keys].filter((k) => !enSet.has(k));
expect(missing, `${file} is missing keys:\n ${missing.join('\n ')}`).toEqual([]);
expect(orphans, `${file} has keys absent from en-US:\n ${orphans.join('\n ')}`).toEqual([]);
}
});
});
@@ -0,0 +1,51 @@
import { render } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
import { withTheme } from '../../.storybook/preview';
import { ThemeProvider } from '@/hooks/useTheme';
function Story() {
return <div>Story</div>;
}
function StorybookTheme({ theme }: { theme: 'light' | 'dark' }) {
return withTheme(Story, { globals: { theme } } as Partial<Parameters<typeof withTheme>[1]> as Parameters<typeof withTheme>[1]);
}
afterEach(() => {
document.body.className = '';
document.documentElement.removeAttribute('data-theme');
});
test('preserves unrelated body classes when applying the Storybook theme', () => {
document.body.className = 'storybook-fixture dark';
document.documentElement.setAttribute('data-theme', 'ultra-dark');
const { rerender } = render(<StorybookTheme theme="light" />);
expect(document.body.classList.contains('storybook-fixture')).toBe(true);
expect(document.body.classList.contains('light')).toBe(true);
expect(document.body.classList.contains('dark')).toBe(false);
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
rerender(<StorybookTheme theme="dark" />);
expect(document.body.classList.contains('storybook-fixture')).toBe(true);
expect(document.body.classList.contains('dark')).toBe(true);
expect(document.body.classList.contains('light')).toBe(false);
});
test('preserves unrelated body classes when applying the panel theme', () => {
document.body.className = 'panel-fixture';
const message = document.createElement('div');
message.id = 'message';
message.className = 'message-fixture';
document.body.append(message);
render(<ThemeProvider><div>Panel</div></ThemeProvider>);
expect(document.body.classList.contains('panel-fixture')).toBe(true);
expect(document.body.classList.contains('dark')).toBe(true);
expect(document.body.classList.contains('light')).toBe(false);
expect(message.classList.contains('message-fixture')).toBe(true);
expect(message.classList.contains('dark')).toBe(true);
});
+12 -2
View File
@@ -1,10 +1,20 @@
import type { ReactElement } from 'react';
import { render, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/hooks/useTheme';
export function renderWithProviders(ui: ReactElement) {
return render(<ThemeProvider>{ui}</ThemeProvider>);
export function makeTestQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
export function renderWithProviders(ui: ReactElement, options?: { queryClient?: QueryClient }) {
const queryClient = options?.queryClient ?? makeTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>{ui}</ThemeProvider>
</QueryClientProvider>,
);
}
export function fieldLabels(): string[] {
+105
View File
@@ -0,0 +1,105 @@
import type { ReactNode } from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useAllSettings } from '@/api/queries/useAllSettings';
import { keys } from '@/api/queryKeys';
import { makeTestQueryClient } from '@/test/test-utils';
import { HttpUtil, Msg } from '@/utils';
afterEach(() => {
vi.restoreAllMocks();
});
describe('useAllSettings', () => {
it('keeps backend-accepted settings editable when the frontend schema is stricter', async () => {
const subJsonUserAgentRegex = 'x'.repeat(2_049);
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', { subJsonUserAgentRegex }));
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useAllSettings(), { wrapper });
await waitFor(() => expect(result.current.fetched).toBe(true));
expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
});
it('keeps an edited setting when a refetch returns older server data', async () => {
const values = [
{ webPort: 2053 },
{ webPort: 2054 },
];
let index = 0;
vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', values[index++]));
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useAllSettings(), { wrapper });
await waitFor(() => expect(result.current.fetched).toBe(true));
act(() => result.current.updateSetting({ webPort: 3000 }));
await queryClient.invalidateQueries({ queryKey: keys.settings.all() });
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2));
expect(result.current.allSetting.webPort).toBe(3000);
expect(result.current.saveDisabled).toBe(false);
});
it('hydrates redacted secrets after a successful save', async () => {
let fetchCount = 0;
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
if (url === '/panel/api/setting/all') {
fetchCount += 1;
return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
}
return new Msg(true, '');
});
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useAllSettings(), { wrapper });
await waitFor(() => expect(result.current.fetched).toBe(true));
act(() => result.current.updateSetting({ tgBotToken: 'secret' }));
await act(async () => {
await result.current.saveAll();
});
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3));
expect(result.current.allSetting.tgBotToken).toBe('');
expect(result.current.allSetting.hasTgBotToken).toBe(true);
expect(result.current.saveDisabled).toBe(true);
});
it('establishes a saved baseline for a full-payload security save', async () => {
let fetchCount = 0;
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
if (url === '/panel/api/setting/all') {
fetchCount += 1;
return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
}
return new Msg(true, '');
});
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useAllSettings(), { wrapper });
await waitFor(() => expect(result.current.fetched).toBe(true));
act(() => result.current.updateSetting({ tgBotToken: 'secret' }));
await act(async () => {
await result.current.savePayload({ ...result.current.allSetting, twoFactorEnable: false, twoFactorToken: '' });
});
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3));
expect(result.current.allSetting.tgBotToken).toBe('');
expect(result.current.allSetting.hasTgBotToken).toBe(true);
expect(result.current.saveDisabled).toBe(true);
});
});
@@ -0,0 +1,70 @@
import type { ReactNode } from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useXraySetting } from '@/hooks/useXraySetting';
import { makeTestQueryClient } from '@/test/test-utils';
import { HttpUtil, Msg } from '@/utils';
function xrayPayload(overrides: Record<string, unknown> = {}) {
return {
xraySetting: {},
inboundTags: [],
clientReverseTags: [],
outboundTestUrl: 'https://test.example',
subscriptionOutbounds: [],
subscriptionOutboundTags: [],
...overrides,
};
}
afterEach(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
});
describe('useXraySetting', () => {
it('refreshes server-derived outbounds while the editor is dirty', async () => {
let payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'before' }] });
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
return new Msg(true, '');
});
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useXraySetting(), { wrapper });
await waitFor(() => expect(result.current.fetched).toBe(true));
act(() => result.current.setXraySetting('{"outbounds":[]}'));
payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'after' }] });
await act(async () => result.current.fetchAll());
await waitFor(() => expect(result.current.subscriptionOutbounds).toEqual([{ tag: 'after' }]));
expect(result.current.xraySetting).toBe('{"outbounds":[]}');
});
it('keeps the outbound test URL input empty when it is cleared', async () => {
const payload = xrayPayload({ outboundTestUrl: 'https://www.google.com/generate_204' });
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
return new Msg(true, '');
});
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useXraySetting(), { wrapper });
await waitFor(() => expect(result.current.fetched).toBe(true));
act(() => result.current.setOutboundTestUrl(''));
expect(result.current.outboundTestUrl).toBe('');
expect(result.current.saveDisabled).toBe(true);
});
});
+86
View File
@@ -0,0 +1,86 @@
import { StrictMode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useServerDraft } from '@/hooks/useServerDraft';
describe('useServerDraft', () => {
it('keeps an edited draft when the server refetches', () => {
const { result, rerender } = renderHook(
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
{ initialProps: { server: { value: 'one' } } },
);
act(() => result.current.setDraft({ value: 'edited' }));
rerender({ server: { value: 'two' } });
expect(result.current.draft).toEqual({ value: 'edited' });
expect(result.current.isDirty).toBe(true);
});
it('accepts a refetch that matches the saved draft', () => {
const { result, rerender } = renderHook(
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
{ initialProps: { server: { value: 'one' } } },
);
act(() => result.current.setDraft({ value: 'saved' }));
rerender({ server: { value: 'saved' } });
expect(result.current.draft).toEqual({ value: 'saved' });
expect(result.current.isDirty).toBe(false);
});
it('compares a preserved draft with the latest server value', () => {
const { result, rerender } = renderHook(
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
{ initialProps: { server: { value: 'one' } } },
);
act(() => result.current.setDraft({ value: 'later' }));
rerender({ server: { value: 'saved' } });
act(() => result.current.setDraft({ value: 'one' }));
expect(result.current.isDirty).toBe(true);
});
it('hydrates clean drafts under StrictMode', () => {
const { result, rerender } = renderHook(
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
{
initialProps: { server: { value: 'one' } },
wrapper: StrictMode,
},
);
rerender({ server: { value: 'two' } });
expect(result.current.draft).toEqual({ value: 'two' });
expect(result.current.isDirty).toBe(false);
});
it('preserves an edit made before the first server response', () => {
const { result, rerender } = renderHook(
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
{ initialProps: { server: undefined as { value: string } | undefined } },
);
act(() => result.current.setDraft({ value: 'edited' }));
rerender({ server: { value: 'one' } });
expect(result.current.draft).toEqual({ value: 'edited' });
expect(result.current.isDirty).toBe(true);
});
it('marks a sent draft clean before its refetch arrives', () => {
const { result } = renderHook(
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
{ initialProps: { server: { value: 'one' } } },
);
act(() => result.current.setDraft({ value: 'saved' }));
act(() => result.current.markSaved({ value: 'saved' }));
expect(result.current.isDirty).toBe(false);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { ClientPageResponseSchema } from '@/schemas/client';
import { fetchXrayConfig } from '@/hooks/useXraySetting';
afterEach(() => {
vi.restoreAllMocks();
});
describe('parseMsg', () => {
it('rejects a successful response whose payload violates its schema', () => {
const msg = new Msg(true, '', { id: 'not-a-number' });
expect(() => parseMsg(msg, z.object({ id: z.number() }), 'test/value', { strict: true })).toThrow(
'test/value response failed validation',
);
});
it('preserves a missing successful payload for callers that handle empty values', () => {
expect(parseMsg(new Msg(true, '', null), z.object({ id: z.number() }), 'test/value').obj).toBeNull();
});
it('rejects malformed paged-client payloads', () => {
const payload = { items: [], total: 'one', filtered: 1, page: 1, pageSize: 20 };
expect(() => parseMsg(new Msg(true, '', payload), ClientPageResponseSchema, 'clients/list/paged', { strict: true })).toThrow(
'clients/list/paged response failed validation',
);
});
});
describe('fetchXrayConfig', () => {
it('keeps a malformed xray payload available for repair', async () => {
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', JSON.stringify({ xraySetting: 'not-an-object' })));
await expect(fetchXrayConfig()).resolves.toEqual({ xraySetting: 'not-an-object' });
});
});
+8
View File
@@ -685,6 +685,14 @@ export class CPUFormatter {
}
export class TimeFormatter {
static formatClock(unixSec: number): string {
const d = new Date(unixSec * 1000);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
static formatSecond(second: number): string {
if (second < 60) return second.toFixed(0) + 's';
if (second < 3600) return (second / 60).toFixed(0) + 'm';
+6
View File
@@ -1,10 +1,15 @@
import type { z } from 'zod';
import { Msg } from '@/utils';
interface ParseMsgOptions {
strict?: boolean;
}
export function parseMsg<T extends z.ZodType>(
msg: Msg<unknown>,
schema: T,
context: string,
options: ParseMsgOptions = {},
): Msg<z.infer<T>> {
if (!msg.success || msg.obj == null) {
return msg as Msg<z.infer<T>>;
@@ -12,6 +17,7 @@ export function parseMsg<T extends z.ZodType>(
const result = schema.safeParse(msg.obj);
if (!result.success) {
console.warn(`[zod] ${context} response failed validation`, result.error.issues);
if (options.strict) throw new Error(`${context} response failed validation`);
return msg as Msg<z.infer<T>>;
}
return new Msg<z.infer<T>>(msg.success, msg.msg, result.data);
+7 -1
View File
@@ -171,7 +171,13 @@ export default defineConfig({
build: {
outDir,
emptyOutDir: true,
sourcemap: true,
// Everything in outDir is embedded into the Go binary via embed.FS, so
// production sourcemaps (~18MB across 112 files, 72% of dist) ship inside
// every release build. Nothing consumes them there; `npm run dev` serves
// its own maps regardless of this setting. To debug a minified bundle
// (including the XUI_DEBUG serve-from-disk path), build once with
// XUI_SOURCEMAP=true — no tracked-file edit to accidentally commit.
sourcemap: process.env.XUI_SOURCEMAP === 'true',
target: 'es2020',
chunkSizeWarningLimit: 1500,
rollupOptions: {
+4 -4
View File
@@ -13,13 +13,14 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.49
github.com/mymmrac/telego v1.11.1
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.26.6
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/valyala/fasthttp v1.72.0
github.com/valyala/fasthttp v1.73.0
github.com/xlzd/gotp v0.1.0
github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc
go.uber.org/atomic v1.11.0
@@ -68,7 +69,6 @@ require (
github.com/leodido/go-urn v1.5.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-sqlite3 v1.14.48 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -99,7 +99,7 @@ require (
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.29.0 // indirect
golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0
golang.org/x/sync v0.22.0 // indirect
@@ -108,7 +108,7 @@ require (
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect
google.golang.org/protobuf v1.36.11
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
+8 -8
View File
@@ -129,8 +129,8 @@ github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 h1:YkjVPl/YH5XlJ+
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -204,8 +204,8 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s=
github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
@@ -250,8 +250,8 @@ golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho=
golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260718201538-764159d718ef h1:LkZ48HFgy/TvhTI0bcWkjgFkgLyKUwcTbDjS0DUjw+A=
golang.org/x/exp v0.0.0-20260718201538-764159d718ef/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
@@ -279,8 +279,8 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df h1:O3ig1i5WDDzsVzRp+cCdgelT9vXnlnOFdlEeFtL4HCc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 h1:mJiOtnGp0k/BcSgdu03G2NwnscCfCH+h2QKUBZr18KI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+1 -1
View File
@@ -1 +1 @@
3.5.0
3.6.0
+186
View File
@@ -0,0 +1,186 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestBackupSQLiteProducesValidSnapshotDuringWrites(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
seed := make([]model.Setting, 128)
value := strings.Repeat("x", 1024)
for i := range seed {
seed[i] = model.Setting{Key: fmt.Sprintf("backup-seed-%d", i), Value: value}
}
if err := db.Create(&seed).Error; err != nil {
t.Fatalf("seed database: %v", err)
}
stop := make(chan struct{})
firstWrite := make(chan error, 1)
writesDone := make(chan error, 1)
go func() {
for i := range 128 {
if err := db.Create(&model.Setting{Key: fmt.Sprintf("backup-write-%d", i), Value: value}).Error; err != nil {
if i == 0 {
firstWrite <- err
}
writesDone <- err
return
}
if i == 0 {
firstWrite <- nil
}
select {
case <-stop:
writesDone <- nil
return
default:
}
}
writesDone <- nil
}()
if err := <-firstWrite; err != nil {
t.Fatalf("first concurrent write: %v", err)
}
backupPath := filepath.Join(t.TempDir(), "backup.db")
if err := BackupSQLite(backupPath); err != nil {
close(stop)
<-writesDone
t.Fatalf("BackupSQLite: %v", err)
}
close(stop)
if err := <-writesDone; err != nil {
t.Fatalf("concurrent write: %v", err)
}
if err := ValidateSQLiteDB(backupPath); err != nil {
t.Fatalf("validate backup: %v", err)
}
backup, err := sql.Open("sqlite3", backupPath)
if err != nil {
t.Fatalf("open backup: %v", err)
}
defer backup.Close()
var seedCount int
if err := backup.QueryRow("SELECT count(*) FROM settings WHERE key LIKE 'backup-seed-%'").Scan(&seedCount); err != nil {
t.Fatalf("count seeded rows: %v", err)
}
if seedCount != 128 {
t.Fatalf("seeded row count = %d, want 128", seedCount)
}
var firstWriteCount int
if err := backup.QueryRow("SELECT count(*) FROM settings WHERE key = 'backup-write-0'").Scan(&firstWriteCount); err != nil {
t.Fatalf("count first concurrent write: %v", err)
}
if firstWriteCount != 1 {
t.Fatalf("first concurrent write count = %d, want 1", firstWriteCount)
}
}
func TestBackupSQLiteTimesOutWaitingForSourceConnection(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get database connection pool: %v", err)
}
sqlDB.SetMaxOpenConns(1)
held, err := sqlDB.Conn(context.Background())
if err != nil {
t.Fatalf("hold source connection: %v", err)
}
defer held.Close()
previousTimeout := backupSQLiteTimeout
backupSQLiteTimeout = 20 * time.Millisecond
t.Cleanup(func() { backupSQLiteTimeout = previousTimeout })
err = BackupSQLite(filepath.Join(t.TempDir(), "backup.db"))
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("BackupSQLite error = %v, want context deadline exceeded", err)
}
}
func TestBackupSQLiteRefusesExistingDestination(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
backupPath := filepath.Join(t.TempDir(), "backup.db")
if err := os.WriteFile(backupPath, []byte("existing backup"), 0o600); err != nil {
t.Fatalf("create existing destination: %v", err)
}
err := BackupSQLite(backupPath)
want := fmt.Sprintf("sqlite backup destination already exists: %s", backupPath)
if err == nil || err.Error() != want {
t.Fatalf("BackupSQLite error = %v, want %q", err, want)
}
data, err := os.ReadFile(backupPath)
if err != nil {
t.Fatalf("read existing destination: %v", err)
}
if string(data) != "existing backup" {
t.Fatalf("existing destination = %q, want %q", data, "existing backup")
}
}
func TestBackupSQLiteStepPages(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
if got := backupSQLiteStepPages(); got != -1 {
t.Fatalf("WAL backup step pages = %d, want -1", got)
}
t.Setenv("XUI_DB_JOURNAL_MODE", "DELETE")
if got := backupSQLiteStepPages(); got != 128 {
t.Fatalf("DELETE backup step pages = %d, want 128", got)
}
}
func TestInitDBCleansBackupDirectories(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
dbDir := t.TempDir()
orphanDir := filepath.Join(dbDir, sqliteBackupDirPrefix+"orphan")
if err := os.Mkdir(orphanDir, 0o700); err != nil {
t.Fatalf("create orphan backup directory: %v", err)
}
if err := os.WriteFile(filepath.Join(orphanDir, "backup.db"), []byte("backup"), 0o600); err != nil {
t.Fatalf("write orphan backup: %v", err)
}
regularDir := filepath.Join(dbDir, ".x-ui-keep")
if err := os.Mkdir(regularDir, 0o700); err != nil {
t.Fatalf("create regular directory: %v", err)
}
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if _, err := os.Stat(orphanDir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("orphan backup directory error = %v, want not exist", err)
}
if info, err := os.Stat(regularDir); err != nil || !info.IsDir() {
t.Fatalf("regular directory info = %v, %v; want existing directory", info, err)
}
}
+113 -5
View File
@@ -3,6 +3,7 @@ package database
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
@@ -12,6 +13,7 @@ import (
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"slices"
"strconv"
@@ -24,6 +26,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/mattn/go-sqlite3"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
@@ -32,6 +35,8 @@ import (
var db *gorm.DB
var backupSQLiteTimeout = 2 * time.Minute
const (
DialectSQLite = "sqlite"
DialectPostgres = "postgres"
@@ -52,8 +57,9 @@ func Dialect() string {
}
const (
defaultUsername = "admin"
defaultPassword = "admin"
defaultUsername = "admin"
defaultPassword = "admin"
sqliteBackupDirPrefix = ".x-ui-backup-"
)
func allModels() []any {
@@ -1944,6 +1950,9 @@ func InitDB(dbPath string) error {
if err = os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err = cleanupSQLiteBackupDirs(filepath.Dir(dbPath)); err != nil {
log.Printf("clean SQLite backup directories: %v", err)
}
sync := sqliteSynchronous()
journal := sqliteJournalMode()
@@ -2045,6 +2054,31 @@ func sqliteJournalMode() string {
}
}
func backupSQLiteStepPages() int {
if sqliteJournalMode() == "DELETE" {
return 128
}
return -1
}
func cleanupSQLiteBackupDirs(dir string) error {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), sqliteBackupDirPrefix) {
if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil {
return err
}
}
}
return nil
}
func sqliteSynchronous() string {
switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) {
case "OFF":
@@ -2099,11 +2133,85 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
return bytes.Equal(buf, signature), nil
}
func Checkpoint() error {
func BackupSQLite(dstPath string) (err error) {
if IsPostgres() {
return nil
return errors.New("sqlite backup is unavailable for PostgreSQL")
}
return db.Exec("PRAGMA wal_checkpoint(TRUNCATE);").Error
if db == nil {
return errors.New("database is not initialized")
}
if _, err := os.Lstat(dstPath); err == nil {
return fmt.Errorf("sqlite backup destination already exists: %s", dstPath)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
defer func() {
if err != nil {
_ = os.Remove(dstPath)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), backupSQLiteTimeout)
defer cancel()
sourceDB, err := db.DB()
if err != nil {
return err
}
sourceConn, err := sourceDB.Conn(ctx)
if err != nil {
return err
}
defer sourceConn.Close()
destinationDB, err := sql.Open("sqlite3", dstPath)
if err != nil {
return err
}
defer destinationDB.Close()
destinationConn, err := destinationDB.Conn(ctx)
if err != nil {
return err
}
defer destinationConn.Close()
return sourceConn.Raw(func(sourceDriver any) error {
source, ok := sourceDriver.(*sqlite3.SQLiteConn)
if !ok {
return fmt.Errorf("unexpected SQLite source connection type %T", sourceDriver)
}
return destinationConn.Raw(func(destinationDriver any) error {
destination, ok := destinationDriver.(*sqlite3.SQLiteConn)
if !ok {
return fmt.Errorf("unexpected SQLite destination connection type %T", destinationDriver)
}
backup, err := destination.Backup("main", source, "main")
if err != nil {
return err
}
finished := false
defer func() {
if !finished {
_ = backup.Finish()
}
}()
for {
done, err := backup.Step(backupSQLiteStepPages())
if err != nil {
return err
}
if done {
finished = true
return backup.Finish()
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
})
})
}
func ValidateSQLiteDB(dbPath string) error {
-41
View File
@@ -1,12 +1,8 @@
package database
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func journalModeOf(t *testing.T) string {
@@ -43,40 +39,3 @@ func TestSqliteJournalModeEnvOverrideDelete(t *testing.T) {
t.Fatalf("journal_mode = %q, want delete", got)
}
}
func TestWalCheckpointMakesRawFileBackupComplete(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
dbDir := t.TempDir()
dbPath := filepath.Join(dbDir, "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if err := db.Create(&model.Setting{Key: "walBackupProbe", Value: "42"}).Error; err != nil {
t.Fatalf("write setting: %v", err)
}
if err := Checkpoint(); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
raw, err := os.ReadFile(dbPath)
if err != nil {
t.Fatalf("read db file: %v", err)
}
copyPath := filepath.Join(t.TempDir(), "copy.db")
if err := os.WriteFile(copyPath, raw, 0o600); err != nil {
t.Fatalf("write copy: %v", err)
}
if err := ValidateSQLiteDB(copyPath); err != nil {
t.Fatalf("checkpointed raw copy must be a valid sqlite db: %v", err)
}
dump, err := DumpSQLiteToBytes(copyPath)
if err != nil {
t.Fatalf("dump copy: %v", err)
}
if !bytes.Contains(dump, []byte("walBackupProbe")) {
t.Fatal("raw-file backup taken after Checkpoint must contain the latest write")
}
}
+10
View File
@@ -21,6 +21,16 @@ func TestMain(m *testing.M) {
fmt.Fprintf(f, "%d\n", os.Getpid())
f.Close()
}
if exitFile := os.Getenv("MTG_FAKE_EXIT_FILE"); exitFile != "" {
for {
if _, err := os.Stat(exitFile); err == nil {
os.Exit(1)
} else if !os.IsNotExist(err) {
os.Exit(2)
}
time.Sleep(time.Millisecond)
}
}
select {}
}
os.Exit(m.Run())
+50 -29
View File
@@ -109,6 +109,7 @@ func (w *procLogWriter) LastLine() string {
// Process wraps a single mtg process invocation for one mtproto inbound.
type Process struct {
mu sync.RWMutex
cmd *exec.Cmd
done chan struct{}
configPath string
@@ -126,20 +127,20 @@ func newProcess(configPath, label string) *Process {
// IsRunning reports whether the mtg process is currently running.
func (p *Process) IsRunning() bool {
if p.cmd == nil || p.cmd.Process == nil {
p.mu.RLock()
cmd, done := p.cmd, p.done
p.mu.RUnlock()
if cmd == nil || cmd.Process == nil {
return false
}
if p.done != nil {
if done != nil {
select {
case <-p.done:
case <-done:
return false
default:
}
}
if p.cmd.ProcessState == nil {
return true
}
return false
return true
}
// GetResult returns the last log line or the exit error from the mtg process.
@@ -147,8 +148,11 @@ func (p *Process) GetResult() string {
if line := p.logWriter.LastLine(); line != "" {
return line
}
if p.exitErr != nil {
return p.exitErr.Error()
p.mu.RLock()
exitErr := p.exitErr
p.mu.RUnlock()
if exitErr != nil {
return exitErr.Error()
}
return ""
}
@@ -161,22 +165,27 @@ func (p *Process) Start() error {
cmd := exec.CommandContext(context.Background(), GetBinaryPath(), "run", p.configPath)
cmd.Stdout = p.logWriter
cmd.Stderr = p.logWriter
done := make(chan struct{})
p.mu.Lock()
p.cmd = cmd
p.done = make(chan struct{})
p.done = done
p.exitErr = nil
p.mu.Unlock()
p.intentionalStop.Store(false)
if err := cmd.Start(); err != nil {
close(p.done)
close(done)
p.mu.Lock()
p.cmd = nil
p.mu.Unlock()
return err
}
attachChildLifetime(cmd)
go p.wait(cmd)
go p.wait(cmd, done)
return nil
}
func (p *Process) wait(cmd *exec.Cmd) {
defer close(p.done)
func (p *Process) wait(cmd *exec.Cmd, done chan struct{}) {
defer close(done)
err := cmd.Wait()
p.logWriter.Flush()
if err == nil || p.intentionalStop.Load() {
@@ -184,12 +193,18 @@ func (p *Process) wait(cmd *exec.Cmd) {
}
if runtime.GOOS == "windows" {
if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
p.exitErr = err
p.setExitErr(err)
return
}
}
logger.Errorf("mtproto: mtg process exited: %v", err)
p.setExitErr(err)
}
func (p *Process) setExitErr(err error) {
p.mu.Lock()
p.exitErr = err
p.mu.Unlock()
}
// Stop terminates the running mtg process gracefully, falling back to a kill.
@@ -198,40 +213,46 @@ func (p *Process) Stop() error {
return errors.New("mtg is not running")
}
p.intentionalStop.Store(true)
if runtime.GOOS == "windows" {
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return p.waitForExit(forceStopTimeout)
p.mu.RLock()
cmd, done := p.cmd, p.done
p.mu.RUnlock()
if cmd == nil || cmd.Process == nil {
return errors.New("mtg is not running")
}
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
if runtime.GOOS == "windows" {
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return waitForExit(done, forceStopTimeout)
}
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
if errors.Is(err, os.ErrProcessDone) {
return p.waitForExit(forceStopTimeout)
return waitForExit(done, forceStopTimeout)
}
return err
}
if err := p.waitForExit(gracefulStopTimeout); err == nil {
if err := waitForExit(done, gracefulStopTimeout); err == nil {
return nil
}
logger.Warning("mtproto: mtg did not stop after SIGTERM, killing process")
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return p.waitForExit(forceStopTimeout)
return waitForExit(done, forceStopTimeout)
}
func (p *Process) waitForExit(timeout time.Duration) error {
if p.done == nil {
func waitForExit(done <-chan struct{}, timeout time.Duration) error {
if done == nil {
return nil
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-p.done:
case <-done:
return nil
case <-timer.C:
return fmt.Errorf("timed out waiting for mtg process to stop after %s", timeout)
+106
View File
@@ -0,0 +1,106 @@
package mtproto
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestProcessLifecycleFieldsRaceSafe(t *testing.T) {
proc := newProcess("", "test")
stop := make(chan struct{})
var workers sync.WaitGroup
defer func() {
close(stop)
workers.Wait()
}()
workers.Go(func() {
for {
select {
case <-stop:
return
default:
}
proc.mu.Lock()
proc.cmd = &exec.Cmd{}
proc.done = make(chan struct{})
proc.mu.Unlock()
proc.setExitErr(errors.New("exit"))
}
})
for range 4 {
workers.Go(func() {
for {
select {
case <-stop:
return
default:
}
_ = proc.IsRunning()
_ = proc.GetResult()
}
})
}
time.Sleep(50 * time.Millisecond)
}
func TestProcessStatusDuringExit(t *testing.T) {
pidFile := installFakeMtg(t)
exitFile := filepath.Join(t.TempDir(), "exit")
t.Setenv("MTG_FAKE_EXIT_FILE", exitFile)
configPath := filepath.Join(t.TempDir(), "mtg.toml")
if err := os.WriteFile(configPath, nil, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
proc := newProcess(configPath, "test")
if err := proc.Start(); err != nil {
t.Fatalf("start process: %v", err)
}
t.Cleanup(func() {
_ = proc.Stop()
})
waitSpawnCount(t, pidFile, 1)
stopReads := make(chan struct{})
var readers sync.WaitGroup
defer func() {
close(stopReads)
readers.Wait()
}()
for range 4 {
readers.Go(func() {
for {
select {
case <-stopReads:
return
default:
_ = proc.IsRunning()
_ = proc.GetResult()
}
}
})
}
proc.mu.RLock()
done := proc.done
proc.mu.RUnlock()
if err := os.WriteFile(exitFile, nil, 0o600); err != nil {
t.Fatalf("trigger exit: %v", err)
}
if err := waitForExit(done, time.Second); err != nil {
t.Fatalf("wait for process exit: %v", err)
}
if proc.IsRunning() {
t.Fatal("process must not be running after exit")
}
if got := proc.GetResult(); !strings.Contains(got, "exit status 1") {
t.Fatalf("GetResult after an unexpected exit = %q, want exit status", got)
}
}
+54 -9
View File
@@ -16,8 +16,9 @@ import (
// with a short timeout so a slow/dead provider can't stall a client's sub.
const (
subscriptionCacheTTL = 5 * time.Minute
subscriptionMaxBytes = 2 << 20 // 2 MiB
subscriptionCacheTTL = 5 * time.Minute
subscriptionMaxBytes = 2 << 20 // 2 MiB
subscriptionCacheCapacity = 256
)
var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second}
@@ -27,10 +28,19 @@ type subscriptionCacheEntry struct {
fetchedAt time.Time
}
type subscriptionFetch struct {
done chan struct{}
links []string
}
var subscriptionCache = struct {
sync.Mutex
m map[string]subscriptionCacheEntry
}{m: make(map[string]subscriptionCacheEntry)}
m map[string]subscriptionCacheEntry
inflight map[string]*subscriptionFetch
}{
m: make(map[string]subscriptionCacheEntry),
inflight: make(map[string]*subscriptionFetch),
}
// fetchSubscriptionLinks returns the share links contained in a remote
// subscription URL, using a short-lived cache. On any failure it returns the
@@ -44,24 +54,59 @@ func fetchSubscriptionLinks(rawURL string) []string {
subscriptionCache.Lock()
cached, ok := subscriptionCache.m[rawURL]
subscriptionCache.Unlock()
if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
subscriptionCache.Unlock()
return cached.links
}
if fetch, waiting := subscriptionCache.inflight[rawURL]; waiting {
subscriptionCache.Unlock()
<-fetch.done
return fetch.links
}
fetch := &subscriptionFetch{done: make(chan struct{})}
subscriptionCache.inflight[rawURL] = fetch
subscriptionCache.Unlock()
defer func() {
subscriptionCache.Lock()
close(fetch.done)
delete(subscriptionCache.inflight, rawURL)
subscriptionCache.Unlock()
}()
links, err := doFetchSubscriptionLinks(rawURL)
if err != nil {
// Serve stale on error rather than dropping the client's configs.
if ok {
return cached.links
fetch.links = cached.links
}
return nil
return fetch.links
}
subscriptionCache.Lock()
subscriptionCache.m[rawURL] = subscriptionCacheEntry{links: links, fetchedAt: time.Now()}
trimSubscriptionCacheLocked(rawURL)
subscriptionCache.Unlock()
return links
fetch.links = links
return fetch.links
}
func trimSubscriptionCacheLocked(keep string) {
for len(subscriptionCache.m) > subscriptionCacheCapacity {
var oldestURL string
var oldest time.Time
for rawURL, entry := range subscriptionCache.m {
if rawURL == keep {
continue
}
if oldestURL == "" || entry.fetchedAt.Before(oldest) {
oldestURL = rawURL
oldest = entry.fetchedAt
}
}
if oldestURL == "" {
return
}
delete(subscriptionCache.m, oldestURL)
}
}
func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
+136
View File
@@ -4,10 +4,146 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func resetSubscriptionCache(t *testing.T) {
t.Helper()
subscriptionCache.Lock()
previousEntries := subscriptionCache.m
previousInflight := subscriptionCache.inflight
subscriptionCache.m = make(map[string]subscriptionCacheEntry)
subscriptionCache.inflight = make(map[string]*subscriptionFetch)
subscriptionCache.Unlock()
t.Cleanup(func() {
subscriptionCache.Lock()
subscriptionCache.m = previousEntries
subscriptionCache.inflight = previousInflight
subscriptionCache.Unlock()
})
}
func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
resetSubscriptionCache(t)
var requests atomic.Int32
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
<-release
_, _ = w.Write([]byte("vless://uuid@example.com:443"))
}))
defer srv.Close()
const callers = 16
results := make(chan []string, callers)
var wg sync.WaitGroup
for range callers {
wg.Go(func() {
results <- fetchSubscriptionLinks(srv.URL)
})
}
time.Sleep(100 * time.Millisecond)
close(release)
wg.Wait()
close(results)
for links := range results {
if len(links) != 1 || links[0] != "vless://uuid@example.com:443" {
t.Fatalf("links = %#v", links)
}
}
if got := requests.Load(); got != 1 {
t.Fatalf("requests = %d, want 1", got)
}
}
func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) {
resetSubscriptionCache(t)
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
_, _ = w.Write([]byte("vless://uuid@example.com:443"))
}))
defer srv.Close()
for i := range subscriptionCacheCapacity + 1 {
links := fetchSubscriptionLinks(srv.URL + "?id=" + strconv.Itoa(i))
if len(links) != 1 {
t.Fatalf("links at %d = %#v", i, links)
}
}
subscriptionCache.Lock()
entries := len(subscriptionCache.m)
subscriptionCache.Unlock()
if entries != subscriptionCacheCapacity {
t.Fatalf("cache entries = %d, want %d", entries, subscriptionCacheCapacity)
}
if got := requests.Load(); got != subscriptionCacheCapacity+1 {
t.Fatalf("requests = %d, want %d", got, subscriptionCacheCapacity+1)
}
}
func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T) {
resetSubscriptionCache(t)
stale := []string{"vless://stale@example.com:443"}
release := make(chan struct{})
var staleRequests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/stale" {
staleRequests.Add(1)
<-release
w.WriteHeader(http.StatusBadGateway)
return
}
_, _ = w.Write([]byte("vless://fresh@example.com:443"))
}))
defer srv.Close()
staleURL := srv.URL + "/stale"
subscriptionCache.Lock()
subscriptionCache.m[staleURL] = subscriptionCacheEntry{
links: stale,
fetchedAt: time.Now().Add(-subscriptionCacheTTL),
}
for i := range subscriptionCacheCapacity - 1 {
subscriptionCache.m["cached-"+strconv.Itoa(i)] = subscriptionCacheEntry{fetchedAt: time.Now()}
}
subscriptionCache.Unlock()
const callers = 16
results := make(chan []string, callers)
var wg sync.WaitGroup
for range callers {
wg.Go(func() {
results <- fetchSubscriptionLinks(staleURL)
})
}
time.Sleep(100 * time.Millisecond)
if links := fetchSubscriptionLinks(srv.URL + "/fresh"); len(links) != 1 || links[0] != "vless://fresh@example.com:443" {
t.Fatalf("fresh links = %#v", links)
}
close(release)
wg.Wait()
close(results)
for links := range results {
if len(links) != 1 || links[0] != stale[0] {
t.Fatalf("links = %#v, want %#v", links, stale)
}
}
if got := staleRequests.Load(); got != 1 {
t.Fatalf("requests = %d, want 1", got)
}
}
func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(strings.Repeat("a", subscriptionMaxBytes+1)))
+92
View File
@@ -0,0 +1,92 @@
package sub
import (
"net"
"net/netip"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
var warnSuppressedForwardedOnce sync.Once
var forwardedHeaderNames = [...]string{"X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP"}
func (s *SubService) forwardedHeadersTrusted(c *gin.Context) (trusted bool) {
if !hasForwardedHeaders(c) {
return true
}
trusted = true
defer func() {
_ = recover()
}()
configured, err := s.settingService.GetTrustedProxyCIDRs()
if err != nil {
return true
}
configured = strings.TrimSpace(configured)
if configured == "" || configured == service.DefaultTrustedProxyCIDRs {
return true
}
return remoteAddrInCIDRs(c.Request.RemoteAddr, configured)
}
func hasForwardedHeaders(c *gin.Context) bool {
for _, name := range forwardedHeaderNames {
if c.GetHeader(name) != "" {
return true
}
}
return false
}
func warnSuppressedForwardedHeaders(c *gin.Context) {
present := make([]string, 0, 3)
for _, name := range forwardedHeaderNames {
if c.GetHeader(name) != "" {
present = append(present, name)
}
}
if len(present) == 0 {
return
}
headers := strings.Join(present, ", ")
logger.Debugf("sub: ignoring %s from %s, which is outside trustedProxyCIDRs", headers, c.Request.RemoteAddr)
warnSuppressedForwardedOnce.Do(func() {
logger.Warningf("sub: ignoring %s from %s because it is outside trustedProxyCIDRs; subscription URLs will use the request host. Add the proxy to that setting, or set subURI, if the generated links look wrong.", headers, c.Request.RemoteAddr)
})
}
func remoteAddrInCIDRs(remoteAddr, cidrs string) bool {
host := remoteAddr
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = h
}
addr, err := netip.ParseAddr(strings.TrimSpace(host))
if err != nil {
return false
}
addr = addr.Unmap()
for value := range strings.SplitSeq(cidrs, ",") {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if prefix, err := netip.ParsePrefix(value); err == nil {
if prefix.Contains(addr) {
return true
}
continue
}
if proxyIP, err := netip.ParseAddr(value); err == nil && proxyIP.Unmap() == addr.Unmap() {
return true
}
}
return false
}
+201
View File
@@ -0,0 +1,201 @@
package sub
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
func requestFrom(t *testing.T, remoteAddr string, headers map[string]string) *gin.Context {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/sub/abc", nil)
req.Host = "panel.example.com:2096"
req.RemoteAddr = remoteAddr
for k, v := range headers {
req.Header.Set(k, v)
}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = req
return c
}
func setTrustedProxyCIDRs(t *testing.T, value string) {
t.Helper()
if err := database.GetDB().Create(&model.Setting{Key: "trustedProxyCIDRs", Value: value}).Error; err != nil {
t.Fatalf("set trustedProxyCIDRs: %v", err)
}
settingService := service.SettingService{}
stored, err := settingService.GetTrustedProxyCIDRs()
if err != nil {
t.Fatalf("read trustedProxyCIDRs back through SettingService: %v", err)
}
if stored != value {
t.Fatalf("SettingService reads trustedProxyCIDRs as %q, want %q — the key this helper writes has drifted", stored, value)
}
}
func TestResolveRequest_ForwardedHeaderTrust(t *testing.T) {
tests := []struct {
name string
stored *string
remoteAddr string
wantScheme string
wantHost string
wantHostWithPort string
wantHostHeader string
}{
{
name: "no stored row keeps trusting forwarded headers",
stored: nil,
remoteAddr: "203.0.113.9:51000",
wantScheme: "https",
wantHost: "sub.example.net",
wantHostWithPort: "sub.example.net",
wantHostHeader: "sub.example.net",
},
{
name: "empty stored value keeps trusting forwarded headers",
stored: new(""),
remoteAddr: "203.0.113.9:51000",
wantScheme: "https",
wantHost: "sub.example.net",
wantHostWithPort: "sub.example.net",
wantHostHeader: "sub.example.net",
},
{
name: "stored shipped default keeps trusting forwarded headers",
stored: new(service.DefaultTrustedProxyCIDRs),
remoteAddr: "203.0.113.9:51000",
wantScheme: "https",
wantHost: "sub.example.net",
wantHostWithPort: "sub.example.net",
wantHostHeader: "sub.example.net",
},
{
name: "declared boundary ignores an origin outside it",
stored: new("10.0.0.0/8"),
remoteAddr: "203.0.113.9:51000",
wantScheme: "http",
wantHost: "panel.example.com",
wantHostWithPort: "panel.example.com:2096",
wantHostHeader: "panel.example.com",
},
{
name: "declared boundary trusts an origin inside it",
stored: new("10.0.0.0/8"),
remoteAddr: "10.1.2.3:44000",
wantScheme: "https",
wantHost: "sub.example.net",
wantHostWithPort: "sub.example.net",
wantHostHeader: "sub.example.net",
},
{
name: "declared boundary ignores an unparsable origin",
stored: new("10.0.0.0/8"),
remoteAddr: "not-an-ip",
wantScheme: "http",
wantHost: "panel.example.com",
wantHostWithPort: "panel.example.com:2096",
wantHostHeader: "panel.example.com",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
initSubDB(t)
if tc.stored != nil {
setTrustedProxyCIDRs(t, *tc.stored)
}
s := &SubService{}
c := requestFrom(t, tc.remoteAddr, map[string]string{
"X-Forwarded-Host": "sub.example.net",
"X-Forwarded-Proto": "https",
})
scheme, host, hostWithPort, hostHeader := s.ResolveRequest(c)
if scheme != tc.wantScheme {
t.Errorf("scheme = %q, want %q", scheme, tc.wantScheme)
}
if host != tc.wantHost {
t.Errorf("host = %q, want %q", host, tc.wantHost)
}
if hostWithPort != tc.wantHostWithPort {
t.Errorf("hostWithPort = %q, want %q", hostWithPort, tc.wantHostWithPort)
}
if hostHeader != tc.wantHostHeader {
t.Errorf("hostHeader = %q, want %q", hostHeader, tc.wantHostHeader)
}
})
}
}
func TestResolveRequest_GatesRealIPFallback(t *testing.T) {
initSubDB(t)
setTrustedProxyCIDRs(t, "10.0.0.0/8")
s := &SubService{}
c := requestFrom(t, "203.0.113.9:51000", map[string]string{
"X-Real-IP": "198.51.100.7",
})
_, host, _, hostHeader := s.ResolveRequest(c)
if host != "panel.example.com" {
t.Errorf("host = %q, want the request host — X-Real-IP from an untrusted origin must be ignored", host)
}
if hostHeader != "panel.example.com" {
t.Errorf("hostHeader = %q, want the request host", hostHeader)
}
}
func TestHasForwardedHeaders(t *testing.T) {
tests := []struct {
name string
headers map[string]string
want bool
}{
{name: "no forwarded headers", want: false},
{name: "forwarded host", headers: map[string]string{"X-Forwarded-Host": "sub.example.net"}, want: true},
{name: "forwarded proto", headers: map[string]string{"X-Forwarded-Proto": "https"}, want: true},
{name: "real ip", headers: map[string]string{"X-Real-IP": "10.1.2.3"}, want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := hasForwardedHeaders(requestFrom(t, "10.1.2.3:1234", tc.headers)); got != tc.want {
t.Errorf("hasForwardedHeaders() = %v, want %v", got, tc.want)
}
})
}
}
func TestRemoteAddrInCIDRs(t *testing.T) {
tests := []struct {
name string
remoteAddr string
cidrs string
want bool
}{
{name: "inside cidr", remoteAddr: "10.1.2.3:1234", cidrs: "10.0.0.0/8", want: true},
{name: "ipv4 mapped address inside cidr", remoteAddr: "[::ffff:10.1.2.3]:1234", cidrs: "10.0.0.0/8", want: true},
{name: "outside cidr", remoteAddr: "203.0.113.9:1234", cidrs: "10.0.0.0/8", want: false},
{name: "bare address entry", remoteAddr: "192.168.1.5:80", cidrs: "192.168.1.5", want: true},
{name: "ipv6 loopback", remoteAddr: "[::1]:8080", cidrs: "::1/128", want: true},
{name: "no port", remoteAddr: "10.1.2.3", cidrs: "10.0.0.0/8", want: true},
{name: "unparsable origin", remoteAddr: "not-an-ip", cidrs: "10.0.0.0/8", want: false},
{name: "empty entries skipped", remoteAddr: "10.1.2.3:1", cidrs: " , 10.0.0.0/8 , ", want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := remoteAddrInCIDRs(tc.remoteAddr, tc.cidrs); got != tc.want {
t.Errorf("remoteAddrInCIDRs(%q, %q) = %v, want %v", tc.remoteAddr, tc.cidrs, got, tc.want)
}
})
}
}
+17 -6
View File
@@ -2486,18 +2486,29 @@ type PageData struct {
// ResolveRequest extracts scheme and host info from request/headers consistently.
// ResolveRequest extracts scheme, host, and header information from an HTTP request.
func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) {
trusted := s.forwardedHeadersTrusted(c)
if !trusted {
warnSuppressedForwardedHeaders(c)
}
forwarded := func(name string) string {
if !trusted {
return ""
}
return c.GetHeader(name)
}
// scheme
scheme = "http"
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
if c.Request.TLS != nil || strings.EqualFold(forwarded("X-Forwarded-Proto"), "https") {
scheme = "https"
}
// base host (no port)
if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil && h != "" {
if h, err := getHostFromXFH(forwarded("X-Forwarded-Host")); err == nil && h != "" {
host = h
}
if host == "" {
host = c.GetHeader("X-Real-IP")
host = forwarded("X-Real-IP")
}
if host == "" {
var err error
@@ -2508,7 +2519,7 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
}
// host:port for URLs
hostWithPort = c.GetHeader("X-Forwarded-Host")
hostWithPort = forwarded("X-Forwarded-Host")
if hostWithPort == "" {
hostWithPort = c.Request.Host
}
@@ -2517,9 +2528,9 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
}
// header display host
hostHeader = c.GetHeader("X-Forwarded-Host")
hostHeader = forwarded("X-Forwarded-Host")
if hostHeader == "" {
hostHeader = c.GetHeader("X-Real-IP")
hostHeader = forwarded("X-Real-IP")
}
if hostHeader == "" {
hostHeader = host
+2
View File
@@ -78,6 +78,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
api.Use(middleware.ConfigEnvelopeMiddleware())
api.Use(middleware.CSRFMiddleware())
api.GET("/openapi.json", ServeOpenAPISpec)
// Inbounds API
inbounds := api.Group("/inbounds")
a.inboundController = NewInboundController(inbounds)
-166
View File
@@ -1,166 +0,0 @@
package controller
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
type routeDef struct {
Method string
Path string
}
// routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler)
var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
// docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.ts.
var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`)
// buildDocSet parses frontend/src/pages/api-docs/endpoints.ts and returns the
// set of documented "METHOD PATH" keys. WS pseudo-routes and subscription
// placeholders (paths starting with /{...}) are skipped because they aren't
// registered on the main Gin engine.
func buildDocSet(t *testing.T) map[string]bool {
t.Helper()
controllerDir, err := filepath.Abs(".")
if err != nil {
t.Fatalf("failed to get current dir: %v", err)
}
endpointsPath := filepath.Join(controllerDir, "..", "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts")
data, err := os.ReadFile(endpointsPath)
if err != nil {
t.Fatalf("failed to read endpoints.ts at %s: %v", endpointsPath, err)
}
docSet := make(map[string]bool)
for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) {
method, path := m[1], m[2]
if method == "WS" {
continue
}
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "/{") {
continue
}
docSet[method+" "+path] = true
}
if len(docSet) == 0 {
t.Fatalf("no documented routes parsed from %s — regex or file format may have changed", endpointsPath)
}
return docSet
}
func TestAPIRoutesDocumented(t *testing.T) {
docSet := buildDocSet(t)
controllerDir, err := filepath.Abs(".")
if err != nil {
t.Fatalf("failed to get current dir: %v", err)
}
var allRoutes []routeDef
entries, err := os.ReadDir(controllerDir)
if err != nil {
t.Fatalf("failed to read controller dir: %v", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
data, err := os.ReadFile(filepath.Join(controllerDir, entry.Name()))
if err != nil {
t.Fatalf("failed to read %s: %v", entry.Name(), err)
}
src := string(data)
// Determine the base path for this file based on its initRouter patterns
basePath := ""
switch entry.Name() {
case "index.go":
basePath = ""
case "spa.go":
basePath = "/panel"
case "api.go":
basePath = "/panel/api"
case "inbound.go":
basePath = "/panel/api/inbounds"
case "client.go":
basePath = "/panel/api/clients"
case "group.go":
basePath = "/panel/api/clients"
case "server.go":
basePath = "/panel/api/server"
case "node.go":
basePath = "/panel/api/nodes"
case "host.go":
basePath = "/panel/api/hosts"
case "setting.go":
basePath = "/panel/api/setting"
case "xray_setting.go":
basePath = "/panel/api/xray"
case "websocket.go":
basePath = ""
}
// Find all route registrations
matches := routePattern.FindAllStringSubmatch(src, -1)
for _, m := range matches {
method := m[2]
path := strings.TrimSpace(m[3])
if basePath == "" {
allRoutes = append(allRoutes, routeDef{Method: method, Path: path})
} else {
fullPath := basePath + path
allRoutes = append(allRoutes, routeDef{Method: method, Path: fullPath})
}
}
}
// The WebSocket route /ws is registered in web/web.go (not a controller file)
allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"})
missingFromDocs := 0
foundInDoc := 0
sourceSet := make(map[string]bool)
for _, r := range allRoutes {
key := r.Method + " " + r.Path
// Skip SPA page routes (these are UI pages, not API endpoints)
spaPages := map[string]bool{
"/": true, "/panel/": true, "/panel/inbounds": true,
"/panel/clients": true, "/panel/groups": true,
"/panel/nodes": true, "/panel/settings": true,
"/panel/xray": true, "/panel/outbound": true,
"/panel/routing": true, "/panel/api-docs": true,
}
if spaPages[r.Path] {
continue
}
// Skip /panel/csrf-token (documented under auth as /csrf-token)
if r.Path == "/panel/csrf-token" {
continue
}
// Skip Chrome DevTools route
if strings.Contains(r.Path, ".well-known") {
continue
}
sourceSet[key] = true
if docSet[key] {
foundInDoc++
} else {
missingFromDocs++
t.Errorf("Route not documented in endpoints.ts: %s %s", r.Method, r.Path)
}
}
t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d",
len(sourceSet), len(docSet), foundInDoc, missingFromDocs)
if missingFromDocs > 0 {
t.Errorf("Found %d undocumented route(s). Update endpoints.ts to match.", missingFromDocs)
}
}
+5
View File
@@ -65,6 +65,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
g.POST("/all", a.getAllSetting)
g.POST("/defaultSettings", a.getDefaultSettings)
g.POST("/factoryDefaults", a.getFactoryDefaults)
g.POST("/update", a.updateSetting)
g.POST("/validateRegex", a.validateRegex)
g.POST("/updateUser", a.updateUser)
@@ -112,6 +113,10 @@ func (a *SettingController) getDefaultSettings(c *gin.Context) {
jsonObj(c, result, nil)
}
func (a *SettingController) getFactoryDefaults(c *gin.Context) {
jsonObj(c, a.settingService.GetFactoryDefaults(), nil)
}
// updateSetting updates all settings with the provided data.
func (a *SettingController) updateSetting(c *gin.Context) {
form, ok := middleware.BindAndValidate[updateSettingForm](c)
+1 -1
View File
@@ -71,7 +71,7 @@ func isTrustedProxy(ip string) bool {
}
func trustedProxyCIDRs() (trusted string) {
trusted = "127.0.0.1/32,::1/128"
trusted = service.DefaultTrustedProxyCIDRs
defer func() {
_ = recover()
}()
+27 -9
View File
@@ -29,6 +29,31 @@ type XrayTrafficJob struct {
// refetch for the rest.
const clientStatsSnapshotMaxClients = 5000
// splitMovedClientTraffics keeps the rows that actually moved bytes this poll,
// alongside the active-email list and set derived from the same pass.
//
// Xray reports a row for every known email whether or not it transferred
// anything, so on a large panel nearly every delta is zero. The database writes
// and the external-API inform consume the full slice before this point; the
// WebSocket frame only feeds the dashboard's live speed column, where an absent
// row and a zero row render identically. Broadcasting just the movers keeps that
// frame from growing with the client count — at 5k clients it was carrying about
// a megabyte of zeros every five seconds.
func splitMovedClientTraffics(clientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, []string, map[string]bool) {
moved := make([]*xray.ClientTraffic, 0, len(clientTraffics))
emails := make([]string, 0, len(clientTraffics))
active := make(map[string]bool, len(clientTraffics))
for _, ct := range clientTraffics {
if ct == nil || ct.Up+ct.Down <= 0 {
continue
}
moved = append(moved, ct)
emails = append(emails, ct.Email)
active[ct.Email] = true
}
return moved, emails, active
}
const externalInformTimeout = 3 * time.Second
var externalInformClient = &fasthttp.Client{
@@ -88,14 +113,7 @@ func (j *XrayTrafficJob) Run() {
// than the shared last_online column, which remote-node syncs also bump
// and would otherwise make a client active only on a remote node appear
// online on local inbounds.
activeEmails := make([]string, 0, len(clientTraffics))
deltaActive := make(map[string]bool, len(clientTraffics))
for _, ct := range clientTraffics {
if ct != nil && ct.Up+ct.Down > 0 {
activeEmails = append(activeEmails, ct.Email)
deltaActive[ct.Email] = true
}
}
movedTraffics, activeEmails, deltaActive := splitMovedClientTraffics(clientTraffics)
// When the core supports the online-stats API, union in connection-based
// onlines. Neither signal alone covers everything: an idle-but-connected
// client moves no bytes between polls (the delta heuristic's blind spot),
@@ -179,7 +197,7 @@ func (j *XrayTrafficJob) Run() {
}
websocket.BroadcastTraffic(map[string]any{
"traffics": traffics,
"clientTraffics": clientTraffics,
"clientTraffics": movedTraffics,
"onlineClients": onlineClients,
"onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
@@ -0,0 +1,59 @@
package job
import (
"slices"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestSplitMovedClientTraffics(t *testing.T) {
rows := []*xray.ClientTraffic{
{Email: "idle@x", Up: 0, Down: 0},
{Email: "up@x", Up: 1024, Down: 0},
{Email: "down@x", Up: 0, Down: 2048},
nil,
{Email: "both@x", Up: 512, Down: 512},
{Email: "alsoidle@x", Up: 0, Down: 0},
}
moved, emails, active := splitMovedClientTraffics(rows)
t.Run("only the rows that moved bytes are broadcast", func(t *testing.T) {
got := make([]string, 0, len(moved))
for _, ct := range moved {
got = append(got, ct.Email)
}
want := []string{"up@x", "down@x", "both@x"}
if !slices.Equal(got, want) {
t.Fatalf("moved = %v, want %v", got, want)
}
})
t.Run("the active list and set agree with the broadcast rows", func(t *testing.T) {
want := []string{"up@x", "down@x", "both@x"}
if !slices.Equal(emails, want) {
t.Fatalf("activeEmails = %v, want %v", emails, want)
}
if len(active) != len(want) {
t.Fatalf("deltaActive has %d entries, want %d", len(active), len(want))
}
for _, e := range want {
if !active[e] {
t.Fatalf("deltaActive missing %q", e)
}
}
if active["idle@x"] {
t.Fatal("an idle client must not count as active")
}
})
t.Run("an all-idle poll broadcasts nothing", func(t *testing.T) {
moved, emails, active := splitMovedClientTraffics([]*xray.ClientTraffic{
{Email: "a@x"}, {Email: "b@x"},
})
if len(moved) != 0 || len(emails) != 0 || len(active) != 0 {
t.Fatalf("expected an empty split, got %d/%d/%d", len(moved), len(emails), len(active))
}
})
}
+139
View File
@@ -0,0 +1,139 @@
package web
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
"time"
"github.com/robfig/cron/v3"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
)
/*
frontend/src/pages/api-docs/endpoints.ts is a hand-maintained registry: an
API route omitted there silently vanishes from the generated OpenAPI docs,
and an entry for a removed route documents an endpoint that 404s. This test
constructs the real router and diffs it against the registry both ways.
Scope: everything under /panel/api/ plus the session-auth surface the
registry also documents (/login, /logout, /csrf-token, /getTwoFactorEnable,
/ws). SPA page routes are UI, not API, and stay out; registry paths that
start with "/{" describe the standalone subscription server, which this
engine does not serve.
*/
var contractExtraRoutes = map[string]bool{
"POST /login": true,
"POST /logout": true,
"GET /csrf-token": true,
"POST /getTwoFactorEnable": true,
"GET /ws": true,
}
func inContractScope(method, path string) bool {
return strings.HasPrefix(path, "/panel/api/") || contractExtraRoutes[method+" "+path]
}
func registeredContractRoutes(t *testing.T) map[string]bool {
t.Helper()
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
previous := global.GetWebServer()
s := NewServer()
s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds())
global.SetWebServer(s)
t.Cleanup(func() {
s.cancel()
global.SetWebServer(previous)
})
engine, err := s.initRouter()
if err != nil {
t.Fatalf("init router: %v", err)
}
routes := make(map[string]bool)
for _, r := range engine.Routes() {
routes[r.Method+" "+r.Path] = true
}
if len(routes) == 0 {
t.Fatal("no routes registered; router construction is broken")
}
return routes
}
func documentedContractRoutes(t *testing.T) map[string]bool {
t.Helper()
source, err := os.ReadFile(filepath.Join("..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts"))
if err != nil {
t.Fatalf("read endpoints.ts: %v", err)
}
text := string(source)
methodRe := regexp.MustCompile(`method:\s*'(GET|POST|PUT|DELETE|PATCH|WS)'`)
pathRe := regexp.MustCompile(`path:\s*'([^']+)'`)
methods := methodRe.FindAllStringSubmatchIndex(text, -1)
if declared := strings.Count(text, "method: '"); len(methods) != declared {
t.Fatalf("parsed %d method fields but endpoints.ts declares %d — the parser regex no longer matches the file shape", len(methods), declared)
}
docs := make(map[string]bool)
for i, m := range methods {
segmentEnd := len(text)
if i+1 < len(methods) {
segmentEnd = methods[i+1][0]
}
pathMatch := pathRe.FindStringSubmatch(text[m[1]:segmentEnd])
if pathMatch == nil {
t.Fatalf("entry %d in endpoints.ts has a method but no path before the next entry — the parser cannot pair it", i)
}
method := text[m[2]:m[3]]
if strings.HasPrefix(pathMatch[1], "/{") || !strings.HasPrefix(pathMatch[1], "/") {
continue
}
docs[method+" "+pathMatch[1]] = true
}
if len(docs) == 0 {
t.Fatal("no entries parsed from endpoints.ts; the parser regex is broken")
}
return docs
}
func TestRouteRegistryContract(t *testing.T) {
registered := registeredContractRoutes(t)
documented := documentedContractRoutes(t)
t.Run("every API route is documented", func(t *testing.T) {
var missing []string
for route := range registered {
fields := strings.Fields(route)
if inContractScope(fields[0], fields[1]) && !documented[route] {
missing = append(missing, route)
}
}
sort.Strings(missing)
for _, route := range missing {
t.Error(fmt.Errorf("route %s is registered but absent from endpoints.ts — add an entry or it vanishes from the API docs", route))
}
})
t.Run("every documented route is registered", func(t *testing.T) {
var stale []string
for route := range documented {
if !registered[route] {
stale = append(stale, route)
}
}
sort.Strings(stale)
for _, route := range stale {
t.Error(fmt.Errorf("endpoints.ts documents %s but the server does not register it — remove or fix the entry", route))
}
})
}
+486 -399
View File
@@ -1,13 +1,16 @@
package service
import (
"slices"
"sort"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
// ClientSlim is the row-shape used by the clients page. It drops fields the
@@ -74,33 +77,262 @@ type ClientPageResponse struct {
// ClientsSummary collects per-bucket counts plus the matching email lists so
// the clients page can render the dashboard stat cards and their hover
// popovers without shipping the full client array.
// popovers without shipping the full client array. The counters are exact;
// the lists stop at clientSummaryEmailCap entries and only back the popovers.
type ClientsSummary struct {
Total int `json:"total"`
Active int `json:"active"`
Online []string `json:"online"`
Depleted []string `json:"depleted"`
Expiring []string `json:"expiring"`
Deactive []string `json:"deactive"`
Total int `json:"total"`
Active int `json:"active"`
OnlineCount int `json:"onlineCount"`
DepletedCount int `json:"depletedCount"`
ExpiringCount int `json:"expiringCount"`
DeactiveCount int `json:"deactiveCount"`
Online []string `json:"online"`
Depleted []string `json:"depleted"`
Expiring []string `json:"expiring"`
Deactive []string `json:"deactive"`
}
const (
clientPageDefaultSize = 25
clientPageMaxSize = 200
// clientSummaryEmailCap bounds each bucket's email list. Shipping every
// matching email made the response — and the Zod validation the page runs
// over it — grow with the client count on a request that repeats every 5s,
// and left the hover popover rendering thousands of rows.
clientSummaryEmailCap = 200
// sqlNeverSentinel sorts "never expires" / "unlimited quota" clients last,
// matching the sentinel the in-memory comparator used.
sqlNeverSentinel = "4611686018427387903"
// sqlClientEnabled tolerates a NULL enable column, which GORM scans as
// false: without the COALESCE such a row would match neither the enabled
// nor the disabled branch of any predicate.
sqlClientEnabled = "COALESCE(c.enable, FALSE)"
)
// ListPaged loads every client (with traffic + attachments) into memory,
// applies the requested filter / search / protocol predicates, sorts, and
// returns the requested page along with total and filtered counts. The DB
// query itself is unchanged from List(); the win is that the response
// only carries 25-ish slim rows over the wire instead of all 2000 full
// records, which on real panels was the dominant cost.
func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
all, err := s.List()
if err != nil {
return nil, err
const clientSearchCond = `(LOWER(c.email) LIKE ? ESCAPE '\'
OR LOWER(COALESCE(c.sub_id, '')) LIKE ? ESCAPE '\'
OR LOWER(COALESCE(c.comment, '')) LIKE ? ESCAPE '\'
OR LOWER(COALESCE(c.uuid, '')) LIKE ? ESCAPE '\'
OR LOWER(COALESCE(c.password, '')) LIKE ? ESCAPE '\'
OR LOWER(COALESCE(c.auth, '')) LIKE ? ESCAPE '\'
OR (COALESCE(c.tg_id, 0) <> 0 AND CAST(c.tg_id AS TEXT) LIKE ? ESCAPE '\'))`
// clientQuery builds the statements behind the clients page: a clients row
// joined to its traffic counters, plus the expressions every bucket predicate
// shares. Filtering, sorting, paging and the summary all run in the database.
// Loading every client (with attachments and traffic) into Go and doing it in
// memory cost ~200ms per request at 20k clients on a page that polls every
// 5 seconds, which is what made the table feel stuck on large panels.
type clientQuery struct {
db *gorm.DB
joins []clientQueryJoin
usedExpr string
nowMs int64
expireDiffMs int64
trafficDiffBytes int64
}
type clientQueryJoin struct {
sql string
args []any
}
func newClientQuery(db *gorm.DB, nowMs, expireDiffMs, trafficDiffBytes int64) clientQuery {
q := clientQuery{
db: db,
nowMs: nowMs,
expireDiffMs: expireDiffMs,
trafficDiffBytes: trafficDiffBytes,
joins: []clientQueryJoin{{sql: "LEFT JOIN client_traffics ct ON ct.email = c.email"}},
usedExpr: "(COALESCE(ct.up, 0) + COALESCE(ct.down, 0))",
}
total := len(all)
freshSince := globalTrafficFreshSince()
var probe int64
err := db.Model(&model.ClientGlobalTraffic{}).
Where("updated_at >= ?", freshSince).
Limit(1).Count(&probe).Error
if err != nil || probe == 0 {
return q
}
// A master still pushes cross-panel usage here, so the predicates have to
// see the same raised counters overlayGlobalTraffic applies on read.
q.joins = append(q.joins, clientQueryJoin{
sql: "LEFT JOIN (SELECT email, MAX(up) AS up, MAX(down) AS down FROM client_global_traffics" +
" WHERE updated_at >= ? GROUP BY email) g ON g.email = c.email",
args: []any{freshSince},
})
q.usedExpr = "(CASE WHEN COALESCE(g.up, 0) > COALESCE(ct.up, 0) THEN COALESCE(g.up, 0) ELSE COALESCE(ct.up, 0) END" +
" + CASE WHEN COALESCE(g.down, 0) > COALESCE(ct.down, 0) THEN COALESCE(g.down, 0) ELSE COALESCE(ct.down, 0) END)"
return q
}
func (q clientQuery) from() *gorm.DB {
tx := q.db.Table("clients AS c")
for _, j := range q.joins {
tx = tx.Joins(j.sql, j.args...)
}
return tx
}
func (q clientQuery) depletedExpr() string {
return "((c.total_gb > 0 AND " + q.usedExpr + " >= c.total_gb)" +
" OR (c.expiry_time > 0 AND c.expiry_time <= " + sqlInt(q.nowMs) + "))"
}
func (q clientQuery) nearDepletionExpr() string {
return "((c.expiry_time > 0 AND c.expiry_time - " + sqlInt(q.nowMs) + " < " + sqlInt(q.expireDiffMs) + ")" +
" OR (c.total_gb > 0 AND c.total_gb - " + q.usedExpr + " < " + sqlInt(q.trafficDiffBytes) + "))"
}
func (q clientQuery) expiringExpr() string {
return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND " + q.nearDepletionExpr() + ")"
}
func (q clientQuery) activeExpr() string {
return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND NOT " + q.nearDepletionExpr() + ")"
}
// summaryDeactiveExpr is narrower than the "deactive" bucket filter: a disabled
// client that also ran out counts once, under depleted, so the stat cards add
// up to the client total.
func (q clientQuery) summaryDeactiveExpr() string {
return "(NOT " + sqlClientEnabled + " AND NOT " + q.depletedExpr() + ")"
}
// applyParams narrows tx by every predicate the clients page sends. Matching is
// OR within a field and AND across fields, mirroring the query-param contract.
// The second return says whether anything narrowed the set, so an unfiltered
// request can reuse the total count instead of scanning for it again.
func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines []string) (*gorm.DB, bool) {
narrowed := false
where := func(cond string, args ...any) {
narrowed = true
tx = tx.Where(cond, args...)
}
if needle := strings.ToLower(strings.TrimSpace(params.Search)); needle != "" {
pattern := "%" + escapeLikeLiteral(needle) + "%"
where(clientSearchCond, pattern, pattern, pattern, pattern, pattern, pattern, pattern)
}
if protocols := parseCSVStrings(params.Protocol); len(protocols) > 0 {
where("EXISTS (SELECT 1 FROM client_inbounds ci JOIN inbounds ib ON ib.id = ci.inbound_id"+
" WHERE ci.client_id = c.id AND LOWER(ib.protocol) IN ?)", protocols)
}
if inboundIds := parseCSVInts(params.Inbound); len(inboundIds) > 0 {
where("EXISTS (SELECT 1 FROM client_inbounds ci WHERE ci.client_id = c.id AND ci.inbound_id IN ?)", inboundIds)
}
if buckets := parseCSVStrings(params.Filter); len(buckets) > 0 {
cond, args := q.bucketCond(buckets, onlines)
where(cond, args...)
}
if params.ExpiryFrom > 0 || params.ExpiryTo > 0 {
// 0 means "never expires" and a negative value is the delayed-start
// sentinel; both sit outside any bounded range.
where("c.expiry_time > 0")
if params.ExpiryFrom > 0 {
where("c.expiry_time >= ?", params.ExpiryFrom)
}
if params.ExpiryTo > 0 {
where("c.expiry_time <= ?", params.ExpiryTo)
}
}
if params.UsageFrom > 0 {
where(q.usedExpr+" >= ?", params.UsageFrom)
}
if params.UsageTo > 0 {
where(q.usedExpr+" <= ?", params.UsageTo)
}
switch strings.ToLower(strings.TrimSpace(params.AutoRenew)) {
case "on":
where("COALESCE(c.reset, 0) > 0")
case "off":
where("COALESCE(c.reset, 0) <= 0")
}
switch strings.ToLower(strings.TrimSpace(params.HasTgID)) {
case "yes":
where("COALESCE(c.tg_id, 0) <> 0")
case "no":
where("COALESCE(c.tg_id, 0) = 0")
}
switch strings.ToLower(strings.TrimSpace(params.HasComment)) {
case "yes":
where("TRIM(COALESCE(c.comment, '')) <> ''")
case "no":
where("TRIM(COALESCE(c.comment, '')) = ''")
}
if groups := parseCSVStrings(params.Group); len(groups) > 0 {
where("LOWER(TRIM(COALESCE(c.group_name, ''))) IN ?", groups)
}
return tx, narrowed
}
func (q clientQuery) bucketCond(buckets, onlines []string) (string, []any) {
conds := make([]string, 0, len(buckets))
args := make([]any, 0, len(buckets))
for _, b := range buckets {
switch b {
case "active":
conds = append(conds, "("+sqlClientEnabled+" AND NOT "+q.depletedExpr()+")")
case "deactive":
conds = append(conds, "(NOT "+sqlClientEnabled+")")
case "depleted":
conds = append(conds, q.depletedExpr())
case "expiring":
conds = append(conds, q.expiringExpr())
case "online":
cond, inArgs := emailInCond("c.email", onlines)
conds = append(conds, "("+sqlClientEnabled+" AND "+cond+")")
args = append(args, inArgs...)
default:
// An unrecognised bucket name matched every client before the
// predicates moved into SQL; keep that so a stale saved filter
// cannot silently empty the table.
conds = append(conds, "(1 = 1)")
}
}
return "(" + strings.Join(conds, " OR ") + ")", args
}
func (q clientQuery) applyOrder(tx *gorm.DB, sortKey, order string) *gorm.DB {
dir := " ASC"
if order == "descend" {
dir = " DESC"
}
// createdAt / updatedAt / lastOnline broke ties on the client id inside the
// comparator, so reversing the sort reversed the tiebreak with it. The
// other keys leaned on a stable sort over an id-ordered slice instead.
tieDir := " ASC"
var expr string
switch sortKey {
case "enable":
expr = sqlClientEnabled
case "email":
expr = "LOWER(c.email)"
case "inboundIds":
expr = "(SELECT COUNT(*) FROM client_inbounds ci WHERE ci.client_id = c.id)"
case "traffic":
expr = q.usedExpr
case "remaining":
expr = "CASE WHEN c.total_gb > 0 THEN c.total_gb - " + q.usedExpr + " ELSE " + sqlNeverSentinel + " END"
case "expiryTime":
expr = "CASE WHEN c.expiry_time > 0 THEN c.expiry_time ELSE " + sqlNeverSentinel + " END"
case "createdAt":
expr, tieDir = "c.created_at", dir
case "updatedAt":
expr, tieDir = "c.updated_at", dir
case "lastOnline":
expr, tieDir = "COALESCE(ct.last_online, 0)", dir
default:
return tx.Order("c.id ASC")
}
return tx.Order(expr + dir + ", c.id" + tieDir)
}
// ListPaged returns one page of clients together with the counts the clients
// page header needs. Every predicate runs in SQL, so the cost tracks the page
// size rather than the number of clients on the panel.
func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
db := database.GetDB()
pageSize := params.PageSize
if pageSize <= 0 {
@@ -114,27 +346,6 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
page = 1
}
protocols := parseCSVStrings(params.Protocol)
inboundIDs := parseCSVInts(params.Inbound)
buckets := parseCSVStrings(params.Filter)
var protocolByInbound map[int]string
if len(protocols) > 0 {
inbounds, err := inboundSvc.GetAllInbounds()
if err == nil {
protocolByInbound = make(map[int]string, len(inbounds))
for _, ib := range inbounds {
protocolByInbound[ib.Id] = string(ib.Protocol)
}
}
}
onlines := inboundSvc.GetOnlineClients()
onlineSet := make(map[string]struct{}, len(onlines))
for _, e := range onlines {
onlineSet[e] = struct{}{}
}
var expireDiffMs, trafficDiffBytes int64
if settingSvc != nil {
if v, err := settingSvc.GetExpireDiff(); err == nil {
@@ -145,77 +356,44 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
}
}
nowMs := time.Now().UnixMilli()
summary := buildClientsSummary(all, onlineSet, nowMs, expireDiffMs, trafficDiffBytes)
onlines := inboundSvc.GetOnlineClients()
q := newClientQuery(db, time.Now().UnixMilli(), expireDiffMs, trafficDiffBytes)
needle := strings.ToLower(strings.TrimSpace(params.Search))
filtered := make([]ClientWithAttachments, 0, len(all))
for _, c := range all {
if needle != "" && !clientMatchesSearch(c, needle) {
continue
}
if len(protocols) > 0 && !clientMatchesAnyProtocol(c, protocols, protocolByInbound) {
continue
}
if len(inboundIDs) > 0 && !clientMatchesAnyInbound(c, inboundIDs) {
continue
}
if len(buckets) > 0 && !clientMatchesAnyBucket(c, buckets, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
continue
}
if !clientMatchesExpiryRange(c, params.ExpiryFrom, params.ExpiryTo) {
continue
}
if !clientMatchesUsageRange(c, params.UsageFrom, params.UsageTo) {
continue
}
if !clientMatchesAutoRenew(c, params.AutoRenew) {
continue
}
if !clientMatchesHasTgID(c, params.HasTgID) {
continue
}
if !clientMatchesHasComment(c, params.HasComment) {
continue
}
if !clientMatchesAnyGroup(c, params.Group) {
continue
}
filtered = append(filtered, c)
var total int64
if err := db.Model(&model.ClientRecord{}).Count(&total).Error; err != nil {
return nil, err
}
sortClients(filtered, params.Sort, params.Order)
filteredCount := len(filtered)
start := (page - 1) * pageSize
end := start + pageSize
if start > filteredCount {
start = filteredCount
}
if end > filteredCount {
end = filteredCount
}
pageRows := filtered[start:end]
items := make([]ClientSlim, 0, len(pageRows))
for _, c := range pageRows {
items = append(items, toClientSlim(c))
summary, err := q.summary(onlines, int(total))
if err != nil {
return nil, err
}
groupRows, gErr := s.ListGroups()
if gErr != nil {
return nil, gErr
filtered := total
if scoped, narrowed := q.applyParams(q.from(), params, onlines); narrowed {
if err := scoped.Count(&filtered).Error; err != nil {
return nil, err
}
}
groups := make([]string, 0, len(groupRows))
for _, g := range groupRows {
groups = append(groups, g.Name)
items := []ClientSlim{}
offset := (page - 1) * pageSize
if int64(offset) < filtered {
items, err = q.pageRows(params, onlines, offset, pageSize)
if err != nil {
return nil, err
}
}
groups, err := s.listGroupNames()
if err != nil {
return nil, err
}
return &ClientPageResponse{
Items: items,
Total: total,
Filtered: filteredCount,
Total: int(total),
Filtered: int(filtered),
Page: page,
PageSize: pageSize,
Summary: summary,
@@ -223,77 +401,229 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
}, nil
}
func buildClientsSummary(all []ClientWithAttachments, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) ClientsSummary {
// pageRows resolves the requested page to client ids, then loads the records,
// attachments and traffic for those ids only. A page never exceeds
// clientPageMaxSize rows, which stays under sqlInChunk, so the follow-up IN
// lists need no chunking.
func (q clientQuery) pageRows(params ClientPageParams, onlines []string, offset, limit int) ([]ClientSlim, error) {
tx, _ := q.applyParams(q.from(), params, onlines)
var ids []int
if err := q.applyOrder(tx, params.Sort, params.Order).
Offset(offset).Limit(limit).
Pluck("c.id", &ids).Error; err != nil {
return nil, err
}
if len(ids) == 0 {
return []ClientSlim{}, nil
}
var records []model.ClientRecord
if err := q.db.Where("id IN ?", ids).Find(&records).Error; err != nil {
return nil, err
}
byId := make(map[int]*model.ClientRecord, len(records))
emails := make([]string, 0, len(records))
for i := range records {
byId[records[i].Id] = &records[i]
if records[i].Email != "" {
emails = append(emails, records[i].Email)
}
}
var links []model.ClientInbound
if err := q.db.Where("client_id IN ?", ids).Order("inbound_id ASC").Find(&links).Error; err != nil {
return nil, err
}
attachments := make(map[int][]int, len(ids))
for _, l := range links {
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
}
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
if len(emails) > 0 {
var stats []xray.ClientTraffic
if err := q.db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
return nil, err
}
overlayGlobalTrafficValues(q.db, stats)
for i := range stats {
trafficByEmail[stats[i].Email] = &stats[i]
}
}
items := make([]ClientSlim, 0, len(ids))
for _, id := range ids {
rec := byId[id]
if rec == nil {
continue
}
items = append(items, ClientSlim{
Email: rec.Email,
SubID: rec.SubID,
Enable: rec.Enable,
TotalGB: rec.TotalGB,
ExpiryTime: rec.ExpiryTime,
LimitIP: rec.LimitIP,
Reset: rec.Reset,
Group: rec.Group,
Comment: rec.Comment,
InboundIds: attachments[rec.Id],
Traffic: trafficByEmail[rec.Email],
CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt,
})
}
return items, nil
}
func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error) {
s := ClientsSummary{
Total: len(all),
Total: total,
Online: []string{},
Depleted: []string{},
Expiring: []string{},
Deactive: []string{},
}
for _, c := range all {
used := int64(0)
if c.Traffic != nil {
used = c.Traffic.Up + c.Traffic.Down
var counts struct {
Active int64
Depleted int64
Expiring int64
Deactive int64
}
// SUM over an empty table yields NULL, which not every driver scans into an
// int; COALESCE keeps a panel with no clients from erroring out.
if err := q.from().Select(
"COALESCE(SUM(CASE WHEN " + q.activeExpr() + " THEN 1 ELSE 0 END), 0) AS active," +
" COALESCE(SUM(CASE WHEN " + q.depletedExpr() + " THEN 1 ELSE 0 END), 0) AS depleted," +
" COALESCE(SUM(CASE WHEN " + q.expiringExpr() + " THEN 1 ELSE 0 END), 0) AS expiring," +
" COALESCE(SUM(CASE WHEN " + q.summaryDeactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive",
).Scan(&counts).Error; err != nil {
return s, err
}
s.Active = int(counts.Active)
s.DepletedCount = int(counts.Depleted)
s.ExpiringCount = int(counts.Expiring)
s.DeactiveCount = int(counts.Deactive)
buckets := []struct {
cond string
count int
out *[]string
}{
{q.depletedExpr(), s.DepletedCount, &s.Depleted},
{q.expiringExpr(), s.ExpiringCount, &s.Expiring},
{q.summaryDeactiveExpr(), s.DeactiveCount, &s.Deactive},
}
for _, b := range buckets {
// The counter already says the bucket is empty, so skip the scan that
// would look for emails it cannot find.
if b.count == 0 {
continue
}
exhausted := c.TotalGB > 0 && used >= c.TotalGB
expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
if c.Enable {
if _, ok := onlineSet[c.Email]; ok {
s.Online = append(s.Online, c.Email)
var emails []string
if err := q.from().Where(b.cond).
Order("c.id ASC").Limit(clientSummaryEmailCap).
Pluck("c.email", &emails).Error; err != nil {
return s, err
}
if len(emails) > 0 {
*b.out = emails
}
}
online, onlineCount, err := q.onlineEmails(onlines)
if err != nil {
return s, err
}
s.Online = online
s.OnlineCount = onlineCount
return s, nil
}
// onlineEmails intersects the emails xray reports as connected with the enabled
// clients this panel stores. The online set lives in memory and is bounded by
// live connections, so it drives the query rather than a scan of every client.
func (q clientQuery) onlineEmails(onlines []string) ([]string, int, error) {
matched := []string{}
count := 0
for _, batch := range chunkStrings(onlines, sqlInChunk) {
var page []string
if err := q.db.Model(&model.ClientRecord{}).
Where("COALESCE(enable, FALSE) = TRUE AND email IN ?", batch).
Order("id ASC").
Pluck("email", &page).Error; err != nil {
return nil, 0, err
}
count += len(page)
if room := clientSummaryEmailCap - len(matched); room > 0 {
matched = append(matched, page[:min(room, len(page))]...)
}
}
return matched, count, nil
}
// listGroupNames returns the group names the clients page offers as filters:
// the stored groups plus any name a client still carries. ListGroups also sums
// per-client traffic per group, which this page never reads and which costs a
// full join over client_traffics on every poll.
func (s *ClientService) listGroupNames() ([]string, error) {
db := database.GetDB()
var stored []string
if err := db.Model(&model.ClientGroup{}).Pluck("name", &stored).Error; err != nil {
return nil, err
}
var used []string
if err := db.Model(&model.ClientRecord{}).
Where("group_name <> ''").
Distinct().
Pluck("group_name", &used).Error; err != nil {
return nil, err
}
seen := make(map[string]struct{}, len(stored)+len(used))
out := make([]string, 0, len(stored)+len(used))
for _, list := range [][]string{stored, used} {
for _, name := range list {
if name == "" {
continue
}
}
if exhausted || expired {
s.Depleted = append(s.Depleted, c.Email)
continue
}
if !c.Enable {
s.Deactive = append(s.Deactive, c.Email)
continue
}
nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
if nearExpiry || nearLimit {
s.Expiring = append(s.Expiring, c.Email)
} else {
s.Active++
if _, dup := seen[name]; dup {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
}
return s
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i]) < strings.ToLower(out[j])
})
return out, nil
}
func toClientSlim(c ClientWithAttachments) ClientSlim {
return ClientSlim{
Email: c.Email,
SubID: c.SubID,
Enable: c.Enable,
TotalGB: c.TotalGB,
ExpiryTime: c.ExpiryTime,
LimitIP: c.LimitIP,
Reset: c.Reset,
Group: c.Group,
Comment: c.Comment,
InboundIds: c.InboundIds,
Traffic: c.Traffic,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
func sqlInt(v int64) string {
return strconv.FormatInt(v, 10)
}
func clientMatchesSearch(c ClientWithAttachments, needle string) bool {
if needle == "" {
return true
// escapeLikeLiteral neutralises LIKE wildcards so searching for "a_b" keeps
// matching literally, the way strings.Contains did.
func escapeLikeLiteral(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
// emailInCond renders an IN over a possibly large email set, split so no single
// IN list outgrows the drivers' bind-parameter ceiling.
func emailInCond(column string, emails []string) (string, []any) {
if len(emails) == 0 {
return "1 = 0", nil
}
candidates := [...]string{c.Email, c.SubID, c.Comment, c.UUID, c.Password, c.Auth}
for _, v := range candidates {
if v != "" && strings.Contains(strings.ToLower(v), needle) {
return true
}
chunks := chunkStrings(emails, sqlInChunk)
parts := make([]string, 0, len(chunks))
args := make([]any, 0, len(chunks))
for _, chunk := range chunks {
parts = append(parts, column+" IN ?")
args = append(args, chunk)
}
if c.TgID != 0 && strings.Contains(strconv.FormatInt(c.TgID, 10), needle) {
return true
}
return false
return "(" + strings.Join(parts, " OR ") + ")", args
}
// parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
@@ -339,246 +669,3 @@ func parseCSVInts(raw string) []int {
}
return out
}
func clientMatchesAnyProtocol(c ClientWithAttachments, protocols []string, byInbound map[int]string) bool {
for _, id := range c.InboundIds {
p := byInbound[id]
if p == "" {
continue
}
if slices.Contains(protocols, strings.ToLower(p)) {
return true
}
}
return false
}
func clientMatchesAnyInbound(c ClientWithAttachments, inboundIds []int) bool {
for _, id := range c.InboundIds {
if slices.Contains(inboundIds, id) {
return true
}
}
return false
}
func clientMatchesAnyBucket(c ClientWithAttachments, buckets []string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
for _, b := range buckets {
if clientMatchesBucket(c, b, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
return true
}
}
return false
}
func clientMatchesExpiryRange(c ClientWithAttachments, fromMs, toMs int64) bool {
if fromMs <= 0 && toMs <= 0 {
return true
}
// expiryTime of 0 means "never expires"; treat it as outside any bounded
// range so users filtering by date see only clients with concrete expiries.
if c.ExpiryTime == 0 {
return false
}
// Negative expiry is the "delayed start" sentinel; same treatment as never.
if c.ExpiryTime < 0 {
return false
}
if fromMs > 0 && c.ExpiryTime < fromMs {
return false
}
if toMs > 0 && c.ExpiryTime > toMs {
return false
}
return true
}
func clientMatchesUsageRange(c ClientWithAttachments, fromBytes, toBytes int64) bool {
if fromBytes <= 0 && toBytes <= 0 {
return true
}
used := int64(0)
if c.Traffic != nil {
used = c.Traffic.Up + c.Traffic.Down
}
if fromBytes > 0 && used < fromBytes {
return false
}
if toBytes > 0 && used > toBytes {
return false
}
return true
}
func clientMatchesAutoRenew(c ClientWithAttachments, mode string) bool {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "on":
return c.Reset > 0
case "off":
return c.Reset <= 0
}
return true
}
func clientMatchesHasTgID(c ClientWithAttachments, mode string) bool {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "yes":
return c.TgID != 0
case "no":
return c.TgID == 0
}
return true
}
func clientMatchesHasComment(c ClientWithAttachments, mode string) bool {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "yes":
return strings.TrimSpace(c.Comment) != ""
case "no":
return strings.TrimSpace(c.Comment) == ""
}
return true
}
func clientMatchesAnyGroup(c ClientWithAttachments, csv string) bool {
groups := parseCSVStrings(csv)
if len(groups) == 0 {
return true
}
current := strings.TrimSpace(c.Group)
for _, g := range groups {
if g == "" {
if current == "" {
return true
}
continue
}
if strings.EqualFold(g, current) {
return true
}
}
return false
}
func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
if bucket == "" {
return true
}
used := int64(0)
if c.Traffic != nil {
used = c.Traffic.Up + c.Traffic.Down
}
exhausted := c.TotalGB > 0 && used >= c.TotalGB
expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
switch bucket {
case "online":
if onlineSet == nil {
return false
}
_, ok := onlineSet[c.Email]
return ok && c.Enable
case "depleted":
return exhausted || expired
case "deactive":
return !c.Enable
case "active":
return c.Enable && !exhausted && !expired
case "expiring":
if !c.Enable || exhausted || expired {
return false
}
nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
return nearExpiry || nearLimit
}
return true
}
func sortClients(rows []ClientWithAttachments, sortKey, order string) {
if sortKey == "" {
return
}
desc := order == "descend"
less := func(i, j int) bool {
a, b := rows[i], rows[j]
switch sortKey {
case "enable":
if a.Enable == b.Enable {
return false
}
return !a.Enable && b.Enable
case "email":
return strings.ToLower(a.Email) < strings.ToLower(b.Email)
case "inboundIds":
return len(a.InboundIds) < len(b.InboundIds)
case "traffic":
ua := int64(0)
if a.Traffic != nil {
ua = a.Traffic.Up + a.Traffic.Down
}
ub := int64(0)
if b.Traffic != nil {
ub = b.Traffic.Up + b.Traffic.Down
}
return ua < ub
case "remaining":
ra := int64(1<<62 - 1)
if a.TotalGB > 0 {
used := int64(0)
if a.Traffic != nil {
used = a.Traffic.Up + a.Traffic.Down
}
ra = a.TotalGB - used
}
rb := int64(1<<62 - 1)
if b.TotalGB > 0 {
used := int64(0)
if b.Traffic != nil {
used = b.Traffic.Up + b.Traffic.Down
}
rb = b.TotalGB - used
}
return ra < rb
case "expiryTime":
ea := int64(1<<62 - 1)
if a.ExpiryTime > 0 {
ea = a.ExpiryTime
}
eb := int64(1<<62 - 1)
if b.ExpiryTime > 0 {
eb = b.ExpiryTime
}
return ea < eb
case "createdAt":
if a.CreatedAt == b.CreatedAt {
return a.Id < b.Id
}
return a.CreatedAt < b.CreatedAt
case "updatedAt":
if a.UpdatedAt == b.UpdatedAt {
return a.Id < b.Id
}
return a.UpdatedAt < b.UpdatedAt
case "lastOnline":
la := int64(0)
if a.Traffic != nil {
la = a.Traffic.LastOnline
}
lb := int64(0)
if b.Traffic != nil {
lb = b.Traffic.LastOnline
}
if la == lb {
return a.Id < b.Id
}
return la < lb
}
return false
}
sort.SliceStable(rows, func(i, j int) bool {
if desc {
return less(j, i)
}
return less(i, j)
})
}
+624
View File
@@ -0,0 +1,624 @@
package service
import (
"slices"
"strconv"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
const (
pagingDay = int64(86400000)
pagingGB = int64(1) << 30
)
type pagingSeed struct {
email string
enable bool
totalGB int64
expiryTime int64
used int64
subID string
uuid string
password string
auth string
comment string
group string
tgID int64
reset int
lastOnline int64
inbounds []int
}
// seedPagingClients writes one vless and one trojan inbound plus a fixed client
// set covering every bucket, sort key and search field ListPaged supports.
// Returns "now" so the expectations can be phrased relative to it.
func seedPagingClients(t *testing.T) (int64, []pagingSeed) {
t.Helper()
db := database.GetDB()
now := time.Now().UnixMilli()
vless := &model.Inbound{UserId: 1, Tag: "in-vless", Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
trojan := &model.Inbound{UserId: 1, Tag: "in-trojan", Enable: true, Port: 40002, Protocol: model.Trojan, Settings: `{"clients":[]}`}
for _, ib := range []*model.Inbound{vless, trojan} {
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound %s: %v", ib.Tag, err)
}
}
seeds := []pagingSeed{
{email: "alpha@x", enable: true, totalGB: 0, expiryTime: 0, used: 5 * pagingGB, subID: "sub-alpha", uuid: "uuid-alpha", inbounds: []int{vless.Id}, lastOnline: now - 10*pagingDay},
{email: "bravo@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: pagingGB, password: "pw-bravo", inbounds: []int{vless.Id, trojan.Id}, lastOnline: now - pagingDay},
{email: "charlie@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10 * pagingGB, auth: "auth-charlie", inbounds: []int{trojan.Id}},
{email: "delta@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, used: pagingGB, inbounds: []int{vless.Id}},
{email: "echo@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, inbounds: []int{vless.Id}},
{email: "foxtrot@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, inbounds: []int{trojan.Id}},
{email: "golf@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 2*pagingDay, inbounds: []int{vless.Id}},
{email: "hotel@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10*pagingGB - pagingGB/2, inbounds: []int{vless.Id}},
{email: "india@x", enable: true, totalGB: 0, expiryTime: -5 * pagingDay, inbounds: []int{vless.Id}},
{email: "juliet@x", enable: true, comment: " vip customer ", group: "VIP", tgID: 555, reset: 7, inbounds: []int{vless.Id}},
{email: "kilo_1@x", enable: true, group: "vip", inbounds: nil},
{email: "kilo1@x", enable: true, inbounds: []int{trojan.Id}},
}
for i, s := range seeds {
rec := model.ClientRecord{
Email: s.email,
SubID: s.subID,
UUID: s.uuid,
Password: s.password,
Auth: s.auth,
Comment: s.comment,
Group: s.group,
TgID: s.tgID,
Reset: s.reset,
Enable: s.enable,
TotalGB: s.totalGB,
ExpiryTime: s.expiryTime,
CreatedAt: now - int64(len(seeds)-i)*pagingDay,
UpdatedAt: now - int64(i)*pagingDay,
}
if err := db.Create(&rec).Error; err != nil {
t.Fatalf("create client %s: %v", s.email, err)
}
if !s.enable {
// clients.enable carries a `default:true` tag, so GORM leaves the
// zero value out of the INSERT and the column comes back true.
// Restate updated_at so the autoUpdateTime hook cannot reshuffle
// the sort fixtures.
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).
Updates(map[string]any{"enable": false, "updated_at": rec.UpdatedAt}).Error; err != nil {
t.Fatalf("disable %s: %v", s.email, err)
}
}
traffic := xray.ClientTraffic{
Email: s.email,
Enable: s.enable,
Up: s.used / 2,
Down: s.used - s.used/2,
Total: s.totalGB,
ExpiryTime: s.expiryTime,
LastOnline: s.lastOnline,
}
if err := db.Create(&traffic).Error; err != nil {
t.Fatalf("create traffic %s: %v", s.email, err)
}
for _, id := range s.inbounds {
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: id}).Error; err != nil {
t.Fatalf("attach %s to %d: %v", s.email, id, err)
}
}
}
return now, seeds
}
func pagedEmails(items []ClientSlim) []string {
out := make([]string, 0, len(items))
for _, it := range items {
out = append(out, it.Email)
}
return out
}
func setupPagingServices(t *testing.T) (*ClientService, *InboundService, *SettingService) {
t.Helper()
setupBulkDB(t)
settingSvc := &SettingService{}
if err := settingSvc.setInt("expireDiff", 3); err != nil {
t.Fatalf("set expireDiff: %v", err)
}
if err := settingSvc.setInt("trafficDiff", 1); err != nil {
t.Fatalf("set trafficDiff: %v", err)
}
return &ClientService{}, &InboundService{}, settingSvc
}
func TestListPagedFilters(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
now, _ := seedPagingClients(t)
tests := []struct {
name string
params ClientPageParams
want []string
}{
{
name: "no filter returns every client in id order",
params: ClientPageParams{PageSize: 50},
want: []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
},
{
name: "depleted bucket covers quota and expiry",
params: ClientPageParams{PageSize: 50, Filter: "depleted"},
want: []string{"charlie@x", "delta@x", "foxtrot@x"},
},
{
name: "deactive bucket is every disabled client",
params: ClientPageParams{PageSize: 50, Filter: "deactive"},
want: []string{"echo@x", "foxtrot@x"},
},
{
name: "expiring bucket covers near expiry and near quota",
params: ClientPageParams{PageSize: 50, Filter: "expiring"},
want: []string{"golf@x", "hotel@x"},
},
{
name: "active bucket keeps enabled clients that still have room",
params: ClientPageParams{PageSize: 50, Filter: "active"},
want: []string{"alpha@x", "bravo@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
},
{
name: "buckets are ORed",
params: ClientPageParams{PageSize: 50, Filter: "depleted,expiring"},
want: []string{"charlie@x", "delta@x", "foxtrot@x", "golf@x", "hotel@x"},
},
{
name: "unknown bucket keeps matching everything",
params: ClientPageParams{PageSize: 50, Filter: "nonsense"},
want: []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
},
{
name: "protocol filter follows the attachments",
params: ClientPageParams{PageSize: 50, Protocol: "trojan"},
want: []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"},
},
{
name: "inbound filter follows the attachments",
params: ClientPageParams{PageSize: 50, Inbound: "2"},
want: []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"},
},
{
name: "search matches the email",
params: ClientPageParams{PageSize: 50, Search: "KILO"},
want: []string{"kilo_1@x", "kilo1@x"},
},
{
name: "search treats LIKE wildcards literally",
params: ClientPageParams{PageSize: 50, Search: "kilo_1"},
want: []string{"kilo_1@x"},
},
{
name: "search matches the subId",
params: ClientPageParams{PageSize: 50, Search: "sub-alpha"},
want: []string{"alpha@x"},
},
{
name: "search matches the uuid",
params: ClientPageParams{PageSize: 50, Search: "uuid-alpha"},
want: []string{"alpha@x"},
},
{
name: "search matches the password",
params: ClientPageParams{PageSize: 50, Search: "pw-bravo"},
want: []string{"bravo@x"},
},
{
name: "search matches the auth",
params: ClientPageParams{PageSize: 50, Search: "auth-charlie"},
want: []string{"charlie@x"},
},
{
name: "search matches the comment",
params: ClientPageParams{PageSize: 50, Search: "vip customer"},
want: []string{"juliet@x"},
},
{
name: "search matches the telegram id",
params: ClientPageParams{PageSize: 50, Search: "555"},
want: []string{"juliet@x"},
},
{
name: "group filter is case insensitive",
params: ClientPageParams{PageSize: 50, Group: "vip"},
want: []string{"juliet@x", "kilo_1@x"},
},
{
name: "hasComment yes",
params: ClientPageParams{PageSize: 50, HasComment: "yes"},
want: []string{"juliet@x"},
},
{
name: "hasTgId yes",
params: ClientPageParams{PageSize: 50, HasTgID: "yes"},
want: []string{"juliet@x"},
},
{
name: "autoRenew on",
params: ClientPageParams{PageSize: 50, AutoRenew: "on"},
want: []string{"juliet@x"},
},
{
name: "usage range is inclusive on both bounds",
params: ClientPageParams{PageSize: 50, UsageFrom: pagingGB, UsageTo: 5 * pagingGB},
want: []string{"alpha@x", "bravo@x", "delta@x"},
},
{
name: "expiry range excludes never and delayed start",
params: ClientPageParams{PageSize: 50, ExpiryFrom: now, ExpiryTo: now + 10*pagingDay},
want: []string{"golf@x"},
},
{
name: "filters combine with AND",
params: ClientPageParams{PageSize: 50, Filter: "depleted", Protocol: "trojan"},
want: []string{"charlie@x", "foxtrot@x"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
resp, err := svc.ListPaged(inboundSvc, settingSvc, tc.params)
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
got := pagedEmails(resp.Items)
if !slices.Equal(got, tc.want) {
t.Fatalf("emails = %v, want %v", got, tc.want)
}
if resp.Filtered != len(tc.want) {
t.Fatalf("filtered = %d, want %d", resp.Filtered, len(tc.want))
}
if resp.Total != 12 {
t.Fatalf("total = %d, want 12", resp.Total)
}
})
}
}
func TestListPagedSorting(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
seedPagingClients(t)
tests := []struct {
name string
sort string
order string
want []string
}{
{
name: "no sort key keeps insertion order",
want: []string{"alpha@x", "bravo@x", "charlie@x"},
},
{
name: "email ascending", sort: "email", order: "ascend",
want: []string{"alpha@x", "bravo@x", "charlie@x"},
},
{
name: "email descending", sort: "email", order: "descend",
want: []string{"kilo_1@x", "kilo1@x", "juliet@x"},
},
{
name: "traffic descending", sort: "traffic", order: "descend",
want: []string{"charlie@x", "hotel@x", "alpha@x"},
},
{
name: "remaining descending puts unlimited quotas first", sort: "remaining", order: "descend",
want: []string{"alpha@x", "india@x", "juliet@x"},
},
{
name: "expiry ascending starts with the expired", sort: "expiryTime", order: "ascend",
want: []string{"delta@x", "foxtrot@x", "golf@x"},
},
{
name: "createdAt ascending follows insertion", sort: "createdAt", order: "ascend",
want: []string{"alpha@x", "bravo@x", "charlie@x"},
},
{
name: "updatedAt descending starts with the newest", sort: "updatedAt", order: "descend",
want: []string{"alpha@x", "bravo@x", "charlie@x"},
},
{
name: "lastOnline descending breaks ties on the id, reversed too", sort: "lastOnline", order: "descend",
want: []string{"bravo@x", "alpha@x", "kilo1@x"},
},
{
name: "enable ascending puts disabled first", sort: "enable", order: "ascend",
want: []string{"echo@x", "foxtrot@x", "alpha@x"},
},
{
name: "inboundIds descending puts the widest attachment first", sort: "inboundIds", order: "descend",
want: []string{"bravo@x", "alpha@x", "charlie@x"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 3, Sort: tc.sort, Order: tc.order})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
got := pagedEmails(resp.Items)
if !slices.Equal(got, tc.want) {
t.Fatalf("emails = %v, want %v", got, tc.want)
}
})
}
}
func TestListPagedPagination(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
seedPagingClients(t)
t.Run("second page continues where the first stopped", func(t *testing.T) {
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 2, PageSize: 5, Sort: "email", Order: "ascend"})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
want := []string{"foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x"}
if got := pagedEmails(resp.Items); !slices.Equal(got, want) {
t.Fatalf("emails = %v, want %v", got, want)
}
if resp.Page != 2 || resp.PageSize != 5 {
t.Fatalf("page/pageSize = %d/%d, want 2/5", resp.Page, resp.PageSize)
}
})
t.Run("page past the end is empty but keeps the counts", func(t *testing.T) {
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 9, PageSize: 5})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
if len(resp.Items) != 0 {
t.Fatalf("items = %v, want none", pagedEmails(resp.Items))
}
if resp.Filtered != 12 || resp.Total != 12 {
t.Fatalf("filtered/total = %d/%d, want 12/12", resp.Filtered, resp.Total)
}
})
t.Run("page size is clamped to the maximum", func(t *testing.T) {
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 5000})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
if resp.PageSize != clientPageMaxSize {
t.Fatalf("pageSize = %d, want %d", resp.PageSize, clientPageMaxSize)
}
})
}
func TestListPagedRowContents(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
seedPagingClients(t)
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
byEmail := make(map[string]ClientSlim, len(resp.Items))
for _, it := range resp.Items {
byEmail[it.Email] = it
}
t.Run("attachments are reported in inbound order", func(t *testing.T) {
got := byEmail["bravo@x"].InboundIds
if !slices.Equal(got, []int{1, 2}) {
t.Fatalf("inboundIds = %v, want [1 2]", got)
}
})
t.Run("an unattached client reports no inbounds", func(t *testing.T) {
if got := byEmail["kilo_1@x"].InboundIds; len(got) != 0 {
t.Fatalf("inboundIds = %v, want none", got)
}
})
t.Run("traffic counters ride along with the row", func(t *testing.T) {
got := byEmail["hotel@x"].Traffic
if got == nil {
t.Fatal("traffic = nil, want the seeded counters")
}
if want := 10*pagingGB - pagingGB/2; got.Up+got.Down != want {
t.Fatalf("used = %d, want %d", got.Up+got.Down, want)
}
})
t.Run("groups list every name in use", func(t *testing.T) {
if !slices.Equal(resp.Groups, []string{"vip", "VIP"}) && !slices.Equal(resp.Groups, []string{"VIP", "vip"}) {
t.Fatalf("groups = %v, want VIP and vip", resp.Groups)
}
})
}
func TestListPagedSummary(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
seedPagingClients(t)
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 5, Filter: "depleted"})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
s := resp.Summary
t.Run("counts stay whole-panel while the page is filtered", func(t *testing.T) {
if s.Total != 12 {
t.Fatalf("total = %d, want 12", s.Total)
}
if s.DepletedCount != 3 {
t.Fatalf("depletedCount = %d, want 3", s.DepletedCount)
}
if s.ExpiringCount != 2 {
t.Fatalf("expiringCount = %d, want 2", s.ExpiringCount)
}
if s.DeactiveCount != 1 {
t.Fatalf("deactiveCount = %d, want 1", s.DeactiveCount)
}
if s.Active != 6 {
t.Fatalf("active = %d, want 6", s.Active)
}
})
t.Run("every client lands in exactly one counter", func(t *testing.T) {
if sum := s.Active + s.DepletedCount + s.ExpiringCount + s.DeactiveCount; sum != s.Total {
t.Fatalf("buckets sum to %d, want %d", sum, s.Total)
}
})
t.Run("bucket lists carry the matching emails", func(t *testing.T) {
if want := []string{"charlie@x", "delta@x", "foxtrot@x"}; !slices.Equal(s.Depleted, want) {
t.Fatalf("depleted = %v, want %v", s.Depleted, want)
}
if want := []string{"golf@x", "hotel@x"}; !slices.Equal(s.Expiring, want) {
t.Fatalf("expiring = %v, want %v", s.Expiring, want)
}
if want := []string{"echo@x"}; !slices.Equal(s.Deactive, want) {
t.Fatalf("deactive = %v, want %v", s.Deactive, want)
}
})
}
func TestListPagedSummaryEmailListsAreCapped(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
db := database.GetDB()
const n = clientSummaryEmailCap + 25
past := time.Now().UnixMilli() - pagingDay
for i := range n {
rec := model.ClientRecord{Email: "bulk-" + strconv.Itoa(i) + "@x", Enable: true, TotalGB: pagingGB, ExpiryTime: past}
if err := db.Create(&rec).Error; err != nil {
t.Fatalf("create client %d: %v", i, err)
}
}
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 25})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
if resp.Summary.DepletedCount != n {
t.Fatalf("depletedCount = %d, want %d", resp.Summary.DepletedCount, n)
}
if len(resp.Summary.Depleted) != clientSummaryEmailCap {
t.Fatalf("depleted list = %d entries, want %d", len(resp.Summary.Depleted), clientSummaryEmailCap)
}
}
func TestListPagedGlobalTrafficOverlay(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
seedPagingClients(t)
// bravo has used 1GB of its 10GB locally; a master reporting 10GB of
// cross-panel usage has to move it into the depleted bucket.
if err := inboundSvc.AcceptGlobalTraffic("master-guid", []*xray.ClientTraffic{
{Email: "bravo@x", Up: 4 * pagingGB, Down: 6 * pagingGB},
}); err != nil {
t.Fatalf("AcceptGlobalTraffic: %v", err)
}
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50, Filter: "depleted"})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
want := []string{"bravo@x", "charlie@x", "delta@x", "foxtrot@x"}
if got := pagedEmails(resp.Items); !slices.Equal(got, want) {
t.Fatalf("depleted = %v, want %v", got, want)
}
if resp.Summary.DepletedCount != 4 {
t.Fatalf("depletedCount = %d, want 4", resp.Summary.DepletedCount)
}
for _, it := range resp.Items {
if it.Email != "bravo@x" {
continue
}
if it.Traffic == nil || it.Traffic.Up+it.Traffic.Down != 10*pagingGB {
t.Fatalf("bravo traffic = %+v, want the overlaid 10GB", it.Traffic)
}
}
}
func TestClientQueryOnlineEmails(t *testing.T) {
_, _, _ = setupPagingServices(t)
seedPagingClients(t)
q := newClientQuery(database.GetDB(), time.Now().UnixMilli(), 0, 0)
emails, count, err := q.onlineEmails([]string{"alpha@x", "echo@x", "ghost@x", "kilo1@x"})
if err != nil {
t.Fatalf("onlineEmails: %v", err)
}
if want := []string{"alpha@x", "kilo1@x"}; !slices.Equal(emails, want) {
t.Fatalf("online = %v, want %v (disabled and unknown emails drop out)", emails, want)
}
if count != 2 {
t.Fatalf("count = %d, want 2", count)
}
}
func TestEmailInCondChunksLargeSets(t *testing.T) {
emails := make([]string, sqlInChunk+1)
for i := range emails {
emails[i] = "e" + strconv.Itoa(i)
}
cond, args := emailInCond("c.email", emails)
if want := "(c.email IN ? OR c.email IN ?)"; cond != want {
t.Fatalf("cond = %q, want %q", cond, want)
}
if len(args) != 2 {
t.Fatalf("args = %d chunks, want 2", len(args))
}
if first, ok := args[0].([]string); !ok || len(first) != sqlInChunk {
t.Fatalf("first chunk = %v, want %d entries", args[0], sqlInChunk)
}
emptyCond, emptyArgs := emailInCond("c.email", nil)
if emptyCond != "1 = 0" || emptyArgs != nil {
t.Fatalf("empty set = %q/%v, want an always-false predicate", emptyCond, emptyArgs)
}
}
func TestEscapeLikeLiteral(t *testing.T) {
tests := []struct {
in string
want string
}{
{"plain", "plain"},
{"a_b", `a\_b`},
{"50%", `50\%`},
{`back\slash`, `back\\slash`},
}
for _, tc := range tests {
if got := escapeLikeLiteral(tc.in); got != tc.want {
t.Fatalf("escapeLikeLiteral(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestListPagedEmptyPanel(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{})
if err != nil {
t.Fatalf("ListPaged on a panel with no clients: %v", err)
}
if len(resp.Items) != 0 || resp.Total != 0 || resp.Filtered != 0 {
t.Fatalf("items/total/filtered = %d/%d/%d, want 0/0/0", len(resp.Items), resp.Total, resp.Filtered)
}
if resp.Summary.Active != 0 || resp.Summary.DepletedCount != 0 {
t.Fatalf("summary = %+v, want zeroed counters", resp.Summary)
}
if resp.Groups == nil {
t.Fatal("groups = nil, want an empty list so the filter drawer renders")
}
}
@@ -8,6 +8,7 @@ import (
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"maps"
"math/big"
"os"
"path/filepath"
@@ -256,9 +257,7 @@ func TestGoldenStreamFixturesBuildInXray(t *testing.T) {
case "stream":
stream = fixture
case "security":
for key, value := range fixture {
stream[key] = value
}
maps.Copy(stream, fixture)
case "sockopt":
stream["sockopt"] = fixture
case "finalmask":
+4 -4
View File
@@ -19,7 +19,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
now := time.Now().Unix() * 1000
needRestart := false
if p != nil {
if process := currentXrayProcess(); process != nil {
var tags []string
err := tx.Table("inbounds").
Select("inbounds.tag").
@@ -28,7 +28,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
if err != nil {
return false, 0, err
}
_ = s.xrayApi.Init(p.GetAPIPort())
_ = s.xrayApi.Init(process.GetAPIPort())
for _, tag := range tags {
err1 := s.xrayApi.DelInbound(tag)
if err1 == nil {
@@ -153,8 +153,8 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
}
}
if p != nil && len(localTargets) > 0 {
_ = s.xrayApi.Init(p.GetAPIPort())
if process := currentXrayProcess(); process != nil && len(localTargets) > 0 {
_ = s.xrayApi.Init(process.GetAPIPort())
for _, t := range localTargets {
err1 := s.xrayApi.RemoveUser(t.Tag, t.Email)
if err1 == nil {
+18 -15
View File
@@ -1036,7 +1036,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
}
}
if p != nil {
if process := currentXrayProcess(); process != nil {
tree := snap.OnlineTree
switch {
case len(tree) == 0 && len(snap.OnlineEmails) > 0:
@@ -1058,17 +1058,18 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
tree = remapped
}
}
p.SetNodeOnlineTree(nodeID, tree)
process.SetNodeOnlineTree(nodeID, tree)
}
return structuralChange, nil
}
func (s *InboundService) GetOnlineClients() []string {
if p == nil {
process := currentXrayProcess()
if process == nil {
return []string{}
}
return p.GetOnlineClients()
return process.GetOnlineClients()
}
// GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
@@ -1077,11 +1078,12 @@ func (s *InboundService) GetOnlineClients() []string {
// node-id keying so a client three hops down is attributed to its real node,
// not the intermediate one it was synced through.
func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
if p == nil {
process := currentXrayProcess()
if process == nil {
return map[string][]string{}
}
out := p.GetMergedNodeTrees()
if local := p.GetLocalOnlineClients(); len(local) > 0 {
out := process.GetMergedNodeTrees()
if local := process.GetLocalOnlineClients(); len(local) > 0 {
if guid := s.panelGuid(); guid != "" {
out[guid] = mergeEmails(out[guid], local)
}
@@ -1094,10 +1096,11 @@ func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
// report per-inbound activity, so a GUID missing from the map means "don't
// gate" for that node's inbounds.
func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
if p == nil {
process := currentXrayProcess()
if process == nil {
return map[string][]string{}
}
active := p.GetLocalActiveInbounds()
active := process.GetLocalActiveInbounds()
if len(active) == 0 {
return map[string][]string{}
}
@@ -1109,14 +1112,14 @@ func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
}
func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
if p != nil {
p.SetNodeOnlineTree(nodeID, tree)
if process := currentXrayProcess(); process != nil {
process.SetNodeOnlineTree(nodeID, tree)
}
}
func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
if p != nil {
p.ClearNodeOnlineClients(nodeID)
if process := currentXrayProcess(); process != nil {
process.ClearNodeOnlineClients(nodeID)
}
}
@@ -1177,8 +1180,8 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
// xray.Process for why the local sets are kept separate from the shared
// last_online column.
func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {
if p != nil {
p.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
if process := currentXrayProcess(); process != nil {
process.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
}
}
+2 -2
View File
@@ -452,8 +452,8 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
if err = clearGlobalTraffic(tx, renewEmails...); err != nil {
return false, 0, err
}
if p != nil {
err1 = s.xrayApi.Init(p.GetAPIPort())
if process := currentXrayProcess(); process != nil {
err1 = s.xrayApi.Init(process.GetAPIPort())
if err1 != nil {
return true, int64(len(traffics)), nil
}
@@ -30,8 +30,8 @@ import (
// CheckXrayConfig's version gate.
func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
coreVersion := "Unknown"
if p != nil {
coreVersion = p.GetXrayVersion()
if process := currentXrayProcess(); process != nil {
coreVersion = process.GetXrayVersion()
}
kept := make([]any, 0, len(outbounds))
var dropped []string
+22 -20
View File
@@ -624,8 +624,8 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
status.AppStats.Mem = rtm.Sys
}
status.AppStats.Threads = uint32(runtime.NumGoroutine())
if p != nil && p.IsRunning() {
status.AppStats.Uptime = p.GetUptime()
if process := currentXrayProcess(); process != nil && process.IsRunning() {
status.AppStats.Uptime = process.GetUptime()
} else {
status.AppStats.Uptime = 0
}
@@ -668,8 +668,8 @@ func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
systemMetrics.append("tcpCount", t, float64(status.TcpCount))
systemMetrics.append("udpCount", t, float64(status.UdpCount))
online := 0
if p != nil && p.IsRunning() {
online = len(p.GetOnlineClients())
if process := currentXrayProcess(); process != nil && process.IsRunning() {
online = len(process.GetOnlineClients())
}
systemMetrics.append("online", t, float64(online))
if len(status.Loads) >= 3 {
@@ -1303,25 +1303,26 @@ func (s *ServerService) GetDb() ([]byte, error) {
if database.IsPostgres() {
return s.exportPostgresDB()
}
// Update by manually trigger a checkpoint operation
err := database.Checkpoint()
backupPath, cleanup, err := s.backupSQLite()
if err != nil {
return nil, err
}
// Open the file for reading
file, err := os.Open(config.GetDBPath())
if err != nil {
return nil, err
}
defer file.Close()
defer cleanup()
return os.ReadFile(backupPath)
}
// Read the file contents
fileContents, err := io.ReadAll(file)
func (s *ServerService) backupSQLite() (string, func(), error) {
backupDir, err := os.MkdirTemp(filepath.Dir(config.GetDBPath()), ".x-ui-backup-")
if err != nil {
return nil, err
return "", nil, err
}
return fileContents, nil
cleanup := func() { _ = os.RemoveAll(backupDir) }
backupPath := filepath.Join(backupDir, "backup.db")
if err := database.BackupSQLite(backupPath); err != nil {
cleanup()
return "", nil, err
}
return backupPath, cleanup, nil
}
// BackupFilename returns the filename for a database backup, named after the
@@ -1421,11 +1422,12 @@ func (s *ServerService) GetMigration() ([]byte, string, error) {
return data, "x-ui.db", nil
}
// SQLite panel: checkpoint so the .db reflects the latest writes, then dump.
if err := database.Checkpoint(); err != nil {
backupPath, cleanup, err := s.backupSQLite()
if err != nil {
return nil, "", err
}
data, err := database.DumpSQLiteToBytes(config.GetDBPath())
defer cleanup()
data, err := database.DumpSQLiteToBytes(backupPath)
if err != nil {
return nil, "", err
}

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