Compare commits

...

82 Commits

Author SHA1 Message Date
MHSanaei 12d51d7195 perf(tests): copy a migrated template DB instead of migrating per test
Most tests opened a throwaway panel DB with database.InitDB, which runs the
full AutoMigrate + seed on an empty file every time: ~230ms, and ~850ms under
-race because GORM's reflection-heavy migration is what the detector slows
most. internal/web/service does this in ~550 of its 830 tests, so the CI race
job spent ~10 of its ~14.6 minutes re-migrating empty databases.

internal/database/dbtest.InitDB migrates once per test process, then hands
each test its own copy of that file (~130ms under -race) and registers the
CloseDB cleanup. The copy then goes through InitDB like a panel restart, so
every test still starts from the state a fresh install has. Tests that reopen
an existing file, migrate a hand-built legacy DB or target Postgres keep
calling database.InitDB.

Locally under -race: internal/web/service 626s (last CI run) -> 114s,
internal/sub 246s -> 35s.
2026-09-27 03:04:50 +02:00
mrchatam 33a469315a feat(clients): preserve traffic counters in portable export/import (#6469)
* feat(clients): preserve traffic counters in portable export/import

ExportAll now attaches client_traffics up/down (plus resetCount and
last-seen fields) on each portable payload, and ImportClients restores
them only for newly created emails so skipped/existing clients keep
their live counters. Fixes #5858.

* fix(clients): restore imported traffic only onto rows the import created

Review of the portable-traffic export/import (#5858) found four defects:

- An orphan's restored row was hand-built, dropping reset_weekday and
  forcing enable=true; a row kept by a keepTraffic delete kept the old
  client's limits. depletedClientsClause then matched a weekly-renewing
  over-quota orphan and DelDepleted deleted it. Orphan rows now go
  through AddClientStat, whose upsert refreshes config and keeps counters,
  so the unused traffic.total field is dropped from the export.
- Created clients were inferred from Skipped emails, so a duplicate email
  in the file left the created copy with zero counters. bulkCreate now
  reports which payloads inserted a record, and only those are restored.
- Each client took its own serialized-writer commit: 2000 clients spent
  3.66s instead of 0.52s. Counters now apply in batched transactions
  (0.51s).
- importClients discarded needRestart when the late restore step failed
  after clients were committed; it now flags and notifies first, as
  create already does.

The /clients/export and /clients/import API docs now describe traffic.

* fix(groups): keep imported traffic out of group totals

Group totals keep a deleted client's usage (#5675), and the portable
import restores that same usage onto the re-created client. Export,
delete, re-import therefore counted it twice in ListGroups, and a fresh
panel showed the migrated usage as consumption of its groups.

Restored counters are usage from before the import, so the import now
shifts each group's baseline up by what it restored, in the same
transaction. A group total no longer moves at import time; only traffic
consumed afterwards counts. The baseline shift reuses the #5675 helper,
now signed.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-27 02:48:40 +02:00
MHSanaei ac43b19cfa chore(ci): stop release and CodeQL runs on PRs, drop deploy smoke tests
The release matrix (7 Linux cross-builds + a CGO Windows build) ran on every
PR and on every branch push, so a PR from a repo branch built everything
twice. Release binaries now build only on main (dev channel) and version
tags; any other branch can still be built via workflow_dispatch.

CodeQL keeps its push-to-main and weekly scans but no longer runs per PR.

The deploy smoke workflow fired on every Release completion only to skip
its jobs; deploy/test/smoke-noninteractive.sh stays for manual runs.
2026-09-27 02:44:14 +02:00
DIMFLIX 0ef94b686e feat(tgbot): add /broadcast to relay an admin message to all clients (#6510)
* feat(tgbot): add /broadcast to relay an admin message to all clients

Admins had no way to reach every client at once: notifications only
cover exhausted quotas, so an operator had to copy a message to each
client chat by hand. Add an admin-only /broadcast flow to the bot:

- /broadcast asks for a message; any message the admin sends — text,
  rich text, photo, video, file, sticker or a whole album — becomes the
  broadcast by reference (admin chat + message ids), and a preview
  self-copy shows the admin exactly what recipients will get while
  rejecting content Telegram cannot copy before the run starts.
- The draft references the original instead of parsing its content, so
  copyMessage/copyMessages deliver everything 1:1 on behalf of the bot
  with no forward header (the admin's identity stays private), no
  caption length pitfalls, and future Telegram message types work
  without new parsing.
- A media group arrives as separate updates; its ids are buffered with
  a short debounce, sorted, and delivered as one copyMessages call so
  recipients see the original album.
- Delivery runs in a background goroutine (common.GoRecover): sequential
  sends with a small pause, 429 retry_after honored per recipient,
  failures counted without stopping the run, progress edited into one
  card at most every 25 sends or 3 seconds, a cancel button checked
  between sends, and a final delivered/failed/skipped summary. The
  summary is edited into the card (only sent separately if the card is
  gone), so it is never duplicated.
- Recipients repeat the notifyExhausted walk: clients with a linked
  tg_id, deduplicated, admins excluded — they already receive the
  reports. The message content is never logged.

New i18n keys are added to all 13 locales.

* fix(tgbot): harden broadcast composition per review

- Key the composition per admin chat instead of one process-wide draft:
  two admins can now compose at once without dropping each other's
  drafts, and one admin's /broadcast no longer wipes another chat's
  half-collected album.
- Bind each preview card to its own draft via a random token carried in
  the confirm callback, so a stale Send tap is answered with an error
  instead of delivering a newer, unapproved draft.
- Ignore non-admin senders while a chat composes: the awaiting state is
  keyed by chat id, and in a group that chat is shared.
- Check the cancel flag inside the flood-control retry loop, so a 429
  with a long retry_after no longer holds the single broadcast slot
  after the admin cancelled.
- Scale the per-recipient pause by the copied batch size, so an album
  keeps the same per-second ceiling as a single message.
- Trim the comment blocks that exceeded the two-line cap.

* fix(tgbot): reset broadcast state on stop and classify 403 as skipped

- Clear compositions and cancel the active run from StopBot, next to the
  per-chat draft resets: an album debounce timer, a confirmable token or
  a held runner slot must not outlive the receiver that created them.
- Sleep flood-control waits in 5 s slices and re-check cancel and bot
  state between them, so a minutes-long retry_after no longer parks the
  single-runner slot after the admin cancelled or the bot stopped.
- Count Telegram 403 (the chat never started the bot, or blocked it) as
  skipped instead of failed, log it at debug rather than one warning per
  recipient, and append one line to the summary naming the reason.
- Trim the remaining comment blocks over the two-line cap.

* fix(tgbot): count unreachable recipients in broadcast progress throttle

The progress card refresh was keyed on sent+failed, which a 403 does not
advance since unreachable chats were split out of the failure count. A
streak of unreachable recipients while that sum sat on a multiple of
broadcastProgressEvery (0 included, so from the very first recipient)
edited the card once per chat, doubling the request rate the send delay
is sized for and defeating the throttle. Count processed recipients.

* fix(tgbot): key broadcast compositions by admin, not chat

After #6604 moved conversation state to the admin (chatUser), the
broadcast draft map stayed keyed by chat. Two admins composing in one
group then shared a slot: the second admin's message dropped the first
admin's draft, whose Send tap answered "went wrong" while only the other
draft could go out - the same class #6604 fixed for the add-client
wizard. Drafts, album buffers and confirm tokens now live under the
admin who ran /broadcast.

The router now hands handleBroadcastInput only the admin whose own
/broadcast is awaiting input, so its sender re-check and the test that
fed it a non-admin message directly (an input no route can deliver)
are removed.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-09-27 01:43:32 +02:00
DIMFLIX 71e38367c1 feat(sub): add Incy app-management parameters (#6650)
* feat(sub): add Incy app-management parameters

The panel already pushes a set of Happ headers, but INCY documents its own
lowercase header names and its own value domains, so a Happ-shaped payload gets
ignored by the client (per-app mode is bypass|proxy, not on|bypass, and
per-app-proxy-enable has no Happ counterpart at all). Add a sibling Incy path
that emits exactly the documented headers.

Covered, per https://docs.incy.cc/en/app-management/:
- profile-description, sort-order, support-email, announce-url, premium-url
- banner text/button/URL and the two hex colours
- hide-url, hide-check, no-limit-enabled
- per-app split tunnelling (enable/mode/list)
- TCP fragmentation (enable/length/interval/packets)
- UDP noise packets (enable/type/packet/delay)
- DoH pre-resolution (enable/domain/IP)

Each string setting is tri-state: an empty value omits the header, so an
untouched panel never overrides the subscriber's own choice in the app. Values
are validated against the documented domains and dropped when they do not
match, and non-ASCII text is base64-wrapped the way the docs require for
Cyrillic. INCY identifies itself as INCY/<version>/<platform>, which gates the
headers behind the same auto-detect switch the Happ path uses.

Headers the panel already emits for every client (Profile-Title, Support-Url,
Profile-Web-Page-Url, Announce, Profile-Update-Interval, Subscription-Userinfo)
and Incy's routing line are left as they are.

The Premium API (theme, defaultPingProtocol, fallbackHosts, ...) is a separate
encrypted endpoint and stays out of scope here.

* fix(sub): keep Incy per-app list entries separate on the wire

The Incy settings textarea takes one package per line, as Incy documents for
per-app-proxy-list, but the header path ran the value through
sanitizeHeaderValue, which deletes CR/LF. "com.google.chrome\norg.telegram.messenger"
reached the client as the single bogus package
"com.google.chromeorg.telegram.messenger", so per-app split tunnelling silently
matched no app. Join comma- or line-separated entries as CSV instead.

Also drop three tests that could not fail: TestIncyExcludesHappOnlyHeaders
(ApplyIncyHeaders has no path that emits Happ headers, and the non-Happ UA
gate is already pinned by TestApplyHappHeaders_Gating) and two UI tests that
only asserted updateSetting received the key the JSX passes it.

---------

Co-authored-by: DIMFLIX <dimflix@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-09-27 01:06:17 +02:00
mrchatam bd9ccde1f4 feat(sub): make external subscription fetch User-Agent configurable (#6613)
* feat(sub): make external subscription fetch User-Agent configurable

Some providers reject fetches that do not send a known client User-Agent.
Expose externalSubUserAgent as a panel setting (default v2rayNG/1.8.5)
and use it when fetching client external subscription URLs.

Fixes #6383

* ci: retrigger frontend after npm registry maintenance

The frontend job failed solely on `npm audit` while registry.npmjs.org
returned 503 (Service Under Maintenance). Lint, typecheck, vitest, vite
build, and storybook all passed. Local `npm audit --omit=dev
--audit-level=high` now reports 0 vulnerabilities.

* fix(sub): fall back to the default UA when the DB is not initialised

externalSubUserAgent read the setting through SettingService.getSetting,
which calls Model() on database.GetDB() and panics on a nil *gorm.DB.
The fetch path's other DB read, service.ExternalSubscriptionHwid, already
treats a nil DB as unreachable and sends no header; the new UA lookup
did not, so any fetch before InitDB panicked instead of sending the
historical v2rayNG/1.8.5.

Production initialises the DB before the sub server starts, but the
internal/sub fetch tests run without one: under make test-go's
-shuffle=on, whenever one of them ran before the first InitDB test the
panic aborted the whole package. Reproduced deterministically with
go test -run '^TestDoFetchSubscriptionLinks_RejectsOversizedBody$'.

---------

Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-09-26 23:36:33 +02:00
Jack 9672249edb feat(clients): add calendar weekly renewal and schedule previews (#6524)
* feat(clients): add calendar weekly renewal and schedule previews

Expose fixed-day, calendar-weekly, calendar-monthly, and disabled renewal
through one shared selector in individual and bulk client forms. Store the
weekly weekday separately (Monday 1 through Sunday 7) and use panel-local
calendar dates rather than a fixed 168-hour duration. Resolve skipped or
repeated midnights to the first valid instant of the selected date, and skip
an entirely nonexistent calendar date rather than changing the weekday.

Reuse the existing renewal writer and share its boundary alignment and
per-period catch-up calculation with an authenticated, read-only preview.
Keep monthly precedence for legacy records, fixed-day interval semantics,
maximum renewal allowances, first-use durations, and operator-disabled
settings unchanged. Selecting a mode does not rewrite an existing cutoff;
an unset calendar cutoff requires an explicit action to choose the first.
The last-valid-second preview uses the stored exclusive expiry, even when
the billing calculation aligns a legacy last-second cutoff up to midnight.

Carry weekly schedules through client persistence, paging, enable toggles,
inbound settings, and node traffic reconciliation. Migrate missing or nullable
weekday columns to disabled by default without altering existing limits, and
include the new isolated-schema PostgreSQL regression in the live CI gate.

Regenerate API contracts and reference documentation, add lifecycle and form
regressions, and document timezone, quota-reset, and upgrade considerations.
All participating nodes must be upgraded before weekly mode is enabled;
older binaries ignore the new field. Independent periodic traffic resets and
the optional month-end subscription-header display are not changed.

* fix(clients): validate renewal schedules across inbound write paths

Reject conflicting weekly/interval/monthly schedules and out-of-range
weekdays on inbound creation and edits, legacy one-client apply paths,
record/link synchronization, and traffic metadata writes. Validate imported
traffic snapshots as well, before any inbound or client is persisted, so
an inbound API cannot create a client that the clients page cannot toggle.

Merge a weekly-related schedule as one timestamp-selected tuple rather
than filling its zero fields from another renewal mode. Preserve empty
migration snapshots and the existing non-weekly monthly/interval merge
semantics. Renewal caps, counters, credentials, and deadlines are unchanged.

Add regressions for nine write paths, unchanged records and runtime calls
after rejection, valid inbound clients remaining editable, and duplicate
record merges between individually valid renewal modes.

* docs(clients): clarify depleted-client deletion risks on downgrade

Explain in English and Chinese that older versions not only stop weekly
renewal: their depleted-client cleanup can delete a weekly-only client once
its expiry or quota is exhausted. This is conditional on cleanup, not an
automatic deletion caused by downgrade itself.

Recommend backing up and converting weekly schedules to a mode supported
by every participating version before rollback, and avoiding cleanup while
mixed versions or unconverted clients remain. Merely disabling weekly
renewal does not restore the old binary's missing purge protection.

* fix(clients): bound weekly renewal date searches

Limit the search for a valid weekly calendar date to eight candidates so
an unusual timezone cannot monopolize the single traffic writer. Exhaustion
returns the original instant, allowing the existing catch-up forward-progress
guard to stop without advancing expiry, consuming an allowance, resetting
traffic, or falling back to a fixed-duration schedule that can drift.

Reject a non-future calendar suggestion in the read-only preview instead of
offering an immediately expired initial cutoff. Also report failed weekly
catch-up as a search error when allowances remain, not as cap exhaustion.
Existing preview errors use the form's current warning; no API schema or
locale changes are needed.

Exercise exhaustion with a synthetic valid TZif containing twelve skipped
Sundays. This fault-injection case was red without the bound; it is not a
claim that a production IANA timezone was observed hanging. Keep the Havana
and Apia regressions for real skipped/repeated midnights and absent dates.

* fix(tests): isolate weekly renewal preview timezone

Stop the weekly search regression from replacing process-global time.Local.
CI caught that assignment and its cleanup racing with background timer reads
through time.Now, even though the top-level tests do not use t.Parallel.

Pass the timezone and current instant into the unchanged preview calculation.
The public service still validates the request and resolves the panel timezone;
API responses, renewal accounting, and persisted client data are unchanged.

Use fixed dates for both suggestion and catch-up exhaustion, removing the
test's dependency on today's date and its unnecessary database setup. Keep a
bounded-lifetime background clock reader to expose future global-timezone
mutations under the existing race gate rather than disabling that check.

* ci: retrigger PR checks

Create an empty commit to request a fresh pull-request CI run after release dependency downloads failed with network errors.

No source, dependency, or workflow changes are included. Retry the existing checks without bypassing them.

* ci: retry PR checks and record deferred download hardening

Request another CI run after the amd64 release job compiled successfully but failed during dependency fetching with exit code 4 (network failure).

Record possible follow-up improvements for the Linux release fetch helper:
- Print each download URL and destination, and preserve error details.
- Reuse the existing curl configuration with up to five retries; add connection and per-attempt timeouts and a bounded retry window.
- Download to a temporary file and promote it to the final filename only after a successful, non-empty transfer. Keep the job failing if downloads ultimately fail.
- Validate successful downloads, recovery after a temporary failure, and correct failure after persistent errors before shipping such a change.

These improvements are intentionally deferred, not implemented or tested by this commit. This commit is empty: renewal logic, dependencies, workflow configuration, check requirements, and TLS verification remain unchanged.

---------

Co-authored-by: JacktheRanger <219502738+JacktheRanger@users.noreply.github.com>
2026-09-26 22:59:23 +02:00
NgaiYeanCoi 5e15120cec feat(happ): add routing editor, optional ad blocking, and LAN bypass preset (#6545)
* feat(happ): make ad blocking optional in routing presets

Add an independent AdBlock toggle for Iran, China, and global presets, applied only when generating routing rules. Update the China preset to Bypass-CN and cover preset behavior and localized controls.

* feat: add visual routing editor with JSON support and localization updates

- Implemented a new modal for editing routing profiles with basic and advanced tabs.
- Added functionality to load, parse, and generate routing profiles in JSON format.
- Enhanced user experience with validation and error handling for JSON input.
- Updated translations for Russian, Turkish, Ukrainian, Vietnamese, Chinese (Simplified and Traditional) to include new routing editor terms.
- Created helper functions for managing routing profiles and generating deep links.
- Added unit tests for routing editor functionalities and JSON handling.

* feat: update routing editor to preserve null lists in profiles and enhance validation messages
2026-09-26 22:57:01 +02:00
MHSanaei 94fa317e76 chore(gen): regenerate types for addrFamily
Regenerate frontend/src/generated/types.ts and zod.ts to include the new addrFamily enum type, produced by tools/openapigen from Go struct changes.
2026-09-26 22:46:04 +02:00
Kirill Rudenko 788b76c544 fix(amneziawg): sniff the relay with routeOnly; scope the v6 egress to IPv6 (#6654)
* fix(amneziawg): sniff the relay with routeOnly

The embedded AmneziaWG relay sniffed without routeOnly, so a sniffed SNI
replaced the dial target. Telegram's FakeTLS recovery dials
194.221.250.50:443 with SNI www.google.com; the rewrite sent it to real
Google and the client looped on "TLS hash mismatch", stuck on "Connecting".

Sniffing here exists only so domain routing rules can match; routeOnly
keeps that and dials the IP the peer resolved. Fake-pool targets are
still rewritten (the dispatcher ignores routeOnly for fakedns).

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

* fix(amneziawg): send only IPv6 targets through the peer's v6 egress

The per-peer IPv6 egress rule matched every flow of that peer, but its
freedom outbound binds a v6 sendThrough and cannot dial an IPv4 target, so
IPv4 DNS and any other unsniffed IPv4 traffic of such a peer failed.
Sniffed TLS/HTTP only worked because the sniffed domain replaced the IP;
with routeOnly on the relay that no longer happens.

Limiting the rule to ::/0 keeps the peer's IPv6 identity for IPv6 targets
and lets IPv4 targets take the regular outbound.

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

---------

Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-26 22:04:08 +02:00
SERGE BLCHV 6ac0c88084 fix(xray): hot-apply Hysteria client changes without replacing the inbound (#6606)
* fix(xray): hot-apply Hysteria client changes without replacing the inbound

diffInboundUsers only allowed per-user AlterInbound ops for vless, vmess
and trojan. For a hysteria inbound every client add/remove/update became
DelInbound + AddInbound: the UDP listener was recreated and all QUIC
sessions of that inbound were lost. quic-go sends no stateless reset, so
every connected client stalled until its idle timeout (30s by default)
after each unrelated client mutation.

XrayAPI.AddUser already builds a hysteria account and Xray-core's
hysteria server implements AddUser/RemoveUser, so adding the protocol to
userDiffableProtocols is sufficient.

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

* test(xray): say which branch each drop-guard protocol takes

With hysteria in userDiffableProtocols its dropped client reaches the
guard through the per-user diff, so the test named for protocols the
diff cannot handle no longer described its hysteria case.

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-09-26 22:03:43 +02:00
Kirill Rudenko 6ab718f813 fix(sub): make Happ require auth on its local SOCKS/HTTP proxy (#6628)
Happ ships its local SOCKS5 (127.0.0.1:10808) and HTTP inbounds with
authorization disabled by default. Any app on the same device can then
connect to that proxy, bypassing Android's per-app VPN routing, and learn
the VPN server address - the leak publicly described in March-April 2026
for Happ, v2rayNG and other VLESS clients. Happ fixed its Xray API
exposure, but the unauthenticated local proxy remained.

Happ exposes a standard subscription header for this (no Provider ID
required): socks-auth-mode / http-auth-mode = auto|manual|from-json|
disable. A new subscription setting, subHappLocalProxyAuth (default
"auto"), sends both headers to Happ clients. Like every other Happ header
it is emitted only when Happ auto-detect is enabled and the User-Agent is
Happ, so panels that never opted into the Happ integration see no change.
An empty value sends nothing and keeps the client's own setting.

Verified on Happ Android 4.4.1 (Xray 26.7.28): a subscription carrying
socks-auth-mode manual + a test user/password switched the client's
Inbounds screen to Manual with those credentials on "refresh subscription",
and "auto" switched it to Auto with generated credentials.

Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-26 22:03:20 +02:00
Kirill Rudenko 8979072bd9 fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H (#6642)
* fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H

The native AmneziaWG validator, both Zod schemas, both forms and the docs now
follow the rules amneziawg-go actually enforces.

S1-S3. A padded handshake message is 148+S1, 92+S2 or 64+S3 bytes
(device/send.go). The peer reads each datagram into a [MaxMessageSize]byte
buffer, where MaxMessageSize = MaxSegmentSize (device/pools.go,
constants.go). MaxSegmentSize is 65535 on Linux/Android, 2016 on Windows and
1700 on iOS (device/queueconstants_*.go). The limits are therefore
S1 <= 1552, S2 <= 1608 and S3 <= 1636. Before, S1/S2 allowed 65535, which
iOS peers silently drop, and S3 was capped at 64, a number inherited from the
coinman-dev/3ax-ui port in #6105 with no stated reason. That cap blocked real
configs such as Amnezia Premium's S3=1045. RandomTrailers only tops a packet
up to 500 bytes (DefaultUdpWindow), so it never pushes a message past these
limits.

H1-H4. amneziawg-go refuses the whole device when the header ranges overlap
("headers must not overlap", device/uapi.go mergeWithDevice), and so does the
kernel module (src/netlink.c). The panel did not check this, so an inbound
with overlapping ranges saved and then failed to apply. A blank H is never
sent, so the engine keeps its default, WireGuard's own type 1-4; the check
treats blank fields that way. The docs said 1-4 "must not be used". They are
valid and are the engine default, only unobfuscated without a
HeaderProtectionKey. The docs also said amneziawg-go rejects S1+56 == S2. It
does not (IpcSet accepts it). The panel keeps that rule as a fingerprint
guard, and the docs now say so.

Tests: the new params_test cases and the Zod bounds fail on the old code.
TestValidatedObfuscationAlwaysApplies runs every accepted set through a real
amneziawg-go IpcSet and now covers overlap, blank-H defaults, H=1-4, the
exact S bounds and the full Amnezia Premium set. Before this fix it failed
with "headers must not overlap".

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

* fix(amneziawg): bound only inbound padding by the iOS receive buffer

The 1700-byte iOS buffer limits what an inbound's clients can receive,
but ValidateObfuscation also runs for outbounds, and the Xray template
save re-validates every AmneziaWG outbound. An outbound whose remote
server uses S1 above 1552 would have blocked every Xray settings save,
though its values come from that server and are received on Linux.

ValidateObfuscation keeps amneziawg-go's uint16 UAPI width for S1-S3;
ValidateServerObfuscation adds the receive-buffer bounds and is what
inbounds call. The outbound schema and form follow the same split.

---------

Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-26 22:02:49 +02:00
Xinny Lin f3b100282a fix: allow IPv4 and IPv6 inbounds to share a port (#6603)
* fix: distinguish IPv4 and IPv6 listen conflicts

* fix(ports): let an IPv4 address share a port only with a v6only wildcard

xray listens on tcp/udp, and Go opens every wildcard listen, 0.0.0.0
included, as one dual-stack socket unless sockopt.v6only is set. Treating
:: and 0.0.0.0 as separate families let the panel save pairs the core
then fails to bind, and it broke main's own TestListenOverlaps.

listenOverlaps now takes the inbound's sockopt.v6only: a wildcard claims
both families, or only IPv6 with v6only, so :: with v6only may share its
port with an IPv4 address while a plain :: or 0.0.0.0 still may not.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-09-26 22:02:22 +02:00
Roman Chesnakov bd01f923fb fix(tgbot): unstick the add-client wizard's inbound picker (#6621)
* fix(tgbot): unstick the add-client wizard's inbound picker

Tapping "➕ Новый клиент" always sent the "choose inbound" message,
even when the button list ended up empty after protocol filtering.
getInboundsAddClient checked len(inbounds)==0 before filtering but
never re-checked after, so an admin whose every inbound was excluded
got a message with nothing to tap and no further feedback.

WireGuard and AmneziaWG were excluded outright too, a holdover from
the wizard's original 2025 implementation, before
defaultWireguardClients
and defaultAmneziaWGClients existed. Both now auto-generate a keypair +
AllowedIPs for a client with none set, and the subscription server
already emits wireguard:// and vpn:// share links for them, so both
inbound types flow through the same generic Create path as VLESS/Trojan
already used by the bot. Mixed/HTTP/Tunnel stay excluded: they have no
per-client model in this codebase.

- getInboundsAddClient now returns getInboundsFailed when the button
  list is empty after filtering, instead of sending an unusable keyboard
- WireGuard/AmneziaWG removed from the exclusion list in both
  getInboundsAddClient and getInboundsAttachPicker
- the previously duplicated excludedProtocols map is now a single
  package-level addClientExcludedProtocols shared by both functions

* test(tgbot): pin which inbounds the add-client picker offers

The picker change had no test. One drives a database holding WireGuard,
AmneziaWG, VLESS and Mixed inbounds and wants the first three offered;
the other holds only Mixed, HTTP and Tunnel and wants getInboundsFailed
instead of an empty keyboard. Both fail on the previous picker.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-09-26 22:01:56 +02:00
mrchatam b3a5be9da4 fix(amneziawg): stop AAAA fallback on v4-only tunnels and expose I2–I5 (#6611)
* fix(amneziawg): stop AAAA fallback on v4-only tunnels and expose I2–I5

Gate tunnel DNS queries to address families the device can actually dial,
reject undialable literal IPs early, and surface I2–I5 on the outbound form.

Fixes #6570

* ci: retrigger frontend after npm registry maintenance

The frontend job failed solely on `npm audit` while registry.npmjs.org
returned 503 (Service Under Maintenance). Lint, typecheck, vitest, vite
build, and storybook all passed. Local `npm audit --omit=dev
--audit-level=high` now reports 0 vulnerabilities.

* style(amneziawg): keep the tunnel DNS family comments to two lines

CLAUDE.md caps a comment block at two lines.

---------

Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-09-26 22:01:04 +02:00
mrchatam 8f1201553e fix(ip-limit): CAS-retry inbound_client_ips merges under Postgres (#6612)
* fix(ip-limit): CAS-retry inbound_client_ips merges under Postgres

Two writers RMW the same ips JSON blob; on PostgreSQL a lost update drops
remote IPs that partitionLiveIps only sees through that blob (#6587).
Compare-and-set on the previous blob with re-merge on miss, matching the
repo's conditional Where+RowsAffected pattern.

Fixes #6587

* ci: retrigger frontend after npm registry maintenance

The frontend job failed solely on `npm audit` while registry.npmjs.org
returned 503 (Service Under Maintenance). Lint, typecheck, vitest, vite
build, and storybook all passed. Local `npm audit --omit=dev
--audit-level=high` now reports 0 vulnerabilities.

* test(ip-limit): cover the scan's CAS against a mid-scan node sync

The job-side compare-and-set had no test of its own. A write injected
between the scan's read and its update now has to keep the node's remote
IP; main's blind Save drops it. Also keeps the new comments to two lines,
as CLAUDE.md requires.

---------

Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-09-26 22:00:40 +02:00
sdhfsl d42e2133c7 fix(sub): keep serverDescription literal in external link fragments (#6580)
* fix(panel): accept 2FA codes from adjacent TOTP windows

CheckUser compared only gotp.Now(), so a code submitted at the end of
its 30s window (or with slight client/server clock drift) failed with
'invalid 2fa code', while the immediate retry in the next window
succeeded. Accept current +/-1 window, the standard TOTP skew
tolerance.

Fixes MHSanaei/3x-ui#6535

* fix(panel): share TOTP skew tolerance with VerifyTwoFactorCode

Move the +/-1 window helper to internal/util/totp so both 2FA
acceptance points use it: login (CheckUser) and disable/rebind plus
username/password changes (VerifyTwoFactorCode). Also shrink comments
to the 2-line house rule and anchor the unit test mid-window to avoid
a step-boundary flake.

Addresses review on #6546 (MEDIUM + 2 LOWs).

* fix(sub): keep serverDescription literal in external link fragments

Client external links escaped the whole remark, turning
?serverDescription=<base64> into %3F...%2F... so Happ lost its
subtitle. Split on ?serverDescription= like appendQueryAndFragment
(#6488): escape only the display name, keep a clean base64 tail
literal, fall back to full escaping otherwise.

Fixes MHSanaei/3x-ui#6575

* refactor(sub): share one serverDescription fragment split across link paths

#6488 fixed the split in appendQueryAndFragment and #6575 was the same
bug on the external-link path, which had its own copy. Both now call
escapeLinkFragment with their own escaper, so a later change to the tail
check cannot reach one path and miss the other.

---------

Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-09-26 22:00:09 +02:00
sdhfsl a2ca023336 fix(sub): send panel guid as X-HWID on outbound subscription fetch (#6579)
* fix(panel): accept 2FA codes from adjacent TOTP windows

CheckUser compared only gotp.Now(), so a code submitted at the end of
its 30s window (or with slight client/server clock drift) failed with
'invalid 2fa code', while the immediate retry in the next window
succeeded. Accept current +/-1 window, the standard TOTP skew
tolerance.

Fixes MHSanaei/3x-ui#6535

* fix(panel): share TOTP skew tolerance with VerifyTwoFactorCode

Move the +/-1 window helper to internal/util/totp so both 2FA
acceptance points use it: login (CheckUser) and disable/rebind plus
username/password changes (VerifyTwoFactorCode). Also shrink comments
to the 2-line house rule and anchor the unit test mid-window to avoid
a step-boundary flake.

Addresses review on #6546 (MEDIUM + 2 LOWs).

* fix(sub): send panel guid as X-HWID on outbound subscription fetch

Outbound subscriptions hit the same HWID-limited donor 404 as client
external links (#6559/#6567). Identify this panel with GetPanelGuid
plus X-Device-OS, honoring the externalSubSendHwid opt-out.

Fixes MHSanaei/3x-ui#6574

* fix(sub): send the external-subscription X-HWID from outbound fetches too

The outbound fetch used panelGuid while client external links send the
externalSubHwid id from #6567, so an HWID-limited provider counted one
panel as two devices. It also re-added the externalSubSendHwid opt-out
that #6567 dropped.

Move the id into service.ExternalSubscriptionHwid, keeping the
externalSubHwid row so existing installs keep their slot, and send it
from both paths. The outbound test now fails on the panelGuid version.

---------

Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-09-26 21:59:29 +02:00
MHSanaei 3fe92df7ad docs: adopt correct-fix-over-small-fix and TDD policy
Replace the "smallest fix" rule with a "correct fix over small fix" policy: fix root causes properly, regardless of size, while still disallowing speculative additions. Add a dedicated TDD section (red-green-refactor, fake-test prohibitions) to CLAUDE.md and CONTRIBUTING.md, consolidating prior scattered testing guidance. Also promote jackc/pgx/v5 from an indirect to a direct go.mod dependency.
2026-09-26 21:58:25 +02:00
mrchatam 169cd86e00 fix(sub): never use X-Real-IP as the subscription host (#6608)
ResolveRequest and the panel's resolveHost fell back to X-Real-IP for the host when a trusted proxy sent no X-Forwarded-Host. X-Real-IP names the visitor, so behind nginx with only that header set, subscription and exported links advertised the subscriber's own public IP as the server.

The host now comes from a trusted X-Forwarded-Host, else the dialed request Host. X-Real-IP stays a client-IP source only.

Fixes #6589.
2026-09-26 21:13:46 +02:00
n0ctal 66df77665f test(database): give each package its own schema when tests run on PostgreSQL (#6594)
With XUI_DB_TYPE=postgres every test package shared one database and worked in public. Go runs package test binaries concurrently, so migrations raced and rows a previous run left behind leaked into the next.

testpg.IsolatePackage creates a schema for the calling package, puts it first on search_path and drops it when the package finishes. It returns at once unless XUI_DB_TYPE is postgres. internal/web/service's TestMain adopts it.
2026-09-26 21:13:42 +02:00
mrchatam c54c28d92d fix(sub): drop legacy freedom.domainStrategy from JSON sub template (#6609)
The JSON-subscription template still set settings.domainStrategy on its freedom outbound, the placement #6515 moved off everywhere else, so xray-core migrated it to sockopt with a deprecation warning on every load. AsIs is the core default when the key is absent, so dropping it changes nothing else.

Fixes #6482.
2026-09-26 21:13:39 +02:00
SakikoTogawa 5d41e65a3c fix(sub): preserve external VLESS encryption in Clash subscriptions (#6576)
* fix(sub): preserve external VLESS encryption in Clash subscriptions

Copy non-empty, non-none encryption from parsed external VLESS settings,
matching local proxy export. This prevents merged Clash/Mihomo subscriptions
from losing the encryption parameters of externally added nodes.

Cover encryption normalization and omission, plus merged YAML from pasted
links and HTTPS subscriptions containing plain or Base64 share-link lists.

Validation: regression cases fail before the fix and pass after it; the full
subscription package and go build ./... pass. Four unrelated packages still
fail on Windows, with the same failures reproduced using the original code.

Refs: MHSanaei/3x-ui#6572

* test(nodes): wait for chart effects in history panel assertions

The DOM can commit its accessible labels before Sparkline updates the refs used by uPlot range callbacks. Wait for the existing assertions together so the test does not read the empty-data range.

Reproduced the original CI failure locally on attempt 5. The fixed test passed 12 consecutive runs; lint, format and TypeScript checks pass. The full frontend suite passed 1607 of 1608 tests, including Storybook. The unrelated input-number guard fails on Windows because execFileSync cannot launch the extensionless oxlint shim (ENOENT); invoking oxlint.cmd reports all three expected diagnostics.
2026-09-26 20:31:50 +02:00
Matt Van Horn 0dec3d65ba fix(sub): preserve per-inbound tunnel identity in subscriptions (#6653)
matchingClients primed the per-request link cache with the shared clients rows, whose wg_* columns hold whichever WireGuard/AmneziaWG inbound synced last. A client on several such inbounds (one per node) therefore got the same tunnel address and keys in every subscription profile.

Membership, subId, enable, quota and expiry still come from the normalized tables. For WireGuard and AmneziaWG the tunnel identity (keys, AllowedIPs, keepalive) is now overlaid from this inbound's own settings, the source clientsForLinkExport already uses for direct links. A member with no settings entry, or malformed settings, yields no link for that inbound rather than another inbound's credentials.

Fixes #6641.
2026-09-26 20:31:47 +02:00
libmur-dev 07ee638a50 fix(sub): emit Hysteria certificate pin for Mihomo (#6651)
buildHysteriaProxy dropped pinnedPeerCertSha256 from Clash/Mihomo YAML although the raw share link already carries it as pinSHA256, so Mihomo rejected a self-signed Hysteria2 certificate whenever allowInsecure was off.

Emit the first valid SHA-256 pin as Mihomo's fingerprint field in its colon-separated form, honouring an external endpoint's override. client-fingerprint stays the uTLS setting. Mihomo accepts a single fingerprint, so of several pins the first valid one wins.

Refs #4683.
2026-09-26 20:31:42 +02:00
sdhfsl ee2ff48c81 fix(tgbot): scope the add-client wizard to the admin, not the chat (#6604)
Two admins in one group chat shared one draft and one wizard step: clientDrafts and userStateStore were keyed by chat id alone, so the second admin's wizard opened on the first one's email and limits, and whichever of them tapped a control last decided what the other created.

Key both stores by (chat, user) instead. A private chat is unaffected: its two ids are equal, so the key matches what the chat alone used to be. A message with no sender (a channel post) keys to user 0, which no admin holds.

Fixes #6593.
2026-09-26 20:31:39 +02:00
n0ctal 3b9ca47a4e fix(database): keep the legacy tag cleanup from colliding with an existing tag (#6592)
* fix(database): avoid legacy inbound tag cleanup collisions

* test(database): assert the legacy tag cleanup keeps the migration green

The collision guard's test asserted only that the colliding tag was left
alone, which an unguarded cleanup also produces: the UPDATE fails on the
unique index and the row is unchanged either way. The cleanup shares a
transaction with every other requirement, so that failure rolls all of
them back on every boot and only reaches the log. Assert the call itself
succeeds, which is what actually distinguishes the two.

---------

Co-authored-by: n0ctal <n0ctal@users.noreply.github.com>
2026-09-26 20:30:25 +02:00
n0ctal c0c0136037 fix(hwid): serialize the device-limit write with its trim (#6591)
setClientLimitHwidByEmail wrote clients.limit_hwid and then trimmed client_hwids as two independent statements. A traffic-cycle Save that read the record before the limit changed could write the stale value back after it, and a failed trim committed the new limit anyway.

Both halves now run inside runSerializedTx, the transaction the traffic writer already owns. setClientLimitHwidByEmailTx and clearClientHwidsBySubIDTx refuse a handle that is not that transaction (errClientHwidWriteNotSerialized) instead of falling back to the shared handle. Client delete moves onto the same writer, and BulkCreate withdraws a re-created client's tombstone before applying its optional HWID limit.

TestSetClientLimitHwidIsSerializedWithSyncInbound holds a stale traffic-cycle Save open across the limit change and fails without the serialization (limit_hwid = 5, want 1).
2026-09-26 20:30:22 +02:00
n0ctal d86a3def85 fix(ip-limit): append fail2ban lines only after the scan commits (#6590)
updateInboundClientIps wrote the [LIMIT_IP] lines that drive the jail
while the scan's transaction was still open, and marked the addresses in
bannedSeen at the same time. A commit failure after that point rolls the
database back but takes nothing back from the log: fail2ban proceeds to
ban addresses the panel never recorded, and the in-memory bannedSeen
entry makes the next scan skip them, so the rollback is never repaired.

Selection stays inside the transaction. processObserved now collects one
pendingBan per enforced client and publishes after the commit succeeds,
disconnecting only the clients whose lines actually reached the log. The
Xray disconnects already ran after the commit for the same reason.

Recording moved with the write rather than with the decision:
selectAdvancedSinceLastBan no longer mutates anything, and
recordBannedSeen runs once a line is on disk. It also runs for clients
with nothing to ban, because that is the pass that forgets addresses a
client no longer exceeds its limit with - pruning used to be a side
effect of the filter, and skipping it left a stale entry that suppressed
the next legitimate ban.

The log file is opened once per scan instead of once per client, the
write error is checked instead of discarded, and Close is reported.

updateInboundClientIps no longer reports shouldCleanLog, because the
only thing that set it was the ban branch that moved out; processObserved
sets it when a publication actually happens. disAllowedIps went with the
write it served.

Tests: a transaction failed at COMMIT through a deferred foreign key
leaves no line and no bannedSeen entry; a publication that cannot open
the log leaves the address retryable; a client returning under its limit
has its entry forgotten, so going over again is banned a second time; a
committed over-limit scan publishes and reports; and writeBanLines
surfaces a write error rather than swallowing it.
2026-09-26 20:30:18 +02:00
MHSanaei dcaadd4857 fix(panel): validate sponsor logo name before any file or network use
The public /sponsors/logo/:name route only accepted names matching an
active sponsor's logo, which was already regex-filtered, but that guard
was indirect. Checking sponsorLogoRe on the name itself makes the
path/URL safety local and clears CodeQL alerts #113 (go/request-forgery)
and #114 (go/path-injection).
2026-09-26 12:40:31 +02:00
MHSanaei fd7b3559bc feat(panel): add sponsor slots fed from sponsors.sanaei.dev
Monthly sponsor placements need to change without cutting a panel
release. Panels now read 3X/sponsors.json from the MHSanaei/sponsors
repo (GitHub Pages on sponsors.sanaei.dev) and show active sponsors in
four slots: an overview banner, a rotating sidebar card (max three), the
login page and a new Sponsors page that also lists open placements.

An entry shows only while enable is not false and until is in the
future; links must be https and logos are png/webp/jpg by name only.
The list is cached for an hour and the last good copy survives upstream
failures; logos are proxied through /sponsors/logo/:name with failures
cached, so CSP stays 'self' and admin browsers never reach a third
party. Admins can hide a slot for 24h. Under XUI_DEBUG the panel reads
a sibling ../sponsors/3X checkout so edits can be previewed before push.
2026-09-26 03:31:51 +02:00
MHSanaei 89e200ead4 fix(frontend): key geo entries by page position and clear test-suite noise
Zod 4: use the `error` param instead of the deprecated `message`.
lint:deprecated missed these because tsgolint's no-deprecated does not
resolve object-literal properties on a `string | Params` union.

Geodata: key geo entry rows by page position. antd deprecates rowKey's
index argument, and kind:value repeats within a page because the reader
drops domain attributes (22 pairs in geosite_IR.dat, 108 in geosite_RU).

Nord/PIA: the "All cities/regions" option used a null value, which antd
warns on. Map it through a sentinel at the Select boundary so form state
stays null, with tests that fail when the sentinel is not mapped back.

Tests:
- Run the oxlint guard through node; .bin/oxlint is a sh shim Windows
  cannot spawn, and the swallowed error left both guard cases vacuous.
- Start unit workers with --no-experimental-webstorage; msw's localStorage
  probe made Node 25+ warn once per forked worker.
- Set IS_REACT_ACT_ENVIRONMENT, which RTL never sets with globals: false,
  and settle the async updates it exposed inside act(). The row-cells
  memo test now fails when memo is removed.
- Disable antd's click wave in Storybook; it re-rendered inside the next
  story's act() and tripped "not configured to support act".
- Assert InboundFormModal's validation log instead of leaking it, and
  give the rule-form test a well-formed clients/list response.
2026-09-25 21:21:03 +02:00
MHSanaei a03228c455 ci: update Claude workflow model settings
Use Claude Opus 5.5 with high effort for issue analysis and PR reviews.
2026-09-25 19:19:50 +02:00
MHSanaei 86302d2f2d chore(deps): update toolchains and dependencies
Raise the frontend baseline to Node 26/npm 11 and refresh contributor documentation. Update frontend, documentation-site, and Go dependencies with regenerated lockfiles and module checksums.
2026-09-25 17:39:38 +02:00
Farhan Zare 95f19b192f fix(nodes): stop a restarting panel from reporting itself as down
Adding a node fails right after that node's panel restarts. nodes/add
probes the node's /panel/api/server/status first, and that endpoint
returns whatever the @2s ticker last sampled - nil until the first tick
lands, so the master reads a healthy panel as unreachable and rejects it
with "Add node (remote returned success=false: )", an error whose
message is empty because the node answered success with a null obj.

The window is far wider than one tick: GetStatus resolved the public
IPv4/IPv6 addresses inline and held s.mu across every lookup, so a box
with no IPv6 route spent 3s per service - about 15s of nil status after
each restart, and the same stall on a fresh panel's first sample.

- status now answers from CurrentStatus, which samples on demand when
  the ticker has not run yet instead of returning a null obj
- the public-IP lookups run in the background and outside s.mu, so a
  status sample never waits on them
- probe tells "no status yet" apart from a genuine success=false, so the
  master's error says something when it meets an older node
2026-09-18 13:25:24 +03:00
BlindMaster24 1c0ce80e8e fix(ci): keep a refused Claude credential from reddening a pull request (#6585)
* fix(ci): keep a refused Claude credential from reddening a PR

An expired subscription ends the claude-code-action step with exit 0, so the
classifier that exists for "the API refused this run" never sees it -- its
condition is a failed step -- and the final "posted nothing" step reddens the
pull request although nothing is wrong with the repository.

Verified against five real runs (35159059540, 35184688775, 35185722358,
35186543654, 35187380192): step 8 success, step 10 found no cause, step 11
failure, transcript {"error":"oauth_org_not_allowed"} plus a result entry with
api_error_status 403. A usage-limited run carries 429 and a rejected
rate_limit_event, and a real review carries is_error false with no status, so
the 401/403 test fires on the refused credential alone.

* fix(ci): stop a refused credential reddening the issue analysis

The same exit-0 refusal reaches this workflow's "posted no reply" check, which
fails for the same reason and shows up as seven failed runs in a day. It never
attaches to a pull request -- the trigger excludes them -- so this is the same
step and the same 401/403 transcript test applied where the refusal lands.

Reported only as a warning annotation: nothing was analysed, and there is no
comment worth posting about a credential the maintainer has to renew.
2026-09-17 10:54:12 +03:00
n0ctal f8db7f6c29 fix(nodes): say which half of node mTLS failed, and say it as an error (#6565)
* fix(nodes): say which half of node mTLS failed, and say it as an error

A configured client CA bundle that will not parse produced the same
warning as a settings read that failed, and both read as though mTLS
were merely unavailable. It is not: the node API silently stops
accepting client certificates, callers fall back to a bearer token or
lose their only credential, and the one line saying so is a warning at
boot.

Report it at error level, and distinguish the two causes rather than
attributing a storage fault to the operator's certificate bundle.
NodeMtlsClientCAPool now tags the parse failure with
ErrNodeMtlsTrustBundleInvalid; its message text is unchanged, so
anything matching on the existing string still matches.

Startup is deliberately left alone. Refusing to boot was considered and
rejected: the bundle is one of two equal credentials here, a panel that
will not start takes the proxies and the subscription server with it,
and bundles written before the stricter validation landed in #6188 are
already stored, editable only through the panel that would no longer
come up.

The tests pin the tag on an unusable bundle and its absence on an unset
one; without the tag the first goes red.

* test(nodes): drop a duplicate node mTLS trust-bundle test

TestNodeMtlsClientCAPoolLeavesUnsetBundleUntagged asserted only that an
unset nodeMtlsClientCAPem yields (nil, nil). That path returns before the
line the sentinel change touched, so the test was green with and without
ErrNodeMtlsTrustBundleInvalid, and TestNodeMtlsClientCAPool already pins
the same two assertions on the same fixture. A test that passes either way
certifies nothing and then gets cited as coverage for the sentinel.

TestNodeMtlsClientCAPoolTagsAnInvalidBundle, which does go red without the
sentinel, stays as the regression guard.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-16 12:30:44 +02:00
sdhfsl 536f9a6338 fix(tgbot): localize QR caption via I18nBot (#6564)
* fix(panel): accept 2FA codes from adjacent TOTP windows

CheckUser compared only gotp.Now(), so a code submitted at the end of
its 30s window (or with slight client/server clock drift) failed with
'invalid 2fa code', while the immediate retry in the next window
succeeded. Accept current +/-1 window, the standard TOTP skew
tolerance.

Fixes MHSanaei/3x-ui#6535

* fix(panel): share TOTP skew tolerance with VerifyTwoFactorCode

Move the +/-1 window helper to internal/util/totp so both 2FA
acceptance points use it: login (CheckUser) and disable/rebind plus
username/password changes (VerifyTwoFactorCode). Also shrink comments
to the 2-line house rule and anchor the unit test mid-window to avoid
a step-boundary flake.

Addresses review on #6546 (MEDIUM + 2 LOWs).

* fix(tgbot): localize QR caption via I18nBot

sendClientQRLinks hardcoded English 'QRCode for client <email>:',
bypassing I18nBot, so non-English bot languages (e.g. ru-RU) still
got English. Add tgbot.answers.qrCodeForClient key with Email param
in all 13 locales and route the caption through I18nBot.

Fixes MHSanaei/3x-ui#6562

* fix(tgbot): repair locale JSON syntax, harden QR i18n test

- Add missing separators so all 13 locale files parse again.
- Rewrite the regression test to read the real shipped files
  (fails on malformed JSON or missing key).
- Add TestTgbotLocalesQrKeyValid covering every locale file.

* chore(tgbot): drop QR caption tests that cannot catch the bug

TestQRCodeForClientLocalizes never calls sendClientQRLinks: it registers
two messages in a synthetic bundle and asserts on I18nBot, a passthrough
to go-i18n. With the tgbot_client.go line reverted to the hardcoded
English caption, both it and TestTgbotLocalesQrKeyValid still pass, so
neither certifies the fix.

The malformed-locale class they were added for is already pinned twice:
the discord package's TestMain loads every translation file through
locale.InitLocalizer and panics on invalid JSON, and
frontend/src/test/i18n-dead-keys.test.ts parses all 13 locales and
checks each carries the en-US key set. Both go red on the #6564 syntax
error this PR first shipped.

---------

Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-16 12:23:42 +02:00
sdhfsl d59b77bcdb fix(sub): send stable X-HWID on external subscription fetch (#6567)
* fix(panel): accept 2FA codes from adjacent TOTP windows

CheckUser compared only gotp.Now(), so a code submitted at the end of
its 30s window (or with slight client/server clock drift) failed with
'invalid 2fa code', while the immediate retry in the next window
succeeded. Accept current +/-1 window, the standard TOTP skew
tolerance.

Fixes MHSanaei/3x-ui#6535

* fix(panel): share TOTP skew tolerance with VerifyTwoFactorCode

Move the +/-1 window helper to internal/util/totp so both 2FA
acceptance points use it: login (CheckUser) and disable/rebind plus
username/password changes (VerifyTwoFactorCode). Also shrink comments
to the 2-line house rule and anchor the unit test mid-window to avoid
a step-boundary flake.

Addresses review on #6546 (MEDIUM + 2 LOWs).

* fix(sub): send stable X-HWID on external subscription fetch

A Master panel fetching a donor subscription sent no X-HWID, so an
HWID-limited donor rejected it with 404. Identify this panel with a
stable per-installation id (persisted in settings), occupying exactly
one donor device slot.

Fixes MHSanaei/3x-ui#6559

* fix(sub): address review on external X-HWID

- Serialize first-time id creation with a mutex so concurrent
  first fetches cannot mint two UUIDs.
- Fix goimports grouping for the new third-party import.
- Add externalSubSendHwid opt-out (default send); document it.
- Cover header send/omit with httptest in TestFetchSendsStableHwid.

* fix(sub): drop the SQL-only X-HWID opt-out

The externalSubSendHwid opt-out added in 227ed818 had no settings
field, CLI flag or docs, so an operator could only reach it by editing
the settings table by hand, while every cache-miss fetch paid a query
for it. CLAUDE.md rules out config knobs on a one-header fix.

Also drop the test assertions that only restated the 3x-ui-server-
prefix constant; TestFetchSendsStableHwid still goes red without the
header.

---------

Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-16 12:16:26 +02:00
Sanaei 17e89db979 feat(hosts): add a cipher suites override and accept custom suites
The inbound TLS form offered cipherSuites as a closed single-choice list,
but xray reads the value as a colon-separated list and accepts any name Go
knows, so several suites or one missing from the list could not be set.
Both the inbound and the new host field now use a tag picker that keeps
the stored value as the colon-joined string xray expects; old single
values open unchanged.

A host's cipher suites replace the inbound's in the JSON subscription
stream, and a blank field inherits them. Share links and Clash carry no
cipher suite parameter, so their output is unchanged.
2026-09-16 11:56:20 +02:00
Sanaei 040d01c5dc fix(clients): list HWID devices when the HWID limit is 0
EnforceHwidForSubID returned before recording anything when a sub had no
limit, so the panel's HWID Devices list stayed empty for every unlimited
client. Devices are now upserted on the (sub_id, hwid_hash) index without
enforcement or X-Hwid-* headers; the write is best-effort and only logs on
failure, so tracking can never deny a subscription nothing restricts.
2026-09-16 11:50:49 +02:00
Sanaei b78dd82869 fix(nodes): chart node net throughput in KB/s, not percent
The node history panel passed its Net Up / Net Down series to Sparkline
without valueMax or yFormatter, so they inherited the percentage defaults:
a fixed 0-100 scale and a "%" label. Any node above 100 KB/s drew off the
top of the chart and every axis tick and tooltip read as a percentage.

A Sparkline fed non-percentage data has to declare its own scale and unit;
every other call site already did, only the two node net series did not.
2026-09-16 11:48:55 +02:00
Sanaei 7ef22f94c9 fix(logger): fix data race in InitLogger
Replace the package logger variable with an atomic.Pointer so InitLogger swapping the handle no longer races with concurrent Debug/Info/Warning/Error calls from other goroutines. Also guard fileRotate with a mutex, and add a regression test that reproduces the race under concurrent logging.
2026-09-16 02:29:42 +02:00
Sanaei ec9fbae645 v3.8.5 2026-09-16 02:08:08 +02:00
Sanaei e26cf1d3ed feat(sub): redesign the subscription page around usage, tabs and app imports
The info page was a long key/value table followed by every link and two
app dropdowns, and it rendered left-to-right even for Persian and Arabic.
It now leads with a usage ring, the remaining quota and a stats grid, and
splits the rest into Subscription / Apps / Configs tabs.

- Status tells expired, data-used-up and disabled apart instead of one
  "Inactive", replacing the hard-coded English expiry chip.
- The Apps tab keeps every Android and iOS app with its existing deep
  link, preselects the visitor's platform and adds Windows: Hiddify and
  Clash Verge Rev import directly, v2rayN copies the link.
- fa-IR and ar-EG render right-to-left; URLs, IDs and sizes stay LTR.
- The footer shows the support link and the client refresh interval, so
  subPageContext now carries subUpdates (also in ?format=info).
- Status, days-left and app deep-link logic lives in subPageModel.ts,
  with unit tests pinning the deep links the page already shipped.
2026-09-16 01:55:56 +02:00
Sanaei 01ce2bcecb feat(api-docs): split the API docs page into tabs
The page stacked the WebSocket event cards above every Panel API operation
in one long scroll. The WebSocket events and the 3X-UI Panel API now sit in
separate tabs, and the Panel API shows one OpenAPI tag at a time through
section tabs placed between the Authorize bar and the operations.

The section tabs replace Swagger UI's FilterContainer and wrap the
taggedOperations selector, so all sections share one Swagger instance and
keep authorization and try-it-out state. Swagger's own filter matches tags
by substring ("Settings" would also show "Xray Settings") and does nothing
until set, so the wrapper matches the exact tag and defaults to the first.
Tag names come from the loaded spec rather than importing endpoints.ts,
which would have grown the page chunk from 23 kB to 119 kB.
2026-09-15 23:01:15 +02:00
Sanaei c9e62451e6 fix(outbounds): keep subscription tags on their server when reality params rotate
A subscription outbound's tag must stay bound to the upstream server it
was assigned to for as long as that server stays in the subscription;
balancers and routing rules select by that tag.

The identity used to recognise a server across refreshes included every
query parameter. A 3x-ui upstream picks a random shortId and SNI of a
reality inbound on every request (older releases a random spiderX too),
so no reality link was ever recognised, the stable-tag reservation never
engaged, and every tag was handed out by list position. Removing or
inserting a server then re-pointed existing tags at other servers:
sub-germany carried France, sub-sweden Germany, and Sweden became
sub-sweden-1. The identity now ignores sid, sni and spx when
security=reality, since none of them selects the server. TLS sni still
counts: it can pick the backend behind a shared front.

Two more paths broke the same rule:
- A link repeated in one body (same identity, different remark) shared a
  single link_identities key, so both tags gained a -N suffix on every
  refresh. Repeats are now numbered.
- Links the core rejects were dropped after tagging, so the stored list
  that drives positional reuse was shorter than the parsed one and a
  rotated server behind a dropped link took its neighbour's tag. The
  filter now runs first; a dropped link's warning names its remark
  instead of a tag it never used.

A mapping an older build already swapped stays swapped: its stored
identities no longer match, so positional reuse reproduces it. Deleting
and re-adding the subscription reallocates the tags from the remarks.

Closes #6556
2026-09-15 22:39:59 +02:00
Sanaei 5008906c4c feat(clients): filter the client list by clicking a summary stat card
Each card on the Clients page now toggles its status bucket as the sole
filter, and the Clients card clears it. The bucket filters used to be
wider than the card counts: "active" still included clients near
depletion and "deactive" included disabled clients that had run out, so
a filtered list could disagree with the number on the card. Both filters
now reuse the summary expressions, and a test pins each card's count to
the size of its filtered list.
2026-09-15 22:06:22 +02:00
Sanaei 5fe4f241c1 style(logs): widen the row-count selector in the log modals
At 70px the selector truncated its larger values, so the chosen row
count was hard to read in the panel, Xray and AmneziaWG log modals.
2026-09-15 22:06:21 +02:00
Sanaei e8bab17c2f fix(clients): stop the Edit Client modal showing a stray light scrollbar
The client form body is capped at the viewport and scrolls internally
(49ef1449). Every tab ends with a Form.Item that keeps antd's 24px bottom
margin, so when the fields themselves fit, that empty margin alone pushed
the body past the cap: 752px of content in 740px at a 900px window. The
last item of each tab now drops the margin, so the body scrolls only when
real content overflows.

When it does scroll, the bar was painted light inside the dark modal: the
dark themes set body.dark and data-theme but never color-scheme, which is
what native scrollbars read. applyDom (panel, login and subscription
bundles) and the Storybook decorator now set it on the root element.
2026-09-15 22:04:00 +02:00
Sanaei 14b92fbcff fix(nodes): stop flagging a node on the other update channel as outdated
A node's "update available" tag compares its reported panel version with the
master's latest, and any non-semver side fell back to string inequality. A
dev build reports dev+<sha> (config.GetPanelVersion), so a node moved to the
dev channel from a master on the stable channel kept the tag forever; the
reverse, a stable node under a master on the dev channel, was flagged too and
the tag's default stable update installed nothing new.

A dev label and a release tag carry no order, so the comparison now only
decides within one channel; dev-to-dev still compares commits, which keeps a
node on the current dev-latest commit untagged as config.go intends.
2026-09-15 21:21:36 +02:00
NgaiYeanCoi 1d85ef138e fix(sub): prevent default profile page URL disclosure (#6538)
* fix(sub): prevent default profile page URL disclosure

Add explicit none, builtin, and custom profile page modes.
Preserve existing custom URLs and warn before exposing the built-in page.
Cover mode selection, legacy settings, and subscription response headers.

* fix(subscription): add profile page link options and upgrade notes
2026-09-15 21:13:29 +02:00
Sanaei 3fa44915c1 perf(nodes): keep the node table element across unrelated re-renders
rc-table re-runs every cell renderer whenever the Table re-renders, and
NodeList rebuilt its columns and table props on every render (the relative
time formatter was a fresh function each time). Any re-render of the Nodes
page therefore re-rendered all rows even when no node had changed: about
390ms per re-render for 150 nodes in jsdom.

The formatter is now stable and the table element is memoized on its
inputs, so a re-render that leaves the nodes untouched costs 0.5ms. A
heartbeat push that does change the nodes still re-renders every row.
2026-09-15 21:06:32 +02:00
Sanaei 7fc86f87de perf(inbounds): keep unchanged rows and online sets across websocket pushes
Every client_stats push carries the totals of all inbounds, and
applyClientStatsEvent rebuilt each row it listed, so every push replaced
all rows, re-ran the client rollup (a JSON parse of every inbound's
settings) and re-rendered the whole table even when no number moved. Every
traffic push also built new online and active maps, re-running the same
rollup.

Rows are now rebuilt only when their totals or a client's numbers change,
and the previous maps are kept when a push repeats the same sets. Measured
in jsdom with 450 inbounds of 50 clients each: an unchanged client_stats
push went from 7.9ms to 0.6ms with no row rebuilt, and a repeated traffic
push from 13.5ms to 8.3ms without the rollup.
2026-09-15 21:06:32 +02:00
Sanaei 3c1498d806 fix(ldap): apply LDAP enable, disable and cleanup through the bulk paths
The LDAP sync enabled, disabled and detached clients one at a time. Each
per-client call locked the inbound and pushed to its node under that lock
with a 4s timeout, so users sharing an inbound on a node that answers its
status probe but hangs on client writes queued one push timeout apiece:
five users took 20s in the test, and hundreds of directory users behind a
hung node stretched one run over hours. Each changed email was also queued
once per configured tag, repeating a no-op lookup for every extra tag.

Enable and disable now go through BulkSetEnable, and the cleanup through
one BulkDetach per inbound: each inbound is locked, written and pushed
once, and its push stops at the first failure for the reconcile to finish.
The same five users now cost a single push timeout.
2026-09-15 21:06:32 +02:00
Sanaei d1c4e0261b chore(node): cover the sync tick's online prune from the job package
The traffic sync's call that drops online sets of nodes it no longer
fetches had no test: a job-package test cannot install an xray process,
so online state was invisible there and removing the call passed.

SetXrayProcessForTest installs a test process for tests in other packages,
the same kind of seam as Manager.SetRuntimeOverride. The new job test runs
a real tick with a disabled node and a deleted one and fails without the
call.
2026-09-15 20:39:32 +02:00
Sanaei bc49c1a68f fix(node): release a deleted node's metric series and HTTP client
Deleting a node must free what the master keeps per node in memory.

Delete dropped the node's cpu and mem series but not netUp and netDown,
which the heartbeat records too, so each deleted node leaked two tiered
histories. It now drops every NodeMetricKeys entry.

InvalidateNode, called on node edit, disable and delete, cleared only the
cached Remote. The pooled HTTP client and its transport stayed cached until
a later call for the same node pruned them, which a deleted node never
makes. InvalidateNode now drops those too, outside the manager lock; an
edited node pays one fresh handshake on its next call.
2026-09-15 20:39:32 +02:00
Sanaei 3c8cf35734 perf(node): sync up to 32 nodes at once, like the heartbeat
The traffic sync is scheduled every 5s but synced only 8 nodes at a time,
each needing four to seven sequential requests. Past about 125 nodes 80ms
away a tick outlasted its interval, so dashboard traffic, online clients
and quota enforcement moved at a fraction of the intended cadence.

Measured with 150-300 fake nodes over real HTTP, 80ms latency, a dashboard
connected and client-IP sync on:

  SQLite, 300 nodes        8: 25-30s   16: 14-16s   32: 6-9.5s
  SQLite, 150 (20% slow)   8: 24-27s   16: 13-14s   32: 6-7.5s
  Postgres, 150 nodes      8: 13-18s   16: 7.5-10s  32: 6.5-8.3s

No database-locked, pool or writer-queue errors at any setting, and the
merged inbound and client traffic counts matched. Postgres's one-off
adoption tick is slower at 32 than at 16 (16.5s vs 9.7s) as goroutines
wait on its 25-connection pool; steady ticks are fastest at 32.
2026-09-15 20:22:19 +02:00
Sanaei dea7cd9cc1 fix(traffic): reset due inbounds and clients concurrently
The periodic reset job reset every due inbound, then every due client, one
at a time, and each waited on its node: up to 10s per node inbound, and 4s
per attached node inbound for a client. A few hanging nodes stretched a
single run over hours.

Both loops now run eight at a time. With the per-client fan-out of four
that stays within the 32 concurrent node calls the other node fan-outs use.
2026-09-15 20:07:19 +02:00
Sanaei 56bb876d8d fix(node): send one alert for a burst of node transitions
A master-side network blip flips every node in one heartbeat tick, and
each node published its own node.down, then node.up. A notifier queue holds
64 events and the rate limiter keys on the node name, so with 150 nodes most
alerts were dropped and the rest ran into Telegram and Discord limits.

Past five same-direction transitions in one tick the heartbeat publishes a
single event per direction naming the nodes (the first ten, sorted, then
+N). Smaller ticks keep per-node events with their health data, and the
notifiers already read the node name from Source, so no formatter changed.
2026-09-15 20:07:19 +02:00
Sanaei eb11e8c85a fix(node): fan out operations that call every node
An operation that calls every node has to finish inside the panel's 30s
write timeout. Reset all traffic, UpdatePanels and bulk inbound delete
walked the nodes one at a time, up to 10s per hanging node, so 15 hanging
nodes out of 150 kept each request running for 2m41s while the browser
had already been told it failed.

All three now fan out through fanoutInboundResults, bounded by
nodeFanoutConcurrency (32, the heartbeat's bound), and UpdatePanels keeps
its results in request order. Bulk delete still removes the rows one at a
time, since each rewrites shared routing references, and only fans out the
node pushes that delInbound now hands back.
2026-09-15 20:07:18 +02:00
Sanaei a84bbeab2e fix(node): drop online clients and sub-nodes of nodes no longer synced
What the master derives from a node's reports (online clients, active
inbounds, learned sub-nodes) must live only while that node is still
synced; ClearNodeOnlineClients states it: a downed node must not keep its
clients listed as online.

Only a failed snapshot fetch cleared the online set, and only a failed
probe cleared sub-nodes. A disabled node (both jobs skip it), a node marked
offline before the sync tick reached it, a deleted node, and a node whose
snapshot fetched but failed to merge all kept their clients online in
onlineClients, onlineByGuid and activeInbounds, which the dashboard and a
parent master's /clients/onlines read. Disabled and deleted nodes also kept
their sub-nodes on the Nodes page until the panel restarted.

The traffic sync now keeps online sets only for enabled, online nodes in
its list, the heartbeat keeps sub-nodes only for enabled listed nodes, both
before the empty-list return, and a failed merge clears like a failed
fetch. The sync job's one-line call has no job-level test: that package
cannot install the xray process, so RetainSyncedNodeOnlineClients carries
the tested rule.
2026-09-15 19:27:38 +02:00
Sanaei ea66aa4971 fix(traffic): push depletion changes to nodes off the serial writer
Node I/O on the traffic-accounting path must never stall accounting; the
serial writer states it ("Keep network I/O (node pushes) OUT of fn").

AddTraffic still applied the depletion UpdateInbound for every node
inbound inside the writer closure, one at a time with context.Background.
One hanging node held the single writer for each push, freezing traffic
polls, node snapshot merges and every client edit for the whole wave; a
client shared by 150 nodes expiring could hold it for tens of minutes.
The opt-in restart on client disable then ran node by node on the same
traffic job.

Remote plans now leave the writer and go through nodePushPlan and the 4s
nodePushContext, fanned out like client pushes: an offline or slow node
defers to the reconcile its dirty flag already schedules. The node restart
runs in its own goroutine, since nothing replays or waits on it.

TestTrafficDisableImmediatelyUpdatesNodeRuntime called addTrafficLocked
directly, which pinned the push inside the writer; it now calls AddTraffic
and still requires the push to have landed on return.
2026-09-15 19:04:55 +02:00
BlindMaster24 cfa8350d10 fix(clients): keep a vless reverse client's handler across a re-add (#6558)
* fix(clients): keep a vless reverse client's handler across a re-add

RemoveUser also drops the client's reverse outbound handler, and the account
every live remove/re-add path rebuilt carried no reverse at all: buildUserAccount
read id/flow/testseed/testpre and nothing else. Editing, bulk re-enabling, quota
renewal and adding a client to an existing inbound therefore left a reverse
client able to connect but not to open its tunnel until Xray restarted, with
nothing logged. A traffic reset is the route operators hit most, since a
depleted client is removed and re-added on every renewal.

buildUserAccount now carries the tag (it accepts either the settings JSON object
or a typed client value), and the five account maps those paths build include
the client's reverse. Core chain, read from the pinned xray-core:
AddUserOperation -> User.ToMemoryUser -> vless.Account.AsAccount copies Reverse
(proxy/vless/account.go:24), and GetReverse rebuilds the handler from the stored
account's tag (proxy/vless/inbound/inbound.go:193-205).

Each path has a test that fails without its fix; the account-level test fails on
both input shapes.

* refactor(clients): drop an account map helper nothing calls

Local.AddClient and Local.UpdateUser are only reachable through runtime.Runtime,
and all four call sites of those two methods sit in a node branch, where the
runtime is a *Remote -- Remote.AddUser ignores the map and pushes the inbound
snapshot instead. So the extraction and its test covered a path no deployment
takes, the reverse key it added could never reach a core, and the previous
commit's claim that the node-push paths go through it was wrong.

The four account maps that do reach buildUserAccount are untouched. Reported by
the PR review.
2026-09-15 18:00:54 +03:00
Sanaei af466b6a24 fix(node): push a node only the client IPs it hosts
A master's per-node sync must scope what it sends to the clients that node
serves, so its cost tracks the node and not the fleet. The global-usage
push already did (node_client_traffics by node_id); the 10s client-IP push
sent GetAllInboundClientIps, the whole table, to every node.

Each node's MergeInboundClientIps then created a row for every foreign
email, and its next GET clientIps echoed the whole fleet back. Its IP-limit
job only ever reads rows for its own clients, so none of it was used. With
150 nodes x 150 clients, one IP tick pushed 299 MB and pulled 264 MB, every
node held 22,500 rows instead of 150, and sync ticks grew 3.8s -> 10.2s
even at 1ms latency; the cost grows with the square of the fleet.

Both pushes now share nodeHostedEmails. After the change the same fleet
moves 2.0 MB / 1.8 MB per tick and ticks stay near 3.2s. Nodes upgraded
with foreign rows shed them within 30 minutes via pruneStaleIpRows.
2026-09-15 16:28:31 +02:00
Sanaei 789a03065a chore(docs): bump dependencies and adapt to fumadocs-core 16.15.11
Updates the docs site's dependencies, including the Fumadocs packages,
Next 16.3.5, React 19.3 and three majors: mermaid 12, vitest 5 and
pnpm 12. Two code changes follow from the bump:

- fumadocs-core 16.15.11 makes `llms().index()` return a Promise, so
  the llms.txt route now awaits it; tsc rejected the old synchronous
  call
- lucide-react 1.46 renamed the BookMarked icon to BookBookmark. The
  old name is still exported, but lucideIconsPlugin looks names up in
  lucide's `icons` map, which only has the new one, so the Reference
  section lost its sidebar icon in all four locales. The build only
  printed a warning.

minimumReleaseAgeExclude gains entries for the newly installed
versions.

Checked with typecheck, lint, vitest (106 tests) and a full build: no
plugin warnings, and each locale's rendered /docs page contains the
book-bookmark icon.
2026-09-15 16:00:05 +02:00
Sanaei bc424f0968 fix(xray): stop a lone dns qType 0 from matching every query
The core reads a dns rule's qType as a PortList, which drops a bare numeric
0 (infra/conf/common.go: `if number != 0`), and a rule with no qTypes
matches every query. A stored `"qType": 0` therefore does not target query
type 0: it drops, refuses or hijacks all DNS through that outbound.

A qType the panel writes has to be read by the core as exactly the query
types it names. Four writers broke that:

- DNSOutboundLegacyKeysFix rewrote a lone blockTypes [0] into "qType": 0,
  so "block type 0" became "block everything" on upgrade.
- That seeder shipped in v3.8.0 and is recorded as done, so fixing it does
  not reach installs that already ran it. DNSOutboundQTypeZeroFix spells
  any stored numeric qType 0 as "0" once, protocol id matched like the core.
- The outbound form adapter turned a typed "0" into the number 0.
- The Xray template editor saves raw JSON past that adapter; the save now
  applies the same rewrite.

Each writer is pinned by a test that fails without its part. The rewrite
and the repair compare policies as the pinned core builds them, and the
repair runs through runSeeders over a database whose legacy-keys seeder
already ran, on SQLite and PostgreSQL 16.
2026-09-15 16:00:05 +02:00
BlindMaster24 ac3fc12077 fix(ports): refuse an inbound on a port an AmneziaWG peer forwards (#6554)
* fix(ports): refuse an inbound on a port an AmneziaWG peer forwards

checkForwardedPortsConflict only ever ran from the AmneziaWG save path, and only
in one direction: an AmneziaWG client's forwardedPorts were checked against the
ports other inbounds already hold, while the reverse -- an ordinary inbound
saved onto a port some peer forwards -- had no guard at all. The forward
listener binds that port on every interface in both directions
(amneziawgnet/portfwd.go's attachTCP/attachUDP), so the two listeners want the
same socket: the loser either leaves the peer's forward silently dead or fails
the inbound's listen.

checkPortConflictTx now resolves that owner the same way the relay-slot checks
do -- same host, peers derived from the stored settings with the shared
InstanceFromInbound -- and names the peer in the refusal. Sitting inside
checkPortConflictTx covers both the save and the enable path added in #6549.

TestAddInboundRefusesAPortAnAmneziaWGPeerForwards fails without this -- watched
red, the create is allowed -- and its node-row companion pins the scoping that
keeps a node row legal on a locally forwarded port.

* fix(ports): name only a peer that binds as the owner of a forwarded port

The owner lookup read instance.Peers and ForwardedPortsInclude directly, so a
peer the forward supervisor skips (no email, or no address the tunnel routes
to) was reported as holding a port nothing binds -- refusing a create that is
legal with a message naming a row whose own port is its WireGuard one. It also
repeated the candidate's listen address as the forward's location, though the
forward binds :port on every interface.

Share the supervisor's own gate through amneziawgnet.ForwardedPortOwner, report
the wildcard bind, and propagate a failed owner query instead of reading it as
"no conflict", matching the sibling checks in the same file.

* style(ports): keep the forwarded-key doc block within the 2-line cap

The reworded desiredPortForwardKeys doc ran to three lines, against the rule
this repo sets for committed Go comments.
2026-09-15 16:58:21 +03:00
BlindMaster24 d9c7c76fb0 fix(limit-ip): leave a reverse client out of the temporary disconnect (#6553)
* fix(limit-ip): leave a reverse client out of the temporary disconnect

The LIMIT_IP cycle removes the client and adds it back 100 ms later. For a vless
client carrying a reverse config that is not reversible: RemoveUser calls
RemoveReverse and deletes the client's outbound handler, while the account added
back is built without the reverse field, so the tunnel stays down until Xray
restarts and the core's forward-proxy guard for that client no longer fires
(proxy/vless/inbound/inbound.go:245 and :542-544 at the pinned core). The cycle
now skips such a client and says so, instead of trading a limit violation for a
tunnel that needs a restart to come back.

TestDisconnectClientTemporarilySkipsReverseClient fails without this -- watched
red, the client is removed and re-added -- and asserts the skip is logged rather
than silent.

* style(limit-ip): keep the reverse-client comment within the 2-line cap

The block explaining why a reverse client is skipped was three lines, against
the rule this repo sets for committed Go comments; the same why fits in two.
2026-09-15 16:57:23 +03:00
sdhfsl d440c2b932 fix(panel): accept 2FA codes from adjacent TOTP windows (#6546)
* fix(panel): accept 2FA codes from adjacent TOTP windows

CheckUser compared only gotp.Now(), so a code submitted at the end of
its 30s window (or with slight client/server clock drift) failed with
'invalid 2fa code', while the immediate retry in the next window
succeeded. Accept current +/-1 window, the standard TOTP skew
tolerance.

Fixes MHSanaei/3x-ui#6535

* fix(panel): share TOTP skew tolerance with VerifyTwoFactorCode

Move the +/-1 window helper to internal/util/totp so both 2FA
acceptance points use it: login (CheckUser) and disable/rebind plus
username/password changes (VerifyTwoFactorCode). Also shrink comments
to the 2-line house rule and anchor the unit test mid-window to avoid
a step-boundary flake.

Addresses review on #6546 (MEDIUM + 2 LOWs).

---------

Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com>
2026-09-15 15:42:30 +03:00
BlindMaster24 e790f46757 fix(xray): restart when a diff strands a client's live session (#6550)
* fix(xray): restart when a diff strands a client's live session

Disabling or deleting a client took it out of the generated config and the
hot path applied that with AlterInbound/RemoveUser, which only drops the
credential (vless, vmess, trojan and shadowsocks all keep the established
session running) -- so the panel showed a disabled client whose connection
kept passing traffic, and the core offers no API to close one session.

A diff that removes a user without re-adding the same email under the same
tag is that case: honour the operator's restart-on-client-disable setting and
let the caller replace the process, which is already how an auto-disabled
client loses its session. An edit re-adds the email and keeps the hot path.

* chore(i18n): cover manual disable and delete in the restart-setting description

The setting now also decides what happens when a client is disabled or deleted
by hand, so the description cannot keep naming only the automatic path. All 13
locales updated in the same commit to keep the wording consistent.

* fix(xray): reach the guard from the manual switch and from every protocol

Round-1 findings on this PR. The guard sat in tryHotApply, but a manual disable
or delete applies through runtime.Runtime and finishes with needRestart false,
so none of the three RestartXray schedulers fired and the predicate was never
reached: the session in #6533 kept flowing. The apply layer now asks for the
restart the setting promises when the client actually leaves the config, on the
single-client update and delete paths and on bulk disable, and only for local
inbounds so a node row cannot make the master restart its own core.

The predicate itself could not fire for shadowsocks or hysteria either, because
RemovedUsers is only produced for the protocols diffInboundUsers will diff. The
diff now also compares settings.clients of an inbound present in both configs,
which is the one shape every account list shares, so those protocols reach the
guard through the inbound instead of through nothing.

TestManualClientDisableHonoursRestartSetting fails without the apply-layer fix
("needRestart = false, want true" with the setting on) and
TestHotDiffDropsUsersOnProtocolsItCannotDiff fails without the diff fix -- both
watched red. The two three-line comments this PR added are back inside the cap.

* docs(i18n): stop scoping restartXrayOnClientDisable to auto-disable

The setting now covers a client disabled or deleted by hand as well, so its
title no longer says "Auto" in all 13 locales, and the docs callouts in en, ru,
zh and fa describe the same behaviour instead of the auto-only one.
2026-09-15 15:39:40 +03:00
BlindMaster24 d089adeeea docs(limit-ip): correct what the temporary disconnect can actually do (#6551)
* docs(limit-ip): correct what the temporary disconnect can actually do

The comment claimed removing and re-adding a user "disconnect[s] all
connections". RemoveUser only clears the core's credential validator in vless,
vmess, trojan, shadowsocks and hysteria alike, so a session already up keeps
running and the fail2ban ban on the logged IP is what ends the traffic. Comment
only: the protocol gate and its test are untouched.

* docs(limit-ip): say what the disconnect cycle really does per protocol
2026-09-15 15:30:55 +03:00
BlindMaster24 4a8fdceed6 perf(nodes): reuse one pooled client per node instead of rebuilding it (#6548)
* perf(nodes): reuse one pooled client per node instead of rebuilding it

The heartbeat probe asks for a client every 5s per node, and for skip, pin and
mtls modes HTTPClientForNode built a client with its own transport each time:
every tick paid a full TCP+TLS handshake per node, which is the CPU a 100-node
fleet reports. Cache the client per node identity, close the previous one when
that identity changes, and raise the idle pool caps above any real fleet size
so a node's connection survives to its next tick.

* perf(nodes): keep one client per node in the pooled cache

Round-1 findings on this PR. The eviction dropped only entries whose key did not
start with the current identity, so every proxy variant of that identity stayed
for the life of the process. That variant is often a fresh loopback port:
withOutboundBridge mints one per call and tears the bridge down on return, so
each operator "test node" or remote-inbounds action added a client whose key can
never be hit again, and a node switched to verify mode orphaned its old entry by
returning before the loop. Replacing that filter with one entry per node bounds
the cache at the fleet size, and the verify-mode return now clears the node too.

TestHTTPClientForNodeKeepsOneClientPerNode fails without this -- watched red,
"2, want 1" -- and pins the verify-mode cleanup on the same cache.

* style(nodes): keep the eviction comment inside the two-line cap
2026-09-15 15:29:49 +03:00
BlindMaster24 574caa63e9 fix(inbounds): check ports when an inbound is enabled, not only when it is saved (#6549)
* fix(inbounds): check ports when an inbound is enabled, not only when it is saved

The save-time guards compare enabled rows, so a row could be created while
another disabled row held its port and only collide once the disabled one was
switched on. Run the same checks before the flag moves: the refusal names the
row that owns the port, the flag is left alone, and tcp/udp coexistence and
node rows keep working.

* docs(inbounds): state the real reason the enable path needs its own check
2026-09-15 15:29:32 +03:00
BlindMaster24 baef3cdd07 fix(xray): refuse a config the running core cannot bind (#6547)
* fix(xray): refuse a config the running core cannot bind

RestartXray stopped a working core before handing it a config whose listens
collide, so the failed bind exited the whole process (main/run.go:94) and the
one-second watchdog retried it in a loop: every protocol down, cause only in
the logs. The save-time port guards cannot cover this -- SetInboundEnable, the
AmneziaWG relay created on the first peer, template and bridge edits all reach
a colliding config with no guard on that path.

Probe the generated config at the single restart funnel instead. Collisions the
running core already serves are excused, so an established setup is never
refused by a static read being wrong about it, and the port-bucketed pass costs
nothing on a clean config.

* fix(xray): surface a refused config and re-key the bind excuse set

Round-1 findings on this PR. Refusing the swap left the running core on its
previous config with nothing but a log line to show for it, so the status
response now carries the reason while the core runs and the overview marks it;
the node list picks the same field up through that response. The excuse set is
keyed on the two listens, the port and the shared transports instead of the tag
pair, so a pair whose listen moves onto the other's address is refused again,
while the same two sockets stay excused however the generator orders them.

TestBindConflicts/excused_pair_whose_listen_changed_into_a_real_collision fails
without the key change -- watched red first.
2026-09-15 15:27:28 +03:00
BlindMaster24 43e64993fc fix(amneziawg): refuse a row's own relay port and keep a disabled row's slot reserved (#6544)
* fix(amneziawg): refuse a WireGuard port that is the row's own relay port

All three relay checks filter themselves out of the candidates with id !=
ignoreId, so nothing ever compared an AmneziaWG row's own WireGuard listen port
with the relay port its own id derives. Saving a row on that exact port left the
embedded device (UDP on the inbound's listen address, amneziawgnet/device.go:137)
and its injected relay (TCP and UDP on 127.0.0.1, amneziawgnet/relay.go:47-61)
bound to the same UDP port, so whichever loses the race dies -- and when the
relay loses it, Xray refuses the whole config and takes every other protocol on
the host with it. The first AmneziaWG inbound on port 65101 was enough to reach
it: id 1 derives exactly that port.

The row now states the rule its three siblings do: it owns the slot its id
derives. A node-hosted row still keeps its own port, since it binds no relay on
this host.

TestAddInbound_AmneziawgRefusesItsOwnRelayPort and
TestUpdateInbound_AmneziawgRefusesItsOwnRelayPort fail without this -- both were
watched red first -- and pin the two separate call sites, AddInbound's post-Save
block and checkPortConflictTx's ignoreId > 0 block.

* fix(amneziawg): keep a disabled row's relay port reserved for port forwards

loadPortConflictContext filtered its query with enable = true, so a client's
ForwardedPorts spec could claim the relay port a disabled AmneziaWG row's id
derives. That row's relay appears with its first client -- a path that runs no
port check -- and when the relay then loses the loopback bind race to the
forward listener, Xray refuses the whole config instead of losing one forward
(#6542 review, arrived with #6540).

The context now loads every local row and gates only the ordinary-port compare on
enable, which is what a disabled row's own port is worth: free. Its relay slot is
not free, which is the rule #6540 already states for the other two guards.

TestCheckForwardedPortsConflict_DisabledAmneziawgRelayPortIsReserved fails
without this -- watched red first -- and passes with it, while
TestCheckForwardedPortsConflict_IgnoresDisabledInboundPort keeps proving that a
disabled inbound's own port stays available.

* fix(amneziawg): re-run the forward guard once a new row has its own ports

normalizeAmneziaWGSettings validates every client's ForwardedPorts before the row
is saved, and loadPortConflictContext then reads the database -- so the new
AmneziaWG row is never a candidate for itself. A client could forward exactly the
relay port the row's own id derives, or its own WireGuard listen port, and the
create was accepted: at runtime the panel's wildcard forward listener and Xray's
127.0.0.1 relay race for the same port, and a lost relay bind makes Xray refuse
the whole generated config (#6544 review, pre-existing).

The post-Save block is the only place the id is known, so it re-runs the guard
there. Both callers now share amneziaWGForwardedPortsConflict, so the collision
message lives in one place instead of two.

TestAddInbound_AmneziawgRefusesAClientForwardingItsOwnRelayPort fails without
this -- watched red first -- and passes with it.

* fix(amneziawg): stop blocking stored forward specs on a disabled row's slot

Round 2 flagged this PR's widening as the one MEDIUM it introduced, and the code
confirms it: UpdateInboundClient carries a stored ForwardedPorts spec forward for
a partial edit (client_inbound_apply.go:763-765) and re-validates it (:772 and
:909), so after an in-place upgrade an edit that never submitted the field -- a
bot enable/expiry toggle -- is refused over a slot the operator did not touch,
for a relay injectAmneziawgnetSocks does not emit while the row is disabled. The
inbound-save path re-validates every stored spec the same way.

The trade does not pay for itself: the slot this reserves is claimable only by a
spec an operator authors onto 65101-65535, while the cost lands on unrelated
operations. The precise fix -- refuse a newly claimed spec rather than a stored
one, and check the enable transition in SetInboundEnable, where the conflict is
actually created -- is larger than the hole, so the slot goes back to a
documented pre-existing item with its own follow-up.

The create-path re-run added in 80eb5712 is unaffected: it reads the settings
submitted in the same request, so it never refuses a stored value, and its test
still passes.
2026-09-15 13:31:07 +03:00
BlindMaster24 d52b598abf fix(amneziawg): reserve the relay port before an AmneziaWG inbound has a peer (#6542)
* test(amneziawg): pin that a peerless inbound still owns its relay port

checkAmneziawgnetSocksConflict skips a candidate whose settings yield no
qualifying peer, and normalizeAmneziaWGSettings writes Clients: [] for a fresh
AmneziaWG inbound -- so a newly created row reserves nothing, an ordinary
inbound can take its derived port, and adding that row's first client then puts
two inbounds on 127.0.0.1:65101. The client paths run no port check.

Expected red on this head; the fix follows.

* fix(amneziawg): reserve the relay port before the first peer is added

checkAmneziawgnetSocksConflict skipped a candidate whose settings yield no
qualifying peer (amneziawg.InstanceFromInbound), and normalizeAmneziaWGSettings
writes Clients: [] for a fresh AmneziaWG inbound. A newly created row therefore
reserved nothing, an ordinary inbound could be saved onto the port that row
derives, and adding its first client generated the relay next to it: two inbounds
on 127.0.0.1:65101, which makes Xray refuse the whole config and take every other
protocol on the host down with it. Nothing re-checked it later either -- only
AddInbound and UpdateInbound run checkPortConflictTx, and the client paths that
create the first peer run no port check at all.

Ownership now follows the row, so the check states the same rule as its two
siblings, which key on protocol and node_id IS NULL alone. The amneziawg import
goes with the guard.

TestCheckPortConflict_AmneziawgnetSocksRelayReservedBeforeTheFirstPeer fails
without this, on a test-only head whose go-test run failed on exactly that test,
and passes with it.

* docs(amneziawg): stop the forward check's doc block claiming every row gets a relay

Round-1 LOW: the block's justification clause read "every one of them gets a
relay inbound", which is false for exactly the rows this change newly reserves
for -- injectAmneziawgnetSocks skips a row with no peer email, and that is the
row whose port must stay reserved. A reader following the cross-reference landed
on the guard this branch removes and read it as the rule.

Replaced by the two facts that are true, which also brings the block under
CLAUDE.md's two-line cap instead of twelve lines over it. The peerless reason
stays where it is load-bearing, in the two-line comment above the candidate loop.
2026-09-15 11:49:18 +03:00
BlindMaster24 2d8d304850 fix(amneziawg): stop a disabled inbound's relay slot from being taken (#6540)
* test(amneziawg): pin that a disabled row still owns its relay slot

checkAmneziawgnetSocksConflict filters enable = true, so a disabled AmneziaWG
row is not a candidate when an ordinary inbound's configured port is validated.
SetInboundEnable then flips the column with no port check, so enabling that row
later puts a second inbound on 127.0.0.1:65101 and Xray refuses the whole config.
Expected red on this head; the fix follows.

* fix(amneziawg): count a disabled inbound as owning its relay slot

The forward port check filtered its candidates with enable = true, so a disabled
AmneziaWG row was invisible when an ordinary inbound's configured port was
validated. Nothing else covered the gap: the relay is not a database row, and
SetInboundEnable flips the column with no port check, so re-enabling that row put
a second inbound on 127.0.0.1:65101 and made Xray refuse its whole config,
taking every other protocol on the host down with it.

A row owns the slot its id derives for as long as the row exists, which is the
rule the reverse-direction check already follows. TestCheckPortConflict_
DisabledAmneziawgStillOwnsItsRelaySlot fails without this, on a test-only head
whose go-test run failed on exactly that test, and passes with it.

* test(amneziawg): drop the disabled-row case that asserts the reversed rule

TestCheckPortConflict_AmneziawgnetSocksRelayIgnoredWhenDisabled stated, in its
name and its doc comment, that a disabled AmneziaWG inbound's port must not
block anything -- the rule the parent commit reverses. It also never reached the
predicate it named: its fixture seeds Settings: {}, which
amneziawg.InstanceFromInbound rejects on parsed.Server == nil one statement
before the enable column is read, so it passed with or without the filter.

Leaving it would document both rules for the same operator state with nothing
failing to flag the contradiction. The rule this PR pins is covered for real by
TestCheckPortConflict_DisabledAmneziawgStillOwnsItsRelaySlot, whose fixture
carries a qualifying server block and an enabled peer.
2026-09-15 11:33:02 +03:00
BlindMaster24 a036ddd66f fix(amneziawg): wrap the relay port window instead of refusing ids past it (#6539)
* fix(amneziawg): wrap the relay port window instead of refusing ids past it

An AmneziaWG inbound's loopback relay port is SOCKSBasePort + row id, and
AddInbound refused any id that pushed it past 65535. The inbounds table is
AUTOINCREMENT, so an id is never reused and the counter is only reset when the
table empties: the 435-port window was a lifetime budget, and a database that
had ever created more inbounds could never create another AmneziaWG one --
the reporter's counter sits at 70350, so the protocol never worked there at all
(#6537).

Ids now wrap into the same 435 ports, which leaves every id up to 435 with the
exact port it had, so no existing row, relay or generated config moves.

Wrapping makes the id -> port map non-injective, and nothing compared two
derived relay ports before -- two relays on one port would leave Xray with a
duplicate listen and refuse to start, taking the whole panel's proxy down.
checkAmneziawgnetSocksRelayCollision now refuses a create or an edit whose
derived port another local AmneziaWG row already owns, disabled rows included:
a row owns its slot for good, and enabling it later re-runs no port check.

* test(amneziawg): give each relay-window fixture its own client email

Every fixture built the same client email, and an email is unique across the
whole panel, so AddInbound refused the second create with "Duplicate email"
before either new guard ran -- CI exercised neither the wrap nor the collision
refusal. Each fixture now derives its email from its own tag, which is what the
tag already exists for.

* fix(amneziawg): say relay port in the relay conflict message

A refusal that named the port of the automatic loopback relay read as if the
named inbound listened on an unrelated port -- its own port is the WireGuard
one. portConflictDetail now carries Relay, and both messages that report a
derived relay port say "relay port N"; messages that report a configured port
render byte-for-byte as before.

* test(amneziawg): pin that a node-assigned inbound owns no relay slot

A row adopted from a node carries a NodeID and the protocol it arrived with
(inbound_node.go:737), yet injectAmneziawgnetSocks skips it, so it binds no
loopback relay. The gate this PR added to checkPortConflictTx never looked at
NodeID, so editing such a row can be refused for a slot it does not own.
Expected red on this head; the fix follows.

* fix(amneziawg): skip the relay guards for node-assigned inbounds

Round-2 review finding: the gate this PR added to checkPortConflictTx keyed on
inbound.Protocol alone, so it also ran for a row adopted from a node. Such a row
carries a NodeID and gets no loopback relay -- injectAmneziawgnetSocks skips it
and the desired-instance query is node_id IS NULL -- so it owns no slot and can
collide with nothing, yet editing it was refused with "relay port N ... already
used by inbound '<local>'", naming a port the edited row never binds.

Wrapping made this visible: before it, an adopted id above 435 derived a port
above 65535 that no row could hold, so the pre-existing reverse check under the
same gate could not fire.

Both call sites now require NodeID == nil, matching the local-only predicate the
forward check already used. TestCheckPortConflict_NodeAssignedAmneziawgOwnsNoRelaySlot
fails without this, with the exact false refusal, and passes with it.
2026-09-15 10:07:14 +03:00
BlindMaster24 78ab7a9246 fix(amneziawg): read the outbound pseudo-protocol id like the core (#6531)
* fix(amneziawg): read the outbound pseudo-protocol id like the core

IsAmneziaWGOutbound compared the id exactly while every reader around it does
not: the probe lane already reads the same id with strings.EqualFold
(outbound/probe_http.go, pinned by TestBuildBatchTestConfigReadsTheProtocolIDLikeTheCore),
and the core lowercases a protocol id before it resolves the handler.

A template entry spelled "AmneziaWG" therefore stayed unbridged in two paths.
transformAmneziaWGOutbounds skipped it and handed the raw pseudo-protocol to
the core, which answers "unknown config id: amneziawg" -- Xray then fails to
start, since bridging is what makes that entry a socks outbound. The amneziawg
job skipped it too, so the reconcile loop never created the instance and the
outbound silently carried no tunnel.

The exact comparison also made the save path answer two ways for one spelling:
CheckXrayConfig routed the exact match to the panel's own validator and the
case variant to the core's, so the operator was told the core does not know a
protocol the panel implements (probe output, before: `xray core rejects
outbound "t1": infra/conf: unknown config id: amneziawg` for "AmneziaWG" and
`amneziawg outbound "t1": privateKey is required` for "amneziawg"; after: the
panel's own message for both).

Reachable only from a template that did not come through the panel's save,
which rejects the case variant today -- a restored backup, a direct DB edit, a
scripted template, or a legacy DB. That is the same class of data the
UppercaseFreedomFinalRulesFix seeder exists to repair, so the panel already
treats non-lowercase protocol ids as real operator input.

strings.EqualFold is the whole change; the package already imports strings.

* style(service): trim the amneziawg outbound test comment to two lines

The review flagged the three-line block: CLAUDE.md caps a committed Go
comment block at two lines and the test name already carries the what. The
remaining two lines keep the why — the core folds the id's case before
resolving it, so a mixed-case spelling must bridge here too.
2026-09-15 08:23:18 +03:00
BlindMaster24 a810f497e6 fix(xray): read the last two inboundTag protocol ids like the core (#6530)
The core lowercases an outbound's protocol id before it resolves the handler,
so an outbound spelled "Loopback" still is the loopback outbound. Both
readers that keep a loopback outbound's inboundTag in step with the inbound
it names compared the id exactly, so such an outbound was skipped: renaming
or deleting that inbound left settings.inboundTag pointing at a tag that no
longer exists, and traffic returning through the loopback outbound arrives
under a tag no routing rule can match (infra/conf/loopback.go:15 carries the
tag, proxy/loopback/loopback.go:43 uses it as the inbound identity).

The probe lane's "nothing to test here" gate had the same exact comparison,
so a "Freedom"/"Blackhole" outbound reported the vaguer "No testable
endpoint" where the canonical spelling reports "Outbound has no testable
endpoint" — the two spellings took different paths to the same rejection.

Both readers now compare case-insensitively; the outbound package reuses its
existing equalsAnyFold helper rather than adding a second one. The service
reads the config template an operator edits, so a case variant is reachable
there; server.go's GetDefaultLogOutboundTags scans the embedded config.json
instead, whose protocols are canonical by construction, so it is left as is
and no test can tell a case-insensitive read there from an exact one.
2026-09-14 21:18:31 +03:00
416 changed files with 25980 additions and 4997 deletions
+2 -2
View File
@@ -100,8 +100,8 @@ question it already answers.
subtests and `t.Helper()` on helpers. An assertion must pin the exact value, subtests and `t.Helper()` on helpers. An assertion must pin the exact value,
typed error or emitted string — `err != nil` and `len(x) > 0` are findings, typed error or emitted string — `err != nil` and `len(x) > 0` are findings,
not nits. Prefer real dependencies: a throwaway DB via not nits. Prefer real dependencies: a throwaway DB via
`database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` with `t.Cleanup`, and `dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))`
`httptest` for HTTP. `internal/sub`'s `initSubDB(t)` is the template. (`internal/database/dbtest`), and `httptest` for HTTP. `internal/sub`'s `initSubDB(t)` is the template.
A test must FAIL without its fix; one that passes either way certifies A test must FAIL without its fix; one that passes either way certifies
nothing and then gets cited as proof the fix works. nothing and then gets cited as proof the fix works.
+5 -5
View File
@@ -83,12 +83,12 @@ jobs:
- name: PostgreSQL schema and migration tests - name: PostgreSQL schema and migration tests
run: | run: |
set -o pipefail set -o pipefail
go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres|TestClientWeeklyRenewMigration_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log
# Both must pass. Counting, not SKIP-matching: renaming either test would # All must pass. Counting, not SKIP-matching: renaming a test would
# otherwise leave this step green while testing nothing. # otherwise leave this step green while testing nothing.
passed=$(grep -c -- '--- PASS' /tmp/postgres-schema.log || true) passed=$(grep -c -- '^--- PASS' /tmp/postgres-schema.log || true)
if [ "$passed" -lt 2 ]; then if [ "$passed" -lt 3 ]; then
echo "expected 2 passing PostgreSQL schema tests, got $passed" >&2 echo "expected at least 3 passing PostgreSQL schema tests, got $passed" >&2
exit 1 exit 1
fi fi
+19 -3
View File
@@ -44,8 +44,8 @@ jobs:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_non_write_users: "*" allowed_non_write_users: "*"
claude_args: | claude_args: |
--model claude-opus-5 --model claude-opus-5-5
--effort xhigh --effort medium
--max-turns 300 --max-turns 300
--allowedTools "Bash(gh label list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh issue comment ${{ github.event.issue.number }}:*),Bash(gh issue edit ${{ github.event.issue.number }} --add-label:*),Bash(gh issue edit ${{ github.event.issue.number }} --remove-label:*),Bash(gh issue edit ${{ github.event.issue.number }} --title:*),Bash(gh issue close ${{ github.event.issue.number }}:*),Bash(gh search issues:*),Bash(gh search commits:*),Bash(gh search prs:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr list:*),Bash(gh release list:*),Bash(gh release view:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(git ls-tree:*),Bash(git tag:*),Read,Glob,Grep,Write(//tmp/**),Edit(//tmp/**)" --allowedTools "Bash(gh label list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh issue comment ${{ github.event.issue.number }}:*),Bash(gh issue edit ${{ github.event.issue.number }} --add-label:*),Bash(gh issue edit ${{ github.event.issue.number }} --remove-label:*),Bash(gh issue edit ${{ github.event.issue.number }} --title:*),Bash(gh issue close ${{ github.event.issue.number }}:*),Bash(gh search issues:*),Bash(gh search commits:*),Bash(gh search prs:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr list:*),Bash(gh release list:*),Bash(gh release view:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(git ls-tree:*),Bash(git tag:*),Read,Glob,Grep,Write(//tmp/**),Edit(//tmp/**)"
--disallowedTools "Read(//**/.git/**),Edit(//**/.git/**)" --disallowedTools "Read(//**/.git/**),Edit(//**/.git/**)"
@@ -437,8 +437,24 @@ jobs:
path: ${{ runner.temp }}/claude-execution-output.json path: ${{ runner.temp }}/claude-execution-output.json
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 7 retention-days: 7
- name: Fail if the analysis posted no reply # A refused credential ends the action with exit 0, so the step below cannot
# tell it from a reply that landed: the transcript is the only place it appears.
- name: Report an analysis the credential refused
id: refused
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
env:
TRANSCRIPT: ${{ runner.temp }}/claude-execution-output.json
ISSUE: ${{ github.event.issue.number }}
run: |
set -euo pipefail
[ -f "$TRANSCRIPT" ] || exit 0
jq -e 'any(.[]; .type == "result" and ((.api_error_status // 0) == 401 or (.api_error_status // 0) == 403))' "$TRANSCRIPT" >/dev/null 2>&1 \
|| jq -e 'any(.[]; ((.error // "") | test("^(oauth_|authentication_|invalid_api_key)")))' "$TRANSCRIPT" >/dev/null 2>&1 \
|| exit 0
echo "skipped=true" >> "$GITHUB_OUTPUT"
echo "::warning::No analysis of #${ISSUE}: the Claude credential was refused, so this issue was not examined."
- name: Fail if the analysis posted no reply
if: ${{ !cancelled() && steps.refused.outputs.skipped != 'true' }}
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
+18 -3
View File
@@ -118,8 +118,8 @@ jobs:
# allowedTools only pre-approves; it denies nothing. Only the deny list # allowedTools only pre-approves; it denies nothing. Only the deny list
# stops the review executing what it just checked out, or delegating. # stops the review executing what it just checked out, or delegating.
claude_args: | claude_args: |
--model claude-opus-5 --model claude-opus-5-5
--effort xhigh --effort medium
--max-turns 300 --max-turns 300
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh api:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment ${{ env.PR }}:*),Bash(grep:*),Bash(rg:*),Bash(ls:*),Bash(find:*),Bash(sed:*),Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git blame:*),Bash(go doc:*),Bash(go env:*),Read,Glob,Grep,WebFetch,WebSearch" --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh api:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment ${{ env.PR }}:*),Bash(grep:*),Bash(rg:*),Bash(ls:*),Bash(find:*),Bash(sed:*),Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git blame:*),Bash(go doc:*),Bash(go env:*),Read,Glob,Grep,WebFetch,WebSearch"
--disallowedTools "Agent,Bash(go build:*),Bash(go run:*),Bash(go test:*),Bash(go generate:*),Bash(go install:*),Bash(make:*),Bash(npm:*),Bash(npx:*),Bash(pnpm:*),Bash(yarn:*),Bash(node:*),Bash(bash:*),Bash(sh:*),Bash(docker:*),Bash(chmod:*),Edit,Write,NotebookEdit" --disallowedTools "Agent,Bash(go build:*),Bash(go run:*),Bash(go test:*),Bash(go generate:*),Bash(go install:*),Bash(make:*),Bash(npm:*),Bash(npx:*),Bash(pnpm:*),Bash(yarn:*),Bash(node:*),Bash(bash:*),Bash(sh:*),Bash(docker:*),Bash(chmod:*),Edit,Write,NotebookEdit"
@@ -228,10 +228,25 @@ jobs:
echo "skipped=true" >> "$GITHUB_OUTPUT" echo "skipped=true" >> "$GITHUB_OUTPUT"
echo "::notice::No review of #${PR}: ${reason}." echo "::notice::No review of #${PR}: ${reason}."
gh pr comment "$PR" --repo "$REPO" --body "No review ran on this head: ${reason}. Nothing in this pull request was examined. A maintainer can ask for one with \`@claude review\`." gh pr comment "$PR" --repo "$REPO" --body "No review ran on this head: ${reason}. Nothing in this pull request was examined. A maintainer can ask for one with \`@claude review\`."
# A refused credential ends the action with exit 0, so the step above never
# sees it: the transcript is the only place that refusal appears.
- name: Report a review the credential refused
id: refused
if: ${{ !cancelled() }}
env:
TRANSCRIPT: ${{ runner.temp }}/claude-execution-output.json
run: |
set -euo pipefail
[ -f "$TRANSCRIPT" ] || exit 0
jq -e 'any(.[]; .type == "result" and ((.api_error_status // 0) == 401 or (.api_error_status // 0) == 403))' "$TRANSCRIPT" >/dev/null 2>&1 \
|| jq -e 'any(.[]; ((.error // "") | test("^(oauth_|authentication_|invalid_api_key)")))' "$TRANSCRIPT" >/dev/null 2>&1 \
|| exit 0
echo "skipped=true" >> "$GITHUB_OUTPUT"
echo "::warning::No review of #${PR}: the Claude credential was refused, so nothing in this pull request was examined."
# updated_at, not created_at: a re-review may edit its earlier comment. # updated_at, not created_at: a re-review may edit its earlier comment.
# --paginate prints one jq count per page, so the pages are summed. # --paginate prints one jq count per page, so the pages are summed.
- name: Fail if the review posted nothing - name: Fail if the review posted nothing
if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' && steps.reviewed.outputs.done != 'true' && steps.throttled.outputs.skipped != 'true' }} if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' && steps.reviewed.outputs.done != 'true' && steps.throttled.outputs.skipped != 'true' && steps.refused.outputs.skipped != 'true' }}
env: env:
HEAD_SHA: ${{ steps.pinned-sha.outputs.sha }} HEAD_SHA: ${{ steps.pinned-sha.outputs.sha }}
STARTED_AT: ${{ steps.started.outputs.at }} STARTED_AT: ${{ steps.started.outputs.at }}
-8
View File
@@ -11,12 +11,6 @@ on:
- "go.mod" - "go.mod"
- "go.sum" - "go.sum"
- "frontend/**" - "frontend/**"
pull_request:
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "frontend/**"
schedule: schedule:
- cron: "18 2 * * 2" - cron: "18 2 * * 2"
@@ -24,8 +18,6 @@ jobs:
analyze: analyze:
name: Analyze (${{ matrix.language }}) name: Analyze (${{ matrix.language }})
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
env:
CODEQL_ACTION_FILE_COVERAGE_ON_PRS: true
permissions: permissions:
security-events: write security-events: write
packages: read packages: read
+3 -12
View File
@@ -2,9 +2,11 @@ name: Release 3X-UI
on: on:
workflow_dispatch: workflow_dispatch:
# Only main (dev channel) and version tags ship binaries; build any other
# branch on demand via workflow_dispatch.
push: push:
branches: branches:
- "**" - main
tags: tags:
- "v*.*.*" - "v*.*.*"
paths: paths:
@@ -17,17 +19,6 @@ on:
- "x-ui.service.arch" - "x-ui.service.arch"
- "x-ui.service.rhel" - "x-ui.service.rhel"
- ".github/workflows/release.yml" - ".github/workflows/release.yml"
pull_request:
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.sh"
- "frontend/**"
- "x-ui.service.debian"
- "x-ui.service.arch"
- "x-ui.service.rhel"
- ".github/workflows/release.yml"
jobs: jobs:
build: build:
-69
View File
@@ -1,69 +0,0 @@
name: Deploy Smoke Tests
# Container smoke test for the unattended (cloud-init) install path.
# Runs when the install/deploy assets change on a branch push or PR, and
# again after a release-tag build finishes uploading its assets — passing the
# tag as an explicit version, so the green result verifies the release
# actually being shipped. That job deliberately runs the script from the
# default branch rather than checking out the tag: workflow_run executes in
# main's cache scope, so executing checked-out code there is a cache-poisoning
# surface (CodeQL actions/cache-poisoning/poisonable-step), and users pipe
# main's install.sh anyway.
# Tag pushes must NOT trigger the unpinned job directly: at that moment
# releases/latest still points at the previous release (#5756), and a `paths`
# filter alone cannot exclude them because a brand-new tag ref has no diff
# base, so it runs on every tag push.
on:
push:
branches:
- "**"
paths:
- "install.sh"
- "deploy/**"
- ".github/workflows/smoke.yml"
pull_request:
paths:
- "install.sh"
- "deploy/**"
- ".github/workflows/smoke.yml"
workflow_run:
workflows: ["Release 3X-UI"]
types: [completed]
permissions:
contents: read
jobs:
noninteractive-install:
if: github.event_name != 'workflow_run'
strategy:
fail-fast: false
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Non-interactive install smoke test
run: bash deploy/test/smoke-noninteractive.sh
release-tag-install:
if: >-
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v') &&
contains(github.event.workflow_run.head_branch, '.')
strategy:
fail-fast: false
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Pinned release install smoke test
env:
XUI_SMOKE_VERSION: ${{ github.event.workflow_run.head_branch }}
run: bash deploy/test/smoke-noninteractive.sh "$XUI_SMOKE_VERSION"
+1 -1
View File
@@ -1 +1 @@
24 26
+48 -15
View File
@@ -75,11 +75,18 @@ file locations when it can answer in one hop.
share-link or install-command output changes. share-link or install-command output changes.
## Hard rules (non-negotiable) ## Hard rules (non-negotiable)
- Fix size must match bug size. Find the root cause, then make the SMALLEST - Correct fix over small fix. Find the root cause and fix it the right way, however
change that removes it — a one-line guard beats a new subsystem. A small bug much code that takes. Size the change by what the correct fix needs, never by
does not earn new columns, jobs, abstractions, config knobs or helper layers. line count: when the right fix spans many files, or needs a migration, a shared
If a fix genuinely needs new architecture, say so and get agreement first; helper or a new abstraction, write it. A guard that hides the symptom while the
never ship it unasked next to the fix. cause survives is the wrong fix, however small. Two limits remain:
- Everything added must be something the correct fix needs. No speculative
knobs, unused extension points or "while I was here" rewrites. Unrelated
refactors and cleanups go in their own commit.
- Stop and ask only when the right fix needs a decision the code cannot answer:
a deliberate user-visible behaviour change, or two sound designs with a real
trade-off. Ask with a recommendation. Size alone is never a reason to stop,
defer or ship a smaller patch.
- Comments in committed Go/TS: 2 lines MAX per comment block. Make the name - Comments in committed Go/TS: 2 lines MAX per comment block. Make the name
carry the meaning first and rename rather than annotate; spend the 2 lines on carry the meaning first and rename rather than annotate; spend the 2 lines on
the *why* a name cannot hold — an invariant, an issue number, a non-obvious the *why* a name cannot hold — an invariant, an issue number, a non-obvious
@@ -113,19 +120,45 @@ file locations when it can answer in one hop.
explaining the why. Types in use: `fix`, `feat`, `chore`, `refactor`, `perf`, explaining the why. Types in use: `fix`, `feat`, `chore`, `refactor`, `perf`,
`docs`, `style`. `docs`, `style`.
## Tests: TDD, and only tests that can fail (Go and frontend)
- Work red → green → refactor.
- Bug: turn the reproduction into a test first, and watch it fail for the
reported reason.
- Feature: write the test for the first behaviour before writing its code.
- Then write the code that makes it pass, and refactor with the suite green.
If a test was written after the code, prove it anyway: revert the code, watch
the test go red, then restore. A test that passes either way is worse than no
test. It certifies nothing, and then gets cited as proof the fix works.
- Every test must name the failure it catches. When no test can reach a change
(workflow YAML, pure wiring, layout), say so and name the command that
demonstrates it. Never write a stand-in test.
- Fake tests are forbidden. Delete any you write or meet in the code you touch:
- tests of a getter, a constant, a rename, a pure map lookup, or an input the
function can never receive;
- tests that restate the implementation, such as recomputing the expected
value with the same formula or asserting that a mock was called exactly the
way the code calls it;
- mocking the unit under test, or mocking so much around it that the real
code path never runs;
- assertions too weak to fail: `err != nil`, `len > 0`, `toBeDefined()`, or
`not.toThrow()` alone;
- golden files or snapshots regenerated to match whatever the code now outputs;
- extra cases that exercise no distinct branch, and tests written to raise
coverage.
One real test that drives the bug through the actual code path beats five
that restate the code.
## Go conventions ## Go conventions
- Stdlib `testing` only (no testify). Table-driven, `t.Run` subtests, - Stdlib `testing` only (no testify). Table-driven, `t.Run` subtests,
`t.Helper()` on helpers. Assert the exact value / typed error / emitted `t.Helper()` on helpers. Assert the exact value / typed error / emitted
string, never just `err != nil`. Prefer real deps over mocks: throwaway DB via string, never just `err != nil`. Prefer real deps over mocks: throwaway DB via
`database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` + `dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))`
`t.Cleanup(func() { _ = database.CloseDB() })`; `httptest` for HTTP. (`internal/database/dbtest`: copies a once-migrated template and registers
`internal/sub`'s `initSubDB(t)` is the template. `CloseDB` cleanup; a fresh `database.InitDB` costs ~7x more, ~850ms under
- A test must fail without its fix. Write it, revert the fix, watch it go red, `-race`); `httptest` for HTTP. Keep `database.InitDB` for reopening a file or
restore. A test that passes either way is worse than no test: it certifies migrating a hand-built legacy DB. `internal/sub`'s `initSubDB(t)` is the template.
nothing and then gets cited as proof the fix works.
- Test what can actually break. No test for a getter, a constant, a rename, a
pure map lookup, or inputs the function can never receive. One real test that
drives the bug through the actual code path beats five that restate the code.
- Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`. - Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`.
- Postgres, xray-gRPC-e2e and scale tests `t.Skip` unless `XUI_TEST_PG_DSN`, - Postgres, xray-gRPC-e2e and scale tests `t.Skip` unless `XUI_TEST_PG_DSN`,
`XUI_DB_TYPE`+`XUI_DB_DSN`, `XRAY_E2E_BINARY` or `XUI_SCALE_TEST` is set — a `XUI_DB_TYPE`+`XUI_DB_DSN`, `XRAY_E2E_BINARY` or `XUI_SCALE_TEST` is set — a
@@ -136,7 +169,7 @@ file locations when it can answer in one hop.
- TS strict; `@typescript-eslint/no-explicit-any` is an error. Zod schemas in - TS strict; `@typescript-eslint/no-explicit-any` is an error. Zod schemas in
`src/schemas/` are the source of truth; infer types with `z.infer`, never `src/schemas/` are the source of truth; infer types with `z.infer`, never
hand-write. Do not edit `src/generated/`. hand-write. Do not edit `src/generated/`.
- Node 24 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type - Node 26 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type
stripping; Node 22 dies with `ERR_UNKNOWN_FILE_EXTENSION`. `npm test` includes stripping; Node 22 dies with `ERR_UNKNOWN_FILE_EXTENSION`. `npm test` includes
a headless-Chromium Storybook project, so run a headless-Chromium Storybook project, so run
`npx playwright install --with-deps chromium` once or `make verify` fails. `npx playwright install --with-deps chromium` once or `make verify` fails.
+8 -2
View File
@@ -5,7 +5,7 @@ Thanks for taking the time to contribute to 3x-ui. This guide gets a development
## Prerequisites ## Prerequisites
- **Go 1.27+** (the version pinned in `go.mod`) - **Go 1.27+** (the version pinned in `go.mod`)
- **Node.js 24 LTS** (the version pinned in `.nvmrc`) and npm 10+ (for the React frontend) - **Node.js 26** (the version pinned in `.nvmrc`) and npm 11+ (for the React frontend)
- **Git** - **Git**
- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below. - **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.
@@ -243,11 +243,17 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
Tests live next to the code (`foo.go` ↔ `foo_test.go`); frontend specs and golden fixtures live in `frontend/src/test/`. Tests live next to the code (`foo.go` ↔ `foo_test.go`); frontend specs and golden fixtures live in `frontend/src/test/`.
### Test first, and only tests that can fail
- **Red → green → refactor.** Write the test before the code. For a bug, the test reproduces the report; for a feature, it covers the first behaviour. Watch it fail, write the code that makes it pass, then refactor with the suite green.
- **Every test catches a named failure.** Don't test getters, constants or renames. Don't restate the implementation, mock the unit under test, write assertions too weak to fail, or regenerate snapshots to match whatever the code now outputs.
- **Fix the root cause the right way**, even when that takes more code. A small patch that hides the symptom is not a fix.
### Go conventions ### Go conventions
- **Stdlib `testing` only** — no testify. Table-driven with `t.Run` subtests and `t.Helper()` on helpers. - **Stdlib `testing` only** — no testify. Table-driven with `t.Run` subtests and `t.Helper()` on helpers.
- **Assert the contract, not internals.** Pin the exact value / typed error / emitted string — not `err != nil` or `len > 0`. A test that still passes when the behavior is broken is worse than no test. - **Assert the contract, not internals.** Pin the exact value / typed error / emitted string — not `err != nil` or `len > 0`. A test that still passes when the behavior is broken is worse than no test.
- **Real dependencies over mocks.** Get a throwaway DB with `database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` + `t.Cleanup(func() { _ = database.CloseDB() })` (Windows-safe), and use `httptest` servers for HTTP. The `internal/sub` suite's `initSubDB(t)` is the template. - **Real dependencies over mocks.** Get a throwaway DB with `dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))` from `internal/database/dbtest`: it copies a once-migrated template (migrating from scratch per test is ~7x slower, worst under `-race`) and closes the DB before `t.TempDir` cleanup (Windows-safe). Keep `database.InitDB` for reopening an existing file or migrating a hand-built legacy DB. Use `httptest` servers for HTTP. The `internal/sub` suite's `initSubDB(t)` is the template.
### Running ### Running
+2 -4
View File
@@ -9,6 +9,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel" "github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
) )
@@ -16,10 +17,7 @@ import (
func newTokenCLIEnv(t *testing.T) { func newTokenCLIEnv(t *testing.T) {
t.Helper() t.Helper()
t.Setenv("XUI_DB_FOLDER", t.TempDir()) t.Setenv("XUI_DB_FOLDER", t.TempDir())
if err := database.InitDB(config.GetDBPath()); err != nil { dbtest.InitDB(t, config.GetDBPath())
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
} }
func tokenNames(t *testing.T) []string { func tokenNames(t *testing.T) []string {
+2 -2
View File
@@ -3,6 +3,6 @@ import { llms } from 'fumadocs-core/source';
export const revalidate = false; export const revalidate = false;
export function GET() { export async function GET() {
return new Response(llms(source).index()); return new Response(await llms(source).index());
} }
+3 -3
View File
@@ -292,7 +292,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
├── install.sh / update.sh / x-ui.sh # VPS install + management CLI ├── install.sh / update.sh / x-ui.sh # VPS install + management CLI
├── x-ui.service.* / x-ui.rc # systemd units (debian/rhel/arch) + rc script ├── x-ui.service.* / x-ui.rc # systemd units (debian/rhel/arch) + rc script
├── windows_files/ # Windows service support ├── windows_files/ # Windows service support
└── .github/workflows/ # CI: ci.yml, codeql.yml, docker.yml, release.yml, smoke.yml, └── .github/workflows/ # CI: ci.yml, codeql.yml, docker.yml, release.yml,
# mutation.yml, cleanup_caches.yml, claude-pr-review.yml, # mutation.yml, cleanup_caches.yml, claude-pr-review.yml,
# claude-issue-analyst.yml # claude-issue-analyst.yml
``` ```
@@ -565,7 +565,7 @@ golangci-lint run # full lint (gofumpt + goimports formatting)
go run main.go # run the panel locally (serves embedded dist if built) go run main.go # run the panel locally (serves embedded dist if built)
``` ```
**Frontend (`cd frontend`, Node 24 — see `.nvmrc`):** **Frontend (`cd frontend`, Node 26 — see `.nvmrc`):**
```bash ```bash
npm install npm install
@@ -583,7 +583,7 @@ root → `go build ./...` / `go run main.go`.
**Docker:** `docker compose up -d` (uses `Dockerfile` + `DockerEntrypoint.sh`). **Docker:** `docker compose up -d` (uses `Dockerfile` + `DockerEntrypoint.sh`).
**CI** (`.github/workflows/`): `ci.yml` (build/test/lint), `codeql.yml` (security scan), **CI** (`.github/workflows/`): `ci.yml` (build/test/lint), `codeql.yml` (security scan),
`smoke.yml` (smoke tests), `mutation.yml` (mutation testing), `docker.yml` + `release.yml` `mutation.yml` (mutation testing), `docker.yml` + `release.yml`
(multi-arch image + release builds), `cleanup_caches.yml`, `claude-pr-review.yml` (PR review (multi-arch image + release builds), `cleanup_caches.yml`, `claude-pr-review.yml` (PR review
only - it changes no code), `claude-issue-analyst.yml` (issue triage). only - it changes no code), `claude-issue-analyst.yml` (issue triage).
+3 -3
View File
@@ -43,10 +43,10 @@ value defeats the point, since DPI can fingerprint it over time.
| ------------ | ---------------------------------------------------------------------------- | | ------------ | ---------------------------------------------------------------------------- |
| **Jc** | Number of junk packets sent before the handshake. | | **Jc** | Number of junk packets sent before the handshake. |
| **Jmin/Jmax** | Size range (bytes) for those junk packets. `Jmin` must not exceed `Jmax`. | | **Jmin/Jmax** | Size range (bytes) for those junk packets. `Jmin` must not exceed `Jmax`. |
| **S1/S2** | Padding added to the handshake init/response packets. `S1 + 56` must not equal `S2` — amneziawg-go rejects a value that would make both packets the same size. | | **S1/S2** | Padding added to the handshake init/response packets, `0`-`1552` / `0`-`1608`: the packets are `148 + S1` and `92 + S2` bytes and must fit the 1700-byte receive buffer amneziawg-go uses on iOS. The panel also rejects `S1 + 56 = S2`, which would give both packets the same size on the wire (amneziawg-go itself accepts it). An AmneziaWG outbound takes the remote server's values as they are, up to `65535`. |
| **S3** | Cookie-reply padding, `0`-`64`. | | **S3** | Cookie-reply padding, `0`-`1636`: the reply is `64 + S3` bytes and must fit the 1700-byte receive buffer amneziawg-go uses on iOS. An outbound, as with S1/S2, takes the remote server's value up to `65535`. |
| **S4** | Transport (data) packet padding, `0`-`32`. | | **S4** | Transport (data) packet padding, `0`-`32`. |
| **H1-H4** | Magic header values that replace WireGuard's standard message-type bytes. Each is a single integer or a `low-high` range; `1`-`4` are reserved (real WireGuard message types) and must not be used. | | **H1-H4** | Header values that replace WireGuard's message-type field. Each is a single integer or a `low-high` range, and the four must not overlap — amneziawg-go and the kernel module refuse the whole device otherwise. `1`-`4` are WireGuard's own types and the engine default for a blank field: valid, but without a HeaderProtectionKey the type field then reads like plain WireGuard. |
| **I1-I5** | Optional signature packets — random bytes prepended before the handshake, e.g. `<r 148>`. Generated sets fill `I1` only, matching Amnezia's own generator. | | **I1-I5** | Optional signature packets — random bytes prepended before the handshake, e.g. `<r 148>`. Generated sets fill `I1` only, matching Amnezia's own generator. |
| **HeaderProtectionKey** | A base64 32-byte key for the 3.0 header-protection mechanism. Must match on every client config; blank disables it. | | **HeaderProtectionKey** | A base64 32-byte key for the 3.0 header-protection mechanism. Must match on every client config; blank disables it. |
| **ContentPaddingAddition** | A single integer or `low-high` byte range of extra padding on content packets. Kept `<= 64` by the generator so a 1420-MTU tunnel doesn't fragment. | | **ContentPaddingAddition** | A single integer or `low-high` byte range of extra padding on content packets. Kept `<= 64` by the generator so a 1420-MTU tunnel doesn't fragment. |
+65 -3
View File
@@ -20,15 +20,15 @@ inbounds** at once, with per-client traffic accounting.
| **Limit IP** | all (except TUIC) | Max simultaneous source IPs (enforced via Fail2ban). | | **Limit IP** | all (except TUIC) | Max simultaneous source IPs (enforced via Fail2ban). |
| **Total (GB)** | all (except TUIC) | Traffic quota; the client is disabled when exhausted (for TUIC, limits are set at the inbound level). | | **Total (GB)** | all (except TUIC) | Traffic quota; the client is disabled when exhausted (for TUIC, limits are set at the inbound level). |
| **Expiry** | all | Date after which the client stops working. | | **Expiry** | all | Date after which the client stops working. |
| **Reset** | all | Auto-renew period in **days** (rolls the quota over). | | **Auto renewal** | all | Disabled, fixed interval in days, calendar weekly, or calendar monthly. |
| **Telegram ID**| all | Links the client to a Telegram user for self-service/notifications.| | **Telegram ID**| all | Links the client to a Telegram user for self-service/notifications.|
| **Sub ID** | all | Subscription identifier grouping this client's links. | | **Sub ID** | all | Subscription identifier grouping this client's links. |
| **Group** | all | Optional client group for organization and bulk filtering. | | **Group** | all | Optional client group for organization and bulk filtering. |
| **Comment** | all | Free-text note. | | **Comment** | all | Free-text note. |
<Callout type="info"> <Callout type="info">
Reaching the **traffic** or **expiry** limit disables the client; the panel can Reaching the **traffic** or **expiry** limit disables the client, and a client
restart Xray automatically when clients are auto-disabled disabled or deleted by hand counts too; the panel restarts Xray then
(`restartXrayOnClientDisable`, on by default). (`restartXrayOnClientDisable`, on by default).
</Callout> </Callout>
@@ -42,6 +42,68 @@ inbounds** at once, with per-client traffic accounting.
- **Online status** and **last-online** times are tracked per client (and per - **Online status** and **last-online** times are tracked per client (and per
node in multi-node setups). node in multi-node setups).
## Automatic renewal
The individual and bulk-create forms offer one renewal mode at a time:
| Mode | API fields | Schedule |
| --- | --- | --- |
| Disabled | `reset=0`, `resetDay=0`, `resetWeekday=0` | The expiry is not renewed. |
| Fixed interval | `reset=N`, other two fields `0` | Add exactly N × 24 hours to the previous cutoff. |
| Calendar weekly | `resetWeekday=1..7`, other two fields `0` | Renew at panel-local midnight on Monday (1) through Sunday (7). |
| Calendar monthly | `resetDay=1..31`, `resetWeekday=0` | Renew at panel-local midnight on that day; missing dates clamp to the month's last day without losing the configured day. |
Calendar weeks stay on the selected weekday across daylight-saving changes;
they are not equivalent to a fixed seven-day interval. A skipped midnight uses
the first valid instant of that date; a repeated midnight uses the first one.
If a timezone skips the entire selected date, the next matching week is used.
Existing monthly clients
that also have `reset` set retain monthly precedence. The API rejects weekly
renewal combined with a positive `reset` or `resetDay`.
For a full calendar month, select **monthly, day 1** and set the initial cutoff
to the next month's first midnight. For example, `2030-09-01 00:00:00` is valid
through `2030-08-31 23:59:59`. Day 31 renews at the **start** of the 31st and is
not the same schedule. The existing optional month-end subscription-header
display remains a separate setting and is not enabled by this form.
The preview uses the panel's timezone and the same calendar/catch-up calculation
as automatic renewal. It shows the cutoff, last valid second, next expiry, and
allowances needed. It is informational: it does not save, activate, reserve, or
guarantee a future renewal. When no expiry is set, auto-renewal cannot run; an
explicit button can set the first calendar cutoff. Selecting a mode alone never
rewrites an existing expiry. First-use clients keep their initial duration, and
their calendar dates are available after activation.
For legacy last-second calendar cutoffs, the renewal boundary includes the
existing free alignment to the following midnight. The last-valid-second
preview still uses the **stored expiry**, not that alignment: an exclusive
`23:59:59` cutoff is valid through `23:59:58`. Use a next-midnight cutoff for
full-day validity; the preview itself does not repair the initial expiry.
`resetMax=0` means unlimited renewals. A positive limit counts **each elapsed
period**, including offline catch-up, not each scheduler tick or attached inbound.
If the remaining allowances cannot reach a future cutoff, the client stays
expired and its traffic is not reset. Operator-disabled clients stay disabled.
Renewal already resets client traffic. The separate **periodic traffic reset**
does not move the expiry and is unchanged; keep it disabled unless you intend an
additional reset. Quarterly, yearly, and every-N-week/month schedules are not
part of these modes.
<Callout type="warn">
Upgrade the main panel and every participating node before enabling weekly
renewal. Older versions ignore `resetWeekday`; a weekly-only client would not
auto-renew and, after its expiry or quota is exhausted, can be deleted by
**delete depleted clients** because older versions lack the weekly protection.
Back up the database and convert weekly schedules to a renewal mode supported
by every participating version before downgrading. Merely disabling weekly
renewal does not protect a depleted client from deletion. Avoid depleted-client
cleanup while a mixed-version fleet or unconverted weekly clients remain.
Database upgrades default this new field to `0` and preserve existing limits
and dates.
</Callout>
## Share links and external links ## Share links and external links
Every client has share links and a QR code for its inbounds, plus a combined Every client has share links and a QR code for its inbounds, plus a combined
@@ -94,6 +94,25 @@ Subscriptions return standard headers that compatible apps read:
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**, - **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
**`Announce`** — optional branding shown by some clients. **`Announce`** — optional branding shown by some clients.
### Profile page links and upgrades
In **Subscription → Profile → Profile page**, choose `subProfileMode` for all
subscription clients:
- **No link** (`none`, default): omit `Profile-Web-Page-Url`.
- **Built-in subscription page** (`builtin`): link to the client's built-in page.
- **Custom website** (`custom`): use `subProfileUrl`; a blank URL omits the header.
**Upgrade note:** previously, an empty `subProfileUrl` automatically linked to
the built-in page. After upgrading, an unset mode with an empty or whitespace-only
URL becomes **No link**; an existing nonempty URL remains a **Custom website**.
To restore the built-in link, select **Built-in subscription page** above and
save the settings.
The built-in page exposes subscription URLs and node configurations, including
for Happ encrypted subscriptions. Enable it only if you intend to provide that
access.
### Optional month-end expiry display ### Optional month-end expiry display
Under **Subscription → Information**, **Month-end subscription expiry display** Under **Subscription → Information**, **Month-end subscription expiry display**
@@ -22,6 +22,12 @@ _openapi:
this — the middleware short-circuits CSRF for authenticated API this — the middleware short-circuits CSRF for authenticated API
requests. requests.
url: '#mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests' url: '#mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests'
- depth: 2
title: Public. Active paid sponsor placements read from the project
sponsors.json (cached for 1h); expired entries are dropped. Logos are
proxied by the panel at /sponsors/logo/{name}. Used by the login page
and panel sponsor slots.
url: '#public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-expired-entries-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots'
- depth: 2 - depth: 2
title: Returns whether 2FA is enabled on the panel — used by the login page to title: Returns whether 2FA is enabled on the panel — used by the login page to
decide whether to show the OTP field. decide whether to show the OTP field.
@@ -39,6 +45,11 @@ _openapi:
this — the middleware short-circuits CSRF for authenticated API this — the middleware short-circuits CSRF for authenticated API
requests. requests.
id: mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests id: mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests
- content: Public. Active paid sponsor placements read from the project
sponsors.json (cached for 1h); expired entries are dropped. Logos are
proxied by the panel at /sponsors/logo/{name}. Used by the login page
and panel sponsor slots.
id: public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-expired-entries-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots
- content: Returns whether 2FA is enabled on the panel — used by the login page to - content: Returns whether 2FA is enabled on the panel — used by the login page to
decide whether to show the OTP field. decide whether to show the OTP field.
id: returns-whether-2fa-is-enabled-on-the-panel--used-by-the-login-page-to-decide-whether-to-show-the-otp-field id: returns-whether-2fa-is-enabled-on-the-panel--used-by-the-login-page-to-decide-whether-to-show-the-otp-field
@@ -54,7 +65,7 @@ export default function Layout(props) {
return ( return (
<> <>
{props.children} {props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle /> <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/sponsors","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
</> </>
); );
} }
+54 -27
View File
@@ -37,6 +37,9 @@ _openapi:
call. Body is JSON. Per-protocol secrets are generated server-side when call. Body is JSON. Per-protocol secrets are generated server-side when
omitted, so callers can send only the universal fields. omitted, so callers can send only the universal fields.
url: '#create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields' url: '#create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields'
- depth: 2
title: Preview client auto-renewal dates without saving or resetting anything.
url: '#preview-client-auto-renewal-dates-without-saving-or-resetting-anything'
- depth: 2 - depth: 2
title: Update an existing client by email. Changes propagate to every attached title: Update an existing client by email. Changes propagate to every attached
inbound. Body is the JSON client payload — supply the full set of fields inbound. Body is the JSON client payload — supply the full set of fields
@@ -78,21 +81,27 @@ _openapi:
Returns the deleted count. Cannot be undone. Returns the deleted count. Cannot be undone.
url: '#delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-hwid-devices-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone' url: '#delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-hwid-devices-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone'
- depth: 2 - depth: 2
title: Return every client as a {client, inboundIds} array — the same shape title: Return every client as a {client, inboundIds, traffic} array — the shape
/bulkCreate and /import accept — so the payload round-trips straight /import accepts — so the payload round-trips straight back through
back through /import. Clients with no inbound attachment are included /import. traffic carries the usage counters (up, down, resetCount,
with an empty inboundIds list. The UI shows this in a CodeMirror viewer lastOnline, lastSubFetch) and is omitted for a client with no traffic
(copy / download); programmatic callers get the array in obj. row; the quota itself stays in client.totalGB. Clients with no inbound
url: '#return-every-client-as-a-client-inboundids-array--the-same-shape-bulkcreate-and-import-accept--so-the-payload-round-trips-straight-back-through-import-clients-with-no-inbound-attachment-are-included-with-an-empty-inboundids-list-the-ui-shows-this-in-a-codemirror-viewer-copy--download-programmatic-callers-get-the-array-in-obj' attachment are included with an empty inboundIds list. The UI shows this
in a CodeMirror viewer (copy / download); programmatic callers get the
array in obj.
url: '#return-every-client-as-a-client-inboundids-traffic-array--the-shape-import-accepts--so-the-payload-round-trips-straight-back-through-import-traffic-carries-the-usage-counters-up-down-resetcount-lastonline-lastsubfetch-and-is-omitted-for-a-client-with-no-traffic-row-the-quota-itself-stays-in-clienttotalgb-clients-with-no-inbound-attachment-are-included-with-an-empty-inboundids-list-the-ui-shows-this-in-a-codemirror-viewer-copy--download-programmatic-callers-get-the-array-in-obj'
- depth: 2 - depth: 2
title: 'Import clients from a JSON body { "data": "<json>" }, where data is a title: 'Import clients from a JSON body { "data": "<json>" }, where data is a
string-encoded array produced by /export ([{client, inboundIds}]). Items string-encoded array produced by /export ([{client, inboundIds,
with inboundIds are created and attached to those inbounds; items with traffic}]). Items with inboundIds are created and attached to those
an empty inboundIds list are restored as unattached client records. inbounds; items with an empty inboundIds list are restored as unattached
Existing emails are never overwritten — they are returned in skipped. client records. An optional traffic object restores the usage counters,
Triggers a single Xray restart at the end if any target inbound was only for clients this import creates. Existing emails are never
running.' overwritten — they are returned in skipped, and their live counters are
url: '#import-clients-from-a-json-body--data-json--where-data-is-a-string-encoded-array-produced-by-export-client-inboundids-items-with-inboundids-are-created-and-attached-to-those-inbounds-items-with-an-empty-inboundids-list-are-restored-as-unattached-client-records-existing-emails-are-never-overwritten--they-are-returned-in-skipped-triggers-a-single-xray-restart-at-the-end-if-any-target-inbound-was-running' left untouched. Triggers a single Xray restart at the end if any target
inbound was running; a failure while restoring counters still reports
success=false after the clients were created.'
url: '#import-clients-from-a-json-body--data-json--where-data-is-a-string-encoded-array-produced-by-export-client-inboundids-traffic-items-with-inboundids-are-created-and-attached-to-those-inbounds-items-with-an-empty-inboundids-list-are-restored-as-unattached-client-records-an-optional-traffic-object-restores-the-usage-counters-only-for-clients-this-import-creates-existing-emails-are-never-overwritten--they-are-returned-in-skipped-and-their-live-counters-are-left-untouched-triggers-a-single-xray-restart-at-the-end-if-any-target-inbound-was-running-a-failure-while-restoring-counters-still-reports-successfalse-after-the-clients-were-created'
- depth: 2 - depth: 2
title: 'Shift expiry and/or traffic quota for many clients in one call. title: 'Shift expiry and/or traffic quota for many clients in one call.
addDays/addBytes may be negative. Clients with unlimited expiry addDays/addBytes may be negative. Clients with unlimited expiry
@@ -317,6 +326,8 @@ _openapi:
call. Body is JSON. Per-protocol secrets are generated server-side call. Body is JSON. Per-protocol secrets are generated server-side
when omitted, so callers can send only the universal fields. when omitted, so callers can send only the universal fields.
id: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields id: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
- content: Preview client auto-renewal dates without saving or resetting anything.
id: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
- content: Update an existing client by email. Changes propagate to every attached - content: Update an existing client by email. Changes propagate to every attached
inbound. Body is the JSON client payload — supply the full set of inbound. Body is the JSON client payload — supply the full set of
fields you want to keep (the server replaces the row, it does not fields you want to keep (the server replaces the row, it does not
@@ -351,20 +362,26 @@ _openapi:
clearing clients left unattached after their inbounds were removed. clearing clients left unattached after their inbounds were removed.
Returns the deleted count. Cannot be undone. Returns the deleted count. Cannot be undone.
id: delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-hwid-devices-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone id: delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-hwid-devices-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone
- content: Return every client as a {client, inboundIds} array — the same shape - content: Return every client as a {client, inboundIds, traffic} array — the
/bulkCreate and /import accept — so the payload round-trips straight shape /import accepts — so the payload round-trips straight back
back through /import. Clients with no inbound attachment are included through /import. traffic carries the usage counters (up, down,
with an empty inboundIds list. The UI shows this in a CodeMirror resetCount, lastOnline, lastSubFetch) and is omitted for a client with
viewer (copy / download); programmatic callers get the array in obj. no traffic row; the quota itself stays in client.totalGB. Clients with
id: return-every-client-as-a-client-inboundids-array--the-same-shape-bulkcreate-and-import-accept--so-the-payload-round-trips-straight-back-through-import-clients-with-no-inbound-attachment-are-included-with-an-empty-inboundids-list-the-ui-shows-this-in-a-codemirror-viewer-copy--download-programmatic-callers-get-the-array-in-obj no inbound attachment are included with an empty inboundIds list. The
UI shows this in a CodeMirror viewer (copy / download); programmatic
callers get the array in obj.
id: return-every-client-as-a-client-inboundids-traffic-array--the-shape-import-accepts--so-the-payload-round-trips-straight-back-through-import-traffic-carries-the-usage-counters-up-down-resetcount-lastonline-lastsubfetch-and-is-omitted-for-a-client-with-no-traffic-row-the-quota-itself-stays-in-clienttotalgb-clients-with-no-inbound-attachment-are-included-with-an-empty-inboundids-list-the-ui-shows-this-in-a-codemirror-viewer-copy--download-programmatic-callers-get-the-array-in-obj
- content: 'Import clients from a JSON body { "data": "<json>" }, where data is a - content: 'Import clients from a JSON body { "data": "<json>" }, where data is a
string-encoded array produced by /export ([{client, inboundIds}]). string-encoded array produced by /export ([{client, inboundIds,
Items with inboundIds are created and attached to those inbounds; traffic}]). Items with inboundIds are created and attached to those
items with an empty inboundIds list are restored as unattached client inbounds; items with an empty inboundIds list are restored as
records. Existing emails are never overwritten — they are returned in unattached client records. An optional traffic object restores the
skipped. Triggers a single Xray restart at the end if any target usage counters, only for clients this import creates. Existing emails
inbound was running.' are never overwritten — they are returned in skipped, and their live
id: import-clients-from-a-json-body--data-json--where-data-is-a-string-encoded-array-produced-by-export-client-inboundids-items-with-inboundids-are-created-and-attached-to-those-inbounds-items-with-an-empty-inboundids-list-are-restored-as-unattached-client-records-existing-emails-are-never-overwritten--they-are-returned-in-skipped-triggers-a-single-xray-restart-at-the-end-if-any-target-inbound-was-running counters are left untouched. Triggers a single Xray restart at the end
if any target inbound was running; a failure while restoring counters
still reports success=false after the clients were created.'
id: import-clients-from-a-json-body--data-json--where-data-is-a-string-encoded-array-produced-by-export-client-inboundids-traffic-items-with-inboundids-are-created-and-attached-to-those-inbounds-items-with-an-empty-inboundids-list-are-restored-as-unattached-client-records-an-optional-traffic-object-restores-the-usage-counters-only-for-clients-this-import-creates-existing-emails-are-never-overwritten--they-are-returned-in-skipped-and-their-live-counters-are-left-untouched-triggers-a-single-xray-restart-at-the-end-if-any-target-inbound-was-running-a-failure-while-restoring-counters-still-reports-successfalse-after-the-clients-were-created
- content: 'Shift expiry and/or traffic quota for many clients in one call. - content: 'Shift expiry and/or traffic quota for many clients in one call.
addDays/addBytes may be negative. Clients with unlimited expiry addDays/addBytes may be negative. Clients with unlimited expiry
(expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the
@@ -592,6 +609,16 @@ _openapi:
one per line. `limitHwid` is applied only when every inbound one per line. `limitHwid` is applied only when every inbound
succeeded, so re-run the call after fixing the failure. succeeded, so re-run the call after fixing the failure.
heading: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields heading: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
- content: Uses the same calendar and catch-up calculation as auto-renew in the
panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables
weekly mode; it cannot be combined with positive reset or resetDay.
Existing resetDay takes precedence over reset. With expiryTime=0,
calendar modes suggest a first cutoff but do not activate renewal.
Negative expiryTime waits for first-use activation. resetMax and
resetCount simulate the existing per-period allowance limit; the
preview is informational and does not reserve an allowance or
guarantee node availability.
heading: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
- content: 'The inbounds are applied concurrently and independently: one that - content: 'The inbounds are applied concurrently and independently: one that
fails no longer stops the others. Every inbound error names the fails no longer stops the others. Every inbound error names the
inbound it came from (`inbound 7: <message>`), and several failures inbound it came from (`inbound 7: <message>`), and several failures
@@ -638,7 +665,7 @@ export default function Layout(props) {
return ( return (
<> <>
{props.children} {props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle /> <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/renewalPreview","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
</> </>
); );
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"title": "Reference", "title": "Reference",
"icon": "BookMarked", "icon": "BookBookmark",
"pages": ["env-vars", "database", "ports-firewall", "api"] "pages": ["env-vars", "database", "ports-firewall", "api"]
} }
+2 -2
View File
@@ -27,8 +27,8 @@ icon: Users
| **Comment** | همه | یادداشت متنی آزاد. | | **Comment** | همه | یادداشت متنی آزاد. |
<Callout type="info"> <Callout type="info">
رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال می‌کند؛ پنل می‌تواند رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال می‌کند؛ غیرفعال‌سازی یا
هنگام غیرفعال‌شدن خودکار کلاینت‌ها، Xray را به‌صورت خودکار راه‌اندازی مجدد کند حذف دستی کلاینت هم همین اثر را دارد؛ در این حالت پنل Xray را راه‌اندازی مجدد می‌کند
(`restartXrayOnClientDisable`، به‌صورت پیش‌فرض فعال). (`restartXrayOnClientDisable`، به‌صورت پیش‌فرض فعال).
</Callout> </Callout>
@@ -72,6 +72,23 @@ SOCKS/HTTP روی 127.0.0.1، DNS، مسیریابی، policy) به‌علاوه
- **`Profile-Title`**، **`Support-Url`**، **`Profile-Web-Page-Url`**، - **`Profile-Title`**، **`Support-Url`**، **`Profile-Web-Page-Url`**،
**`Announce`** — برندینگ اختیاری که برخی کلاینت‌ها نمایش می‌دهند. **`Announce`** — برندینگ اختیاری که برخی کلاینت‌ها نمایش می‌دهند.
### لینک صفحه پروفایل
در تنظیمات **سابسکریپشن ← پروفایل**، گزینه **صفحه پروفایل** (`subProfileMode`)
لینک را برای همه کلاینت‌های اشتراک کنترل می‌کند:
- **بدون لینک** (`none`، پیش‌فرض) — هدر `Profile-Web-Page-Url` ارسال نمی‌شود.
- **صفحه اشتراک داخلی** (`builtin`) — لینک صفحه اشتراک داخلی ارائه می‌شود.
- **وب‌سایت سفارشی** (`custom`) — آدرس `subProfileUrl` استفاده می‌شود؛ اگر خالی باشد، هدر ارسال نمی‌شود.
**پس از ارتقا:** اگر `subProfileMode` هنوز تنظیم نشده و مقدار قبلی `subProfileUrl`
خالی یا فقط شامل فاصله باشد، به‌جای لینک خودکار صفحه داخلی، حالت **بدون لینک**
انتخاب می‌شود. آدرس سفارشی غیرخالی قبلی در حالت **وب‌سایت سفارشی** حفظ می‌شود.
برای بازگرداندن لینک قبلی، در همین بخش **صفحه اشتراک داخلی** را انتخاب و تنظیمات
را ذخیره کنید. این صفحه آدرس‌های اشتراک و پیکربندی گره‌ها را آشکار می‌کند، حتی
برای اشتراک‌های رمزگذاری‌شده Happ.
## قالب‌های سفارشی صفحه ## قالب‌های سفارشی صفحه
برای برندینگ صفحه‌ی HTML اشتراک، `subThemeDir` را به یک پوشه‌ی حاوی قالب سفارشیِ برای برندینگ صفحه‌ی HTML اشتراک، `subThemeDir` را به یک پوشه‌ی حاوی قالب سفارشیِ
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"title": "مرجع", "title": "مرجع",
"icon": "BookMarked", "icon": "BookBookmark",
"pages": ["env-vars", "database", "ports-firewall", "api"] "pages": ["env-vars", "database", "ports-firewall", "api"]
} }
+3 -3
View File
@@ -28,9 +28,9 @@ icon: Users
| **Comment** | все | Произвольная текстовая заметка. | | **Comment** | все | Произвольная текстовая заметка. |
<Callout type="info"> <Callout type="info">
Достижение лимита **трафика** или **срока действия** отключает клиента; при Достижение лимита **трафика** или **срока действия** отключает клиента, как и
автоматическом отключении клиентов панель может автоматически перезапускать ручное отключение или удаление; тогда панель перезапускает Xray
Xray (`restartXrayOnClientDisable`, включено по умолчанию). (`restartXrayOnClientDisable`, включено по умолчанию).
</Callout> </Callout>
## Лимиты и контроль IP ## Лимиты и контроль IP
@@ -76,6 +76,24 @@ policy) плюс исходящее соединение `proxy`, указыва
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**, - **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
**`Announce`** — необязательный брендинг, отображаемый некоторыми клиентами. **`Announce`** — необязательный брендинг, отображаемый некоторыми клиентами.
### Ссылка на страницу профиля
В настройках **Подписка → Профиль** поле **Страница профиля** (`subProfileMode`)
управляет ссылкой для всех клиентов подписки:
- **Без ссылки** (`none`, по умолчанию) — заголовок `Profile-Web-Page-Url` не отправляется.
- **Встроенная страница подписки** (`builtin`) — ссылка на встроенную страницу подписки.
- **Свой сайт** (`custom`) — адрес из `subProfileUrl`; если он пуст, заголовок не отправляется.
**После обновления:** если `subProfileMode` ещё не задан, а прежний `subProfileUrl`
пуст или содержит только пробелы, вместо автоматической ссылки на встроенную
страницу теперь используется **Без ссылки**. Существующий непустой пользовательский
адрес сохраняется в режиме **Свой сайт**.
Чтобы вернуть прежнюю ссылку, выберите **Встроенная страница подписки** в этом поле
и сохраните настройки. Эта страница раскрывает URL-адреса подписок и конфигурации
узлов, в том числе для зашифрованных подписок Happ.
## Пользовательские шаблоны страниц ## Пользовательские шаблоны страниц
Укажите в `subThemeDir` папку с пользовательским шаблоном информационной Укажите в `subThemeDir` папку с пользовательским шаблоном информационной
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"title": "Справочник", "title": "Справочник",
"icon": "BookMarked", "icon": "BookBookmark",
"pages": ["env-vars", "database", "ports-firewall", "api"] "pages": ["env-vars", "database", "ports-firewall", "api"]
} }
+55 -3
View File
@@ -19,15 +19,15 @@ icon: Users
| **Limit IP** | 全部(TUIC 除外) | 最大同时连接的源 IP 数量(通过 Fail2ban 强制执行)。 | | **Limit IP** | 全部(TUIC 除外) | 最大同时连接的源 IP 数量(通过 Fail2ban 强制执行)。 |
| **Total (GB)** | 全部(TUIC 除外) | 流量配额;用尽后客户端将被禁用(对于 TUIC,限制在入站级别设置)。 | | **Total (GB)** | 全部(TUIC 除外) | 流量配额;用尽后客户端将被禁用(对于 TUIC,限制在入站级别设置)。 |
| **Expiry** | 全部 | 该日期之后客户端停止工作。 | | **Expiry** | 全部 | 该日期之后客户端停止工作。 |
| **Reset** | 全部 | 以**天**为单位的自动续期周期(滚动重置配额)。 | | **自动续期** | 全部 | 关闭、固定天数、日历每周或日历每月。 |
| **Telegram ID**| 全部 | 将客户端关联到 Telegram 用户,用于自助服务/通知。 | | **Telegram ID**| 全部 | 将客户端关联到 Telegram 用户,用于自助服务/通知。 |
| **Sub ID** | 全部 | 用于对该客户端链接分组的订阅标识符。 | | **Sub ID** | 全部 | 用于对该客户端链接分组的订阅标识符。 |
| **Group** | 全部 | 可选的客户端分组,便于组织管理和批量筛选。 | | **Group** | 全部 | 可选的客户端分组,便于组织管理和批量筛选。 |
| **Comment** | 全部 | 自由文本备注。 | | **Comment** | 全部 | 自由文本备注。 |
<Callout type="info"> <Callout type="info">
达到**流量**或**到期**限制会禁用客户端;当客户端被自动禁用时,面板可以 达到**流量**或**到期**限制会禁用客户端,手动禁用或删除客户端同样如此;
自动重启 Xray(`restartXrayOnClientDisable`,默认开启)。 此时面板会重启 Xray(`restartXrayOnClientDisable`,默认开启)。
</Callout> </Callout>
## 限制与 IP 控制 ## 限制与 IP 控制
@@ -39,6 +39,58 @@ icon: Users
并从该客户端的操作中清除它们。 并从该客户端的操作中清除它们。
- 系统会按客户端(在多节点部署中还会按节点)跟踪**在线状态**和**最后在线**时间。 - 系统会按客户端(在多节点部署中还会按节点)跟踪**在线状态**和**最后在线**时间。
## 自动续期
单个客户端和批量创建表单使用统一的续期模式选择:
| 模式 | API 字段 | 续期规则 |
| --- | --- | --- |
| 关闭 | `reset=0`、`resetDay=0`、`resetWeekday=0` | 不自动延长到期时间。 |
| 固定天数 | `reset=N`,另两个字段为 `0` | 从上次截止时间增加 N × 24 小时。 |
| 日历每周 | `resetWeekday=1..7`,另两个字段为 `0` | 在面板时区每周一(1)至周日(7)的零点续期。 |
| 日历每月 | `resetDay=1..31`、`resetWeekday=0` | 在面板时区指定日的零点续期;短月取月末,之后仍按原配置日续期。 |
日历每周跨夏令时仍保持指定星期,不等于固定 7 天。若零点不存在,使用
该日期第一个有效时刻;零点重复时取第一次。若时区跳过整天,则使用
下一周的同一星期。旧配置同时填写
`reset` 和 `resetDay` 时继续以每月续期为准。API 不允许每周续期与正数
`reset` 或 `resetDay` 同时启用。
整自然月应选**每月、1 日**,首次截止时间设置为下月 1 日零点。例如
`2030-09-01 00:00:00` 表示有效至 `2030-08-31 23:59:59`。
31 日表示在 31 日**开始时**续期,并不是同一边界。订阅头原有的可选
月末显示设置仍独立存在,本表单不会自动开启它。
日期预览使用面板时区和后端实际续期的同一套计算,显示截止时间、最后
有效秒、下次到期时间及需要消耗的续期次数。预览不会保存、激活或预留
续期,也不保证未来一定续期。未设到期时间时自动续期无法运行,可以
明确点击按钮设置首次日历截止时间;仅选择模式不会修改已有到期时间。
“首次使用后开始”保留原来的初始天数,激活后才能确定日历日期。
旧配置以最后一秒为日历截止时间时,续期边界包含原有的不计次数向下个
零点对齐规则。但最后有效秒仍按**已存储的到期时间**计算,不会假装
初始时间已被修改:排他截止时间 `23:59:59` 实际有效至 `23:59:58`。
整天有效应使用下一个零点,预览本身不会修复首次截止时间。
最大续期次数 `resetMax=0` 表示不限次数。正数上限按**每个经过的周期**
计数,包括离线补续,不按定时任务执行次数或关联入站数量计数。剩余
次数不足以续到未来时,客户端继续过期且不会重置流量;手动禁用的
客户端保持禁用。
自动续期本身会重置客户端流量。独立的**定期流量重置**不延长到期时间,
这次未改变其规则;除非需要额外重置,否则保持关闭。本次不包含季度、
年度和每 N 周/月的续期。
<Callout type="warn">
启用每周续期前,需要升级主面板及所有参与节点。旧版本会忽略
`resetWeekday`,仅配置每周的客户端将无法自动续期,且在到期或流量
耗尽后,可能被**删除已耗尽客户端**操作删除,因为旧版没有每周续期
的清理保护。降级前应备份数据库,并将每周配置转换为所有参与版本
都支持的续期模式;仅关闭每周续期并不能防止耗尽后的删除。存在
混合版本或尚未转换的每周客户端时,应避免执行耗尽客户端清理。
数据库升级默认将新字段设为 `0`,保留已有日期和限制。
</Callout>
## 分享链接与外部链接 ## 分享链接与外部链接
每个客户端都有针对其各入站的分享链接和二维码,外加一个合并的 每个客户端都有针对其各入站的分享链接和二维码,外加一个合并的
@@ -73,6 +73,18 @@ Clash 格式自动识别保留原有的 `(?i)(clash|mihomo)` 默认匹配器,
- **`Profile-Update-Interval`** —— 刷新间隔,以小时为单位(`subUpdates`)。 - **`Profile-Update-Interval`** —— 刷新间隔,以小时为单位(`subUpdates`)。
- **`Profile-Title`**、**`Support-Url`**、**`Profile-Web-Page-Url`**、**`Announce`** —— 部分客户端会显示的可选品牌信息。 - **`Profile-Title`**、**`Support-Url`**、**`Profile-Web-Page-Url`**、**`Announce`** —— 部分客户端会显示的可选品牌信息。
### 资料页链接与升级说明
在 **订阅 → 资料 → 资料页方式** 中选择 `subProfileMode`,对所有订阅客户端生效:
- **不提供**(`none`,默认):不发送 `Profile-Web-Page-Url`。
- **内置订阅页**(`builtin`):提供该客户端的内置订阅页链接。
- **自定义网站**(`custom`):使用 `subProfileUrl`;地址留空时不发送该响应头。
**升级提示:** 旧版在 `subProfileUrl` 留空时会自动提供内置订阅页链接。升级后,尚未设置模式且地址为空或仅含空白字符的配置会使用 **不提供**;已有非空地址继续使用 **自定义网站**。需要恢复内置入口时,在上述位置选择 **内置订阅页** 并保存设置。
内置订阅页会公开订阅地址和节点配置,Happ 加密订阅也不例外;请在确定需要提供这些内容时开启。
## 自定义页面模板 ## 自定义页面模板
将 `subThemeDir` 指向一个包含自定义信息页模板的文件夹,即可为 HTML 订阅页面定制品牌。每条链接上的客户端备注完全支持模板化 —— 参见[分享链接 → 备注变量](/docs/config/share-links#remark-template-variables)。 将 `subThemeDir` 指向一个包含自定义信息页模板的文件夹,即可为 HTML 订阅页面定制品牌。每条链接上的客户端备注完全支持模板化 —— 参见[分享链接 → 备注变量](/docs/config/share-links#remark-template-variables)。
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"title": "参考", "title": "参考",
"icon": "BookMarked", "icon": "BookBookmark",
"pages": ["env-vars", "database", "ports-firewall", "api"] "pages": ["env-vars", "database", "ports-firewall", "api"]
} }
+20 -20
View File
@@ -18,34 +18,34 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"fumadocs-core": "^16.15.5", "fumadocs-core": "^16.15.14",
"fumadocs-docgen": "^3.1.0", "fumadocs-docgen": "^3.1.1",
"fumadocs-mdx": "^15.4.0", "fumadocs-mdx": "^15.4.5",
"fumadocs-openapi": "^11.4.0", "fumadocs-openapi": "^12.0.3",
"fumadocs-ui": "^16.15.5", "fumadocs-ui": "^16.15.14",
"lucide-react": "^1.39.0", "lucide-react": "^1.48.0",
"mermaid": "^11.17.2", "mermaid": "^12.0.0",
"next": "16.3.4", "next": "16.3.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.8", "react": "^19.3.0",
"react-dom": "^19.2.8", "react-dom": "^19.3.0",
"react-qr-code": "^2.2.0", "react-qr-code": "^2.2.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.7.0",
"zbsearch": "4.0.0", "zbsearch": "4.0.0",
"zod": "^4.5.4" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@types/mdx": "^2.0.14", "@types/mdx": "^2.0.14",
"@types/node": "^26.4.1", "@types/node": "^26.6.2",
"@types/react": "^19.2.18", "@types/react": "^19.3.0",
"@types/react-dom": "^19.2.5", "@types/react-dom": "^19.3.0",
"oxfmt": "0.66.0", "oxfmt": "0.70.0",
"oxlint": "1.81.0", "oxlint": "1.85.0",
"postcss": "^8.5.26", "postcss": "^8.5.28",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "7.0.2", "typescript": "7.0.2",
"vitest": "^4.1.11" "vitest": "^5.0.2"
}, },
"packageManager": "pnpm@11.25.0" "packageManager": "pnpm@12.6.0"
} }
+1345 -1147
View File
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -12,9 +12,16 @@ minimumReleaseAgeExclude:
- mermaid@11.17.0 - mermaid@11.17.0
- lucide-react@1.33.0 - lucide-react@1.33.0
- postcss@8.5.26 - postcss@8.5.26
- fumadocs-mdx@15.3.0 - fumadocs-mdx@15.3.0 || 15.4.1 || 15.4.5
- '@fumadocs/api-docs@0.2.7' - '@fumadocs/api-docs@0.2.7 || 0.2.9'
- '@types/node@26.4.1' - '@types/node@26.4.1'
- fumadocs-core@16.15.5 - fumadocs-core@16.15.5 || 16.15.11
- fumadocs-openapi@11.4.0 - fumadocs-openapi@11.4.0 || 11.4.3 || 12.0.3
- fumadocs-ui@16.15.5 - fumadocs-ui@16.15.5 || 16.15.11
- '@fumadocs/tailwind@0.1.2'
- '@fumari/image-size@0.1.1'
- '@fumari/stf@1.1.1'
- '@vitest/mocker@5.0.1 || 5.0.2'
- '@vitest/spy@5.0.1 || 5.0.2'
- fumadocs-docgen@3.1.1
- vitest@5.0.1 || 5.0.2
+614 -5
View File
@@ -69,6 +69,9 @@
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"externalSubUserAgent": {
"type": "string"
},
"externalTrafficInformEnable": { "externalTrafficInformEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -288,6 +291,9 @@
"subHappFallbackUrl": { "subHappFallbackUrl": {
"type": "string" "type": "string"
}, },
"subHappLocalProxyAuth": {
"type": "string"
},
"subHappNewUrl": { "subHappNewUrl": {
"type": "string" "type": "string"
}, },
@@ -336,12 +342,97 @@
"subHideSettings": { "subHideSettings": {
"type": "boolean" "type": "boolean"
}, },
"subIncyAnnounceUrl": {
"type": "string"
},
"subIncyAppAutoDetect": {
"description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
"type": "boolean"
},
"subIncyBannerBgColor": {
"type": "string"
},
"subIncyBannerButtonColor": {
"type": "string"
},
"subIncyBannerButtonText": {
"type": "string"
},
"subIncyBannerButtonUrl": {
"type": "string"
},
"subIncyBannerText": {
"type": "string"
},
"subIncyEnableRouting": { "subIncyEnableRouting": {
"type": "boolean" "type": "boolean"
}, },
"subIncyFragmentInterval": {
"type": "string"
},
"subIncyFragmentLength": {
"type": "string"
},
"subIncyFragmentPackets": {
"type": "string"
},
"subIncyFragmentationEnable": {
"type": "string"
},
"subIncyHideCheck": {
"type": "string"
},
"subIncyHideUrl": {
"type": "string"
},
"subIncyNoLimitEnabled": {
"type": "string"
},
"subIncyNoisesDelay": {
"type": "string"
},
"subIncyNoisesEnable": {
"type": "string"
},
"subIncyNoisesPacket": {
"type": "string"
},
"subIncyNoisesType": {
"type": "string"
},
"subIncyPerAppEnable": {
"type": "string"
},
"subIncyPerAppList": {
"type": "string"
},
"subIncyPerAppMode": {
"type": "string"
},
"subIncyPremiumUrl": {
"type": "string"
},
"subIncyProfileDescription": {
"type": "string"
},
"subIncyResolveDnsDomain": {
"type": "string"
},
"subIncyResolveDnsIp": {
"type": "string"
},
"subIncyResolveEnable": {
"type": "string"
},
"subIncyRoutingRules": { "subIncyRoutingRules": {
"type": "string" "type": "string"
}, },
"subIncySortOrder": {
"type": "string"
},
"subIncySupportEmail": {
"type": "string"
},
"subInfoNodeEnable": { "subInfoNodeEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -395,6 +486,9 @@
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"subProfileMode": {
"type": "string"
},
"subProfileUrl": { "subProfileUrl": {
"type": "string" "type": "string"
}, },
@@ -516,6 +610,7 @@
"discordMemory", "discordMemory",
"discordRunTime", "discordRunTime",
"expireDiff", "expireDiff",
"externalSubUserAgent",
"externalTrafficInformEnable", "externalTrafficInformEnable",
"externalTrafficInformURI", "externalTrafficInformURI",
"happLinkEnable", "happLinkEnable",
@@ -583,6 +678,7 @@
"subHappExcludeApns", "subHappExcludeApns",
"subHappExcludeRoutes", "subHappExcludeRoutes",
"subHappFallbackUrl", "subHappFallbackUrl",
"subHappLocalProxyAuth",
"subHappNewUrl", "subHappNewUrl",
"subHappNoLimit", "subHappNoLimit",
"subHappNotificationExpire", "subHappNotificationExpire",
@@ -599,8 +695,36 @@
"subHappTunMode", "subHappTunMode",
"subHappTunType", "subHappTunType",
"subHideSettings", "subHideSettings",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
"subIncyBannerButtonColor",
"subIncyBannerButtonText",
"subIncyBannerButtonUrl",
"subIncyBannerText",
"subIncyEnableRouting", "subIncyEnableRouting",
"subIncyFragmentInterval",
"subIncyFragmentLength",
"subIncyFragmentPackets",
"subIncyFragmentationEnable",
"subIncyHideCheck",
"subIncyHideUrl",
"subIncyNoLimitEnabled",
"subIncyNoisesDelay",
"subIncyNoisesEnable",
"subIncyNoisesPacket",
"subIncyNoisesType",
"subIncyPerAppEnable",
"subIncyPerAppList",
"subIncyPerAppMode",
"subIncyPremiumUrl",
"subIncyProfileDescription",
"subIncyResolveDnsDomain",
"subIncyResolveDnsIp",
"subIncyResolveEnable",
"subIncyRoutingRules", "subIncyRoutingRules",
"subIncySortOrder",
"subIncySupportEmail",
"subInfoNodeEnable", "subInfoNodeEnable",
"subJsonAlwaysArray", "subJsonAlwaysArray",
"subJsonAutoDetect", "subJsonAutoDetect",
@@ -618,6 +742,7 @@
"subListen", "subListen",
"subPath", "subPath",
"subPort", "subPort",
"subProfileMode",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks", "subShowIdentityOnAllLinks",
@@ -696,6 +821,9 @@
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"externalSubUserAgent": {
"type": "string"
},
"externalTrafficInformEnable": { "externalTrafficInformEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -939,6 +1067,9 @@
"subHappFallbackUrl": { "subHappFallbackUrl": {
"type": "string" "type": "string"
}, },
"subHappLocalProxyAuth": {
"type": "string"
},
"subHappNewUrl": { "subHappNewUrl": {
"type": "string" "type": "string"
}, },
@@ -987,12 +1118,97 @@
"subHideSettings": { "subHideSettings": {
"type": "boolean" "type": "boolean"
}, },
"subIncyAnnounceUrl": {
"type": "string"
},
"subIncyAppAutoDetect": {
"description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
"type": "boolean"
},
"subIncyBannerBgColor": {
"type": "string"
},
"subIncyBannerButtonColor": {
"type": "string"
},
"subIncyBannerButtonText": {
"type": "string"
},
"subIncyBannerButtonUrl": {
"type": "string"
},
"subIncyBannerText": {
"type": "string"
},
"subIncyEnableRouting": { "subIncyEnableRouting": {
"type": "boolean" "type": "boolean"
}, },
"subIncyFragmentInterval": {
"type": "string"
},
"subIncyFragmentLength": {
"type": "string"
},
"subIncyFragmentPackets": {
"type": "string"
},
"subIncyFragmentationEnable": {
"type": "string"
},
"subIncyHideCheck": {
"type": "string"
},
"subIncyHideUrl": {
"type": "string"
},
"subIncyNoLimitEnabled": {
"type": "string"
},
"subIncyNoisesDelay": {
"type": "string"
},
"subIncyNoisesEnable": {
"type": "string"
},
"subIncyNoisesPacket": {
"type": "string"
},
"subIncyNoisesType": {
"type": "string"
},
"subIncyPerAppEnable": {
"type": "string"
},
"subIncyPerAppList": {
"type": "string"
},
"subIncyPerAppMode": {
"type": "string"
},
"subIncyPremiumUrl": {
"type": "string"
},
"subIncyProfileDescription": {
"type": "string"
},
"subIncyResolveDnsDomain": {
"type": "string"
},
"subIncyResolveDnsIp": {
"type": "string"
},
"subIncyResolveEnable": {
"type": "string"
},
"subIncyRoutingRules": { "subIncyRoutingRules": {
"type": "string" "type": "string"
}, },
"subIncySortOrder": {
"type": "string"
},
"subIncySupportEmail": {
"type": "string"
},
"subInfoNodeEnable": { "subInfoNodeEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -1046,6 +1262,9 @@
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"subProfileMode": {
"type": "string"
},
"subProfileUrl": { "subProfileUrl": {
"type": "string" "type": "string"
}, },
@@ -1167,6 +1386,7 @@
"discordMemory", "discordMemory",
"discordRunTime", "discordRunTime",
"expireDiff", "expireDiff",
"externalSubUserAgent",
"externalTrafficInformEnable", "externalTrafficInformEnable",
"externalTrafficInformURI", "externalTrafficInformURI",
"happLinkEnable", "happLinkEnable",
@@ -1242,6 +1462,7 @@
"subHappExcludeApns", "subHappExcludeApns",
"subHappExcludeRoutes", "subHappExcludeRoutes",
"subHappFallbackUrl", "subHappFallbackUrl",
"subHappLocalProxyAuth",
"subHappNewUrl", "subHappNewUrl",
"subHappNoLimit", "subHappNoLimit",
"subHappNotificationExpire", "subHappNotificationExpire",
@@ -1258,8 +1479,36 @@
"subHappTunMode", "subHappTunMode",
"subHappTunType", "subHappTunType",
"subHideSettings", "subHideSettings",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
"subIncyBannerButtonColor",
"subIncyBannerButtonText",
"subIncyBannerButtonUrl",
"subIncyBannerText",
"subIncyEnableRouting", "subIncyEnableRouting",
"subIncyFragmentInterval",
"subIncyFragmentLength",
"subIncyFragmentPackets",
"subIncyFragmentationEnable",
"subIncyHideCheck",
"subIncyHideUrl",
"subIncyNoLimitEnabled",
"subIncyNoisesDelay",
"subIncyNoisesEnable",
"subIncyNoisesPacket",
"subIncyNoisesType",
"subIncyPerAppEnable",
"subIncyPerAppList",
"subIncyPerAppMode",
"subIncyPremiumUrl",
"subIncyProfileDescription",
"subIncyResolveDnsDomain",
"subIncyResolveDnsIp",
"subIncyResolveEnable",
"subIncyRoutingRules", "subIncyRoutingRules",
"subIncySortOrder",
"subIncySupportEmail",
"subInfoNodeEnable", "subInfoNodeEnable",
"subJsonAlwaysArray", "subJsonAlwaysArray",
"subJsonAutoDetect", "subJsonAutoDetect",
@@ -1277,6 +1526,7 @@
"subListen", "subListen",
"subPath", "subPath",
"subPort", "subPort",
"subProfileMode",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks", "subShowIdentityOnAllLinks",
@@ -1515,13 +1765,17 @@
"type": "integer" "type": "integer"
}, },
"resetDay": { "resetDay": {
"description": "Calendar renewal day 1-31, 0 = interval mode", "description": "Calendar renewal day 1-31, 0 disables monthly renewal",
"type": "integer" "type": "integer"
}, },
"resetMax": { "resetMax": {
"description": "Max auto-renew count, 0 = unlimited", "description": "Max auto-renew count, 0 = unlimited",
"type": "integer" "type": "integer"
}, },
"resetWeekday": {
"description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
"type": "integer"
},
"reverse": { "reverse": {
"allOf": [ "allOf": [
{ {
@@ -1584,6 +1838,7 @@
"reset", "reset",
"resetDay", "resetDay",
"resetMax", "resetMax",
"resetWeekday",
"security", "security",
"subId", "subId",
"tgId", "tgId",
@@ -1735,6 +1990,9 @@
"resetMax": { "resetMax": {
"type": "integer" "type": "integer"
}, },
"resetWeekday": {
"type": "integer"
},
"reverse": {}, "reverse": {},
"secret": { "secret": {
"type": "string" "type": "string"
@@ -1790,6 +2048,7 @@
"reset", "reset",
"resetDay", "resetDay",
"resetMax", "resetMax",
"resetWeekday",
"reverse", "reverse",
"secret", "secret",
"security", "security",
@@ -1803,6 +2062,97 @@
], ],
"type": "object" "type": "object"
}, },
"ClientRenewalPreview": {
"properties": {
"canRenew": {
"example": true,
"type": "boolean"
},
"delayedStart": {
"example": false,
"type": "boolean"
},
"nextExpiry": {
"example": "2030-02-01T00:00:00Z",
"type": "string"
},
"renewAt": {
"example": "2030-01-01T00:00:00Z",
"type": "string"
},
"renewals": {
"example": 1,
"type": "integer"
},
"suggestedExpiry": {
"example": "2030-01-01T00:00:00Z",
"type": "string"
},
"suggestedExpiryTime": {
"example": 1893456000000,
"format": "int64",
"type": "integer"
},
"timeZone": {
"example": "UTC",
"type": "string"
},
"validThrough": {
"example": "2029-12-31T23:59:59Z",
"type": "string"
}
},
"required": [
"canRenew",
"delayedStart",
"nextExpiry",
"renewAt",
"renewals",
"suggestedExpiry",
"suggestedExpiryTime",
"timeZone",
"validThrough"
],
"type": "object"
},
"ClientRenewalPreviewRequest": {
"properties": {
"expiryTime": {
"example": 1893456000000,
"format": "int64",
"type": "integer"
},
"reset": {
"example": 0,
"type": "integer"
},
"resetCount": {
"example": 0,
"type": "integer"
},
"resetDay": {
"example": 1,
"type": "integer"
},
"resetMax": {
"example": 0,
"type": "integer"
},
"resetWeekday": {
"example": 0,
"type": "integer"
}
},
"required": [
"expiryTime",
"reset",
"resetCount",
"resetDay",
"resetMax",
"resetWeekday"
],
"type": "object"
},
"ClientReverse": { "ClientReverse": {
"properties": { "properties": {
"tag": { "tag": {
@@ -1873,6 +2223,10 @@
"example": 0, "example": 0,
"type": "integer" "type": "integer"
}, },
"resetWeekday": {
"example": 0,
"type": "integer"
},
"subId": { "subId": {
"example": "abcd1234", "example": "abcd1234",
"type": "string" "type": "string"
@@ -1907,6 +2261,7 @@
"reset", "reset",
"resetDay", "resetDay",
"resetMax", "resetMax",
"resetWeekday",
"subId", "subId",
"totalGB", "totalGB",
"updatedAt" "updatedAt"
@@ -1962,7 +2317,7 @@
"type": "integer" "type": "integer"
}, },
"resetDay": { "resetDay": {
"description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.", "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 disables monthly renewal.",
"example": 0, "example": 0,
"type": "integer" "type": "integer"
}, },
@@ -1971,6 +2326,11 @@
"example": 0, "example": 0,
"type": "integer" "type": "integer"
}, },
"resetWeekday": {
"description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
"example": 0,
"type": "integer"
},
"subId": { "subId": {
"example": "i7tvdpeffi0hvvf1", "example": "i7tvdpeffi0hvvf1",
"type": "string" "type": "string"
@@ -2003,6 +2363,7 @@
"resetCount", "resetCount",
"resetDay", "resetDay",
"resetMax", "resetMax",
"resetWeekday",
"subId", "subId",
"total", "total",
"up", "up",
@@ -2293,6 +2654,9 @@
}, },
"type": "array" "type": "array"
}, },
"cipherSuites": {
"type": "string"
},
"createdAt": { "createdAt": {
"format": "int64", "format": "int64",
"type": "integer" "type": "integer"
@@ -2426,6 +2790,7 @@
"address", "address",
"allowInsecure", "allowInsecure",
"alpn", "alpn",
"cipherSuites",
"createdAt", "createdAt",
"echConfigList", "echConfigList",
"excludeFromSubTypes", "excludeFromSubTypes",
@@ -2470,6 +2835,9 @@
}, },
"type": "array" "type": "array"
}, },
"cipherSuites": {
"type": "string"
},
"echConfigList": { "echConfigList": {
"type": "string" "type": "string"
}, },
@@ -2596,6 +2964,7 @@
"required": [ "required": [
"allowInsecure", "allowInsecure",
"alpn", "alpn",
"cipherSuites",
"echConfigList", "echConfigList",
"excludeFromSubTypes", "excludeFromSubTypes",
"finalMask", "finalMask",
@@ -4119,6 +4488,84 @@
], ],
"type": "object" "type": "object"
}, },
"Sponsor": {
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
"properties": {
"enable": {
"example": true,
"nullable": true,
"type": "boolean"
},
"id": {
"example": "acme-2026-10",
"type": "string"
},
"link": {
"example": "https://acme.example/?utm_source=3x-ui",
"type": "string"
},
"logo": {
"example": "/sponsors/logo/acme.png",
"type": "string"
},
"name": {
"example": "Acme VPS",
"type": "string"
},
"slots": {
"items": {
"type": "string"
},
"type": "array"
},
"text": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"title": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"until": {
"example": "2026-11-01T00:00:00Z",
"format": "date-time",
"type": "string"
}
},
"required": [
"id",
"link",
"name",
"slots",
"text",
"title",
"until"
],
"type": "object"
},
"SponsorList": {
"description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
"properties": {
"contact": {
"example": "https://t.me/example",
"type": "string"
},
"sponsors": {
"items": {
"$ref": "#/components/schemas/Sponsor"
},
"type": "array"
}
},
"required": [
"sponsors"
],
"type": "object"
},
"SubBalancer": { "SubBalancer": {
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.", "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
"properties": { "properties": {
@@ -4571,6 +5018,59 @@
} }
} }
}, },
"/sponsors": {
"get": {
"tags": [
"Authentication"
],
"summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.",
"operationId": "get_sponsors",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/SponsorList"
}
}
},
"example": {
"success": true,
"obj": {
"contact": "https://t.me/example",
"sponsors": [
{
"enable": true,
"id": "acme-2026-10",
"link": "https://acme.example/?utm_source=3x-ui",
"logo": "/sponsors/logo/acme.png",
"name": "Acme VPS",
"slots": [
""
],
"text": {},
"title": {},
"until": "2026-11-01T00:00:00Z"
}
]
}
}
}
}
}
}
}
},
"/getTwoFactorEnable": { "/getTwoFactorEnable": {
"post": { "post": {
"tags": [ "tags": [
@@ -4652,6 +5152,7 @@
"resetCount": 0, "resetCount": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
@@ -7886,6 +8387,7 @@
"reset": 0, "reset": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "abcd1234", "subId": "abcd1234",
"totalGB": 53687091200, "totalGB": 53687091200,
"traffic": null, "traffic": null,
@@ -8078,6 +8580,97 @@
} }
} }
}, },
"/panel/api/clients/renewalPreview": {
"post": {
"tags": [
"Clients"
],
"summary": "Preview client auto-renewal dates without saving or resetting anything.",
"operationId": "post_panel_api_clients_renewalPreview",
"description": "Uses the same calendar and catch-up calculation as auto-renew in the panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables weekly mode; it cannot be combined with positive reset or resetDay. Existing resetDay takes precedence over reset. With expiryTime=0, calendar modes suggest a first cutoff but do not activate renewal. Negative expiryTime waits for first-use activation. resetMax and resetCount simulate the existing per-period allowance limit; the preview is informational and does not reserve an allowance or guarantee node availability.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"expiryTime": {
"type": "integer",
"description": "Current cutoff in Unix milliseconds; 0 unlimited, negative first-use duration."
},
"reset": {
"type": "integer",
"description": "Fixed interval in days; 0 disabled."
},
"resetDay": {
"type": "integer",
"description": "Monthly calendar day 1-31; 0 disabled."
},
"resetWeekday": {
"type": "integer",
"description": "Weekly calendar day 1-7 (Monday-Sunday); 0 disabled."
},
"resetMax": {
"type": "integer",
"description": "Maximum renewals; 0 unlimited."
},
"resetCount": {
"type": "integer",
"description": "Renewals already consumed; defaults to 0."
}
},
"required": [
"expiryTime",
"reset",
"resetDay",
"resetWeekday",
"resetMax",
"resetCount"
]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/ClientRenewalPreview"
}
}
},
"example": {
"success": true,
"obj": {
"canRenew": true,
"delayedStart": false,
"nextExpiry": "2030-02-01T00:00:00Z",
"renewAt": "2030-01-01T00:00:00Z",
"renewals": 1,
"suggestedExpiry": "2030-01-01T00:00:00Z",
"suggestedExpiryTime": 1893456000000,
"timeZone": "UTC",
"validThrough": "2029-12-31T23:59:59Z"
}
}
}
}
}
}
}
},
"/panel/api/clients/update/{email}": { "/panel/api/clients/update/{email}": {
"post": { "post": {
"tags": [ "tags": [
@@ -8537,7 +9130,7 @@
"tags": [ "tags": [
"Clients" "Clients"
], ],
"summary": "Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.", "summary": "Return every client as a {client, inboundIds, traffic} array — the shape /import accepts — so the payload round-trips straight back through /import. traffic carries the usage counters (up, down, resetCount, lastOnline, lastSubFetch) and is omitted for a client with no traffic row; the quota itself stays in client.totalGB. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
"operationId": "get_panel_api_clients_export", "operationId": "get_panel_api_clients_export",
"responses": { "responses": {
"200": { "200": {
@@ -8572,7 +9165,13 @@
"inboundIds": [ "inboundIds": [
7, 7,
9 9
] ],
"traffic": {
"up": 1048576,
"down": 2097152,
"resetCount": 0,
"lastOnline": 1735680000000
}
} }
] ]
} }
@@ -8587,7 +9186,7 @@
"tags": [ "tags": [
"Clients" "Clients"
], ],
"summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.", "summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds, traffic}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. An optional traffic object restores the usage counters, only for clients this import creates. Existing emails are never overwritten — they are returned in skipped, and their live counters are left untouched. Triggers a single Xray restart at the end if any target inbound was running; a failure while restoring counters still reports success=false after the clients were created.",
"operationId": "post_panel_api_clients_import", "operationId": "post_panel_api_clients_import",
"requestBody": { "requestBody": {
"required": true, "required": true,
@@ -10137,6 +10736,7 @@
"resetCount": 0, "resetCount": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
@@ -11260,6 +11860,7 @@
"alpn": [ "alpn": [
"" ""
], ],
"cipherSuites": "",
"echConfigList": "", "echConfigList": "",
"excludeFromSubTypes": [ "excludeFromSubTypes": [
"" ""
@@ -11354,6 +11955,7 @@
"alpn": [ "alpn": [
"" ""
], ],
"cipherSuites": "",
"echConfigList": "", "echConfigList": "",
"excludeFromSubTypes": [ "excludeFromSubTypes": [
"" ""
@@ -11451,6 +12053,7 @@
"alpn": [ "alpn": [
"" ""
], ],
"cipherSuites": "",
"echConfigList": "", "echConfigList": "",
"excludeFromSubTypes": [ "excludeFromSubTypes": [
"" ""
@@ -11601,6 +12204,7 @@
"alpn": [ "alpn": [
"" ""
], ],
"cipherSuites": "",
"createdAt": 0, "createdAt": 0,
"echConfigList": "", "echConfigList": "",
"excludeFromSubTypes": [ "excludeFromSubTypes": [
@@ -11722,6 +12326,7 @@
"alpn": [ "alpn": [
"" ""
], ],
"cipherSuites": "",
"createdAt": 0, "createdAt": 0,
"echConfigList": "", "echConfigList": "",
"excludeFromSubTypes": [ "excludeFromSubTypes": [
@@ -11973,6 +12578,7 @@
"alpn": [ "alpn": [
"" ""
], ],
"cipherSuites": "",
"createdAt": 0, "createdAt": 0,
"echConfigList": "", "echConfigList": "",
"excludeFromSubTypes": [ "excludeFromSubTypes": [
@@ -15504,6 +16110,7 @@
"resetCount": 0, "resetCount": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
@@ -15586,6 +16193,7 @@
"resetCount": 0, "resetCount": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
@@ -15632,6 +16240,7 @@
"resetCount": 0, "resetCount": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
+4 -1
View File
@@ -22,10 +22,13 @@ export const withTheme: Decorator = (Story, context) => {
useLayoutEffect(() => { useLayoutEffect(() => {
document.body.classList.remove('dark', 'light'); document.body.classList.remove('dark', 'light');
document.body.classList.add(dark ? 'dark' : 'light'); document.body.classList.add(dark ? 'dark' : 'light');
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-theme');
}, [dark]); }, [dark]);
return ( return (
<ConfigProvider theme={buildAntdThemeConfig(dark, false)}> // The click wave outlives its story and re-renders from a ResizeObserver
// inside the next story's act(), tripping React's act-environment warning.
<ConfigProvider theme={buildAntdThemeConfig(dark, false)} wave={{ disabled: true }}>
<div style={{ padding: 24, minWidth: 320 }}> <div style={{ padding: 24, minWidth: 320 }}>
<Story /> <Story />
</div> </div>
+1164 -830
View File
File diff suppressed because it is too large Load Diff
+18 -18
View File
@@ -5,8 +5,8 @@
"type": "module", "type": "module",
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).", "description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
"engines": { "engines": {
"node": ">=24.0.0", "node": ">=26.0.0",
"npm": ">=10.0.0" "npm": ">=11.0.0"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -39,9 +39,9 @@
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^5.9.1", "@hookform/resolvers": "^5.9.1",
"@noble/hashes": "^2.4.0", "@noble/hashes": "^2.4.0",
"@tanstack/react-query": "^5.102.8", "@tanstack/react-query": "^5.103.2",
"@tanstack/react-query-devtools": "^5.102.8", "@tanstack/react-query-devtools": "^5.103.2",
"antd": "^6.6.4", "antd": "^6.6.5",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"dayjs": "^1.11.23", "dayjs": "^1.11.23",
"i18next": "^26.4.2", "i18next": "^26.4.2",
@@ -50,9 +50,9 @@
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"react-hook-form": "^7.88.0", "react-hook-form": "^7.88.0",
"react-i18next": "^17.0.14", "react-i18next": "^17.0.15",
"react-router": "^8.3.1", "react-router": "^8.4.0",
"swagger-ui-react": "^5.32.15", "swagger-ui-react": "^5.33.0",
"uplot": "^1.6.32", "uplot": "^1.6.32",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
@@ -67,20 +67,20 @@
"@types/react-dom": "^19.3.0", "@types/react-dom": "^19.3.0",
"@types/swagger-ui-react": "^5.18.0", "@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "^6.1.1",
"@vitest/browser-playwright": "5.0.0", "@vitest/browser-playwright": "5.0.2",
"@vitest/coverage-v8": "^5.0.0", "@vitest/coverage-v8": "^5.0.2",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^30.0.1", "jsdom": "^30.1.1",
"lint-staged": "^17.5.1", "lint-staged": "^17.5.1",
"msw": "^2.15.0", "msw": "^2.15.0",
"oxfmt": "0.68.0", "oxfmt": "0.70.0",
"oxlint": "1.83.0", "oxlint": "1.85.0",
"oxlint-tsgolint": "^7.0.2001", "oxlint-tsgolint": "^7.0.2003",
"playwright": "^1.63.0", "playwright": "^1.63.0",
"storybook": "^10.6.0", "storybook": "^10.6.0",
"typescript": "7.0.2", "typescript": "7.0.2",
"vite": "8.3.0", "vite": "8.3.1",
"vitest": "^5.0.0" "vitest": "^5.0.2"
}, },
"overrides": { "overrides": {
"dompurify": "^3.4.11", "dompurify": "^3.4.11",
@@ -98,8 +98,8 @@
}, },
"@storybook/addon-vitest": { "@storybook/addon-vitest": {
"vitest": "^5.0.0", "vitest": "^5.0.0",
"@vitest/browser-playwright": "5.0.0", "@vitest/browser-playwright": "^5.0.0",
"@vitest/browser": "5.0.0" "@vitest/browser": "^5.0.0"
} }
}, },
"allowScripts": { "allowScripts": {
+614 -5
View File
@@ -69,6 +69,9 @@
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"externalSubUserAgent": {
"type": "string"
},
"externalTrafficInformEnable": { "externalTrafficInformEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -288,6 +291,9 @@
"subHappFallbackUrl": { "subHappFallbackUrl": {
"type": "string" "type": "string"
}, },
"subHappLocalProxyAuth": {
"type": "string"
},
"subHappNewUrl": { "subHappNewUrl": {
"type": "string" "type": "string"
}, },
@@ -336,12 +342,97 @@
"subHideSettings": { "subHideSettings": {
"type": "boolean" "type": "boolean"
}, },
"subIncyAnnounceUrl": {
"type": "string"
},
"subIncyAppAutoDetect": {
"description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
"type": "boolean"
},
"subIncyBannerBgColor": {
"type": "string"
},
"subIncyBannerButtonColor": {
"type": "string"
},
"subIncyBannerButtonText": {
"type": "string"
},
"subIncyBannerButtonUrl": {
"type": "string"
},
"subIncyBannerText": {
"type": "string"
},
"subIncyEnableRouting": { "subIncyEnableRouting": {
"type": "boolean" "type": "boolean"
}, },
"subIncyFragmentInterval": {
"type": "string"
},
"subIncyFragmentLength": {
"type": "string"
},
"subIncyFragmentPackets": {
"type": "string"
},
"subIncyFragmentationEnable": {
"type": "string"
},
"subIncyHideCheck": {
"type": "string"
},
"subIncyHideUrl": {
"type": "string"
},
"subIncyNoLimitEnabled": {
"type": "string"
},
"subIncyNoisesDelay": {
"type": "string"
},
"subIncyNoisesEnable": {
"type": "string"
},
"subIncyNoisesPacket": {
"type": "string"
},
"subIncyNoisesType": {
"type": "string"
},
"subIncyPerAppEnable": {
"type": "string"
},
"subIncyPerAppList": {
"type": "string"
},
"subIncyPerAppMode": {
"type": "string"
},
"subIncyPremiumUrl": {
"type": "string"
},
"subIncyProfileDescription": {
"type": "string"
},
"subIncyResolveDnsDomain": {
"type": "string"
},
"subIncyResolveDnsIp": {
"type": "string"
},
"subIncyResolveEnable": {
"type": "string"
},
"subIncyRoutingRules": { "subIncyRoutingRules": {
"type": "string" "type": "string"
}, },
"subIncySortOrder": {
"type": "string"
},
"subIncySupportEmail": {
"type": "string"
},
"subInfoNodeEnable": { "subInfoNodeEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -395,6 +486,9 @@
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"subProfileMode": {
"type": "string"
},
"subProfileUrl": { "subProfileUrl": {
"type": "string" "type": "string"
}, },
@@ -516,6 +610,7 @@
"discordMemory", "discordMemory",
"discordRunTime", "discordRunTime",
"expireDiff", "expireDiff",
"externalSubUserAgent",
"externalTrafficInformEnable", "externalTrafficInformEnable",
"externalTrafficInformURI", "externalTrafficInformURI",
"happLinkEnable", "happLinkEnable",
@@ -583,6 +678,7 @@
"subHappExcludeApns", "subHappExcludeApns",
"subHappExcludeRoutes", "subHappExcludeRoutes",
"subHappFallbackUrl", "subHappFallbackUrl",
"subHappLocalProxyAuth",
"subHappNewUrl", "subHappNewUrl",
"subHappNoLimit", "subHappNoLimit",
"subHappNotificationExpire", "subHappNotificationExpire",
@@ -599,8 +695,36 @@
"subHappTunMode", "subHappTunMode",
"subHappTunType", "subHappTunType",
"subHideSettings", "subHideSettings",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
"subIncyBannerButtonColor",
"subIncyBannerButtonText",
"subIncyBannerButtonUrl",
"subIncyBannerText",
"subIncyEnableRouting", "subIncyEnableRouting",
"subIncyFragmentInterval",
"subIncyFragmentLength",
"subIncyFragmentPackets",
"subIncyFragmentationEnable",
"subIncyHideCheck",
"subIncyHideUrl",
"subIncyNoLimitEnabled",
"subIncyNoisesDelay",
"subIncyNoisesEnable",
"subIncyNoisesPacket",
"subIncyNoisesType",
"subIncyPerAppEnable",
"subIncyPerAppList",
"subIncyPerAppMode",
"subIncyPremiumUrl",
"subIncyProfileDescription",
"subIncyResolveDnsDomain",
"subIncyResolveDnsIp",
"subIncyResolveEnable",
"subIncyRoutingRules", "subIncyRoutingRules",
"subIncySortOrder",
"subIncySupportEmail",
"subInfoNodeEnable", "subInfoNodeEnable",
"subJsonAlwaysArray", "subJsonAlwaysArray",
"subJsonAutoDetect", "subJsonAutoDetect",
@@ -618,6 +742,7 @@
"subListen", "subListen",
"subPath", "subPath",
"subPort", "subPort",
"subProfileMode",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks", "subShowIdentityOnAllLinks",
@@ -696,6 +821,9 @@
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"externalSubUserAgent": {
"type": "string"
},
"externalTrafficInformEnable": { "externalTrafficInformEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -939,6 +1067,9 @@
"subHappFallbackUrl": { "subHappFallbackUrl": {
"type": "string" "type": "string"
}, },
"subHappLocalProxyAuth": {
"type": "string"
},
"subHappNewUrl": { "subHappNewUrl": {
"type": "string" "type": "string"
}, },
@@ -987,12 +1118,97 @@
"subHideSettings": { "subHideSettings": {
"type": "boolean" "type": "boolean"
}, },
"subIncyAnnounceUrl": {
"type": "string"
},
"subIncyAppAutoDetect": {
"description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
"type": "boolean"
},
"subIncyBannerBgColor": {
"type": "string"
},
"subIncyBannerButtonColor": {
"type": "string"
},
"subIncyBannerButtonText": {
"type": "string"
},
"subIncyBannerButtonUrl": {
"type": "string"
},
"subIncyBannerText": {
"type": "string"
},
"subIncyEnableRouting": { "subIncyEnableRouting": {
"type": "boolean" "type": "boolean"
}, },
"subIncyFragmentInterval": {
"type": "string"
},
"subIncyFragmentLength": {
"type": "string"
},
"subIncyFragmentPackets": {
"type": "string"
},
"subIncyFragmentationEnable": {
"type": "string"
},
"subIncyHideCheck": {
"type": "string"
},
"subIncyHideUrl": {
"type": "string"
},
"subIncyNoLimitEnabled": {
"type": "string"
},
"subIncyNoisesDelay": {
"type": "string"
},
"subIncyNoisesEnable": {
"type": "string"
},
"subIncyNoisesPacket": {
"type": "string"
},
"subIncyNoisesType": {
"type": "string"
},
"subIncyPerAppEnable": {
"type": "string"
},
"subIncyPerAppList": {
"type": "string"
},
"subIncyPerAppMode": {
"type": "string"
},
"subIncyPremiumUrl": {
"type": "string"
},
"subIncyProfileDescription": {
"type": "string"
},
"subIncyResolveDnsDomain": {
"type": "string"
},
"subIncyResolveDnsIp": {
"type": "string"
},
"subIncyResolveEnable": {
"type": "string"
},
"subIncyRoutingRules": { "subIncyRoutingRules": {
"type": "string" "type": "string"
}, },
"subIncySortOrder": {
"type": "string"
},
"subIncySupportEmail": {
"type": "string"
},
"subInfoNodeEnable": { "subInfoNodeEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -1046,6 +1262,9 @@
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"subProfileMode": {
"type": "string"
},
"subProfileUrl": { "subProfileUrl": {
"type": "string" "type": "string"
}, },
@@ -1167,6 +1386,7 @@
"discordMemory", "discordMemory",
"discordRunTime", "discordRunTime",
"expireDiff", "expireDiff",
"externalSubUserAgent",
"externalTrafficInformEnable", "externalTrafficInformEnable",
"externalTrafficInformURI", "externalTrafficInformURI",
"happLinkEnable", "happLinkEnable",
@@ -1242,6 +1462,7 @@
"subHappExcludeApns", "subHappExcludeApns",
"subHappExcludeRoutes", "subHappExcludeRoutes",
"subHappFallbackUrl", "subHappFallbackUrl",
"subHappLocalProxyAuth",
"subHappNewUrl", "subHappNewUrl",
"subHappNoLimit", "subHappNoLimit",
"subHappNotificationExpire", "subHappNotificationExpire",
@@ -1258,8 +1479,36 @@
"subHappTunMode", "subHappTunMode",
"subHappTunType", "subHappTunType",
"subHideSettings", "subHideSettings",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
"subIncyBannerButtonColor",
"subIncyBannerButtonText",
"subIncyBannerButtonUrl",
"subIncyBannerText",
"subIncyEnableRouting", "subIncyEnableRouting",
"subIncyFragmentInterval",
"subIncyFragmentLength",
"subIncyFragmentPackets",
"subIncyFragmentationEnable",
"subIncyHideCheck",
"subIncyHideUrl",
"subIncyNoLimitEnabled",
"subIncyNoisesDelay",
"subIncyNoisesEnable",
"subIncyNoisesPacket",
"subIncyNoisesType",
"subIncyPerAppEnable",
"subIncyPerAppList",
"subIncyPerAppMode",
"subIncyPremiumUrl",
"subIncyProfileDescription",
"subIncyResolveDnsDomain",
"subIncyResolveDnsIp",
"subIncyResolveEnable",
"subIncyRoutingRules", "subIncyRoutingRules",
"subIncySortOrder",
"subIncySupportEmail",
"subInfoNodeEnable", "subInfoNodeEnable",
"subJsonAlwaysArray", "subJsonAlwaysArray",
"subJsonAutoDetect", "subJsonAutoDetect",
@@ -1277,6 +1526,7 @@
"subListen", "subListen",
"subPath", "subPath",
"subPort", "subPort",
"subProfileMode",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks", "subShowIdentityOnAllLinks",
@@ -1515,13 +1765,17 @@
"type": "integer" "type": "integer"
}, },
"resetDay": { "resetDay": {
"description": "Calendar renewal day 1-31, 0 = interval mode", "description": "Calendar renewal day 1-31, 0 disables monthly renewal",
"type": "integer" "type": "integer"
}, },
"resetMax": { "resetMax": {
"description": "Max auto-renew count, 0 = unlimited", "description": "Max auto-renew count, 0 = unlimited",
"type": "integer" "type": "integer"
}, },
"resetWeekday": {
"description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
"type": "integer"
},
"reverse": { "reverse": {
"allOf": [ "allOf": [
{ {
@@ -1584,6 +1838,7 @@
"reset", "reset",
"resetDay", "resetDay",
"resetMax", "resetMax",
"resetWeekday",
"security", "security",
"subId", "subId",
"tgId", "tgId",
@@ -1735,6 +1990,9 @@
"resetMax": { "resetMax": {
"type": "integer" "type": "integer"
}, },
"resetWeekday": {
"type": "integer"
},
"reverse": {}, "reverse": {},
"secret": { "secret": {
"type": "string" "type": "string"
@@ -1790,6 +2048,7 @@
"reset", "reset",
"resetDay", "resetDay",
"resetMax", "resetMax",
"resetWeekday",
"reverse", "reverse",
"secret", "secret",
"security", "security",
@@ -1803,6 +2062,97 @@
], ],
"type": "object" "type": "object"
}, },
"ClientRenewalPreview": {
"properties": {
"canRenew": {
"example": true,
"type": "boolean"
},
"delayedStart": {
"example": false,
"type": "boolean"
},
"nextExpiry": {
"example": "2030-02-01T00:00:00Z",
"type": "string"
},
"renewAt": {
"example": "2030-01-01T00:00:00Z",
"type": "string"
},
"renewals": {
"example": 1,
"type": "integer"
},
"suggestedExpiry": {
"example": "2030-01-01T00:00:00Z",
"type": "string"
},
"suggestedExpiryTime": {
"example": 1893456000000,
"format": "int64",
"type": "integer"
},
"timeZone": {
"example": "UTC",
"type": "string"
},
"validThrough": {
"example": "2029-12-31T23:59:59Z",
"type": "string"
}
},
"required": [
"canRenew",
"delayedStart",
"nextExpiry",
"renewAt",
"renewals",
"suggestedExpiry",
"suggestedExpiryTime",
"timeZone",
"validThrough"
],
"type": "object"
},
"ClientRenewalPreviewRequest": {
"properties": {
"expiryTime": {
"example": 1893456000000,
"format": "int64",
"type": "integer"
},
"reset": {
"example": 0,
"type": "integer"
},
"resetCount": {
"example": 0,
"type": "integer"
},
"resetDay": {
"example": 1,
"type": "integer"
},
"resetMax": {
"example": 0,
"type": "integer"
},
"resetWeekday": {
"example": 0,
"type": "integer"
}
},
"required": [
"expiryTime",
"reset",
"resetCount",
"resetDay",
"resetMax",
"resetWeekday"
],
"type": "object"
},
"ClientReverse": { "ClientReverse": {
"properties": { "properties": {
"tag": { "tag": {
@@ -1873,6 +2223,10 @@
"example": 0, "example": 0,
"type": "integer" "type": "integer"
}, },
"resetWeekday": {
"example": 0,
"type": "integer"
},
"subId": { "subId": {
"example": "abcd1234", "example": "abcd1234",
"type": "string" "type": "string"
@@ -1907,6 +2261,7 @@
"reset", "reset",
"resetDay", "resetDay",
"resetMax", "resetMax",
"resetWeekday",
"subId", "subId",
"totalGB", "totalGB",
"updatedAt" "updatedAt"
@@ -1962,7 +2317,7 @@
"type": "integer" "type": "integer"
}, },
"resetDay": { "resetDay": {
"description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.", "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 disables monthly renewal.",
"example": 0, "example": 0,
"type": "integer" "type": "integer"
}, },
@@ -1971,6 +2326,11 @@
"example": 0, "example": 0,
"type": "integer" "type": "integer"
}, },
"resetWeekday": {
"description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
"example": 0,
"type": "integer"
},
"subId": { "subId": {
"example": "i7tvdpeffi0hvvf1", "example": "i7tvdpeffi0hvvf1",
"type": "string" "type": "string"
@@ -2003,6 +2363,7 @@
"resetCount", "resetCount",
"resetDay", "resetDay",
"resetMax", "resetMax",
"resetWeekday",
"subId", "subId",
"total", "total",
"up", "up",
@@ -2293,6 +2654,9 @@
}, },
"type": "array" "type": "array"
}, },
"cipherSuites": {
"type": "string"
},
"createdAt": { "createdAt": {
"format": "int64", "format": "int64",
"type": "integer" "type": "integer"
@@ -2426,6 +2790,7 @@
"address", "address",
"allowInsecure", "allowInsecure",
"alpn", "alpn",
"cipherSuites",
"createdAt", "createdAt",
"echConfigList", "echConfigList",
"excludeFromSubTypes", "excludeFromSubTypes",
@@ -2470,6 +2835,9 @@
}, },
"type": "array" "type": "array"
}, },
"cipherSuites": {
"type": "string"
},
"echConfigList": { "echConfigList": {
"type": "string" "type": "string"
}, },
@@ -2596,6 +2964,7 @@
"required": [ "required": [
"allowInsecure", "allowInsecure",
"alpn", "alpn",
"cipherSuites",
"echConfigList", "echConfigList",
"excludeFromSubTypes", "excludeFromSubTypes",
"finalMask", "finalMask",
@@ -4119,6 +4488,84 @@
], ],
"type": "object" "type": "object"
}, },
"Sponsor": {
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
"properties": {
"enable": {
"example": true,
"nullable": true,
"type": "boolean"
},
"id": {
"example": "acme-2026-10",
"type": "string"
},
"link": {
"example": "https://acme.example/?utm_source=3x-ui",
"type": "string"
},
"logo": {
"example": "/sponsors/logo/acme.png",
"type": "string"
},
"name": {
"example": "Acme VPS",
"type": "string"
},
"slots": {
"items": {
"type": "string"
},
"type": "array"
},
"text": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"title": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"until": {
"example": "2026-11-01T00:00:00Z",
"format": "date-time",
"type": "string"
}
},
"required": [
"id",
"link",
"name",
"slots",
"text",
"title",
"until"
],
"type": "object"
},
"SponsorList": {
"description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
"properties": {
"contact": {
"example": "https://t.me/example",
"type": "string"
},
"sponsors": {
"items": {
"$ref": "#/components/schemas/Sponsor"
},
"type": "array"
}
},
"required": [
"sponsors"
],
"type": "object"
},
"SubBalancer": { "SubBalancer": {
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.", "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
"properties": { "properties": {
@@ -4571,6 +5018,59 @@
} }
} }
}, },
"/sponsors": {
"get": {
"tags": [
"Authentication"
],
"summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.",
"operationId": "get_sponsors",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/SponsorList"
}
}
},
"example": {
"success": true,
"obj": {
"contact": "https://t.me/example",
"sponsors": [
{
"enable": true,
"id": "acme-2026-10",
"link": "https://acme.example/?utm_source=3x-ui",
"logo": "/sponsors/logo/acme.png",
"name": "Acme VPS",
"slots": [
""
],
"text": {},
"title": {},
"until": "2026-11-01T00:00:00Z"
}
]
}
}
}
}
}
}
}
},
"/getTwoFactorEnable": { "/getTwoFactorEnable": {
"post": { "post": {
"tags": [ "tags": [
@@ -4652,6 +5152,7 @@
"resetCount": 0, "resetCount": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
@@ -7886,6 +8387,7 @@
"reset": 0, "reset": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "abcd1234", "subId": "abcd1234",
"totalGB": 53687091200, "totalGB": 53687091200,
"traffic": null, "traffic": null,
@@ -8078,6 +8580,97 @@
} }
} }
}, },
"/panel/api/clients/renewalPreview": {
"post": {
"tags": [
"Clients"
],
"summary": "Preview client auto-renewal dates without saving or resetting anything.",
"operationId": "post_panel_api_clients_renewalPreview",
"description": "Uses the same calendar and catch-up calculation as auto-renew in the panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables weekly mode; it cannot be combined with positive reset or resetDay. Existing resetDay takes precedence over reset. With expiryTime=0, calendar modes suggest a first cutoff but do not activate renewal. Negative expiryTime waits for first-use activation. resetMax and resetCount simulate the existing per-period allowance limit; the preview is informational and does not reserve an allowance or guarantee node availability.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"expiryTime": {
"type": "integer",
"description": "Current cutoff in Unix milliseconds; 0 unlimited, negative first-use duration."
},
"reset": {
"type": "integer",
"description": "Fixed interval in days; 0 disabled."
},
"resetDay": {
"type": "integer",
"description": "Monthly calendar day 1-31; 0 disabled."
},
"resetWeekday": {
"type": "integer",
"description": "Weekly calendar day 1-7 (Monday-Sunday); 0 disabled."
},
"resetMax": {
"type": "integer",
"description": "Maximum renewals; 0 unlimited."
},
"resetCount": {
"type": "integer",
"description": "Renewals already consumed; defaults to 0."
}
},
"required": [
"expiryTime",
"reset",
"resetDay",
"resetWeekday",
"resetMax",
"resetCount"
]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/ClientRenewalPreview"
}
}
},
"example": {
"success": true,
"obj": {
"canRenew": true,
"delayedStart": false,
"nextExpiry": "2030-02-01T00:00:00Z",
"renewAt": "2030-01-01T00:00:00Z",
"renewals": 1,
"suggestedExpiry": "2030-01-01T00:00:00Z",
"suggestedExpiryTime": 1893456000000,
"timeZone": "UTC",
"validThrough": "2029-12-31T23:59:59Z"
}
}
}
}
}
}
}
},
"/panel/api/clients/update/{email}": { "/panel/api/clients/update/{email}": {
"post": { "post": {
"tags": [ "tags": [
@@ -8537,7 +9130,7 @@
"tags": [ "tags": [
"Clients" "Clients"
], ],
"summary": "Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.", "summary": "Return every client as a {client, inboundIds, traffic} array — the shape /import accepts — so the payload round-trips straight back through /import. traffic carries the usage counters (up, down, resetCount, lastOnline, lastSubFetch) and is omitted for a client with no traffic row; the quota itself stays in client.totalGB. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
"operationId": "get_panel_api_clients_export", "operationId": "get_panel_api_clients_export",
"responses": { "responses": {
"200": { "200": {
@@ -8572,7 +9165,13 @@
"inboundIds": [ "inboundIds": [
7, 7,
9 9
] ],
"traffic": {
"up": 1048576,
"down": 2097152,
"resetCount": 0,
"lastOnline": 1735680000000
}
} }
] ]
} }
@@ -8587,7 +9186,7 @@
"tags": [ "tags": [
"Clients" "Clients"
], ],
"summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.", "summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds, traffic}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. An optional traffic object restores the usage counters, only for clients this import creates. Existing emails are never overwritten — they are returned in skipped, and their live counters are left untouched. Triggers a single Xray restart at the end if any target inbound was running; a failure while restoring counters still reports success=false after the clients were created.",
"operationId": "post_panel_api_clients_import", "operationId": "post_panel_api_clients_import",
"requestBody": { "requestBody": {
"required": true, "required": true,
@@ -10137,6 +10736,7 @@
"resetCount": 0, "resetCount": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
@@ -11260,6 +11860,7 @@
"alpn": [ "alpn": [
"" ""
], ],
"cipherSuites": "",
"echConfigList": "", "echConfigList": "",
"excludeFromSubTypes": [ "excludeFromSubTypes": [
"" ""
@@ -11354,6 +11955,7 @@
"alpn": [ "alpn": [
"" ""
], ],
"cipherSuites": "",
"echConfigList": "", "echConfigList": "",
"excludeFromSubTypes": [ "excludeFromSubTypes": [
"" ""
@@ -11451,6 +12053,7 @@
"alpn": [ "alpn": [
"" ""
], ],
"cipherSuites": "",
"echConfigList": "", "echConfigList": "",
"excludeFromSubTypes": [ "excludeFromSubTypes": [
"" ""
@@ -11601,6 +12204,7 @@
"alpn": [ "alpn": [
"" ""
], ],
"cipherSuites": "",
"createdAt": 0, "createdAt": 0,
"echConfigList": "", "echConfigList": "",
"excludeFromSubTypes": [ "excludeFromSubTypes": [
@@ -11722,6 +12326,7 @@
"alpn": [ "alpn": [
"" ""
], ],
"cipherSuites": "",
"createdAt": 0, "createdAt": 0,
"echConfigList": "", "echConfigList": "",
"excludeFromSubTypes": [ "excludeFromSubTypes": [
@@ -11973,6 +12578,7 @@
"alpn": [ "alpn": [
"" ""
], ],
"cipherSuites": "",
"createdAt": 0, "createdAt": 0,
"echConfigList": "", "echConfigList": "",
"excludeFromSubTypes": [ "excludeFromSubTypes": [
@@ -15504,6 +16110,7 @@
"resetCount": 0, "resetCount": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
@@ -15586,6 +16193,7 @@
"resetCount": 0, "resetCount": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
@@ -15632,6 +16240,7 @@
"resetCount": 0, "resetCount": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
+4
View File
@@ -196,6 +196,10 @@ export async function httpRequest(
return { ok: true, status: res.status, statusText: res.statusText, data: parsed }; return { ok: true, status: res.status, statusText: res.statusText, data: parsed };
} }
export function withBasePath(path: string): string {
return basePathPrefix + path;
}
export function setupHttp(): void { export function setupHttp(): void {
let basePath: string | null | undefined = window.X_UI_BASE_PATH; let basePath: string | null | undefined = window.X_UI_BASE_PATH;
if (!basePath) { if (!basePath) {
@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { keys } from '@/api/queryKeys';
import type { SponsorList } from '@/generated/types';
const EMPTY: SponsorList = { sponsors: [] };
async function fetchSponsors(): Promise<SponsorList> {
const msg = await HttpUtil.get<SponsorList>('/sponsors', undefined, { silent: true });
if (!msg?.success || !msg.obj) return EMPTY;
return { contact: msg.obj.contact, sponsors: msg.obj.sponsors ?? [] };
}
export function useSponsorsQuery() {
const query = useQuery({
queryKey: keys.sponsors(),
queryFn: fetchSponsors,
staleTime: 60 * 60 * 1000,
retry: false,
});
return { data: query.data ?? EMPTY, fetched: query.isFetched };
}
+1
View File
@@ -1,4 +1,5 @@
export const keys = { export const keys = {
sponsors: () => ['sponsors'] as const,
server: { server: {
status: () => ['server', 'status'] as const, status: () => ['server', 'status'] as const,
fail2banStatus: () => ['server', 'fail2banStatus'] as const, fail2banStatus: () => ['server', 'fail2banStatus'] as const,
@@ -13,6 +13,7 @@ import {
ClusterOutlined, ClusterOutlined,
CodeOutlined, CodeOutlined,
CopyOutlined, CopyOutlined,
CrownOutlined,
DashboardOutlined, DashboardOutlined,
DatabaseOutlined, DatabaseOutlined,
DiscordOutlined, DiscordOutlined,
@@ -418,6 +419,12 @@ export default function CommandPalette() {
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'], keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
icon: <ApiOutlined />, icon: <ApiOutlined />,
}, },
{
path: '/sponsors',
title: t('menu.sponsors'),
keywords: ['sponsors', 'sponsor', 'partners'],
icon: <CrownOutlined />,
},
]; ];
pages pages
@@ -0,0 +1,39 @@
import { Select } from 'antd';
import type { SelectProps } from 'antd';
import { TLS_CIPHER_OPTION } from '@/schemas/primitives';
const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v }));
type CipherSuitesSelectProps = Omit<
SelectProps<string[]>,
'value' | 'onChange' | 'mode' | 'options'
> & {
// Injected by FormField:
value?: string;
onChange?: (value: string) => void;
};
// xray splits cipherSuites on ':' into a list, so the picker edits tags while
// the stored value stays the single colon-joined string xray reads.
export default function CipherSuitesSelect({
value = '',
onChange,
...rest
}: CipherSuitesSelectProps) {
const suites = value
.split(':')
.map((s) => s.trim())
.filter(Boolean);
return (
<Select
allowClear
tokenSeparators={[':', ',']}
{...rest}
mode="tags"
options={CIPHER_SUITE_OPTIONS}
value={suites}
onChange={(next) => onChange?.(next.join(':'))}
/>
);
}
+1
View File
@@ -3,6 +3,7 @@ export { default as JsonEditor } from './JsonEditor';
export { default as HeaderMapEditor } from './HeaderMapEditor'; export { default as HeaderMapEditor } from './HeaderMapEditor';
export { default as GoRegexInput, validateGoRegex } from './GoRegexInput'; export { default as GoRegexInput, validateGoRegex } from './GoRegexInput';
export { default as SelectAllClearButtons } from './SelectAllClearButtons'; export { default as SelectAllClearButtons } from './SelectAllClearButtons';
export { default as CipherSuitesSelect } from './CipherSuitesSelect';
export { default as RemarkTemplateField } from './RemarkTemplateField'; export { default as RemarkTemplateField } from './RemarkTemplateField';
export { default as RemarkVarPicker } from './RemarkVarPicker'; export { default as RemarkVarPicker } from './RemarkVarPicker';
export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList'; export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';
@@ -24,6 +24,10 @@ import type { GeoCategory, GeoEntry, GeoFile, GeoKind } from '@/generated/types'
import './GeoBrowserModal.css'; import './GeoBrowserModal.css';
const ENTRY_PAGE_SIZE = 100; const ENTRY_PAGE_SIZE = 100;
// Attributes are dropped server-side, so kind:value repeats within real
// geosite categories; the page position is the only unique row key.
type GeoEntryRow = GeoEntry & { position: number };
const CATEGORY_SCROLL_HEIGHT = 438; const CATEGORY_SCROLL_HEIGHT = 438;
const ENTRY_FILTER_DELAY = 500; const ENTRY_FILTER_DELAY = 500;
@@ -224,7 +228,12 @@ export default function GeoBrowserModal({
[t], [t],
); );
const entryColumns: ColumnsType<GeoEntry> = useMemo( const entryRows: GeoEntryRow[] = useMemo(
() => (entriesQuery.data?.items ?? []).map((entry, position) => ({ ...entry, position })),
[entriesQuery.data],
);
const entryColumns: ColumnsType<GeoEntryRow> = useMemo(
() => [ () => [
{ {
dataIndex: 'kind', dataIndex: 'kind',
@@ -391,9 +400,9 @@ export default function GeoBrowserModal({
<Table <Table
size="small" size="small"
showHeader={false} showHeader={false}
rowKey={(entry, index) => `${entry.value}-${index}`} rowKey="position"
columns={entryColumns} columns={entryColumns}
dataSource={entriesQuery.data?.items ?? []} dataSource={entryRows}
loading={entriesQuery.isLoading} loading={entriesQuery.isLoading}
locale={{ locale={{
emptyText: entriesQuery.isError emptyText: entriesQuery.isError
@@ -0,0 +1,216 @@
.sponsor-card {
position: relative;
display: flex;
align-items: stretch;
min-width: 0;
border: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg, 8px);
background: var(--bg-card, var(--ant-color-fill-quaternary));
transition:
border-color 0.2s,
background 0.2s;
}
.sponsor-card:hover {
border-color: var(--ant-color-primary);
}
.sponsor-main {
display: flex;
flex: 1;
align-items: center;
gap: 12px;
min-width: 0;
padding: 12px 16px;
color: var(--ant-color-text);
text-decoration: none;
}
.sponsor-main:hover,
.sponsor-main:focus-visible {
color: var(--ant-color-text);
outline: none;
}
.sponsor-logo {
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: contain;
background: var(--ant-color-bg-elevated);
}
.sponsor-logo-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 18px;
color: var(--ant-color-primary);
background: var(--ant-color-primary-bg);
}
.sponsor-body {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sponsor-head {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.sponsor-tag {
flex: 0 0 auto;
padding: 0 6px;
border: 1px solid var(--ant-color-border);
border-radius: 4px;
font-size: 11px;
line-height: 18px;
color: var(--ant-color-text-tertiary);
}
.sponsor-title {
overflow: hidden;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.sponsor-text {
font-size: 13px;
color: var(--ant-color-text-secondary);
}
.sponsor-visit {
flex: 0 0 auto;
font-size: 13px;
color: var(--ant-color-primary);
white-space: nowrap;
}
.sponsor-close {
flex: 0 0 auto;
align-self: flex-start;
width: 28px;
height: 28px;
margin: 6px 6px 0 0;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--ant-color-text-tertiary);
cursor: pointer;
}
.sponsor-close:hover,
.sponsor-close:focus-visible {
color: var(--ant-color-text);
background: var(--ant-color-fill-tertiary);
outline: none;
}
[dir='rtl'] .sponsor-close {
margin: 6px 0 0 6px;
}
.sponsor-card-compact .sponsor-main {
gap: 10px;
padding: 8px 10px;
}
.sponsor-card-compact .sponsor-logo {
width: 32px;
height: 32px;
}
.sponsor-card-compact .sponsor-head {
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.sponsor-card-compact .sponsor-title {
display: -webkit-box;
max-width: 100%;
font-size: 13px;
white-space: normal;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.sponsor-card-compact .sponsor-text {
display: -webkit-box;
overflow: hidden;
font-size: 12px;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.sponsor-card-compact .sponsor-close {
width: 22px;
height: 22px;
margin: 4px 4px 0 0;
font-size: 11px;
}
.sponsor-card-card {
height: 100%;
}
.sponsor-card-card .sponsor-main {
flex-direction: column;
align-items: flex-start;
padding: 16px;
}
.sponsor-card-card .sponsor-logo {
width: 56px;
height: 56px;
}
.sponsor-card-card .sponsor-visit {
margin-top: auto;
}
.sponsor-card-icon {
justify-content: center;
padding: 6px;
border-color: transparent;
background: transparent;
}
.sponsor-card-icon .sponsor-logo {
width: 32px;
height: 32px;
}
@media (max-width: 576px) {
.sponsor-card-banner .sponsor-main {
flex-wrap: wrap;
padding: 10px 12px;
}
.sponsor-card-banner .sponsor-visit {
flex-basis: 100%;
padding-inline-start: 52px;
}
}
.sponsor-card-card {
transition:
border-color 0.2s,
transform 0.2s,
box-shadow 0.2s;
}
.sponsor-card-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
}
@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import type { Sponsor } from '@/generated/types';
import SponsorCard from './SponsorCard';
const sponsor: Sponsor = {
id: 'acme-2026-10',
name: 'Acme VPS',
slots: ['dashboard', 'sidebar', 'page', 'login'],
until: '2099-01-01T00:00:00Z',
title: { en: 'Acme VPS — fast NVMe servers', fa: 'سرورهای سریع Acme' },
text: { en: 'Deploy 3X-UI in 60 seconds. 20% off for panel users.' },
link: 'https://acme.example/?utm_source=3x-ui',
};
const meta = {
title: 'Sponsor/SponsorCard',
component: SponsorCard,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Paid sponsor placement fed by the project sponsors.json. Always labelled as a sponsor; links open in a new tab with rel="sponsored".',
},
},
},
argTypes: {
sponsor: { description: 'Sponsor entry from GET /sponsors.' },
variant: {
description: 'banner (dashboard), compact (sidebar/login) or card (Sponsors page).',
},
iconOnly: { description: 'Logo-only rendering for the collapsed sidebar rail.' },
onClose: { description: 'When set, shows a close button (temporary dismiss).' },
},
args: { sponsor },
} satisfies Meta<typeof SponsorCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Banner: Story = {
args: { variant: 'banner', onClose: () => {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText('Sponsor')).toBeInTheDocument();
await expect(canvas.getByRole('link')).toHaveAttribute('rel', 'noopener noreferrer sponsored');
},
};
export const Compact: Story = {
args: { variant: 'compact', onClose: () => {} },
render: (args) => (
<div style={{ width: 204 }}>
<SponsorCard {...args} />
</div>
),
};
export const Card: Story = {
args: { variant: 'card' },
render: (args) => (
<div style={{ width: 320 }}>
<SponsorCard {...args} />
</div>
),
};
export const IconOnly: Story = {
args: { iconOnly: true },
};
@@ -0,0 +1,101 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CloseOutlined, ExportOutlined } from '@ant-design/icons';
import { withBasePath } from '@/api/http-init';
import type { Sponsor } from '@/generated/types';
import { pickLocale } from '@/lib/sponsors';
import './SponsorCard.css';
export type SponsorCardVariant = 'banner' | 'compact' | 'card';
export interface SponsorCardProps {
sponsor: Sponsor;
variant?: SponsorCardVariant;
iconOnly?: boolean;
onClose?: () => void;
}
function SponsorLogo({ sponsor }: { sponsor: Sponsor }) {
const [failedSrc, setFailedSrc] = useState('');
if (sponsor.logo && failedSrc !== sponsor.logo) {
return (
<img
className="sponsor-logo"
src={withBasePath(sponsor.logo)}
alt=""
loading="lazy"
onError={() => setFailedSrc(sponsor.logo ?? '')}
/>
);
}
return (
<span className="sponsor-logo sponsor-logo-fallback" aria-hidden="true">
{sponsor.name.slice(0, 1).toUpperCase()}
</span>
);
}
export default function SponsorCard({
sponsor,
variant = 'banner',
iconOnly = false,
onClose,
}: SponsorCardProps) {
const { t, i18n } = useTranslation();
const lang = i18n.resolvedLanguage || i18n.language || 'en';
const title = pickLocale(sponsor.title, lang) || sponsor.name;
const text = pickLocale(sponsor.text, lang);
const tag = t('pages.sponsors.tag');
if (iconOnly) {
return (
<a
className="sponsor-card sponsor-card-icon"
href={sponsor.link}
target="_blank"
rel="noopener noreferrer sponsored"
title={`${tag} · ${title}`}
aria-label={`${tag}: ${title}`}
>
<SponsorLogo sponsor={sponsor} />
</a>
);
}
return (
<div className={`sponsor-card sponsor-card-${variant}`}>
<a
className="sponsor-main"
href={sponsor.link}
target="_blank"
rel="noopener noreferrer sponsored"
>
<SponsorLogo sponsor={sponsor} />
<span className="sponsor-body" dir="auto">
<span className="sponsor-head">
<span className="sponsor-tag">{tag}</span>
<span className="sponsor-title">{title}</span>
</span>
{text && <span className="sponsor-text">{text}</span>}
</span>
{variant !== 'compact' && (
<span className="sponsor-visit">
{t('pages.sponsors.visit')} <ExportOutlined />
</span>
)}
</a>
{onClose && (
<button
type="button"
className="sponsor-close"
aria-label={t('close')}
title={t('close')}
onClick={onClose}
>
<CloseOutlined />
</button>
)}
</div>
);
}
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
import {
dismissSponsor,
isSponsorDismissed,
sponsorsForSlot,
type SponsorSlot as Slot,
} from '@/lib/sponsors';
import SponsorCard, { type SponsorCardVariant } from './SponsorCard';
const ROTATE_MS = 30_000;
interface SponsorSlotProps {
slot: Slot;
variant?: SponsorCardVariant;
iconOnly?: boolean;
rotate?: boolean;
className?: string;
}
export default function SponsorSlot({
slot,
variant = 'banner',
iconOnly,
rotate,
className,
}: SponsorSlotProps) {
const { data } = useSponsorsQuery();
const [, setDismissTick] = useState(0);
const [index, setIndex] = useState(0);
// Re-filtered each render so a close (dismissTick bump) re-reads localStorage.
const visible = sponsorsForSlot(data.sponsors, slot).filter(
(s) => !isSponsorDismissed(s.id, slot),
);
useEffect(() => {
if (!rotate || visible.length < 2) return;
const timer = window.setInterval(() => setIndex((i) => i + 1), ROTATE_MS);
return () => window.clearInterval(timer);
}, [rotate, visible.length]);
if (visible.length === 0) return null;
const sponsor = visible[(rotate ? index : 0) % visible.length];
return (
<div className={className}>
<SponsorCard
sponsor={sponsor}
variant={variant}
iconOnly={iconOnly}
onClose={() => {
dismissSponsor(sponsor.id, slot);
setDismissTick((n) => n + 1);
}}
/>
</div>
);
}
+2
View File
@@ -15,6 +15,8 @@ interface SubPageData {
subJsonUrl?: string; subJsonUrl?: string;
subClashUrl?: string; subClashUrl?: string;
subTitle?: string; subTitle?: string;
subSupportUrl?: string;
subUpdates?: number;
links?: string[]; links?: string[];
emails?: string[]; emails?: string[];
datepicker?: 'gregorian' | 'jalalian'; datepicker?: 'gregorian' | 'jalalian';
+120
View File
@@ -13,6 +13,7 @@ export const EXAMPLES: Record<string, unknown> = {
"discordMemory": 0, "discordMemory": 0,
"discordRunTime": "", "discordRunTime": "",
"expireDiff": 0, "expireDiff": 0,
"externalSubUserAgent": "",
"externalTrafficInformEnable": false, "externalTrafficInformEnable": false,
"externalTrafficInformURI": "", "externalTrafficInformURI": "",
"happLinkEnable": false, "happLinkEnable": false,
@@ -80,6 +81,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subHappExcludeApns": false, "subHappExcludeApns": false,
"subHappExcludeRoutes": "", "subHappExcludeRoutes": "",
"subHappFallbackUrl": "", "subHappFallbackUrl": "",
"subHappLocalProxyAuth": "",
"subHappNewUrl": "", "subHappNewUrl": "",
"subHappNoLimit": false, "subHappNoLimit": false,
"subHappNotificationExpire": false, "subHappNotificationExpire": false,
@@ -96,8 +98,36 @@ export const EXAMPLES: Record<string, unknown> = {
"subHappTunMode": "", "subHappTunMode": "",
"subHappTunType": "", "subHappTunType": "",
"subHideSettings": false, "subHideSettings": false,
"subIncyAnnounceUrl": "",
"subIncyAppAutoDetect": false,
"subIncyBannerBgColor": "",
"subIncyBannerButtonColor": "",
"subIncyBannerButtonText": "",
"subIncyBannerButtonUrl": "",
"subIncyBannerText": "",
"subIncyEnableRouting": false, "subIncyEnableRouting": false,
"subIncyFragmentInterval": "",
"subIncyFragmentLength": "",
"subIncyFragmentPackets": "",
"subIncyFragmentationEnable": "",
"subIncyHideCheck": "",
"subIncyHideUrl": "",
"subIncyNoLimitEnabled": "",
"subIncyNoisesDelay": "",
"subIncyNoisesEnable": "",
"subIncyNoisesPacket": "",
"subIncyNoisesType": "",
"subIncyPerAppEnable": "",
"subIncyPerAppList": "",
"subIncyPerAppMode": "",
"subIncyPremiumUrl": "",
"subIncyProfileDescription": "",
"subIncyResolveDnsDomain": "",
"subIncyResolveDnsIp": "",
"subIncyResolveEnable": "",
"subIncyRoutingRules": "", "subIncyRoutingRules": "",
"subIncySortOrder": "",
"subIncySupportEmail": "",
"subInfoNodeEnable": false, "subInfoNodeEnable": false,
"subJsonAlwaysArray": false, "subJsonAlwaysArray": false,
"subJsonAutoDetect": false, "subJsonAutoDetect": false,
@@ -115,6 +145,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subListen": "", "subListen": "",
"subPath": "", "subPath": "",
"subPort": 1, "subPort": 1,
"subProfileMode": "",
"subProfileUrl": "", "subProfileUrl": "",
"subRoutingRules": "", "subRoutingRules": "",
"subShowIdentityOnAllLinks": false, "subShowIdentityOnAllLinks": false,
@@ -161,6 +192,7 @@ export const EXAMPLES: Record<string, unknown> = {
"discordMemory": 0, "discordMemory": 0,
"discordRunTime": "", "discordRunTime": "",
"expireDiff": 0, "expireDiff": 0,
"externalSubUserAgent": "",
"externalTrafficInformEnable": false, "externalTrafficInformEnable": false,
"externalTrafficInformURI": "", "externalTrafficInformURI": "",
"happLinkEnable": false, "happLinkEnable": false,
@@ -236,6 +268,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subHappExcludeApns": false, "subHappExcludeApns": false,
"subHappExcludeRoutes": "", "subHappExcludeRoutes": "",
"subHappFallbackUrl": "", "subHappFallbackUrl": "",
"subHappLocalProxyAuth": "",
"subHappNewUrl": "", "subHappNewUrl": "",
"subHappNoLimit": false, "subHappNoLimit": false,
"subHappNotificationExpire": false, "subHappNotificationExpire": false,
@@ -252,8 +285,36 @@ export const EXAMPLES: Record<string, unknown> = {
"subHappTunMode": "", "subHappTunMode": "",
"subHappTunType": "", "subHappTunType": "",
"subHideSettings": false, "subHideSettings": false,
"subIncyAnnounceUrl": "",
"subIncyAppAutoDetect": false,
"subIncyBannerBgColor": "",
"subIncyBannerButtonColor": "",
"subIncyBannerButtonText": "",
"subIncyBannerButtonUrl": "",
"subIncyBannerText": "",
"subIncyEnableRouting": false, "subIncyEnableRouting": false,
"subIncyFragmentInterval": "",
"subIncyFragmentLength": "",
"subIncyFragmentPackets": "",
"subIncyFragmentationEnable": "",
"subIncyHideCheck": "",
"subIncyHideUrl": "",
"subIncyNoLimitEnabled": "",
"subIncyNoisesDelay": "",
"subIncyNoisesEnable": "",
"subIncyNoisesPacket": "",
"subIncyNoisesType": "",
"subIncyPerAppEnable": "",
"subIncyPerAppList": "",
"subIncyPerAppMode": "",
"subIncyPremiumUrl": "",
"subIncyProfileDescription": "",
"subIncyResolveDnsDomain": "",
"subIncyResolveDnsIp": "",
"subIncyResolveEnable": "",
"subIncyRoutingRules": "", "subIncyRoutingRules": "",
"subIncySortOrder": "",
"subIncySupportEmail": "",
"subInfoNodeEnable": false, "subInfoNodeEnable": false,
"subJsonAlwaysArray": false, "subJsonAlwaysArray": false,
"subJsonAutoDetect": false, "subJsonAutoDetect": false,
@@ -271,6 +332,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subListen": "", "subListen": "",
"subPath": "", "subPath": "",
"subPort": 1, "subPort": 1,
"subProfileMode": "",
"subProfileUrl": "", "subProfileUrl": "",
"subRoutingRules": "", "subRoutingRules": "",
"subShowIdentityOnAllLinks": false, "subShowIdentityOnAllLinks": false,
@@ -367,6 +429,7 @@ export const EXAMPLES: Record<string, unknown> = {
"reset": 0, "reset": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"reverse": null, "reverse": null,
"secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d", "secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
"security": "", "security": "",
@@ -406,6 +469,7 @@ export const EXAMPLES: Record<string, unknown> = {
"reset": 0, "reset": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "abcd1234", "subId": "abcd1234",
"totalGB": 53687091200, "totalGB": 53687091200,
"traffic": null, "traffic": null,
@@ -455,6 +519,7 @@ export const EXAMPLES: Record<string, unknown> = {
"reset": 0, "reset": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"reverse": null, "reverse": null,
"secret": "", "secret": "",
"security": "", "security": "",
@@ -466,6 +531,25 @@ export const EXAMPLES: Record<string, unknown> = {
"updatedAt": 0, "updatedAt": 0,
"uuid": "" "uuid": ""
}, },
"ClientRenewalPreview": {
"canRenew": true,
"delayedStart": false,
"nextExpiry": "2030-02-01T00:00:00Z",
"renewAt": "2030-01-01T00:00:00Z",
"renewals": 1,
"suggestedExpiry": "2030-01-01T00:00:00Z",
"suggestedExpiryTime": 1893456000000,
"timeZone": "UTC",
"validThrough": "2029-12-31T23:59:59Z"
},
"ClientRenewalPreviewRequest": {
"expiryTime": 1893456000000,
"reset": 0,
"resetCount": 0,
"resetDay": 1,
"resetMax": 0,
"resetWeekday": 0
},
"ClientReverse": { "ClientReverse": {
"tag": "" "tag": ""
}, },
@@ -485,6 +569,7 @@ export const EXAMPLES: Record<string, unknown> = {
"reset": 0, "reset": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "abcd1234", "subId": "abcd1234",
"totalGB": 53687091200, "totalGB": 53687091200,
"traffic": null, "traffic": null,
@@ -503,6 +588,7 @@ export const EXAMPLES: Record<string, unknown> = {
"resetCount": 0, "resetCount": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
@@ -589,6 +675,7 @@ export const EXAMPLES: Record<string, unknown> = {
"alpn": [ "alpn": [
"" ""
], ],
"cipherSuites": "",
"createdAt": 0, "createdAt": 0,
"echConfigList": "", "echConfigList": "",
"excludeFromSubTypes": [ "excludeFromSubTypes": [
@@ -634,6 +721,7 @@ export const EXAMPLES: Record<string, unknown> = {
"alpn": [ "alpn": [
"" ""
], ],
"cipherSuites": "",
"echConfigList": "", "echConfigList": "",
"excludeFromSubTypes": [ "excludeFromSubTypes": [
"" ""
@@ -698,6 +786,7 @@ export const EXAMPLES: Record<string, unknown> = {
"resetCount": 0, "resetCount": 0,
"resetDay": 0, "resetDay": 0,
"resetMax": 0, "resetMax": 0,
"resetWeekday": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
@@ -1015,6 +1104,37 @@ export const EXAMPLES: Record<string, unknown> = {
"key": "", "key": "",
"value": "" "value": ""
}, },
"Sponsor": {
"enable": true,
"id": "acme-2026-10",
"link": "https://acme.example/?utm_source=3x-ui",
"logo": "/sponsors/logo/acme.png",
"name": "Acme VPS",
"slots": [
""
],
"text": {},
"title": {},
"until": "2026-11-01T00:00:00Z"
},
"SponsorList": {
"contact": "https://t.me/example",
"sponsors": [
{
"enable": true,
"id": "acme-2026-10",
"link": "https://acme.example/?utm_source=3x-ui",
"logo": "/sponsors/logo/acme.png",
"name": "Acme VPS",
"slots": [
""
],
"text": {},
"title": {},
"until": "2026-11-01T00:00:00Z"
}
]
},
"SubBalancer": { "SubBalancer": {
"createdAt": 1710000000000, "createdAt": 1710000000000,
"enabled": true, "enabled": true,
+449 -2
View File
@@ -43,6 +43,9 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"externalSubUserAgent": {
"type": "string"
},
"externalTrafficInformEnable": { "externalTrafficInformEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -262,6 +265,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subHappFallbackUrl": { "subHappFallbackUrl": {
"type": "string" "type": "string"
}, },
"subHappLocalProxyAuth": {
"type": "string"
},
"subHappNewUrl": { "subHappNewUrl": {
"type": "string" "type": "string"
}, },
@@ -310,12 +316,97 @@ export const SCHEMAS: Record<string, unknown> = {
"subHideSettings": { "subHideSettings": {
"type": "boolean" "type": "boolean"
}, },
"subIncyAnnounceUrl": {
"type": "string"
},
"subIncyAppAutoDetect": {
"description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
"type": "boolean"
},
"subIncyBannerBgColor": {
"type": "string"
},
"subIncyBannerButtonColor": {
"type": "string"
},
"subIncyBannerButtonText": {
"type": "string"
},
"subIncyBannerButtonUrl": {
"type": "string"
},
"subIncyBannerText": {
"type": "string"
},
"subIncyEnableRouting": { "subIncyEnableRouting": {
"type": "boolean" "type": "boolean"
}, },
"subIncyFragmentInterval": {
"type": "string"
},
"subIncyFragmentLength": {
"type": "string"
},
"subIncyFragmentPackets": {
"type": "string"
},
"subIncyFragmentationEnable": {
"type": "string"
},
"subIncyHideCheck": {
"type": "string"
},
"subIncyHideUrl": {
"type": "string"
},
"subIncyNoLimitEnabled": {
"type": "string"
},
"subIncyNoisesDelay": {
"type": "string"
},
"subIncyNoisesEnable": {
"type": "string"
},
"subIncyNoisesPacket": {
"type": "string"
},
"subIncyNoisesType": {
"type": "string"
},
"subIncyPerAppEnable": {
"type": "string"
},
"subIncyPerAppList": {
"type": "string"
},
"subIncyPerAppMode": {
"type": "string"
},
"subIncyPremiumUrl": {
"type": "string"
},
"subIncyProfileDescription": {
"type": "string"
},
"subIncyResolveDnsDomain": {
"type": "string"
},
"subIncyResolveDnsIp": {
"type": "string"
},
"subIncyResolveEnable": {
"type": "string"
},
"subIncyRoutingRules": { "subIncyRoutingRules": {
"type": "string" "type": "string"
}, },
"subIncySortOrder": {
"type": "string"
},
"subIncySupportEmail": {
"type": "string"
},
"subInfoNodeEnable": { "subInfoNodeEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -369,6 +460,9 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"subProfileMode": {
"type": "string"
},
"subProfileUrl": { "subProfileUrl": {
"type": "string" "type": "string"
}, },
@@ -490,6 +584,7 @@ export const SCHEMAS: Record<string, unknown> = {
"discordMemory", "discordMemory",
"discordRunTime", "discordRunTime",
"expireDiff", "expireDiff",
"externalSubUserAgent",
"externalTrafficInformEnable", "externalTrafficInformEnable",
"externalTrafficInformURI", "externalTrafficInformURI",
"happLinkEnable", "happLinkEnable",
@@ -557,6 +652,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subHappExcludeApns", "subHappExcludeApns",
"subHappExcludeRoutes", "subHappExcludeRoutes",
"subHappFallbackUrl", "subHappFallbackUrl",
"subHappLocalProxyAuth",
"subHappNewUrl", "subHappNewUrl",
"subHappNoLimit", "subHappNoLimit",
"subHappNotificationExpire", "subHappNotificationExpire",
@@ -573,8 +669,36 @@ export const SCHEMAS: Record<string, unknown> = {
"subHappTunMode", "subHappTunMode",
"subHappTunType", "subHappTunType",
"subHideSettings", "subHideSettings",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
"subIncyBannerButtonColor",
"subIncyBannerButtonText",
"subIncyBannerButtonUrl",
"subIncyBannerText",
"subIncyEnableRouting", "subIncyEnableRouting",
"subIncyFragmentInterval",
"subIncyFragmentLength",
"subIncyFragmentPackets",
"subIncyFragmentationEnable",
"subIncyHideCheck",
"subIncyHideUrl",
"subIncyNoLimitEnabled",
"subIncyNoisesDelay",
"subIncyNoisesEnable",
"subIncyNoisesPacket",
"subIncyNoisesType",
"subIncyPerAppEnable",
"subIncyPerAppList",
"subIncyPerAppMode",
"subIncyPremiumUrl",
"subIncyProfileDescription",
"subIncyResolveDnsDomain",
"subIncyResolveDnsIp",
"subIncyResolveEnable",
"subIncyRoutingRules", "subIncyRoutingRules",
"subIncySortOrder",
"subIncySupportEmail",
"subInfoNodeEnable", "subInfoNodeEnable",
"subJsonAlwaysArray", "subJsonAlwaysArray",
"subJsonAutoDetect", "subJsonAutoDetect",
@@ -592,6 +716,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subListen", "subListen",
"subPath", "subPath",
"subPort", "subPort",
"subProfileMode",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks", "subShowIdentityOnAllLinks",
@@ -670,6 +795,9 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"externalSubUserAgent": {
"type": "string"
},
"externalTrafficInformEnable": { "externalTrafficInformEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -913,6 +1041,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subHappFallbackUrl": { "subHappFallbackUrl": {
"type": "string" "type": "string"
}, },
"subHappLocalProxyAuth": {
"type": "string"
},
"subHappNewUrl": { "subHappNewUrl": {
"type": "string" "type": "string"
}, },
@@ -961,12 +1092,97 @@ export const SCHEMAS: Record<string, unknown> = {
"subHideSettings": { "subHideSettings": {
"type": "boolean" "type": "boolean"
}, },
"subIncyAnnounceUrl": {
"type": "string"
},
"subIncyAppAutoDetect": {
"description": "Incy client customization settings (app-management). A \"\" value omits\nthe header so the subscriber's own app setting is left alone.",
"type": "boolean"
},
"subIncyBannerBgColor": {
"type": "string"
},
"subIncyBannerButtonColor": {
"type": "string"
},
"subIncyBannerButtonText": {
"type": "string"
},
"subIncyBannerButtonUrl": {
"type": "string"
},
"subIncyBannerText": {
"type": "string"
},
"subIncyEnableRouting": { "subIncyEnableRouting": {
"type": "boolean" "type": "boolean"
}, },
"subIncyFragmentInterval": {
"type": "string"
},
"subIncyFragmentLength": {
"type": "string"
},
"subIncyFragmentPackets": {
"type": "string"
},
"subIncyFragmentationEnable": {
"type": "string"
},
"subIncyHideCheck": {
"type": "string"
},
"subIncyHideUrl": {
"type": "string"
},
"subIncyNoLimitEnabled": {
"type": "string"
},
"subIncyNoisesDelay": {
"type": "string"
},
"subIncyNoisesEnable": {
"type": "string"
},
"subIncyNoisesPacket": {
"type": "string"
},
"subIncyNoisesType": {
"type": "string"
},
"subIncyPerAppEnable": {
"type": "string"
},
"subIncyPerAppList": {
"type": "string"
},
"subIncyPerAppMode": {
"type": "string"
},
"subIncyPremiumUrl": {
"type": "string"
},
"subIncyProfileDescription": {
"type": "string"
},
"subIncyResolveDnsDomain": {
"type": "string"
},
"subIncyResolveDnsIp": {
"type": "string"
},
"subIncyResolveEnable": {
"type": "string"
},
"subIncyRoutingRules": { "subIncyRoutingRules": {
"type": "string" "type": "string"
}, },
"subIncySortOrder": {
"type": "string"
},
"subIncySupportEmail": {
"type": "string"
},
"subInfoNodeEnable": { "subInfoNodeEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -1020,6 +1236,9 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"subProfileMode": {
"type": "string"
},
"subProfileUrl": { "subProfileUrl": {
"type": "string" "type": "string"
}, },
@@ -1141,6 +1360,7 @@ export const SCHEMAS: Record<string, unknown> = {
"discordMemory", "discordMemory",
"discordRunTime", "discordRunTime",
"expireDiff", "expireDiff",
"externalSubUserAgent",
"externalTrafficInformEnable", "externalTrafficInformEnable",
"externalTrafficInformURI", "externalTrafficInformURI",
"happLinkEnable", "happLinkEnable",
@@ -1216,6 +1436,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subHappExcludeApns", "subHappExcludeApns",
"subHappExcludeRoutes", "subHappExcludeRoutes",
"subHappFallbackUrl", "subHappFallbackUrl",
"subHappLocalProxyAuth",
"subHappNewUrl", "subHappNewUrl",
"subHappNoLimit", "subHappNoLimit",
"subHappNotificationExpire", "subHappNotificationExpire",
@@ -1232,8 +1453,36 @@ export const SCHEMAS: Record<string, unknown> = {
"subHappTunMode", "subHappTunMode",
"subHappTunType", "subHappTunType",
"subHideSettings", "subHideSettings",
"subIncyAnnounceUrl",
"subIncyAppAutoDetect",
"subIncyBannerBgColor",
"subIncyBannerButtonColor",
"subIncyBannerButtonText",
"subIncyBannerButtonUrl",
"subIncyBannerText",
"subIncyEnableRouting", "subIncyEnableRouting",
"subIncyFragmentInterval",
"subIncyFragmentLength",
"subIncyFragmentPackets",
"subIncyFragmentationEnable",
"subIncyHideCheck",
"subIncyHideUrl",
"subIncyNoLimitEnabled",
"subIncyNoisesDelay",
"subIncyNoisesEnable",
"subIncyNoisesPacket",
"subIncyNoisesType",
"subIncyPerAppEnable",
"subIncyPerAppList",
"subIncyPerAppMode",
"subIncyPremiumUrl",
"subIncyProfileDescription",
"subIncyResolveDnsDomain",
"subIncyResolveDnsIp",
"subIncyResolveEnable",
"subIncyRoutingRules", "subIncyRoutingRules",
"subIncySortOrder",
"subIncySupportEmail",
"subInfoNodeEnable", "subInfoNodeEnable",
"subJsonAlwaysArray", "subJsonAlwaysArray",
"subJsonAutoDetect", "subJsonAutoDetect",
@@ -1251,6 +1500,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subListen", "subListen",
"subPath", "subPath",
"subPort", "subPort",
"subProfileMode",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks", "subShowIdentityOnAllLinks",
@@ -1489,13 +1739,17 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "integer" "type": "integer"
}, },
"resetDay": { "resetDay": {
"description": "Calendar renewal day 1-31, 0 = interval mode", "description": "Calendar renewal day 1-31, 0 disables monthly renewal",
"type": "integer" "type": "integer"
}, },
"resetMax": { "resetMax": {
"description": "Max auto-renew count, 0 = unlimited", "description": "Max auto-renew count, 0 = unlimited",
"type": "integer" "type": "integer"
}, },
"resetWeekday": {
"description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
"type": "integer"
},
"reverse": { "reverse": {
"allOf": [ "allOf": [
{ {
@@ -1558,6 +1812,7 @@ export const SCHEMAS: Record<string, unknown> = {
"reset", "reset",
"resetDay", "resetDay",
"resetMax", "resetMax",
"resetWeekday",
"security", "security",
"subId", "subId",
"tgId", "tgId",
@@ -1709,6 +1964,9 @@ export const SCHEMAS: Record<string, unknown> = {
"resetMax": { "resetMax": {
"type": "integer" "type": "integer"
}, },
"resetWeekday": {
"type": "integer"
},
"reverse": {}, "reverse": {},
"secret": { "secret": {
"type": "string" "type": "string"
@@ -1764,6 +2022,7 @@ export const SCHEMAS: Record<string, unknown> = {
"reset", "reset",
"resetDay", "resetDay",
"resetMax", "resetMax",
"resetWeekday",
"reverse", "reverse",
"secret", "secret",
"security", "security",
@@ -1777,6 +2036,97 @@ export const SCHEMAS: Record<string, unknown> = {
], ],
"type": "object" "type": "object"
}, },
"ClientRenewalPreview": {
"properties": {
"canRenew": {
"example": true,
"type": "boolean"
},
"delayedStart": {
"example": false,
"type": "boolean"
},
"nextExpiry": {
"example": "2030-02-01T00:00:00Z",
"type": "string"
},
"renewAt": {
"example": "2030-01-01T00:00:00Z",
"type": "string"
},
"renewals": {
"example": 1,
"type": "integer"
},
"suggestedExpiry": {
"example": "2030-01-01T00:00:00Z",
"type": "string"
},
"suggestedExpiryTime": {
"example": 1893456000000,
"format": "int64",
"type": "integer"
},
"timeZone": {
"example": "UTC",
"type": "string"
},
"validThrough": {
"example": "2029-12-31T23:59:59Z",
"type": "string"
}
},
"required": [
"canRenew",
"delayedStart",
"nextExpiry",
"renewAt",
"renewals",
"suggestedExpiry",
"suggestedExpiryTime",
"timeZone",
"validThrough"
],
"type": "object"
},
"ClientRenewalPreviewRequest": {
"properties": {
"expiryTime": {
"example": 1893456000000,
"format": "int64",
"type": "integer"
},
"reset": {
"example": 0,
"type": "integer"
},
"resetCount": {
"example": 0,
"type": "integer"
},
"resetDay": {
"example": 1,
"type": "integer"
},
"resetMax": {
"example": 0,
"type": "integer"
},
"resetWeekday": {
"example": 0,
"type": "integer"
}
},
"required": [
"expiryTime",
"reset",
"resetCount",
"resetDay",
"resetMax",
"resetWeekday"
],
"type": "object"
},
"ClientReverse": { "ClientReverse": {
"properties": { "properties": {
"tag": { "tag": {
@@ -1847,6 +2197,10 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 0, "example": 0,
"type": "integer" "type": "integer"
}, },
"resetWeekday": {
"example": 0,
"type": "integer"
},
"subId": { "subId": {
"example": "abcd1234", "example": "abcd1234",
"type": "string" "type": "string"
@@ -1881,6 +2235,7 @@ export const SCHEMAS: Record<string, unknown> = {
"reset", "reset",
"resetDay", "resetDay",
"resetMax", "resetMax",
"resetWeekday",
"subId", "subId",
"totalGB", "totalGB",
"updatedAt" "updatedAt"
@@ -1936,7 +2291,7 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "integer" "type": "integer"
}, },
"resetDay": { "resetDay": {
"description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.", "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 disables monthly renewal.",
"example": 0, "example": 0,
"type": "integer" "type": "integer"
}, },
@@ -1945,6 +2300,11 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 0, "example": 0,
"type": "integer" "type": "integer"
}, },
"resetWeekday": {
"description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
"example": 0,
"type": "integer"
},
"subId": { "subId": {
"example": "i7tvdpeffi0hvvf1", "example": "i7tvdpeffi0hvvf1",
"type": "string" "type": "string"
@@ -1977,6 +2337,7 @@ export const SCHEMAS: Record<string, unknown> = {
"resetCount", "resetCount",
"resetDay", "resetDay",
"resetMax", "resetMax",
"resetWeekday",
"subId", "subId",
"total", "total",
"up", "up",
@@ -2267,6 +2628,9 @@ export const SCHEMAS: Record<string, unknown> = {
}, },
"type": "array" "type": "array"
}, },
"cipherSuites": {
"type": "string"
},
"createdAt": { "createdAt": {
"format": "int64", "format": "int64",
"type": "integer" "type": "integer"
@@ -2400,6 +2764,7 @@ export const SCHEMAS: Record<string, unknown> = {
"address", "address",
"allowInsecure", "allowInsecure",
"alpn", "alpn",
"cipherSuites",
"createdAt", "createdAt",
"echConfigList", "echConfigList",
"excludeFromSubTypes", "excludeFromSubTypes",
@@ -2444,6 +2809,9 @@ export const SCHEMAS: Record<string, unknown> = {
}, },
"type": "array" "type": "array"
}, },
"cipherSuites": {
"type": "string"
},
"echConfigList": { "echConfigList": {
"type": "string" "type": "string"
}, },
@@ -2570,6 +2938,7 @@ export const SCHEMAS: Record<string, unknown> = {
"required": [ "required": [
"allowInsecure", "allowInsecure",
"alpn", "alpn",
"cipherSuites",
"echConfigList", "echConfigList",
"excludeFromSubTypes", "excludeFromSubTypes",
"finalMask", "finalMask",
@@ -4093,6 +4462,84 @@ export const SCHEMAS: Record<string, unknown> = {
], ],
"type": "object" "type": "object"
}, },
"Sponsor": {
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
"properties": {
"enable": {
"example": true,
"nullable": true,
"type": "boolean"
},
"id": {
"example": "acme-2026-10",
"type": "string"
},
"link": {
"example": "https://acme.example/?utm_source=3x-ui",
"type": "string"
},
"logo": {
"example": "/sponsors/logo/acme.png",
"type": "string"
},
"name": {
"example": "Acme VPS",
"type": "string"
},
"slots": {
"items": {
"type": "string"
},
"type": "array"
},
"text": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"title": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"until": {
"example": "2026-11-01T00:00:00Z",
"format": "date-time",
"type": "string"
}
},
"required": [
"id",
"link",
"name",
"slots",
"text",
"title",
"until"
],
"type": "object"
},
"SponsorList": {
"description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
"properties": {
"contact": {
"example": "https://t.me/example",
"type": "string"
},
"sponsors": {
"items": {
"$ref": "#/components/schemas/Sponsor"
},
"type": "array"
}
},
"required": [
"sponsors"
],
"type": "object"
},
"SubBalancer": { "SubBalancer": {
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.", "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
"properties": { "properties": {
+107
View File
@@ -3,6 +3,7 @@ export type GeoKind = string;
export type OnlineAPISupport = number; export type OnlineAPISupport = number;
export type ProcessState = string; export type ProcessState = string;
export type Protocol = string; export type Protocol = string;
export type addrFamily = number;
export type staticEgressResolver = string; export type staticEgressResolver = string;
export type trafficLocalApplyAction = number; export type trafficLocalApplyAction = number;
export type transportBits = number; export type transportBits = number;
@@ -20,6 +21,7 @@ export interface AllSetting {
discordMemory: number; discordMemory: number;
discordRunTime: string; discordRunTime: string;
expireDiff: number; expireDiff: number;
externalSubUserAgent: string;
externalTrafficInformEnable: boolean; externalTrafficInformEnable: boolean;
externalTrafficInformURI: string; externalTrafficInformURI: string;
happLinkEnable: boolean; happLinkEnable: boolean;
@@ -87,6 +89,7 @@ export interface AllSetting {
subHappExcludeApns: boolean; subHappExcludeApns: boolean;
subHappExcludeRoutes: string; subHappExcludeRoutes: string;
subHappFallbackUrl: string; subHappFallbackUrl: string;
subHappLocalProxyAuth: string;
subHappNewUrl: string; subHappNewUrl: string;
subHappNoLimit: boolean; subHappNoLimit: boolean;
subHappNotificationExpire: boolean; subHappNotificationExpire: boolean;
@@ -103,8 +106,36 @@ export interface AllSetting {
subHappTunMode: string; subHappTunMode: string;
subHappTunType: string; subHappTunType: string;
subHideSettings: boolean; subHideSettings: boolean;
subIncyAnnounceUrl: string;
subIncyAppAutoDetect: boolean;
subIncyBannerBgColor: string;
subIncyBannerButtonColor: string;
subIncyBannerButtonText: string;
subIncyBannerButtonUrl: string;
subIncyBannerText: string;
subIncyEnableRouting: boolean; subIncyEnableRouting: boolean;
subIncyFragmentInterval: string;
subIncyFragmentLength: string;
subIncyFragmentPackets: string;
subIncyFragmentationEnable: string;
subIncyHideCheck: string;
subIncyHideUrl: string;
subIncyNoLimitEnabled: string;
subIncyNoisesDelay: string;
subIncyNoisesEnable: string;
subIncyNoisesPacket: string;
subIncyNoisesType: string;
subIncyPerAppEnable: string;
subIncyPerAppList: string;
subIncyPerAppMode: string;
subIncyPremiumUrl: string;
subIncyProfileDescription: string;
subIncyResolveDnsDomain: string;
subIncyResolveDnsIp: string;
subIncyResolveEnable: string;
subIncyRoutingRules: string; subIncyRoutingRules: string;
subIncySortOrder: string;
subIncySupportEmail: string;
subInfoNodeEnable: boolean; subInfoNodeEnable: boolean;
subJsonAlwaysArray: boolean; subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean; subJsonAutoDetect: boolean;
@@ -122,6 +153,7 @@ export interface AllSetting {
subListen: string; subListen: string;
subPath: string; subPath: string;
subPort: number; subPort: number;
subProfileMode: string;
subProfileUrl: string; subProfileUrl: string;
subRoutingRules: string; subRoutingRules: string;
subShowIdentityOnAllLinks: boolean; subShowIdentityOnAllLinks: boolean;
@@ -169,6 +201,7 @@ export interface AllSettingView {
discordMemory: number; discordMemory: number;
discordRunTime: string; discordRunTime: string;
expireDiff: number; expireDiff: number;
externalSubUserAgent: string;
externalTrafficInformEnable: boolean; externalTrafficInformEnable: boolean;
externalTrafficInformURI: string; externalTrafficInformURI: string;
happLinkEnable: boolean; happLinkEnable: boolean;
@@ -244,6 +277,7 @@ export interface AllSettingView {
subHappExcludeApns: boolean; subHappExcludeApns: boolean;
subHappExcludeRoutes: string; subHappExcludeRoutes: string;
subHappFallbackUrl: string; subHappFallbackUrl: string;
subHappLocalProxyAuth: string;
subHappNewUrl: string; subHappNewUrl: string;
subHappNoLimit: boolean; subHappNoLimit: boolean;
subHappNotificationExpire: boolean; subHappNotificationExpire: boolean;
@@ -260,8 +294,36 @@ export interface AllSettingView {
subHappTunMode: string; subHappTunMode: string;
subHappTunType: string; subHappTunType: string;
subHideSettings: boolean; subHideSettings: boolean;
subIncyAnnounceUrl: string;
subIncyAppAutoDetect: boolean;
subIncyBannerBgColor: string;
subIncyBannerButtonColor: string;
subIncyBannerButtonText: string;
subIncyBannerButtonUrl: string;
subIncyBannerText: string;
subIncyEnableRouting: boolean; subIncyEnableRouting: boolean;
subIncyFragmentInterval: string;
subIncyFragmentLength: string;
subIncyFragmentPackets: string;
subIncyFragmentationEnable: string;
subIncyHideCheck: string;
subIncyHideUrl: string;
subIncyNoLimitEnabled: string;
subIncyNoisesDelay: string;
subIncyNoisesEnable: string;
subIncyNoisesPacket: string;
subIncyNoisesType: string;
subIncyPerAppEnable: string;
subIncyPerAppList: string;
subIncyPerAppMode: string;
subIncyPremiumUrl: string;
subIncyProfileDescription: string;
subIncyResolveDnsDomain: string;
subIncyResolveDnsIp: string;
subIncyResolveEnable: string;
subIncyRoutingRules: string; subIncyRoutingRules: string;
subIncySortOrder: string;
subIncySupportEmail: string;
subInfoNodeEnable: boolean; subInfoNodeEnable: boolean;
subJsonAlwaysArray: boolean; subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean; subJsonAutoDetect: boolean;
@@ -279,6 +341,7 @@ export interface AllSettingView {
subListen: string; subListen: string;
subPath: string; subPath: string;
subPort: number; subPort: number;
subProfileMode: string;
subProfileUrl: string; subProfileUrl: string;
subRoutingRules: string; subRoutingRules: string;
subShowIdentityOnAllLinks: boolean; subShowIdentityOnAllLinks: boolean;
@@ -362,6 +425,7 @@ export interface Client {
reset: number; reset: number;
resetDay: number; resetDay: number;
resetMax: number; resetMax: number;
resetWeekday: number;
reverse?: ClientReverse | null; reverse?: ClientReverse | null;
secret?: string; secret?: string;
security: string; security: string;
@@ -413,6 +477,7 @@ export interface ClientRecord {
reset: number; reset: number;
resetDay: number; resetDay: number;
resetMax: number; resetMax: number;
resetWeekday: number;
reverse: unknown; reverse: unknown;
secret: string; secret: string;
security: string; security: string;
@@ -425,6 +490,27 @@ export interface ClientRecord {
uuid: string; uuid: string;
} }
export interface ClientRenewalPreview {
canRenew: boolean;
delayedStart: boolean;
nextExpiry: string;
renewAt: string;
renewals: number;
suggestedExpiry: string;
suggestedExpiryTime: number;
timeZone: string;
validThrough: string;
}
export interface ClientRenewalPreviewRequest {
expiryTime: number;
reset: number;
resetCount: number;
resetDay: number;
resetMax: number;
resetWeekday: number;
}
export interface ClientReverse { export interface ClientReverse {
tag: string; tag: string;
} }
@@ -442,6 +528,7 @@ export interface ClientSlim {
reset: number; reset: number;
resetDay: number; resetDay: number;
resetMax: number; resetMax: number;
resetWeekday: number;
subId: string; subId: string;
totalGB: number; totalGB: number;
traffic?: ClientTraffic | null; traffic?: ClientTraffic | null;
@@ -461,6 +548,7 @@ export interface ClientTraffic {
resetCount: number; resetCount: number;
resetDay: number; resetDay: number;
resetMax: number; resetMax: number;
resetWeekday: number;
subId: string; subId: string;
total: number; total: number;
up: number; up: number;
@@ -535,6 +623,7 @@ export interface Host {
address: string; address: string;
allowInsecure: boolean; allowInsecure: boolean;
alpn: string[]; alpn: string[];
cipherSuites: string;
createdAt: number; createdAt: number;
echConfigList: string; echConfigList: string;
excludeFromSubTypes: string[]; excludeFromSubTypes: string[];
@@ -571,6 +660,7 @@ export interface Host {
export interface HostGroup { export interface HostGroup {
allowInsecure: boolean; allowInsecure: boolean;
alpn: string[]; alpn: string[];
cipherSuites: string;
echConfigList: string; echConfigList: string;
excludeFromSubTypes: string[]; excludeFromSubTypes: string[];
finalMask: string; finalMask: string;
@@ -935,6 +1025,23 @@ export interface Setting {
value: string; value: string;
} }
export interface Sponsor {
enable?: boolean | null;
id: string;
link: string;
logo?: string;
name: string;
slots: string[];
text: Record<string, string>;
title: Record<string, string>;
until: string;
}
export interface SponsorList {
contact?: string;
sponsors: Sponsor[];
}
export interface SubBalancer { export interface SubBalancer {
createdAt: number; createdAt: number;
enabled: boolean; enabled: boolean;
+113
View File
@@ -12,6 +12,9 @@ export type ProcessState = z.infer<typeof ProcessStateSchema>;
export const ProtocolSchema = z.string(); export const ProtocolSchema = z.string();
export type Protocol = z.infer<typeof ProtocolSchema>; export type Protocol = z.infer<typeof ProtocolSchema>;
export const addrFamilySchema = z.number().int();
export type addrFamily = z.infer<typeof addrFamilySchema>;
export const staticEgressResolverSchema = z.string(); export const staticEgressResolverSchema = z.string();
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>; export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
@@ -34,6 +37,7 @@ export const AllSettingSchema = z.object({
discordMemory: z.number().int().min(0).max(100), discordMemory: z.number().int().min(0).max(100),
discordRunTime: z.string(), discordRunTime: z.string(),
expireDiff: z.number().int().min(0), expireDiff: z.number().int().min(0),
externalSubUserAgent: z.string(),
externalTrafficInformEnable: z.boolean(), externalTrafficInformEnable: z.boolean(),
externalTrafficInformURI: z.string(), externalTrafficInformURI: z.string(),
happLinkEnable: z.boolean(), happLinkEnable: z.boolean(),
@@ -101,6 +105,7 @@ export const AllSettingSchema = z.object({
subHappExcludeApns: z.boolean(), subHappExcludeApns: z.boolean(),
subHappExcludeRoutes: z.string(), subHappExcludeRoutes: z.string(),
subHappFallbackUrl: z.string(), subHappFallbackUrl: z.string(),
subHappLocalProxyAuth: z.string(),
subHappNewUrl: z.string(), subHappNewUrl: z.string(),
subHappNoLimit: z.boolean(), subHappNoLimit: z.boolean(),
subHappNotificationExpire: z.boolean(), subHappNotificationExpire: z.boolean(),
@@ -117,8 +122,36 @@ export const AllSettingSchema = z.object({
subHappTunMode: z.string(), subHappTunMode: z.string(),
subHappTunType: z.string(), subHappTunType: z.string(),
subHideSettings: z.boolean(), subHideSettings: z.boolean(),
subIncyAnnounceUrl: z.string(),
subIncyAppAutoDetect: z.boolean(),
subIncyBannerBgColor: z.string(),
subIncyBannerButtonColor: z.string(),
subIncyBannerButtonText: z.string(),
subIncyBannerButtonUrl: z.string(),
subIncyBannerText: z.string(),
subIncyEnableRouting: z.boolean(), subIncyEnableRouting: z.boolean(),
subIncyFragmentInterval: z.string(),
subIncyFragmentLength: z.string(),
subIncyFragmentPackets: z.string(),
subIncyFragmentationEnable: z.string(),
subIncyHideCheck: z.string(),
subIncyHideUrl: z.string(),
subIncyNoLimitEnabled: z.string(),
subIncyNoisesDelay: z.string(),
subIncyNoisesEnable: z.string(),
subIncyNoisesPacket: z.string(),
subIncyNoisesType: z.string(),
subIncyPerAppEnable: z.string(),
subIncyPerAppList: z.string(),
subIncyPerAppMode: z.string(),
subIncyPremiumUrl: z.string(),
subIncyProfileDescription: z.string(),
subIncyResolveDnsDomain: z.string(),
subIncyResolveDnsIp: z.string(),
subIncyResolveEnable: z.string(),
subIncyRoutingRules: z.string(), subIncyRoutingRules: z.string(),
subIncySortOrder: z.string(),
subIncySupportEmail: z.string(),
subInfoNodeEnable: z.boolean(), subInfoNodeEnable: z.boolean(),
subJsonAlwaysArray: z.boolean(), subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(), subJsonAutoDetect: z.boolean(),
@@ -136,6 +169,7 @@ export const AllSettingSchema = z.object({
subListen: z.string(), subListen: z.string(),
subPath: z.string(), subPath: z.string(),
subPort: z.number().int().min(1).max(65535), subPort: z.number().int().min(1).max(65535),
subProfileMode: z.string(),
subProfileUrl: z.string(), subProfileUrl: z.string(),
subRoutingRules: z.string(), subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(), subShowIdentityOnAllLinks: z.boolean(),
@@ -184,6 +218,7 @@ export const AllSettingViewSchema = z.object({
discordMemory: z.number().int().min(0).max(100), discordMemory: z.number().int().min(0).max(100),
discordRunTime: z.string(), discordRunTime: z.string(),
expireDiff: z.number().int().min(0), expireDiff: z.number().int().min(0),
externalSubUserAgent: z.string(),
externalTrafficInformEnable: z.boolean(), externalTrafficInformEnable: z.boolean(),
externalTrafficInformURI: z.string(), externalTrafficInformURI: z.string(),
happLinkEnable: z.boolean(), happLinkEnable: z.boolean(),
@@ -259,6 +294,7 @@ export const AllSettingViewSchema = z.object({
subHappExcludeApns: z.boolean(), subHappExcludeApns: z.boolean(),
subHappExcludeRoutes: z.string(), subHappExcludeRoutes: z.string(),
subHappFallbackUrl: z.string(), subHappFallbackUrl: z.string(),
subHappLocalProxyAuth: z.string(),
subHappNewUrl: z.string(), subHappNewUrl: z.string(),
subHappNoLimit: z.boolean(), subHappNoLimit: z.boolean(),
subHappNotificationExpire: z.boolean(), subHappNotificationExpire: z.boolean(),
@@ -275,8 +311,36 @@ export const AllSettingViewSchema = z.object({
subHappTunMode: z.string(), subHappTunMode: z.string(),
subHappTunType: z.string(), subHappTunType: z.string(),
subHideSettings: z.boolean(), subHideSettings: z.boolean(),
subIncyAnnounceUrl: z.string(),
subIncyAppAutoDetect: z.boolean(),
subIncyBannerBgColor: z.string(),
subIncyBannerButtonColor: z.string(),
subIncyBannerButtonText: z.string(),
subIncyBannerButtonUrl: z.string(),
subIncyBannerText: z.string(),
subIncyEnableRouting: z.boolean(), subIncyEnableRouting: z.boolean(),
subIncyFragmentInterval: z.string(),
subIncyFragmentLength: z.string(),
subIncyFragmentPackets: z.string(),
subIncyFragmentationEnable: z.string(),
subIncyHideCheck: z.string(),
subIncyHideUrl: z.string(),
subIncyNoLimitEnabled: z.string(),
subIncyNoisesDelay: z.string(),
subIncyNoisesEnable: z.string(),
subIncyNoisesPacket: z.string(),
subIncyNoisesType: z.string(),
subIncyPerAppEnable: z.string(),
subIncyPerAppList: z.string(),
subIncyPerAppMode: z.string(),
subIncyPremiumUrl: z.string(),
subIncyProfileDescription: z.string(),
subIncyResolveDnsDomain: z.string(),
subIncyResolveDnsIp: z.string(),
subIncyResolveEnable: z.string(),
subIncyRoutingRules: z.string(), subIncyRoutingRules: z.string(),
subIncySortOrder: z.string(),
subIncySupportEmail: z.string(),
subInfoNodeEnable: z.boolean(), subInfoNodeEnable: z.boolean(),
subJsonAlwaysArray: z.boolean(), subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(), subJsonAutoDetect: z.boolean(),
@@ -294,6 +358,7 @@ export const AllSettingViewSchema = z.object({
subListen: z.string(), subListen: z.string(),
subPath: z.string(), subPath: z.string(),
subPort: z.number().int().min(1).max(65535), subPort: z.number().int().min(1).max(65535),
subProfileMode: z.string(),
subProfileUrl: z.string(), subProfileUrl: z.string(),
subRoutingRules: z.string(), subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(), subShowIdentityOnAllLinks: z.boolean(),
@@ -381,6 +446,7 @@ export const ClientSchema = z.object({
reset: z.number().int(), reset: z.number().int(),
resetDay: z.number().int(), resetDay: z.number().int(),
resetMax: z.number().int(), resetMax: z.number().int(),
resetWeekday: z.number().int(),
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(), reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
secret: z.string().optional(), secret: z.string().optional(),
security: z.string(), security: z.string(),
@@ -435,6 +501,7 @@ export const ClientRecordSchema = z.object({
reset: z.number().int(), reset: z.number().int(),
resetDay: z.number().int(), resetDay: z.number().int(),
resetMax: z.number().int(), resetMax: z.number().int(),
resetWeekday: z.number().int(),
reverse: z.unknown(), reverse: z.unknown(),
secret: z.string(), secret: z.string(),
security: z.string(), security: z.string(),
@@ -448,6 +515,29 @@ export const ClientRecordSchema = z.object({
}); });
export type ClientRecord = z.infer<typeof ClientRecordSchema>; export type ClientRecord = z.infer<typeof ClientRecordSchema>;
export const ClientRenewalPreviewSchema = z.object({
canRenew: z.boolean(),
delayedStart: z.boolean(),
nextExpiry: z.string(),
renewAt: z.string(),
renewals: z.number().int(),
suggestedExpiry: z.string(),
suggestedExpiryTime: z.number().int(),
timeZone: z.string(),
validThrough: z.string(),
});
export type ClientRenewalPreview = z.infer<typeof ClientRenewalPreviewSchema>;
export const ClientRenewalPreviewRequestSchema = z.object({
expiryTime: z.number().int(),
reset: z.number().int(),
resetCount: z.number().int(),
resetDay: z.number().int(),
resetMax: z.number().int(),
resetWeekday: z.number().int(),
});
export type ClientRenewalPreviewRequest = z.infer<typeof ClientRenewalPreviewRequestSchema>;
export const ClientReverseSchema = z.object({ export const ClientReverseSchema = z.object({
tag: z.string(), tag: z.string(),
}); });
@@ -466,6 +556,7 @@ export const ClientSlimSchema = z.object({
reset: z.number().int(), reset: z.number().int(),
resetDay: z.number().int(), resetDay: z.number().int(),
resetMax: z.number().int(), resetMax: z.number().int(),
resetWeekday: z.number().int(),
subId: z.string(), subId: z.string(),
totalGB: z.number().int(), totalGB: z.number().int(),
traffic: z.lazy(() => ClientTrafficSchema).nullable().optional(), traffic: z.lazy(() => ClientTrafficSchema).nullable().optional(),
@@ -486,6 +577,7 @@ export const ClientTrafficSchema = z.object({
resetCount: z.number().int(), resetCount: z.number().int(),
resetDay: z.number().int(), resetDay: z.number().int(),
resetMax: z.number().int(), resetMax: z.number().int(),
resetWeekday: z.number().int(),
subId: z.string(), subId: z.string(),
total: z.number().int(), total: z.number().int(),
up: z.number().int(), up: z.number().int(),
@@ -571,6 +663,7 @@ export const HostSchema = z.object({
address: z.string(), address: z.string(),
allowInsecure: z.boolean(), allowInsecure: z.boolean(),
alpn: z.array(z.string()), alpn: z.array(z.string()),
cipherSuites: z.string(),
createdAt: z.number().int(), createdAt: z.number().int(),
echConfigList: z.string(), echConfigList: z.string(),
excludeFromSubTypes: z.array(z.string()), excludeFromSubTypes: z.array(z.string()),
@@ -608,6 +701,7 @@ export type Host = z.infer<typeof HostSchema>;
export const HostGroupSchema = z.object({ export const HostGroupSchema = z.object({
allowInsecure: z.boolean(), allowInsecure: z.boolean(),
alpn: z.array(z.string()), alpn: z.array(z.string()),
cipherSuites: z.string(),
echConfigList: z.string(), echConfigList: z.string(),
excludeFromSubTypes: z.array(z.string()), excludeFromSubTypes: z.array(z.string()),
finalMask: z.string(), finalMask: z.string(),
@@ -994,6 +1088,25 @@ export const SettingSchema = z.object({
}); });
export type Setting = z.infer<typeof SettingSchema>; export type Setting = z.infer<typeof SettingSchema>;
export const SponsorSchema = z.object({
enable: z.boolean().nullable().optional(),
id: z.string(),
link: z.string(),
logo: z.string().optional(),
name: z.string(),
slots: z.array(z.string()),
text: z.record(z.string(), z.string()),
title: z.record(z.string(), z.string()),
until: z.string(),
});
export type Sponsor = z.infer<typeof SponsorSchema>;
export const SponsorListSchema = z.object({
contact: z.string().optional(),
sponsors: z.array(z.lazy(() => SponsorSchema)),
});
export type SponsorList = z.infer<typeof SponsorListSchema>;
export const SubBalancerSchema = z.object({ export const SubBalancerSchema = z.object({
createdAt: z.number().int(), createdAt: z.number().int(),
enabled: z.boolean(), enabled: z.boolean(),
+1
View File
@@ -696,6 +696,7 @@ export function useClients(options: UseClientsOptions = {}) {
tgId: Number(base.tgId) || 0, tgId: Number(base.tgId) || 0,
reset: Number(base.reset) || 0, reset: Number(base.reset) || 0,
resetDay: Number(base.resetDay) || 0, resetDay: Number(base.resetDay) || 0,
resetWeekday: Number(base.resetWeekday) || 0,
resetMax: Number(base.resetMax) || 0, resetMax: Number(base.resetMax) || 0,
trafficReset: base.trafficReset || 'never', trafficReset: base.trafficReset || 'never',
trafficResetDay: Number(base.trafficResetDay) || 1, trafficResetDay: Number(base.trafficResetDay) || 1,
+1
View File
@@ -14,6 +14,7 @@ const TITLE_KEYS: Record<string, string> = {
'/outbound': 'menu.outbounds', '/outbound': 'menu.outbounds',
'/routing': 'menu.routing', '/routing': 'menu.routing',
'/api-docs': 'menu.apiDocs', '/api-docs': 'menu.apiDocs',
'/sponsors': 'menu.sponsors',
}; };
export function usePageTitle() { export function usePageTitle() {
+2
View File
@@ -15,6 +15,8 @@ function readBool(key: string, fallback: boolean): boolean {
function applyDom(isDark: boolean, isUltra: boolean) { function applyDom(isDark: boolean, isUltra: boolean) {
document.body.classList.remove('dark', 'light'); document.body.classList.remove('dark', 'light');
document.body.classList.add(isDark ? 'dark' : 'light'); document.body.classList.add(isDark ? 'dark' : 'light');
// Native scrollbars read color-scheme, not the body class.
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
if (isUltra) { if (isUltra) {
document.documentElement.setAttribute('data-theme', 'ultra-dark'); document.documentElement.setAttribute('data-theme', 'ultra-dark');
} else { } else {
+4
View File
@@ -247,6 +247,10 @@
padding: 8px 8px 12px; padding: 8px 8px 12px;
} }
.sider-sponsor {
margin-bottom: 6px;
}
.sidebar-pin { .sidebar-pin {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+13
View File
@@ -11,6 +11,7 @@ import {
CloudServerOutlined, CloudServerOutlined,
ClusterOutlined, ClusterOutlined,
CodeOutlined, CodeOutlined,
CrownOutlined,
DashboardOutlined, DashboardOutlined,
DatabaseOutlined, DatabaseOutlined,
DiscordOutlined, DiscordOutlined,
@@ -43,6 +44,7 @@ import { formatPanelVersion } from '@/lib/panel-version';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { useAllSettings } from '@/api/queries/useAllSettings'; import { useAllSettings } from '@/api/queries/useAllSettings';
import { useCommandPalette } from '@/components/command-palette/useCommandPalette'; import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
import SponsorSlot from '@/components/sponsor/SponsorSlot';
import './AppSidebar.css'; import './AppSidebar.css';
const DONATE_URL = 'https://donate.sanaei.dev/'; const DONATE_URL = 'https://donate.sanaei.dev/';
@@ -68,6 +70,7 @@ type IconName =
| 'cluster' | 'cluster'
| 'hosts' | 'hosts'
| 'logout' | 'logout'
| 'sponsors'
| 'apidocs' | 'apidocs'
| 'outbound' | 'outbound'
| 'routing'; | 'routing';
@@ -82,6 +85,7 @@ const iconByName: Record<IconName, ComponentType> = {
cluster: ClusterOutlined, cluster: ClusterOutlined,
hosts: GlobalOutlined, hosts: GlobalOutlined,
logout: LogoutOutlined, logout: LogoutOutlined,
sponsors: CrownOutlined,
apidocs: ApiOutlined, apidocs: ApiOutlined,
outbound: ExportOutlined, outbound: ExportOutlined,
routing: SwapOutlined, routing: SwapOutlined,
@@ -232,6 +236,7 @@ export default function AppSidebar() {
{ key: '/settings', icon: 'setting', title: t('menu.settings') }, { key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: '/xray', icon: 'tool', title: t('menu.xray') }, { key: '/xray', icon: 'tool', title: t('menu.xray') },
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') }, { key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
{ key: '/sponsors', icon: 'sponsors', title: t('menu.sponsors') },
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') }, { key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
], ],
[t], [t],
@@ -447,6 +452,13 @@ export default function AppSidebar() {
onClick={onMenuClick} onClick={onMenuClick}
/> />
<div className="sider-footer"> <div className="sider-footer">
<SponsorSlot
slot="sidebar"
variant="compact"
iconOnly={railCollapsed}
rotate
className="sider-sponsor"
/>
<VersionBadge version={panelVersion} collapsed={railCollapsed} /> <VersionBadge version={panelVersion} collapsed={railCollapsed} />
</div> </div>
</Layout.Sider> </Layout.Sider>
@@ -532,6 +544,7 @@ export default function AppSidebar() {
}} }}
/> />
<div className="drawer-footer"> <div className="drawer-footer">
<SponsorSlot slot="sidebar" variant="compact" rotate className="sider-sponsor" />
<VersionBadge version={panelVersion} /> <VersionBadge version={panelVersion} />
</div> </div>
</Drawer> </Drawer>
+3
View File
@@ -28,6 +28,9 @@ export function formatPanelVersion(version: string | undefined | null): string {
export function isPanelUpdateAvailable(latest: string, current: string): boolean { export function isPanelUpdateAvailable(latest: string, current: string): boolean {
if (!latest || !current) return false; if (!latest || !current) return false;
// A dev+<sha> label and a release tag sit on different channels and carry no
// order, so a node moved to the other channel is not "behind" the master's latest.
if (latest.trim().startsWith('dev+') !== current.trim().startsWith('dev+')) return false;
const a = parseVersionParts(latest); const a = parseVersionParts(latest);
const b = parseVersionParts(current); const b = parseVersionParts(current);
if (!a || !b) { if (!a || !b) {
+62
View File
@@ -0,0 +1,62 @@
import type { Sponsor } from '@/generated/types';
export type SponsorSlot = 'dashboard' | 'sidebar' | 'page' | 'login';
const DISMISS_KEY = 'xui.sponsor.dismissed';
export const SPONSOR_DISMISS_MS = 24 * 60 * 60 * 1000;
export function pickLocale(map: Record<string, string> | undefined, lang: string): string {
if (!map) return '';
const short = lang.split('-')[0].toLowerCase();
return map[lang] || map[short] || map.en || '';
}
export function sponsorsForSlot(sponsors: Sponsor[], slot: SponsorSlot): Sponsor[] {
return sponsors.filter((s) => s.slots.includes(slot));
}
function readDismissed(): Record<string, number> {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(DISMISS_KEY) || '{}');
return parsed && typeof parsed === 'object' ? (parsed as Record<string, number>) : {};
} catch {
return {};
}
}
export function isSponsorDismissed(id: string, slot: SponsorSlot, now = Date.now()): boolean {
const at = readDismissed()[`${id}:${slot}`];
return typeof at === 'number' && now - at < SPONSOR_DISMISS_MS;
}
export function dismissSponsor(id: string, slot: SponsorSlot, now = Date.now()) {
const next = Object.fromEntries(
Object.entries(readDismissed()).filter(([, at]) => now - at < SPONSOR_DISMISS_MS),
);
next[`${id}:${slot}`] = now;
try {
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
} catch {}
}
// Mirrors the backend: dashboard/login show one sponsor, the sidebar rotates up to three.
export const SLOT_CAPACITY: Partial<Record<SponsorSlot, number>> = {
dashboard: 1,
login: 1,
sidebar: 3,
};
export interface PlacementStatus {
count: number;
capacity?: number;
takenUntil?: string;
}
// When full, a place frees up once enough bookings end to drop below capacity.
export function placementStatus(sponsors: Sponsor[], slot: SponsorSlot): PlacementStatus {
const booked = sponsorsForSlot(sponsors, slot);
const capacity = SLOT_CAPACITY[slot];
if (!capacity || booked.length < capacity) return { count: booked.length, capacity };
const ends = booked.map((s) => s.until).sort((a, b) => Date.parse(a) - Date.parse(b));
return { count: booked.length, capacity, takenUntil: ends[booked.length - capacity] };
}
@@ -786,7 +786,8 @@ function dnsRuleToWire(r: DnsRuleForm) {
const result: Raw = { action }; const result: Raw = { action };
const qType = r.qType.trim(); const qType = r.qType.trim();
if (qType) { if (qType) {
result.qType = /^\d+$/.test(qType) ? Number(qType) : qType; // The core reads a numeric 0 as no qType at all, which matches every query.
result.qType = /^\d+$/.test(qType) && Number(qType) > 0 ? Number(qType) : qType;
} }
const domains = r.domain const domains = r.domain
.split(',') .split(',')
+42
View File
@@ -1,4 +1,5 @@
import { ObjectUtil } from '@/utils'; import { ObjectUtil } from '@/utils';
import type { SubProfileMode } from '@/schemas/setting';
export class AllSetting { export class AllSetting {
webListen = ''; webListen = '';
@@ -46,6 +47,7 @@ export class AllSetting {
subClashUserAgentRegex = ''; subClashUserAgentRegex = '';
subTitle = ''; subTitle = '';
subSupportUrl = ''; subSupportUrl = '';
subProfileMode: SubProfileMode = 'none';
subProfileUrl = ''; subProfileUrl = '';
subAnnounce = ''; subAnnounce = '';
subEnableRouting = false; subEnableRouting = false;
@@ -64,6 +66,7 @@ export class AllSetting {
restartXrayOnClientDisable = true; restartXrayOnClientDisable = true;
subCertFile = ''; subCertFile = '';
subKeyFile = ''; subKeyFile = '';
externalSubUserAgent = 'v2rayNG/1.8.5';
subUpdates = 12; subUpdates = 12;
subEncrypt = true; subEncrypt = true;
subURI = ''; subURI = '';
@@ -102,6 +105,36 @@ export class AllSetting {
subHappAutoConnectType = 'lowestdelay'; subHappAutoConnectType = 'lowestdelay';
subHappPerAppMode = 'off'; subHappPerAppMode = 'off';
subHappPerAppList = ''; subHappPerAppList = '';
subHappLocalProxyAuth = 'auto';
subIncyAppAutoDetect = false;
subIncyProfileDescription = '';
subIncySortOrder = '';
subIncySupportEmail = '';
subIncyAnnounceUrl = '';
subIncyPremiumUrl = '';
subIncyBannerText = '';
subIncyBannerButtonText = '';
subIncyBannerButtonUrl = '';
subIncyBannerBgColor = '';
subIncyBannerButtonColor = '';
subIncyHideUrl = '';
subIncyHideCheck = '';
subIncyNoLimitEnabled = '';
subIncyPerAppEnable = '';
subIncyPerAppMode = '';
subIncyPerAppList = '';
subIncyFragmentationEnable = '';
subIncyFragmentLength = '';
subIncyFragmentInterval = '';
subIncyFragmentPackets = '';
subIncyNoisesEnable = '';
subIncyNoisesType = '';
subIncyNoisesPacket = '';
subIncyNoisesDelay = '';
subIncyResolveEnable = '';
subIncyResolveDnsDomain = '';
subIncyResolveDnsIp = '';
timeLocation = 'Local'; timeLocation = 'Local';
@@ -167,6 +200,15 @@ export class AllSetting {
if (data != null) { if (data != null) {
ObjectUtil.cloneProps(this, data); ObjectUtil.cloneProps(this, data);
} }
// Legacy settings with a custom URL retain it until an explicit mode is saved.
if (
typeof data === 'object' &&
data !== null &&
(!('subProfileMode' in data) || data.subProfileMode === undefined) &&
this.subProfileUrl.trim() !== ''
) {
this.subProfileMode = 'custom';
}
const cpu = Math.round(Number(this.tgCpu)); const cpu = Math.round(Number(this.tgCpu));
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80; this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
const threshold = Math.round(Number(this.outboundDownThreshold)); const threshold = Math.round(Number(this.outboundDownThreshold));
+2 -3
View File
@@ -45,15 +45,14 @@
} }
.api-docs-page .websocket-events { .api-docs-page .websocket-events {
margin-bottom: 16px;
padding: 20px; padding: 20px;
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--ant-color-border-secondary); border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px; border-radius: 8px;
} }
.api-docs-page .websocket-events h2 { .api-docs-page .swagger-ui .section-tabs {
margin-top: 0; margin-top: 20px;
} }
.api-docs-page .websocket-events pre { .api-docs-page .websocket-events pre {
+104 -31
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card, Col, ConfigProvider, Layout, Row, Typography } from 'antd'; import { Card, Col, ConfigProvider, Layout, Row, Tabs, Typography } from 'antd';
import SwaggerUI from 'swagger-ui-react'; import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css'; import 'swagger-ui-react/swagger-ui.css';
@@ -14,6 +14,61 @@ const basePath = window.X_UI_BASE_PATH || '';
const openApiUrl = `${basePath}panel/api/openapi.json`; const openApiUrl = `${basePath}panel/api/openapi.json`;
const websocketEvents = buildWebSocketEvents(EXAMPLES); const websocketEvents = buildWebSocketEvents(EXAMPLES);
interface TaggedOperations {
keySeq: () => { first: () => string | undefined };
filter: (keep: (operations: unknown, tag: string) => boolean) => TaggedOperations;
}
interface LayoutSelectors {
currentFilter: () => string | false;
}
interface SectionTabsProps {
specSelectors: { tags: () => { toJS: () => { name: string }[] } };
layoutSelectors: LayoutSelectors;
layoutActions: { updateFilter: (tag: string) => void };
}
function SectionTabs({ specSelectors, layoutSelectors, layoutActions }: SectionTabsProps) {
const tags = specSelectors
.tags()
.toJS()
.map((tag) => tag.name);
return (
<div className="wrapper section-tabs">
<Tabs
size="small"
activeKey={layoutSelectors.currentFilter() || tags[0]}
onChange={layoutActions.updateFilter}
items={tags.map((tag) => ({ key: tag, label: tag }))}
/>
</div>
);
}
// Shows one tag at a time, the first until a tab is picked. Swagger's own filter is a
// substring match ("Settings" would also show "Xray Settings") and no-op while unset.
const sectionTabsPlugin = {
statePlugins: {
spec: {
wrapSelectors: {
taggedOperations:
(
select: (...args: unknown[]) => TaggedOperations,
system: { getSystem: () => { layoutSelectors: LayoutSelectors } },
) =>
(...args: unknown[]) => {
const operations = select(...args);
const active =
system.getSystem().layoutSelectors.currentFilter() || operations.keySeq().first();
return operations.filter((_, tag) => tag === active);
},
},
},
},
components: { FilterContainer: SectionTabs },
};
export default function ApiDocsPage() { export default function ApiDocsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, antdThemeConfig } = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -32,36 +87,54 @@ export default function ApiDocsPage() {
<Layout className="content-shell"> <Layout className="content-shell">
<Layout.Content className="content-area"> <Layout.Content className="content-area">
<section className="websocket-events" aria-labelledby="websocket-events-title"> <Tabs
<Typography.Title id="websocket-events-title" level={2}> items={[
WebSocket events {
</Typography.Title> key: 'panel-api',
<Typography.Paragraph> label: '3X-UI Panel API',
After the cookie-authenticated <Typography.Text code>GET /ws</Typography.Text>{' '} children: (
upgrade, every server message uses{' '} <div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The time value <SwaggerUI
is Unix milliseconds. url={openApiUrl}
</Typography.Paragraph> docExpansion="list"
<Row gutter={[12, 12]}> deepLinking={false}
{websocketEvents.map((event) => ( plugins={[sectionTabsPlugin]}
<Col key={event.type} xs={24} sm={12} xl={8}> tryItOutEnabled
<Card size="small" title={<Typography.Text code>{event.type}</Typography.Text>}> persistAuthorization
<Typography.Paragraph>{event.summary}</Typography.Paragraph> />
<pre>{JSON.stringify(event.example, null, 2)}</pre> </div>
</Card> ),
</Col> },
))} {
</Row> key: 'websocket-events',
</section> label: 'WebSocket events',
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}> children: (
<SwaggerUI <section className="websocket-events">
url={openApiUrl} <Typography.Paragraph>
docExpansion="list" After the cookie-authenticated{' '}
deepLinking={false} <Typography.Text code>GET /ws</Typography.Text> upgrade, every server
tryItOutEnabled message uses{' '}
persistAuthorization <Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The
/> time value is Unix milliseconds.
</div> </Typography.Paragraph>
<Row gutter={[12, 12]}>
{websocketEvents.map((event) => (
<Col key={event.type} xs={24} sm={12} xl={8}>
<Card
size="small"
title={<Typography.Text code>{event.type}</Typography.Text>}
>
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
<pre>{JSON.stringify(event.example, null, 2)}</pre>
</Card>
</Col>
))}
</Row>
</section>
),
},
]}
/>
</Layout.Content> </Layout.Content>
</Layout> </Layout>
</Layout> </Layout>
+56 -3
View File
@@ -236,6 +236,13 @@ export const sections: readonly Section[] = [
'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.', 'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.',
response: '{\n "success": true,\n "obj": "csrf-token-string"\n}', response: '{\n "success": true,\n "obj": "csrf-token-string"\n}',
}, },
{
method: 'GET',
path: '/sponsors',
summary:
'Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.',
responseSchema: 'SponsorList',
},
{ {
method: 'POST', method: 'POST',
path: '/getTwoFactorEnable', path: '/getTwoFactorEnable',
@@ -1162,6 +1169,52 @@ export const sections: readonly Section[] = [
body: '{\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "tgId": 0,\n "limitIp": 0,\n "limitHwid": 0,\n "enable": true\n },\n "inboundIds": [3, 5]\n}', body: '{\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "tgId": 0,\n "limitIp": 0,\n "limitHwid": 0,\n "enable": true\n },\n "inboundIds": [3, 5]\n}',
response: '{\n "success": true,\n "msg": "Client added"\n}', response: '{\n "success": true,\n "msg": "Client added"\n}',
}, },
{
method: 'POST',
path: '/panel/api/clients/renewalPreview',
summary: 'Preview client auto-renewal dates without saving or resetting anything.',
description:
'Uses the same calendar and catch-up calculation as auto-renew in the panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables weekly mode; it cannot be combined with positive reset or resetDay. Existing resetDay takes precedence over reset. With expiryTime=0, calendar modes suggest a first cutoff but do not activate renewal. Negative expiryTime waits for first-use activation. resetMax and resetCount simulate the existing per-period allowance limit; the preview is informational and does not reserve an allowance or guarantee node availability.',
params: [
{
name: 'expiryTime',
in: 'body (json)',
type: 'integer',
desc: 'Current cutoff in Unix milliseconds; 0 unlimited, negative first-use duration.',
},
{
name: 'reset',
in: 'body (json)',
type: 'integer',
desc: 'Fixed interval in days; 0 disabled.',
},
{
name: 'resetDay',
in: 'body (json)',
type: 'integer',
desc: 'Monthly calendar day 1-31; 0 disabled.',
},
{
name: 'resetWeekday',
in: 'body (json)',
type: 'integer',
desc: 'Weekly calendar day 1-7 (Monday-Sunday); 0 disabled.',
},
{
name: 'resetMax',
in: 'body (json)',
type: 'integer',
desc: 'Maximum renewals; 0 unlimited.',
},
{
name: 'resetCount',
in: 'body (json)',
type: 'integer',
desc: 'Renewals already consumed; defaults to 0.',
},
],
responseSchema: 'ClientRenewalPreview',
},
{ {
method: 'POST', method: 'POST',
path: '/panel/api/clients/update/:email', path: '/panel/api/clients/update/:email',
@@ -1276,15 +1329,15 @@ export const sections: readonly Section[] = [
method: 'GET', method: 'GET',
path: '/panel/api/clients/export', path: '/panel/api/clients/export',
summary: summary:
'Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.', 'Return every client as a {client, inboundIds, traffic} array — the shape /import accepts — so the payload round-trips straight back through /import. traffic carries the usage counters (up, down, resetCount, lastOnline, lastSubFetch) and is omitted for a client with no traffic row; the quota itself stays in client.totalGB. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.',
response: response:
'{\n "success": true,\n "obj": [\n {\n "client": {\n "email": "alice@example.com",\n "id": "...",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "limitHwid": 2,\n "enable": true,\n "subId": "..."\n },\n "inboundIds": [7, 9]\n }\n ]\n}', '{\n "success": true,\n "obj": [\n {\n "client": {\n "email": "alice@example.com",\n "id": "...",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "limitHwid": 2,\n "enable": true,\n "subId": "..."\n },\n "inboundIds": [7, 9],\n "traffic": {\n "up": 1048576,\n "down": 2097152,\n "resetCount": 0,\n "lastOnline": 1735680000000\n }\n }\n ]\n}',
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/clients/import', path: '/panel/api/clients/import',
summary: summary:
'Import clients from a JSON body { "data": "<json>" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.', 'Import clients from a JSON body { "data": "<json>" }, where data is a string-encoded array produced by /export ([{client, inboundIds, traffic}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. An optional traffic object restores the usage counters, only for clients this import creates. Existing emails are never overwritten — they are returned in skipped, and their live counters are left untouched. Triggers a single Xray restart at the end if any target inbound was running; a failure while restoring counters still reports success=false after the clients were created.',
body: '{\n "data": "[{\\"client\\":{\\"email\\":\\"alice@example.com\\",\\"enable\\":true},\\"inboundIds\\":[7]}]"\n}', body: '{\n "data": "[{\\"client\\":{\\"email\\":\\"alice@example.com\\",\\"enable\\":true},\\"inboundIds\\":[7]}]"\n}',
response: response:
'{\n "success": true,\n "obj": {\n "created": 2,\n "skipped": [\n { "email": "alice@example.com", "reason": "email already in use: alice@example.com" }\n ]\n }\n}', '{\n "success": true,\n "obj": {\n "created": 2,\n "skipped": [\n { "email": "alice@example.com", "reason": "email already in use: alice@example.com" }\n ]\n }\n}',
@@ -25,6 +25,7 @@ import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { FormField } from '@/components/form/rhf'; import { FormField } from '@/components/form/rhf';
import { useClients, type InboundOption } from '@/hooks/useClients'; import { useClients, type InboundOption } from '@/hooks/useClients';
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery'; import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
import ClientRenewalFields from './ClientRenewalFields';
import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client'; import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL); const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
@@ -57,6 +58,7 @@ const EMPTY: ClientBulkAddFormValues = {
expiryTime: 0, expiryTime: 0,
reset: 0, reset: 0,
resetDay: 0, resetDay: 0,
resetWeekday: 0,
resetMax: 0, resetMax: 0,
trafficReset: 'never' as const, trafficReset: 'never' as const,
trafficResetDay: 1, trafficResetDay: 1,
@@ -215,6 +217,7 @@ export default function ClientBulkAddModal({
expiryTime: current.expiryTime, expiryTime: current.expiryTime,
reset: Number(current.reset) || 0, reset: Number(current.reset) || 0,
resetDay: Number(current.resetDay) || 0, resetDay: Number(current.resetDay) || 0,
resetWeekday: Number(current.resetWeekday) || 0,
resetMax: Number(current.resetMax) || 0, resetMax: Number(current.resetMax) || 0,
trafficReset: current.trafficReset || 'never', trafficReset: current.trafficReset || 'never',
trafficResetDay: Number(current.trafficResetDay) || 1, trafficResetDay: Number(current.trafficResetDay) || 1,
@@ -437,32 +440,13 @@ export default function ClientBulkAddModal({
</Form.Item> </Form.Item>
)} )}
<FormField <ClientRenewalFields
name="reset" active={open}
label={t('pages.clients.renew')} delayedStart={delayedStart}
tooltip={t('pages.clients.renewDesc')} expiryTime={expiryTime}
transform={{ output: (v) => Number(v) || 0 }} bulk
> setExpiry={(expiry) => methods.setValue('expiryTime', expiry)}
<InputNumber min={0} /> />
</FormField>
<FormField
name="resetDay"
label={t('pages.clients.renewOnDay')}
tooltip={t('pages.clients.renewOnDayDesc')}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} max={31} />
</FormField>
<FormField
name="resetMax"
label={t('pages.clients.renewMax')}
tooltip={t('pages.clients.renewMaxDesc')}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} />
</FormField>
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}> <FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
<Select <Select
@@ -0,0 +1,5 @@
/* The body is capped at the viewport and scrolls; a trailing item margin
alone must not push it past the cap and summon a scrollbar. */
.client-form-modal .ant-tabs-content > .ant-form-item:last-child {
margin-bottom: 0;
}
+18 -29
View File
@@ -48,7 +48,9 @@ import type {
ExternalLinkInput, ExternalLinkInput,
} from '@/hooks/useClients'; } from '@/hooks/useClients';
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery'; import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
import ClientRenewalFields from './ClientRenewalFields';
import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client'; import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
import './ClientFormModal.css';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL); const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const; const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
@@ -154,6 +156,7 @@ const EMPTY: Values = {
delayedDays: 0, delayedDays: 0,
reset: 0, reset: 0,
resetDay: 0, resetDay: 0,
resetWeekday: 0,
resetMax: 0, resetMax: 0,
trafficReset: 'never' as const, trafficReset: 'never' as const,
trafficResetDay: 1, trafficResetDay: 1,
@@ -258,6 +261,7 @@ export default function ClientFormModal({
const methods = useForm<Values>({ defaultValues: EMPTY }); const methods = useForm<Values>({ defaultValues: EMPTY });
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' }); const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
const delayedStart = useWatch({ control: methods.control, name: 'delayedStart' }); const delayedStart = useWatch({ control: methods.control, name: 'delayedStart' });
const delayedDays = useWatch({ control: methods.control, name: 'delayedDays' });
const expiryDate = useWatch({ control: methods.control, name: 'expiryDate' }); const expiryDate = useWatch({ control: methods.control, name: 'expiryDate' });
const enable = useWatch({ control: methods.control, name: 'enable' }); const enable = useWatch({ control: methods.control, name: 'enable' });
const flow = useWatch({ control: methods.control, name: 'flow' }); const flow = useWatch({ control: methods.control, name: 'flow' });
@@ -365,6 +369,7 @@ export default function ClientFormModal({
totalGB: bytesToGB(client.totalGB || 0), totalGB: bytesToGB(client.totalGB || 0),
reset: Number(client.reset) || 0, reset: Number(client.reset) || 0,
resetDay: Number(client.resetDay) || 0, resetDay: Number(client.resetDay) || 0,
resetWeekday: Number(client.resetWeekday) || 0,
resetMax: Number(client.resetMax) || 0, resetMax: Number(client.resetMax) || 0,
trafficReset: (client.trafficReset as ClientFormValues['trafficReset']) || 'never', trafficReset: (client.trafficReset as ClientFormValues['trafficReset']) || 'never',
trafficResetDay: Number(client.trafficResetDay) || 1, trafficResetDay: Number(client.trafficResetDay) || 1,
@@ -662,6 +667,7 @@ export default function ClientFormModal({
delayedDays: values.delayedDays, delayedDays: values.delayedDays,
reset: values.reset, reset: values.reset,
resetDay: values.resetDay, resetDay: values.resetDay,
resetWeekday: values.resetWeekday,
resetMax: values.resetMax, resetMax: values.resetMax,
trafficReset: values.trafficReset, trafficReset: values.trafficReset,
trafficResetDay: values.trafficResetDay, trafficResetDay: values.trafficResetDay,
@@ -695,6 +701,7 @@ export default function ClientFormModal({
expiryTime, expiryTime,
reset: Number(values.reset) || 0, reset: Number(values.reset) || 0,
resetDay: Number(values.resetDay) || 0, resetDay: Number(values.resetDay) || 0,
resetWeekday: Number(values.resetWeekday) || 0,
resetMax: Number(values.resetMax) || 0, resetMax: Number(values.resetMax) || 0,
trafficReset: values.trafficReset || 'never', trafficReset: values.trafficReset || 'never',
trafficResetDay: Number(values.trafficResetDay) || 1, trafficResetDay: Number(values.trafficResetDay) || 1,
@@ -803,6 +810,7 @@ export default function ClientFormModal({
open={open} open={open}
title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')} title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
destroyOnHidden destroyOnHidden
className="client-form-modal"
width={720} width={720}
zIndex={CLIENT_FORM_MODAL_Z_INDEX} zIndex={CLIENT_FORM_MODAL_Z_INDEX}
style={{ top: 20 }} style={{ top: 20 }}
@@ -983,35 +991,16 @@ export default function ClientFormModal({
/> />
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={12} md={6}> <Col xs={24}>
<FormField <ClientRenewalFields
name="reset" active={open}
label={t('pages.clients.renewDays')} delayedStart={delayedStart}
tooltip={t('pages.clients.renewDesc')} expiryTime={
transform={{ output: (v) => Number(v) || 0 }} delayedStart ? -86400000 * (delayedDays || 0) : expiryDate || 0
> }
<InputNumber min={0} style={{ width: '100%' }} /> resetCount={client?.traffic?.resetCount || 0}
</FormField> setExpiry={(expiry) => methods.setValue('expiryDate', expiry)}
</Col> />
<Col xs={12} md={6}>
<FormField
name="resetDay"
label={t('pages.clients.renewOnDay')}
tooltip={t('pages.clients.renewOnDayDesc')}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} max={31} style={{ width: '100%' }} />
</FormField>
</Col>
<Col xs={12} md={6}>
<FormField
name="resetMax"
label={t('pages.clients.renewMax')}
tooltip={t('pages.clients.renewMaxDesc')}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
</Col> </Col>
<Col xs={12} md={6}> <Col xs={12} md={6}>
<FormField <FormField
@@ -0,0 +1,197 @@
import { useEffect, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormContext, useWatch } from 'react-hook-form';
import { useQuery } from '@tanstack/react-query';
import { Button, Form, InputNumber, Select, Space, Typography } from 'antd';
import { FormField } from '@/components/form/rhf';
import { ClientRenewalPreviewSchema } from '@/generated/zod';
import { HttpUtil } from '@/utils';
import type { ClientFormValues } from '@/schemas/client';
type RenewalFields = Pick<ClientFormValues, 'reset' | 'resetDay' | 'resetWeekday' | 'resetMax'>;
type RenewalMode = 'none' | 'interval' | 'weekly' | 'monthly';
export default function ClientRenewalFields({
active,
expiryTime,
resetCount = 0,
bulk = false,
delayedStart = false,
setExpiry,
}: {
active: boolean;
expiryTime: number;
resetCount?: number;
bulk?: boolean;
delayedStart?: boolean;
setExpiry: (expiry: number) => void;
}) {
const { t, i18n } = useTranslation();
const formId = useId();
const modeId = 'client-renewal-mode-' + formId;
const { control, setValue } = useFormContext<RenewalFields>();
const [reset, resetDay, resetWeekday, resetMax] = useWatch({
control,
name: ['reset', 'resetDay', 'resetWeekday', 'resetMax'],
});
const mode: RenewalMode =
resetDay > 0 ? 'monthly' : resetWeekday > 0 ? 'weekly' : reset > 0 ? 'interval' : 'none';
const request = useMemo(
() => ({
expiryTime,
reset: reset || 0,
resetDay: resetDay || 0,
resetWeekday: resetWeekday || 0,
resetMax: resetMax || 0,
resetCount,
}),
[expiryTime, reset, resetDay, resetWeekday, resetMax, resetCount],
);
const [debounced, setDebounced] = useState(request);
useEffect(() => {
const timer = setTimeout(() => setDebounced(request), 250);
return () => clearTimeout(timer);
}, [request]);
const query = useQuery({
queryKey: ['clients', 'renewalPreview', debounced],
enabled: active && mode !== 'none' && request === debounced,
retry: false,
queryFn: async () => {
const msg = await HttpUtil.post('/panel/api/clients/renewalPreview', debounced, {
headers: { 'Content-Type': 'application/json' },
silent: true,
});
if (!msg?.success) throw new Error(msg?.msg || 'Renewal preview failed');
return ClientRenewalPreviewSchema.parse(msg.obj);
},
});
const preview = request === debounced ? query.data : undefined;
const weekdayFormatter = new Intl.DateTimeFormat(i18n.language, {
weekday: 'long',
timeZone: 'UTC',
});
function changeMode(next: RenewalMode) {
setValue('reset', next === 'interval' ? Math.max(1, reset || 0) : 0);
setValue('resetDay', next === 'monthly' ? Math.max(1, resetDay || 0) : 0);
setValue('resetWeekday', next === 'weekly' ? Math.max(1, resetWeekday || 0) : 0);
}
return (
<>
<Form.Item label={t('pages.clients.renewMode')} htmlFor={modeId}>
<Select
id={modeId}
value={mode}
onChange={changeMode}
options={[
{ value: 'none', label: t('pages.clients.renewModeNone') },
{ value: 'interval', label: t('pages.clients.renewModeInterval') },
{ value: 'weekly', label: t('pages.clients.renewModeWeekly') },
{ value: 'monthly', label: t('pages.clients.renewModeMonthly') },
]}
/>
</Form.Item>
{mode === 'interval' && (
<FormField
name="reset"
label={bulk ? t('pages.clients.renew') : t('pages.clients.renewDays')}
tooltip={t('pages.clients.renewDesc')}
transform={{ output: (v) => Number(v) || 1 }}
>
<InputNumber id={'client-renewal-interval-' + formId} min={1} style={{ width: '100%' }} />
</FormField>
)}
{mode === 'monthly' && (
<FormField
name="resetDay"
label={t('pages.clients.renewOnDay')}
tooltip={t('pages.clients.renewOnDayDesc')}
transform={{ output: (v) => Number(v) || 1 }}
>
<InputNumber
id={'client-renewal-day-' + formId}
min={1}
max={31}
style={{ width: '100%' }}
/>
</FormField>
)}
{mode === 'weekly' && (
<FormField name="resetWeekday" label={t('pages.clients.renewWeekday')}>
<Select
id={'client-renewal-weekday-' + formId}
options={Array.from({ length: 7 }, (_, i) => ({
value: i + 1,
label: weekdayFormatter.format(new Date(Date.UTC(2026, 0, i + 5))),
}))}
/>
</FormField>
)}
{mode !== 'none' && (
<>
<FormField
name="resetMax"
label={t('pages.clients.renewMax')}
tooltip={t('pages.clients.renewMaxDesc')}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<Typography.Paragraph type="secondary">
{t('pages.clients.renewScheduleDesc')}
</Typography.Paragraph>
{query.isError && request === debounced && (
<Typography.Paragraph type="warning">
{t('pages.clients.renewPreviewError')}
</Typography.Paragraph>
)}
{preview && (
<Space orientation="vertical" size={4} style={{ marginBottom: 16 }}>
<Typography.Text>
{t('pages.clients.renewPreview', { zone: preview.timeZone })}
</Typography.Text>
{delayedStart || preview.delayedStart ? (
<Typography.Text type="secondary">
{t('pages.clients.renewFirstUse')}
</Typography.Text>
) : expiryTime === 0 ? (
<>
<Typography.Text type="warning">
{t('pages.clients.renewNeedsExpiry')}
</Typography.Text>
{preview.suggestedExpiryTime > 0 && (
<Button onClick={() => setExpiry(preview.suggestedExpiryTime)}>
{t('pages.clients.renewSetExpiry')}: {preview.suggestedExpiry}
</Button>
)}
</>
) : (
<>
<Typography.Text>
{t('pages.clients.renewAt')}: {preview.renewAt}
</Typography.Text>
<Typography.Text>
{t('pages.clients.renewValidThrough')}: {preview.validThrough}
</Typography.Text>
{preview.nextExpiry && (
<Typography.Text>
{t('pages.clients.renewNextExpiry')}: {preview.nextExpiry}
</Typography.Text>
)}
<Typography.Text>
{t('pages.clients.renewPeriods', { count: preview.renewals })}
</Typography.Text>
{!preview.canRenew && (
<Typography.Text type="warning">
{t('pages.clients.renewUnavailable')}
</Typography.Text>
)}
</>
)}
</Space>
)}
</>
)}
</>
);
}
@@ -61,6 +61,23 @@
margin: 0; margin: 0;
} }
.summary-stat {
margin: -4px -8px;
padding: 4px 8px;
border-radius: 8px;
cursor: pointer;
transition: background-color 120ms ease;
}
.summary-stat:hover,
.summary-stat:focus-visible {
background: var(--ant-color-fill-tertiary);
}
.summary-stat.selected {
background: var(--ant-color-primary-bg);
}
.dot { .dot {
display: inline-block; display: inline-block;
width: 8px; width: 8px;
+79 -64
View File
@@ -1,4 +1,5 @@
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { useLocation, useSearchParams } from 'react-router'; import { useLocation, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -153,6 +154,40 @@ function ClientEmailList({ emails, total }: { emails: string[]; total: number })
); );
} }
interface SummaryStatProps {
title: string;
value: number;
prefix: ReactNode;
emails?: string[];
selected?: boolean;
onSelect: () => void;
}
function SummaryStat({ title, value, prefix, emails, selected, onSelect }: SummaryStatProps) {
const stat = (
<div
role="button"
tabIndex={0}
aria-pressed={selected}
className={selected ? 'summary-stat selected' : 'summary-stat'}
onClick={onSelect}
onKeyDown={activateOnKey(onSelect)}
>
<Statistic title={title} value={String(value)} prefix={prefix} />
</div>
);
if (!emails) return stat;
return (
<Popover
title={title}
open={value ? undefined : false}
content={<ClientEmailList emails={emails} total={value} />}
>
{stat}
</Popover>
);
}
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring'; type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
interface PersistedFilterState { interface PersistedFilterState {
@@ -1224,6 +1259,15 @@ export default function ClientsPage() {
const someSelected = const someSelected =
selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length; selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
const isOnlyBucket = (bucket: string) =>
filters.buckets.length === 1 && filters.buckets[0] === bucket;
// Clicking the card that is already the sole status filter clears it again.
function selectBucket(bucket: string | null) {
const buckets = bucket && !isOnlyBucket(bucket) ? [bucket] : [];
setFilters({ ...filters, buckets });
}
function clearOneFilter<K extends keyof ClientFilters>(key: K) { function clearOneFilter<K extends keyof ClientFilters>(key: K) {
if (key === 'expiryFrom' || key === 'expiryTo') { if (key === 'expiryFrom' || key === 'expiryTo') {
setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined }); setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
@@ -1265,89 +1309,60 @@ export default function ClientsPage() {
<Card size="small" hoverable className="summary-card"> <Card size="small" hoverable className="summary-card">
<Row gutter={[16, 12]}> <Row gutter={[16, 12]}>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Statistic <SummaryStat
title={t('clients')} title={t('clients')}
value={String(summary.total)} value={summary.total}
prefix={<TeamOutlined />} prefix={<TeamOutlined />}
onSelect={() => selectBucket(null)}
/> />
</Col> </Col>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Popover <SummaryStat
title={t('online')} title={t('online')}
open={summary.onlineCount ? undefined : false} value={summary.onlineCount}
content={ emails={summary.online}
<ClientEmailList prefix={<span className="dot dot-blue" />}
emails={summary.online} selected={isOnlyBucket('online')}
total={summary.onlineCount} onSelect={() => selectBucket('online')}
/> />
}
>
<Statistic
title={t('online')}
value={String(summary.onlineCount)}
prefix={<span className="dot dot-blue" />}
/>
</Popover>
</Col> </Col>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Popover <SummaryStat
title={t('depleted')} title={t('depleted')}
open={summary.depletedCount ? undefined : false} value={summary.depletedCount}
content={ emails={summary.depleted}
<ClientEmailList prefix={<span className="dot dot-red" />}
emails={summary.depleted} selected={isOnlyBucket('depleted')}
total={summary.depletedCount} onSelect={() => selectBucket('depleted')}
/> />
}
>
<Statistic
title={t('depleted')}
value={String(summary.depletedCount)}
prefix={<span className="dot dot-red" />}
/>
</Popover>
</Col> </Col>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Popover <SummaryStat
title={t('depletingSoon')} title={t('depletingSoon')}
open={summary.expiringCount ? undefined : false} value={summary.expiringCount}
content={ emails={summary.expiring}
<ClientEmailList prefix={<span className="dot dot-orange" />}
emails={summary.expiring} selected={isOnlyBucket('expiring')}
total={summary.expiringCount} onSelect={() => selectBucket('expiring')}
/> />
}
>
<Statistic
title={t('depletingSoon')}
value={String(summary.expiringCount)}
prefix={<span className="dot dot-orange" />}
/>
</Popover>
</Col> </Col>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Popover <SummaryStat
title={t('disabled')} title={t('disabled')}
open={summary.deactiveCount ? undefined : false} value={summary.deactiveCount}
content={ emails={summary.deactive}
<ClientEmailList prefix={<span className="dot dot-gray" />}
emails={summary.deactive} selected={isOnlyBucket('deactive')}
total={summary.deactiveCount} onSelect={() => selectBucket('deactive')}
/> />
}
>
<Statistic
title={t('disabled')}
value={String(summary.deactiveCount)}
prefix={<span className="dot dot-gray" />}
/>
</Popover>
</Col> </Col>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Statistic <SummaryStat
title={t('subscription.active')} title={t('subscription.active')}
value={String(summary.active)} value={summary.active}
prefix={<span className="dot dot-green" />} prefix={<span className="dot dot-green" />}
selected={isOnlyBucket('active')}
onSelect={() => selectBucket('active')}
/> />
</Col> </Col>
</Row> </Row>
@@ -17,6 +17,7 @@ import type { HostRecord } from '@/api/queries/useHostsQuery';
import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host'; import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
import type { InboundOption } from '@/schemas/client'; import type { InboundOption } from '@/schemas/client';
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives'; import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
import { CipherSuitesSelect } from '@/components/form';
import { FormField, rhfZodValidate } from '@/components/form/rhf'; import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useNodesQuery } from '@/api/queries/useNodesQuery'; import { useNodesQuery } from '@/api/queries/useNodesQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -56,6 +57,7 @@ function defaultsFor(host: HostRecord | null): FormShape {
path: host?.path ?? '', path: host?.path ?? '',
alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [], alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [],
fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'], fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'],
cipherSuites: host?.cipherSuites ?? '',
overrideSniFromAddress: host?.overrideSniFromAddress ?? false, overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
keepSniBlank: host?.keepSniBlank ?? false, keepSniBlank: host?.keepSniBlank ?? false,
pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [], pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
@@ -332,6 +334,12 @@ export default function HostFormModal({
<FormField name="alpn" label={t('pages.hosts.fields.alpn')}> <FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
<Select mode="multiple" allowClear options={alpnOptions} /> <Select mode="multiple" allowClear options={alpnOptions} />
</FormField> </FormField>
<FormField
name="cipherSuites"
label={t('pages.inbounds.form.cipherSuites')}
>
<CipherSuitesSelect />
</FormField>
<FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}> <FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
<Select mode="tags" allowClear tokenSeparators={[',']} /> <Select mode="tags" allowClear tokenSeparators={[',']} />
</FormField> </FormField>
@@ -103,13 +103,13 @@ export default function AmneziawgFields({
<InputNumber min={0} style={{ width: '100%' }} /> <InputNumber min={0} style={{ width: '100%' }} />
</FormField> </FormField>
<FormField name={['settings', 'server', 's1']} label={t('pages.xray.amneziawg.s1')}> <FormField name={['settings', 'server', 's1']} label={t('pages.xray.amneziawg.s1')}>
<InputNumber min={0} style={{ width: '100%' }} /> <InputNumber min={0} max={1552} style={{ width: '100%' }} />
</FormField> </FormField>
<FormField name={['settings', 'server', 's2']} label={t('pages.xray.amneziawg.s2')}> <FormField name={['settings', 'server', 's2']} label={t('pages.xray.amneziawg.s2')}>
<InputNumber min={0} style={{ width: '100%' }} /> <InputNumber min={0} max={1608} style={{ width: '100%' }} />
</FormField> </FormField>
<FormField name={['settings', 'server', 's3']} label={t('pages.xray.amneziawg.s3')}> <FormField name={['settings', 'server', 's3']} label={t('pages.xray.amneziawg.s3')}>
<InputNumber min={0} max={64} style={{ width: '100%' }} /> <InputNumber min={0} max={1636} style={{ width: '100%' }} />
</FormField> </FormField>
<FormField name={['settings', 'server', 's4']} label={t('pages.xray.amneziawg.s4')}> <FormField name={['settings', 'server', 's4']} label={t('pages.xray.amneziawg.s4')}>
<InputNumber min={0} max={32} style={{ width: '100%' }} /> <InputNumber min={0} max={32} style={{ width: '100%' }} />
@@ -8,11 +8,11 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'; import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { CipherSuitesSelect } from '@/components/form';
import { FormField } from '@/components/form/rhf'; import { FormField } from '@/components/form/rhf';
import { import {
ALPN_OPTION, ALPN_OPTION,
DOMAIN_STRATEGY_OPTION, DOMAIN_STRATEGY_OPTION,
TLS_CIPHER_OPTION,
TLS_VERSION_OPTION, TLS_VERSION_OPTION,
USAGE_OPTION, USAGE_OPTION,
UTLS_FINGERPRINT, UTLS_FINGERPRINT,
@@ -240,12 +240,7 @@ export default function TlsForm({
name={['streamSettings', 'tlsSettings', 'cipherSuites']} name={['streamSettings', 'tlsSettings', 'cipherSuites']}
label={t('pages.inbounds.form.cipherSuites')} label={t('pages.inbounds.form.cipherSuites')}
> >
<Select <CipherSuitesSelect placeholder={t('pages.inbounds.form.autoOption')} />
options={[
{ value: '', label: t('pages.inbounds.form.autoOption') },
...Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => ({ value: v, label: k })),
]}
/>
</FormField> </FormField>
<Form.Item label={t('pages.inbounds.form.minMaxVersion')}> <Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
<Space.Compact block> <Space.Compact block>
+1 -1
View File
@@ -28,7 +28,7 @@
.qr-panel-canvas .qr-code { .qr-panel-canvas .qr-code {
cursor: pointer; cursor: pointer;
background: #fff; background: #fff;
border-radius: 4px; border-radius: 8px;
line-height: 0; line-height: 0;
} }
+1 -1
View File
@@ -141,7 +141,7 @@ export default function QrPanel({
value={value} value={value}
size={size} size={size}
errorLevel="L" errorLevel="L"
marginSize={4} marginSize={2}
type="svg" type="svg"
bordered={false} bordered={false}
color="#000000" color="#000000"
+36 -5
View File
@@ -119,6 +119,18 @@ function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string
return map; return map;
} }
// Most pushes repeat the previous online sets; handing back a new Map anyway
// re-ran the client rollup over every inbound on each traffic event.
function sameGuidSets(a: Map<string, Set<string>>, b: Map<string, Set<string>>): boolean {
if (a.size !== b.size) return false;
for (const [key, set] of b) {
const prev = a.get(key);
if (!prev || prev.size !== set.size) return false;
for (const value of set) if (!prev.has(value)) return false;
}
return true;
}
async function fetchLastOnlineMap(): Promise<Record<string, number>> { async function fetchLastOnlineMap(): Promise<Record<string, number>> {
const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true }); const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
@@ -440,10 +452,12 @@ export function useInbounds() {
setOnlineClients(p.onlineClients); setOnlineClients(p.onlineClients);
} }
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') { if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
setOnlineByGuid(toGuidOnlineMap(p.onlineByGuid)); const next = toGuidOnlineMap(p.onlineByGuid);
setOnlineByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
} }
if (p.activeInbounds && typeof p.activeInbounds === 'object') { if (p.activeInbounds && typeof p.activeInbounds === 'object') {
setActiveByGuid(toGuidOnlineMap(p.activeInbounds)); const next = toGuidOnlineMap(p.activeInbounds);
setActiveByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
} }
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') { if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! })); setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
@@ -537,8 +551,7 @@ export function useInbounds() {
? stats.map((stat) => { ? stats.map((stat) => {
const su = byEmail.get(stat.email); const su = byEmail.get(stat.email);
if (!su) return stat; if (!su) return stat;
statsTouched = true; const merged = {
return {
...stat, ...stat,
up: typeof su.up === 'number' ? su.up : stat.up, up: typeof su.up === 'number' ? su.up : stat.up,
down: typeof su.down === 'number' ? su.down : stat.down, down: typeof su.down === 'number' ? su.down : stat.down,
@@ -546,9 +559,27 @@ export function useInbounds() {
expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime, expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime,
enable: typeof su.enable === 'boolean' ? su.enable : stat.enable, enable: typeof su.enable === 'boolean' ? su.enable : stat.enable,
} as ClientStats; } as ClientStats;
if (
merged.up === stat.up &&
merged.down === stat.down &&
merged.total === stat.total &&
merged.expiryTime === stat.expiryTime &&
merged.enable === stat.enable
) {
return stat;
}
statsTouched = true;
return merged;
}) })
: null; : null;
if (!upd && !statsTouched) return ib; // Every push lists all inbounds' totals, so only a row whose numbers moved counts.
const inboundMoved =
!!upd &&
((typeof upd.up === 'number' && upd.up !== ib.up) ||
(typeof upd.down === 'number' && upd.down !== ib.down) ||
(typeof upd.total === 'number' && upd.total !== ib.total) ||
(typeof upd.enable === 'boolean' && upd.enable !== ib.enable));
if (!inboundMoved && !statsTouched) return ib;
touched = true; touched = true;
const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance; const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance;
if (upd) { if (upd) {
@@ -122,7 +122,7 @@ export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalPr
<Select <Select
value={rows} value={rows}
size="small" size="small"
style={{ width: 70 }} style={{ width: 100 }}
onChange={setRows} onChange={setRows}
options={[ options={[
{ value: '20', label: '20' }, { value: '20', label: '20' },
+3
View File
@@ -22,6 +22,7 @@ import { useStatusQuery } from '@/api/queries/useStatusQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import AppSidebar from '@/layouts/AppSidebar'; import AppSidebar from '@/layouts/AppSidebar';
import { LazyMount } from '@/components/utility'; import { LazyMount } from '@/components/utility';
import SponsorSlot from '@/components/sponsor/SponsorSlot';
import { setMessageInstance } from '@/utils/messageBus'; import { setMessageInstance } from '@/utils/messageBus';
import OverviewActionBar from './OverviewActionBar'; import OverviewActionBar from './OverviewActionBar';
import VitalTile from './VitalTile'; import VitalTile from './VitalTile';
@@ -213,6 +214,8 @@ export default function IndexPage() {
onOpenVersionSwitch={() => setVersionOpen(true)} onOpenVersionSwitch={() => setVersionOpen(true)}
/> />
<SponsorSlot slot="dashboard" />
{health && ( {health && (
<div className="ov-health" style={{ color: health.color }}> <div className="ov-health" style={{ color: health.color }}>
<span className="ov-health-mark" /> <span className="ov-health-mark" />
+1 -1
View File
@@ -107,7 +107,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
<Select <Select
value={rows} value={rows}
size="small" size="small"
style={{ width: 70 }} style={{ width: 100 }}
onChange={setRows} onChange={setRows}
options={[ options={[
{ value: '20', label: '20' }, { value: '20', label: '20' },
@@ -159,7 +159,7 @@ export default function OverviewActionBar({
return ( return (
<div className="ov-bar"> <div className="ov-bar">
{status.xray.state === 'error' && status.xray.errorMsg ? ( {status.xray.errorMsg ? (
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}> <Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
{statePill} {statePill}
</Tooltip> </Tooltip>
@@ -167,6 +167,12 @@ export default function OverviewActionBar({
statePill statePill
)} )}
{status.xray.state === 'running' && status.xray.errorMsg ? (
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
<Tag color="error">{t('pages.index.xrayStatusError')}</Tag>
</Tooltip>
) : null}
{updateAvailable ? ( {updateAvailable ? (
<Tag <Tag
className="ov-update-tag" className="ov-update-tag"
+1 -1
View File
@@ -175,7 +175,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
<Select <Select
value={rows} value={rows}
size="small" size="small"
style={{ width: 70 }} style={{ width: 100 }}
onChange={setRows} onChange={setRows}
options={[ options={[
{ value: '20', label: '20' }, { value: '20', label: '20' },
+4
View File
@@ -411,3 +411,7 @@
.submit-row { .submit-row {
margin-bottom: 0; margin-bottom: 0;
} }
.login-sponsor {
margin-top: 20px;
}
+2
View File
@@ -26,6 +26,7 @@ import { FormProvider, useForm } from 'react-hook-form';
import { HttpUtil, LanguageManager } from '@/utils'; import { HttpUtil, LanguageManager } from '@/utils';
import { FormField, rhfZodValidate } from '@/components/form/rhf'; import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { setMessageInstance } from '@/utils/messageBus'; import { setMessageInstance } from '@/utils/messageBus';
import SponsorSlot from '@/components/sponsor/SponsorSlot';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login'; import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
import './LoginPage.css'; import './LoginPage.css';
@@ -247,6 +248,7 @@ export default function LoginPage() {
</Form.Item> </Form.Item>
</Form> </Form>
</FormProvider> </FormProvider>
<SponsorSlot slot="login" variant="compact" className="login-sponsor" />
</div> </div>
)} )}
</div> </div>
@@ -25,6 +25,8 @@ interface ApiMsg<T = unknown> {
const REFRESH_MS = 15000; const REFRESH_MS = 15000;
const formatKbps = (v: number) => v.toLocaleString(undefined, { maximumFractionDigits: 1 });
export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanelProps) { export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [cpuPoints, setCpuPoints] = useState<number[]>([]); const [cpuPoints, setCpuPoints] = useState<number[]>([]);
@@ -51,7 +53,7 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
}; };
// cpu/mem are percentages (clamp 0-100); net throughput is bytes/sec shown // cpu/mem are percentages (clamp 0-100); net throughput is bytes/sec shown
// as KB/s (no upper clamp, the sparkline auto-scales). // as KB/s, which must opt out of Sparkline's 0-100 "%" defaults.
const fetchSeries = async (metric: string, kind: 'pct' | 'rate') => { const fetchSeries = async (metric: string, kind: 'pct' | 'rate') => {
try { try {
const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`; const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`;
@@ -148,6 +150,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
fillOpacity={0.18} fillOpacity={0.18}
markerRadius={2.6} markerRadius={2.6}
showTooltip showTooltip
valueMax={null}
yFormatter={formatKbps}
/> />
</div> </div>
<div className="series"> <div className="series">
@@ -164,6 +168,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
fillOpacity={0.18} fillOpacity={0.18}
markerRadius={2.6} markerRadius={2.6}
showTooltip showTooltip
valueMax={null}
yFormatter={formatKbps}
/> />
</div> </div>
</div> </div>
+56 -42
View File
@@ -145,17 +145,22 @@ function formatUptime(secs?: number): string {
return `${mins}m`; return `${mins}m`;
} }
// Stable per language: the columns memo depends on it, and a fresh function each
// render rebuilt every column, re-rendering all rows on each heartbeat push.
function useRelativeTime() { function useRelativeTime() {
const { t } = useTranslation(); const { t } = useTranslation();
return (unixSeconds?: number) => { return useMemo(
if (!unixSeconds) return t('pages.nodes.never'); () => (unixSeconds?: number) => {
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds)); if (!unixSeconds) return t('pages.nodes.never');
if (diffSec < 5) return t('pages.nodes.justNow'); const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
if (diffSec < 60) return `${diffSec}s`; if (diffSec < 5) return t('pages.nodes.justNow');
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`; if (diffSec < 60) return `${diffSec}s`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`; if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
return `${Math.floor(diffSec / 86400)}d`; if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
}; return `${Math.floor(diffSec / 86400)}d`;
},
[t],
);
} }
export default function NodeList({ export default function NodeList({
@@ -530,6 +535,47 @@ export default function NodeList({
], ],
); );
// rc-table re-runs every cell renderer whenever the Table re-renders, so keep the
// same element until its inputs change rather than re-rendering all rows each time.
const nodeTable = useMemo(
() => (
<Table<NodeRow>
dataSource={dataSource}
columns={columns}
pagination={false}
loading={loading}
scroll={{ x: 'max-content' }}
size="middle"
rowKey="key"
rowSelection={
dataSource.length > 1
? {
selectedRowKeys: selectedIds,
onChange: (keys) =>
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
getCheckboxProps: (record) => ({
disabled: !!record.transitive || !isUpdateEligible(record),
}),
}
: undefined
}
locale={{
emptyText: (
<div className="card-empty">
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
}}
expandable={{
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
rowExpandable: (record) => !record.transitive,
}}
/>
),
[dataSource, columns, loading, selectedIds, onSelectionChange, t],
);
return ( return (
<Card size="small" hoverable> <Card size="small" hoverable>
<div className="toolbar"> <div className="toolbar">
@@ -806,39 +852,7 @@ export default function NodeList({
</Modal> </Modal>
</> </>
) : ( ) : (
<Table<NodeRow> nodeTable
dataSource={dataSource}
columns={columns}
pagination={false}
loading={loading}
scroll={{ x: 'max-content' }}
size="middle"
rowKey="key"
rowSelection={
dataSource.length > 1
? {
selectedRowKeys: selectedIds,
onChange: (keys) =>
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
getCheckboxProps: (record) => ({
disabled: !!record.transitive || !isUpdateEligible(record),
}),
}
: undefined
}
locale={{
emptyText: (
<div className="card-empty">
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
}}
expandable={{
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
rowExpandable: (record) => !record.transitive,
}}
/>
)} )}
</Card> </Card>
); );
@@ -0,0 +1,204 @@
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Input, Modal, Space, Tabs, Typography } from 'antd';
import JsonEditor from '@/components/form/JsonEditor';
import type { HappRoutingProfile } from '@/schemas/happRouting';
import {
buildHappRoutingDeeplink,
loadHappRouting,
parseHappRoutingJson,
parseHappRoutingList,
type HappRoutingListKey,
} from './happRoutingEditor';
interface HappRoutingEditorModalProps {
input: string;
onCancel: () => void;
onGenerate: (deeplink: string) => void;
}
const basicFields: { key: HappRoutingListKey; label: string; placeholder: string }[] = [
{
key: 'DirectSites',
label: 'subHappDirectDomains',
placeholder: 'domain:ir\ndomain:cn\nexample.local',
},
{
key: 'ProxySites',
label: 'subHappProxyDomains',
placeholder: 'geosite:google\nyoutube.com',
},
{
key: 'BlockSites',
label: 'subHappBlockDomains',
placeholder: 'geosite:category-ads-all\nanalytics.google.com',
},
{
key: 'DirectIp',
label: 'subHappDirectIPs',
placeholder: 'geoip:ir\n192.168.0.0/16\n10.0.0.0/8',
},
{
key: 'ProxyIp',
label: 'subHappProxyIPs',
placeholder: '1.1.1.1/32\n8.8.8.8/32',
},
{
key: 'BlockIp',
label: 'subHappBlockIPs',
placeholder: 'geoip:phishing\n0.0.0.0/8',
},
];
type BasicBuffers = Record<HappRoutingListKey, string>;
function basicBuffers(profile: HappRoutingProfile | null): BasicBuffers {
return {
DirectSites: profile?.DirectSites?.join('\n') ?? '',
ProxySites: profile?.ProxySites?.join('\n') ?? '',
BlockSites: profile?.BlockSites?.join('\n') ?? '',
DirectIp: profile?.DirectIp?.join('\n') ?? '',
ProxyIp: profile?.ProxyIp?.join('\n') ?? '',
BlockIp: profile?.BlockIp?.join('\n') ?? '',
};
}
function mergeBasicRules(profile: HappRoutingProfile, buffers: BasicBuffers): HappRoutingProfile {
const next = { ...profile };
// Only replace edited lists; absent lists and all other profile fields must survive unchanged.
for (const { key } of basicFields) {
if (buffers[key] !== (profile[key]?.join('\n') ?? '')) {
next[key] = parseHappRoutingList(buffers[key]);
}
}
return next;
}
const loadErrorKeys = {
off: 'subHappEditorLoadOff',
remote: 'subHappEditorLoadRemote',
invalid: 'subHappEditorLoadInvalid',
};
export default function HappRoutingEditorModal({
input,
onCancel,
onGenerate,
}: HappRoutingEditorModalProps) {
const { t } = useTranslation();
const fieldId = useId();
const [loaded] = useState(() => loadHappRouting(input));
const [profile, setProfile] = useState(() => (loaded.success ? loaded.profile : null));
const [activeTab, setActiveTab] = useState('basic');
// Keep raw buffers while typing so trailing newlines and temporarily invalid JSON are not lost.
const [buffers, setBuffers] = useState(() => basicBuffers(profile));
const [jsonText, setJsonText] = useState(() => (profile ? JSON.stringify(profile, null, 2) : ''));
const advancedProfile = activeTab === 'advanced' ? parseHappRoutingJson(jsonText) : null;
const invalidJson = activeTab === 'advanced' && advancedProfile === null;
const switchTab = (nextTab: string) => {
if (!profile || nextTab === activeTab) return;
if (nextTab === 'advanced') {
const next = mergeBasicRules(profile, buffers);
setProfile(next);
setJsonText(JSON.stringify(next, null, 2));
} else {
if (!advancedProfile) return;
setProfile(advancedProfile);
setBuffers(basicBuffers(advancedProfile));
}
setActiveTab(nextTab);
};
const generate = () => {
if (!loaded.success || !profile) return;
const next = activeTab === 'advanced' ? advancedProfile : mergeBasicRules(profile, buffers);
if (next) onGenerate(buildHappRoutingDeeplink(next, loaded.mode));
};
return (
<Modal
title={t('pages.settings.subHappModalTitle')}
open
onCancel={onCancel}
onOk={generate}
okText={t('pages.settings.subHappBuildDeeplink')}
okButtonProps={{ disabled: !loaded.success || invalidJson }}
width={650}
>
<Space orientation="vertical" style={{ width: '100%', marginTop: 12 }} size="middle">
{!loaded.success ? (
<Alert type="error" showIcon title={t(`pages.settings.${loadErrorKeys[loaded.error]}`)} />
) : loaded.isNew ? (
<Alert type="info" showIcon title={t('pages.settings.subHappEditorNew')} />
) : null}
{loaded.success ? (
<Tabs
activeKey={activeTab}
onChange={switchTab}
destroyOnHidden
items={[
{
key: 'basic',
label: t('pages.settings.subHappEditorBasic'),
disabled: invalidJson,
children: (
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
<Typography.Text type="secondary" id={`${fieldId}-hint`}>
{t('pages.settings.subHappEditorListHint')}
</Typography.Text>
{basicFields.map(({ key, label, placeholder }) => (
<div key={key}>
<label
htmlFor={`${fieldId}-${key}`}
style={{ display: 'block', fontWeight: 600, marginBottom: 4 }}
>
{t(`pages.settings.${label}`)}
</label>
<Input.TextArea
id={`${fieldId}-${key}`}
aria-describedby={`${fieldId}-hint`}
rows={2}
value={buffers[key]}
placeholder={placeholder}
onChange={(event) =>
setBuffers((previous) => ({ ...previous, [key]: event.target.value }))
}
/>
</div>
))}
</Space>
),
},
{
key: 'advanced',
label: t('pages.settings.subHappEditorAdvanced'),
children: (
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
<Typography.Text type="secondary">
{t('pages.settings.subHappEditorAdvancedHint')}
</Typography.Text>
{invalidJson ? (
<Alert
type="error"
showIcon
title={t('pages.settings.subHappEditorInvalidJson')}
/>
) : null}
<JsonEditor
value={jsonText}
onChange={setJsonText}
minHeight="320px"
maxHeight="50vh"
/>
</Space>
),
},
]}
/>
) : null}
</Space>
</Modal>
);
}
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Input, Modal, Select, Space, Switch, Tabs, message } from 'antd'; import { Button, Input, Select, Space, Switch, Tabs, message } from 'antd';
import { import {
BranchesOutlined, BranchesOutlined,
BuildOutlined, BuildOutlined,
@@ -13,7 +13,8 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { AllSetting } from '@/models/setting'; import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui'; import { SettingListItem } from '@/components/ui';
import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from './happPresets'; import { buildHappPresetDeeplink } from './happPresets';
import HappRoutingEditorModal from './HappRoutingEditorModal';
import { catTabLabel } from './catTabLabel'; import { catTabLabel } from './catTabLabel';
interface HappSettingsContentProps { interface HappSettingsContentProps {
@@ -32,38 +33,20 @@ export default function HappSettingsContent({
defaultActiveTab = 'routing', defaultActiveTab = 'routing',
}: HappSettingsContentProps) { }: HappSettingsContentProps) {
const { t } = useTranslation(); const { t } = useTranslation();
// Generator choices stay local until Apply updates the draft; page Save persists it.
const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass'); const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass');
const [includeAdblock, setIncludeAdblock] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [directDomains, setDirectDomains] = useState('');
const [proxyDomains, setProxyDomains] = useState('');
const [blockDomains, setBlockDomains] = useState('');
const [directIPs, setDirectIPs] = useState('');
const [proxyIPs, setProxyIPs] = useState('');
const [blockIPs, setBlockIPs] = useState('');
const applyPreset = () => { const applyPreset = () => {
const payload = buildHappPresetDeeplink(selectedPreset); const payload = buildHappPresetDeeplink(selectedPreset, includeAdblock);
if (payload) { if (payload) {
updateSetting({ subRoutingRules: payload }); updateSetting({ subRoutingRules: payload });
message.success(t('pages.settings.subHappPresetApplied')); message.success(t('pages.settings.subHappPresetApplied'));
} }
}; };
const handleBuildDeeplink = () => { const handleBuildDeeplink = (deeplink: string) => {
const profile = {
Name: 'Custom Rules',
GlobalProxy: 'true',
DirectSites: parseList(directDomains),
DirectIp: parseList(directIPs),
ProxySites: parseList(proxyDomains),
ProxyIp: parseList(proxyIPs),
BlockSites: parseList(blockDomains),
BlockIp: parseList(blockIPs),
DomainStrategy: 'IPIfNonMatch',
};
const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify(profile));
updateSetting({ subRoutingRules: deeplink }); updateSetting({ subRoutingRules: deeplink });
setIsModalOpen(false); setIsModalOpen(false);
message.success(t('pages.settings.subHappDeeplinkGenerated')); message.success(t('pages.settings.subHappDeeplinkGenerated'));
@@ -112,21 +95,31 @@ export default function HappSettingsContent({
title={t('pages.settings.subHappPresets')} title={t('pages.settings.subHappPresets')}
description={t('pages.settings.subHappPresetsDesc')} description={t('pages.settings.subHappPresetsDesc')}
> >
<Space orientation="horizontal" style={{ width: '100%' }}> <Space orientation="horizontal" wrap style={{ width: '100%' }}>
<Select <Select
aria-label={t('pages.settings.subHappPresets')}
value={selectedPreset} value={selectedPreset}
style={{ minWidth: 170 }} style={{ minWidth: 170 }}
onChange={setSelectedPreset} onChange={setSelectedPreset}
options={[ options={[
{ value: 'iran-bypass', label: t('pages.settings.subHappPresetIran') }, { value: 'iran-bypass', label: t('pages.settings.subHappPresetIran') },
{ value: 'china-direct', label: t('pages.settings.subHappPresetChina') }, { value: 'china-direct', label: t('pages.settings.subHappPresetChina') },
{ value: 'adblock', label: t('pages.settings.subHappPresetAdblock') },
{ value: 'global', label: t('pages.settings.subHappPresetGlobal') }, { value: 'global', label: t('pages.settings.subHappPresetGlobal') },
{ value: 'lan-bypass', label: t('pages.settings.subHappPresetLocal') },
{ value: 'off', label: t('pages.settings.subHappPresetOff') }, { value: 'off', label: t('pages.settings.subHappPresetOff') },
]} ]}
/> />
<Space size="small">
<Switch
aria-label={t('pages.settings.subHappIncludeAdblock')}
checked={includeAdblock}
disabled={selectedPreset === 'off'}
onChange={setIncludeAdblock}
/>
<span>{t('pages.settings.subHappIncludeAdblock')}</span>
</Space>
<Button type="primary" onClick={applyPreset}> <Button type="primary" onClick={applyPreset}>
{t('pages.settings.subHappPresets')} {t('pages.settings.subHappApplyPreset')}
</Button> </Button>
</Space> </Space>
</SettingListItem> </SettingListItem>
@@ -382,6 +375,23 @@ export default function HappSettingsContent({
/> />
</SettingListItem> </SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subHappLocalProxyAuth')}
description={t('pages.settings.subHappLocalProxyAuthDesc')}
>
<Select
value={allSetting.subHappLocalProxyAuth ?? 'auto'}
style={{ width: '100%' }}
onChange={(v) => updateSetting({ subHappLocalProxyAuth: v })}
options={[
{ value: 'auto', label: t('pages.settings.subHappLocalProxyAuthAuto') },
{ value: 'disable', label: t('pages.settings.subHappLocalProxyAuthDisable') },
{ value: '', label: t('pages.settings.subHappLocalProxyAuthUnset') },
]}
/>
</SettingListItem>
<SettingListItem <SettingListItem
paddings="small" paddings="small"
title={t('pages.settings.subHappAutoConnect')} title={t('pages.settings.subHappAutoConnect')}
@@ -572,83 +582,14 @@ export default function HappSettingsContent({
]} ]}
/> />
<Modal {/* Mount per opening so canceled edits are discarded and the latest parent draft is loaded. */}
title={t('pages.settings.subHappModalTitle')} {isModalOpen ? (
open={isModalOpen} <HappRoutingEditorModal
onCancel={() => setIsModalOpen(false)} input={allSetting.subRoutingRules}
onOk={handleBuildDeeplink} onCancel={() => setIsModalOpen(false)}
okText={t('pages.settings.subHappBuildDeeplink')} onGenerate={handleBuildDeeplink}
width={650} />
> ) : null}
<Space orientation="vertical" style={{ width: '100%', marginTop: 12 }} size="middle">
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.settings.subHappDirectDomains')}
</div>
<Input.TextArea
rows={2}
value={directDomains}
placeholder="domain:ir, domain:cn, example.local"
onChange={(e) => setDirectDomains(e.target.value)}
/>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.settings.subHappProxyDomains')}
</div>
<Input.TextArea
rows={2}
value={proxyDomains}
placeholder="geosite:google, youtube.com"
onChange={(e) => setProxyDomains(e.target.value)}
/>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.settings.subHappBlockDomains')}
</div>
<Input.TextArea
rows={2}
value={blockDomains}
placeholder="geosite:category-ads-all, analytics.google.com"
onChange={(e) => setBlockDomains(e.target.value)}
/>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.settings.subHappDirectIPs')}
</div>
<Input.TextArea
rows={2}
value={directIPs}
placeholder="geoip:ir, 192.168.0.0/16, 10.0.0.0/8"
onChange={(e) => setDirectIPs(e.target.value)}
/>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.settings.subHappProxyIPs')}
</div>
<Input.TextArea
rows={2}
value={proxyIPs}
placeholder="1.1.1.1/32, 8.8.8.8/32"
onChange={(e) => setProxyIPs(e.target.value)}
/>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.settings.subHappBlockIPs')}
</div>
<Input.TextArea
rows={2}
value={blockIPs}
placeholder="geoip:phishing, 0.0.0.0/8"
onChange={(e) => setBlockIPs(e.target.value)}
/>
</div>
</Space>
</Modal>
</> </>
); );
} }
@@ -0,0 +1,488 @@
import { useTranslation } from 'react-i18next';
import { Input, Select, Switch, Tabs } from 'antd';
import {
AppstoreOutlined,
BranchesOutlined,
NotificationOutlined,
SafetyOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { catTabLabel } from './catTabLabel';
interface IncySettingsContentProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
isMobile: boolean;
remoteSourceBadge: (val: string) => React.ReactNode;
}
// Incy documents every switch as `1`/`0`; an empty value omits the header so
// the subscriber's own app choice wins.
const onOff = (t: (key: string) => string) => [
{ value: '', label: t('pages.settings.subIncyNotSet') },
{ value: '1', label: t('pages.settings.subIncyOn') },
{ value: '0', label: t('pages.settings.subIncyOff') },
];
export default function IncySettingsContent({
allSetting,
updateSetting,
isMobile,
remoteSourceBadge,
}: IncySettingsContentProps) {
const { t } = useTranslation();
return (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyAppAutoDetect')}
description={t('pages.settings.subIncyAppAutoDetectDesc')}
>
<Switch
checked={allSetting.subIncyAppAutoDetect}
onChange={(v) => updateSetting({ subIncyAppAutoDetect: v })}
/>
</SettingListItem>
<Tabs
type="card"
size="small"
items={[
{
key: 'app',
label: catTabLabel(<AppstoreOutlined />, t('pages.settings.subIncyGroupApp'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyProfileDescription')}
description={t('pages.settings.subIncyProfileDescriptionDesc')}
>
<Input
value={allSetting.subIncyProfileDescription}
maxLength={200}
onChange={(e) => updateSetting({ subIncyProfileDescription: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncySortOrder')}
description={t('pages.settings.subIncySortOrderDesc')}
>
<Select
value={allSetting.subIncySortOrder}
style={{ width: '100%' }}
onChange={(v) => updateSetting({ subIncySortOrder: v })}
options={[
{ value: '', label: t('pages.settings.subIncyNotSet') },
{ value: 'none', label: t('pages.settings.subIncySortNone') },
{ value: 'ping', label: t('pages.settings.subIncySortPing') },
{ value: 'name', label: t('pages.settings.subIncySortName') },
]}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncySupportEmail')}
description={t('pages.settings.subIncySupportEmailDesc')}
>
<Input
value={allSetting.subIncySupportEmail}
placeholder="support@example.com"
onChange={(e) => updateSetting({ subIncySupportEmail: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyAnnounceUrl')}
description={t('pages.settings.subIncyAnnounceUrlDesc')}
>
<Input
value={allSetting.subIncyAnnounceUrl}
placeholder="https://t.me/your_channel"
onChange={(e) => updateSetting({ subIncyAnnounceUrl: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyPremiumUrl')}
description={t('pages.settings.subIncyPremiumUrlDesc')}
>
<Input
value={allSetting.subIncyPremiumUrl}
placeholder="https://example.com/buy"
onChange={(e) => updateSetting({ subIncyPremiumUrl: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: 'banners',
label: catTabLabel(
<NotificationOutlined />,
t('pages.settings.subIncyGroupBanners'),
isMobile,
),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyBannerText')}
description={t('pages.settings.subIncyBannerTextDesc')}
>
<Input
value={allSetting.subIncyBannerText}
onChange={(e) => updateSetting({ subIncyBannerText: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyBannerButtonText')}
description={t('pages.settings.subIncyBannerButtonTextDesc')}
>
<Input
value={allSetting.subIncyBannerButtonText}
onChange={(e) => updateSetting({ subIncyBannerButtonText: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyBannerButtonUrl')}
description={t('pages.settings.subIncyBannerButtonUrlDesc')}
>
<Input
value={allSetting.subIncyBannerButtonUrl}
placeholder="https://example.com/sale"
onChange={(e) => updateSetting({ subIncyBannerButtonUrl: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyBannerBgColor')}
description={t('pages.settings.subIncyBannerBgColorDesc')}
>
<Input
value={allSetting.subIncyBannerBgColor}
placeholder="#E53E3E"
onChange={(e) => updateSetting({ subIncyBannerBgColor: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyBannerButtonColor')}
description={t('pages.settings.subIncyBannerButtonColorDesc')}
>
<Input
value={allSetting.subIncyBannerButtonColor}
placeholder="#38A169"
onChange={(e) => updateSetting({ subIncyBannerButtonColor: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: 'privacy',
label: catTabLabel(
<SafetyOutlined />,
t('pages.settings.subIncyGroupPrivacy'),
isMobile,
),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyHideUrl')}
description={t('pages.settings.subIncyHideUrlDesc')}
>
<Select
value={allSetting.subIncyHideUrl}
style={{ width: '100%' }}
onChange={(v) => updateSetting({ subIncyHideUrl: v })}
options={onOff(t)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyHideCheck')}
description={t('pages.settings.subIncyHideCheckDesc')}
>
<Select
value={allSetting.subIncyHideCheck}
style={{ width: '100%' }}
onChange={(v) => updateSetting({ subIncyHideCheck: v })}
options={onOff(t)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyNoLimit')}
description={t('pages.settings.subIncyNoLimitDesc')}
>
<Select
value={allSetting.subIncyNoLimitEnabled}
style={{ width: '100%' }}
onChange={(v) => updateSetting({ subIncyNoLimitEnabled: v })}
options={onOff(t)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyPerAppEnable')}
description={t('pages.settings.subIncyPerAppEnableDesc')}
>
<Select
value={allSetting.subIncyPerAppEnable}
style={{ width: '100%' }}
onChange={(v) => updateSetting({ subIncyPerAppEnable: v })}
options={onOff(t)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyPerAppMode')}
description={t('pages.settings.subIncyPerAppModeDesc')}
>
<Select
value={allSetting.subIncyPerAppMode}
style={{ width: '100%' }}
onChange={(v) => updateSetting({ subIncyPerAppMode: v })}
options={[
{ value: '', label: t('pages.settings.subIncyNotSet') },
{ value: 'proxy', label: t('pages.settings.subIncyPerAppModeProxy') },
{ value: 'bypass', label: t('pages.settings.subIncyPerAppModeBypass') },
]}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyPerAppList')}
description={t('pages.settings.subIncyPerAppListDesc')}
>
<Input.TextArea
value={allSetting.subIncyPerAppList}
rows={4}
placeholder={
'com.google.chrome\norg.telegram.messenger\n\nor https://.../apps.txt'
}
onChange={(e) => updateSetting({ subIncyPerAppList: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: 'network',
label: catTabLabel(
<ThunderboltOutlined />,
t('pages.settings.subIncyGroupNetwork'),
isMobile,
),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyFragmentationEnable')}
description={t('pages.settings.subIncyFragmentationEnableDesc')}
>
<Select
value={allSetting.subIncyFragmentationEnable}
style={{ width: '100%' }}
onChange={(v) => updateSetting({ subIncyFragmentationEnable: v })}
options={onOff(t)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyFragmentLength')}
description={t('pages.settings.subIncyFragmentLengthDesc')}
>
<Input
value={allSetting.subIncyFragmentLength}
placeholder="10-30"
onChange={(e) => updateSetting({ subIncyFragmentLength: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyFragmentInterval')}
description={t('pages.settings.subIncyFragmentIntervalDesc')}
>
<Input
value={allSetting.subIncyFragmentInterval}
placeholder="20-40"
onChange={(e) => updateSetting({ subIncyFragmentInterval: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyFragmentPackets')}
description={t('pages.settings.subIncyFragmentPacketsDesc')}
>
<Select
value={allSetting.subIncyFragmentPackets}
style={{ width: '100%' }}
onChange={(v) => updateSetting({ subIncyFragmentPackets: v })}
options={[
{ value: '', label: t('pages.settings.subIncyNotSet') },
{ value: 'tlshello', label: 'tlshello' },
{ value: '1-3', label: '1-3' },
{ value: '1', label: '1' },
{ value: 'all', label: 'all' },
]}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyNoisesEnable')}
description={t('pages.settings.subIncyNoisesEnableDesc')}
>
<Select
value={allSetting.subIncyNoisesEnable}
style={{ width: '100%' }}
onChange={(v) => updateSetting({ subIncyNoisesEnable: v })}
options={onOff(t)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyNoisesType')}
description={t('pages.settings.subIncyNoisesTypeDesc')}
>
<Select
value={allSetting.subIncyNoisesType}
style={{ width: '100%' }}
onChange={(v) => updateSetting({ subIncyNoisesType: v })}
options={[
{ value: '', label: t('pages.settings.subIncyNotSet') },
{ value: 'rand', label: 'rand' },
{ value: 'str', label: 'str' },
{ value: 'hex', label: 'hex' },
]}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyNoisesPacket')}
description={t('pages.settings.subIncyNoisesPacketDesc')}
>
<Input
value={allSetting.subIncyNoisesPacket}
placeholder="10-20"
onChange={(e) => updateSetting({ subIncyNoisesPacket: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyNoisesDelay')}
description={t('pages.settings.subIncyNoisesDelayDesc')}
>
<Input
value={allSetting.subIncyNoisesDelay}
placeholder="10-50"
onChange={(e) => updateSetting({ subIncyNoisesDelay: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyResolveEnable')}
description={t('pages.settings.subIncyResolveEnableDesc')}
>
<Select
value={allSetting.subIncyResolveEnable}
style={{ width: '100%' }}
onChange={(v) => updateSetting({ subIncyResolveEnable: v })}
options={onOff(t)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyResolveDnsDomain')}
description={t('pages.settings.subIncyResolveDnsDomainDesc')}
>
<Input
value={allSetting.subIncyResolveDnsDomain}
placeholder="https://common.dot.dns.yandex.net/dns-query"
onChange={(e) => updateSetting({ subIncyResolveDnsDomain: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyResolveDnsIp')}
description={t('pages.settings.subIncyResolveDnsIpDesc')}
>
<Input
value={allSetting.subIncyResolveDnsIp}
placeholder="77.88.8.8"
onChange={(e) => updateSetting({ subIncyResolveDnsIp: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: 'routing',
label: catTabLabel(
<BranchesOutlined />,
t('pages.settings.subIncyGroupRouting'),
isMobile,
),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyEnableRouting')}
description={t('pages.settings.subIncyEnableRoutingDesc')}
>
<Switch
checked={allSetting.subIncyEnableRouting}
onChange={(v) => updateSetting({ subIncyEnableRouting: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyRoutingRules')}
badge={remoteSourceBadge(allSetting.subIncyRoutingRules)}
description={t('pages.settings.subIncyRoutingRulesDesc')}
>
<Input.TextArea
value={allSetting.subIncyRoutingRules}
placeholder="incy://routing/onadd/... or https://.../DEFAULT.JSON"
onChange={(e) => updateSetting({ subIncyRoutingRules: e.target.value })}
/>
</SettingListItem>
</>
),
},
]}
/>
</>
);
}
@@ -1,4 +1,4 @@
import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd'; import { Alert, Button, Input, InputNumber, Select, Switch, Tabs } from 'antd';
import { import {
BranchesOutlined, BranchesOutlined,
CompassOutlined, CompassOutlined,
@@ -11,6 +11,7 @@ import {
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router'; import { useNavigate, useSearchParams } from 'react-router';
import type { AllSetting } from '@/models/setting'; import type { AllSetting } from '@/models/setting';
import type { SubProfileMode } from '@/schemas/setting';
import { onNumber } from '@/utils/onNumber'; import { onNumber } from '@/utils/onNumber';
import { DefaultSettingTag, SettingListItem } from '@/components/ui'; import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { RemarkTemplateField } from '@/components/form'; import { RemarkTemplateField } from '@/components/form';
@@ -18,6 +19,7 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel'; import { catTabLabel } from './catTabLabel';
import { sanitizePath, normalizePath } from './uriPath'; import { sanitizePath, normalizePath } from './uriPath';
import HappSettingsContent from './HappSettingsContent'; import HappSettingsContent from './HappSettingsContent';
import IncySettingsContent from './IncySettingsContent';
import { remoteSourceBadge } from './subscriptionShared'; import { remoteSourceBadge } from './subscriptionShared';
interface SubscriptionGeneralTabProps { interface SubscriptionGeneralTabProps {
@@ -246,6 +248,26 @@ export default function SubscriptionGeneralTab({
onChange={onNumber((v) => updateSetting({ subUpdates: v }))} onChange={onNumber((v) => updateSetting({ subUpdates: v }))}
/> />
</SettingListItem> </SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.externalSubUserAgent')}
badge={
<DefaultSettingTag
settingKey="externalSubUserAgent"
value={allSetting.externalSubUserAgent}
/>
}
description={t('pages.settings.externalSubUserAgentDesc')}
>
<Input
value={allSetting.externalSubUserAgent}
placeholder="v2rayNG/1.8.5"
maxLength={512}
allowClear
onChange={(e) => updateSetting({ externalSubUserAgent: e.target.value })}
/>
</SettingListItem>
</> </>
), ),
}, },
@@ -279,16 +301,44 @@ export default function SubscriptionGeneralTab({
</SettingListItem> </SettingListItem>
<SettingListItem <SettingListItem
paddings="small" paddings="small"
title={t('pages.settings.subProfileUrl')} title={t('pages.settings.subProfileMode')}
description={t('pages.settings.subProfileUrlDesc')} description={t('pages.settings.subProfileModeDesc')}
> >
<RemarkTemplateField <Select<SubProfileMode>
value={allSetting.subProfileUrl} id="sub-profile-mode"
placeholder="https://example.com" aria-label={t('pages.settings.subProfileMode')}
onChange={(v) => updateSetting({ subProfileUrl: v })} value={allSetting.subProfileMode}
metadataOnly style={{ width: '100%' }}
onChange={(value) => updateSetting({ subProfileMode: value })}
options={[
{ value: 'none', label: t('pages.settings.subProfileModeNone') },
{ value: 'builtin', label: t('pages.settings.subProfileModeBuiltin') },
{ value: 'custom', label: t('pages.settings.subProfileModeCustom') },
]}
/> />
</SettingListItem> </SettingListItem>
{allSetting.subProfileMode === 'builtin' ? (
<Alert
type="warning"
showIcon
style={{ margin: '12px 20px' }}
title={t('pages.settings.subProfileBuiltinWarning')}
/>
) : null}
{allSetting.subProfileMode === 'custom' ? (
<SettingListItem
paddings="small"
title={t('pages.settings.subProfileUrl')}
description={t('pages.settings.subProfileUrlDesc')}
>
<RemarkTemplateField
value={allSetting.subProfileUrl}
placeholder="https://example.com"
onChange={(v) => updateSetting({ subProfileUrl: v })}
metadataOnly
/>
</SettingListItem>
) : null}
<SettingListItem <SettingListItem
paddings="small" paddings="small"
title={t('pages.settings.subAnnounce')} title={t('pages.settings.subAnnounce')}
@@ -406,30 +456,12 @@ export default function SubscriptionGeneralTab({
key: '7', key: '7',
label: catTabLabel(<CompassOutlined />, 'Incy', isMobile), label: catTabLabel(<CompassOutlined />, 'Incy', isMobile),
children: ( children: (
<> <IncySettingsContent
<SettingListItem allSetting={allSetting}
paddings="small" updateSetting={updateSetting}
title={t('pages.settings.subIncyEnableRouting')} isMobile={isMobile}
description={t('pages.settings.subIncyEnableRoutingDesc')} remoteSourceBadge={remoteSourceBadge}
> />
<Switch
checked={allSetting.subIncyEnableRouting}
onChange={(v) => updateSetting({ subIncyEnableRouting: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subIncyRoutingRules')}
badge={remoteSourceBadge(allSetting.subIncyRoutingRules)}
description={t('pages.settings.subIncyRoutingRulesDesc')}
>
<Input.TextArea
value={allSetting.subIncyRoutingRules}
placeholder="incy://routing/onadd/... or https://.../DEFAULT.JSON"
onChange={(e) => updateSetting({ subIncyRoutingRules: e.target.value })}
/>
</SettingListItem>
</>
), ),
}, },
]} ]}
+64 -34
View File
@@ -7,16 +7,11 @@ export function toBase64Utf8(str: string): string {
); );
} }
// Splits multiline or comma-separated string into clean unique token arrays.
export function parseList(input: string): string[] {
return input
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean);
}
// Build standard Happ routing deeplink or special state for curated presets. // Build standard Happ routing deeplink or special state for curated presets.
export function buildHappPresetDeeplink(preset: string): string { export function buildHappPresetDeeplink(preset: string, includeAdblock = false): string {
// Ad blocking is opt-in for each generated profile, independent of the base routing rules.
const blockSites = includeAdblock ? ['geosite:category-ads-all'] : [];
switch (preset) { switch (preset) {
case 'off': case 'off':
return 'happ://routing/off'; return 'happ://routing/off';
@@ -27,9 +22,20 @@ export function buildHappPresetDeeplink(preset: string): string {
JSON.stringify({ JSON.stringify({
Name: 'Iran Bypass', Name: 'Iran Bypass',
GlobalProxy: 'true', GlobalProxy: 'true',
DirectSites: ['domain:ir', 'regexp:.*\\.ir$'], RouteOrder: 'block-proxy-direct',
DirectIp: ['geoip:ir', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'], DirectSites: ['geosite:private', 'domain:ir', 'geosite:category-ir'],
BlockSites: ['geosite:category-ads-all'], DirectIp: [
'geoip:ir',
'geoip:private',
'127.0.0.0/8',
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'169.254.0.0/16',
'224.0.0.0/4',
'255.255.255.255',
],
BlockSites: blockSites,
BlockIp: [], BlockIp: [],
ProxySites: [], ProxySites: [],
ProxyIp: [], ProxyIp: [],
@@ -42,32 +48,38 @@ export function buildHappPresetDeeplink(preset: string): string {
'happ://routing/onadd/' + 'happ://routing/onadd/' +
toBase64Utf8( toBase64Utf8(
JSON.stringify({ JSON.stringify({
Name: 'China Direct', Name: 'Bypass-CN',
GlobalProxy: 'true', GlobalProxy: 'true',
DirectSites: ['geosite:cn', 'geosite:geolocation-cn'], RouteOrder: 'block-proxy-direct',
DirectIp: ['geoip:cn', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'], RemoteDNSType: 'DoH',
BlockSites: ['geosite:category-ads-all'], RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
BlockIp: [], RemoteDNSIP: '1.1.1.1',
DomesticDNSType: 'DoH',
DomesticDNSDomain: 'https://dns.alidns.com/dns-query',
DomesticDNSIP: '223.5.5.5',
DnsHosts: {
'cloudflare-dns.com': '1.1.1.1',
'dns.alidns.com': '223.5.5.5',
},
DirectSites: ['geosite:private', 'geosite:cn', 'geosite:geolocation-cn'],
DirectIp: [
'geoip:cn',
'geoip:private',
'127.0.0.0/8',
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'169.254.0.0/16',
'224.0.0.0/4',
'255.255.255.255',
],
ProxySites: [], ProxySites: [],
ProxyIp: [], ProxyIp: [],
DomainStrategy: 'IPIfNonMatch', BlockSites: blockSites,
}),
)
);
case 'adblock':
return (
'happ://routing/onadd/' +
toBase64Utf8(
JSON.stringify({
Name: 'AdBlock',
GlobalProxy: 'true',
DirectSites: [],
DirectIp: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
BlockSites: ['geosite:category-ads-all'],
BlockIp: [], BlockIp: [],
ProxySites: [],
ProxyIp: [],
DomainStrategy: 'IPIfNonMatch', DomainStrategy: 'IPIfNonMatch',
FakeDNS: 'false',
UseChunkFiles: 'true',
}), }),
) )
); );
@@ -80,7 +92,25 @@ export function buildHappPresetDeeplink(preset: string): string {
GlobalProxy: 'true', GlobalProxy: 'true',
DirectSites: [], DirectSites: [],
DirectIp: [], DirectIp: [],
BlockSites: [], BlockSites: blockSites,
BlockIp: [],
ProxySites: [],
ProxyIp: [],
DomainStrategy: 'AsIs',
}),
)
);
// LAN bypass has its own identity so applying it does not overwrite the Global profile.
case 'lan-bypass':
return (
'happ://routing/onadd/' +
toBase64Utf8(
JSON.stringify({
Name: 'Global Bypass Local Network',
GlobalProxy: 'true',
DirectSites: ['geosite:private'],
DirectIp: ['geoip:private'],
BlockSites: blockSites,
BlockIp: [], BlockIp: [],
ProxySites: [], ProxySites: [],
ProxyIp: [], ProxyIp: [],
@@ -0,0 +1,90 @@
import { HappRoutingProfileSchema, type HappRoutingProfile } from '@/schemas/happRouting';
import { toBase64Utf8 } from './happPresets';
import { isRemoteRoutingSource } from './subscriptionShared';
export type HappRoutingMode = 'add' | 'onadd';
export type HappRoutingListKey =
| 'DirectSites'
| 'DirectIp'
| 'ProxySites'
| 'ProxyIp'
| 'BlockSites'
| 'BlockIp';
export type HappRoutingLoadResult =
| { success: true; profile: HappRoutingProfile; mode: HappRoutingMode; isNew: boolean }
| { success: false; error: 'off' | 'remote' | 'invalid' };
export function parseHappRoutingJson(input: string): HappRoutingProfile | null {
try {
const value: unknown = JSON.parse(input);
const result = HappRoutingProfileSchema.safeParse(value);
// Keep the validated input object; schema output can discard some unknown extension keys.
return result.success ? (value as HappRoutingProfile) : null;
} catch {
return null;
}
}
export function loadHappRouting(input: string): HappRoutingLoadResult {
const source = input.trim();
if (!source) {
return {
success: true,
profile: {
Name: 'Custom Rules',
GlobalProxy: 'true',
DirectSites: [],
DirectIp: [],
ProxySites: [],
ProxyIp: [],
BlockSites: [],
BlockIp: [],
DomainStrategy: 'IPIfNonMatch',
},
mode: 'onadd',
isNew: true,
};
}
if (source === 'happ://routing/off') return { success: false, error: 'off' };
if (isRemoteRoutingSource(source)) return { success: false, error: 'remote' };
if (source.startsWith('{')) {
const profile = parseHappRoutingJson(source);
return profile
? { success: true, profile, mode: 'onadd', isNew: false }
: { success: false, error: 'invalid' };
}
const match = /^happ:\/\/routing\/(onadd|add)\/([A-Za-z0-9+/_-]+={0,2})$/.exec(source);
if (!match) return { success: false, error: 'invalid' };
try {
const binary = atob(match[2].replace(/-/g, '+').replace(/_/g, '/'));
// Decode UTF-8 strictly so malformed bytes cannot silently change profile names or rules.
const json = new TextDecoder('utf-8', { fatal: true }).decode(
Uint8Array.from(binary, (character) => character.charCodeAt(0)),
);
const profile = parseHappRoutingJson(json);
return profile
? { success: true, profile, mode: match[1] as HappRoutingMode, isNew: false }
: { success: false, error: 'invalid' };
} catch {
return { success: false, error: 'invalid' };
}
}
export function buildHappRoutingDeeplink(
profile: HappRoutingProfile,
mode: HappRoutingMode = 'onadd',
): string {
return `happ://routing/${mode}/` + toBase64Utf8(JSON.stringify(profile));
}
export function parseHappRoutingList(input: string): string[] {
// Commas can belong to regexp rules, so the editor uses one rule per line.
return input
.split(/\r?\n/)
.map((entry) => entry.trim())
.filter(Boolean);
}
@@ -0,0 +1,142 @@
.sponsors-page .ant-layout,
.sponsors-page .ant-layout-content,
.sponsors-page .content-shell {
background: transparent;
}
.sponsors-page .content-area {
padding: 24px;
}
.sponsors-inner {
max-width: 1200px;
}
.sponsors-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.sponsors-page .sponsors-title {
margin: 0 0 4px;
}
.sponsors-title-icon {
color: #faad14;
}
.sponsors-page .sponsors-section-title {
margin: 32px 0 12px;
color: var(--ant-color-text-secondary);
font-weight: 600;
letter-spacing: 0.3px;
}
.sponsors-yourbrand {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
height: 100%;
min-height: 170px;
padding: 16px;
border: 1.5px dashed var(--ant-color-border);
border-radius: var(--ant-border-radius-lg, 8px);
background: transparent;
}
.sponsors-yourbrand.is-large {
align-items: center;
min-height: 0;
padding: 40px 24px;
text-align: center;
background: var(--bg-card, transparent);
}
.sponsors-yourbrand-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 8px;
font-size: 22px;
color: var(--ant-color-primary);
background: var(--ant-color-primary-bg);
}
.sponsors-yourbrand-title {
font-weight: 600;
font-size: 15px;
color: var(--ant-color-text);
}
.sponsors-yourbrand-text {
max-width: 420px;
margin-bottom: 4px;
font-size: 13px;
color: var(--ant-color-text-secondary);
}
.sponsors-yourbrand:not(.is-large) .ant-btn {
margin-top: auto;
}
.sponsors-placement {
display: flex;
gap: 12px;
height: 100%;
padding: 14px 16px;
border: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg, 8px);
background: var(--bg-card, transparent);
}
.sponsors-placement-icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
font-size: 17px;
color: var(--ant-color-primary);
background: var(--ant-color-primary-bg);
}
.sponsors-placement-title {
font-weight: 600;
color: var(--ant-color-text);
}
.sponsors-placement-body {
min-width: 0;
}
.sponsors-placement-status {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.sponsors-placement-status .ant-tag {
margin: 0;
}
.sponsors-placement-desc {
font-size: 13px;
color: var(--ant-color-text-secondary);
}
@media (max-width: 768px) {
.sponsors-page .content-area {
padding: 12px;
padding-top: 64px;
}
}
@@ -0,0 +1,176 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Col, ConfigProvider, Layout, Row, Spin, Tag, Typography } from 'antd';
import {
CrownOutlined,
DashboardOutlined,
LoginOutlined,
MenuUnfoldOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/hooks/useTheme';
import AppSidebar from '@/layouts/AppSidebar';
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
import SponsorCard from '@/components/sponsor/SponsorCard';
import { IntlUtil } from '@/utils';
import { useDatepicker } from '@/hooks/useDatepicker';
import { placementStatus, sponsorsForSlot, type SponsorSlot } from '@/lib/sponsors';
import './SponsorsPage.css';
function BecomeButton({ contact, block }: { contact?: string; block?: boolean }) {
const { t } = useTranslation();
if (!contact) return null;
return (
<Button
type="primary"
icon={<CrownOutlined />}
href={contact}
target="_blank"
rel="noopener noreferrer"
block={block}
>
{t('pages.sponsors.become')}
</Button>
);
}
function YourBrandCard({ contact, large }: { contact?: string; large?: boolean }) {
const { t } = useTranslation();
return (
<div className={`sponsors-yourbrand${large ? ' is-large' : ''}`}>
<span className="sponsors-yourbrand-icon">
<PlusOutlined />
</span>
<div className="sponsors-yourbrand-title">{t('pages.sponsors.yourBrand')}</div>
<div className="sponsors-yourbrand-text">{t('pages.sponsors.yourBrandText')}</div>
<BecomeButton contact={contact} />
</div>
);
}
export default function SponsorsPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { data, fetched } = useSponsorsQuery();
const sponsors = sponsorsForSlot(data.sponsors, 'page');
const { datepicker } = useDatepicker();
const placements: { slot: SponsorSlot; icon: ReactNode; title: string; desc: string }[] = [
{
slot: 'dashboard',
icon: <DashboardOutlined />,
title: t('pages.sponsors.placementDashboard'),
desc: t('pages.sponsors.placementDashboardDesc'),
},
{
slot: 'sidebar',
icon: <MenuUnfoldOutlined />,
title: t('pages.sponsors.placementSidebar'),
desc: t('pages.sponsors.placementSidebarDesc'),
},
{
slot: 'login',
icon: <LoginOutlined />,
title: t('pages.sponsors.placementLogin'),
desc: t('pages.sponsors.placementLoginDesc'),
},
{
slot: 'page',
icon: <CrownOutlined />,
title: t('pages.sponsors.placementPage'),
desc: t('pages.sponsors.placementPageDesc'),
},
];
const pageClass = useMemo(() => {
const classes = ['sponsors-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
return (
<ConfigProvider theme={antdThemeConfig}>
<Layout className={pageClass}>
<AppSidebar />
<Layout className="content-shell">
<Layout.Content className="content-area">
<div className="sponsors-inner">
<div className="sponsors-header">
<div>
<Typography.Title level={3} className="sponsors-title">
<CrownOutlined className="sponsors-title-icon" /> {t('pages.sponsors.title')}
</Typography.Title>
<Typography.Text type="secondary">{t('pages.sponsors.intro')}</Typography.Text>
</div>
<BecomeButton contact={data.contact} />
</div>
<Spin spinning={!fetched} delay={200}>
{sponsors.length === 0 ? (
fetched && <YourBrandCard contact={data.contact} large />
) : (
<Row gutter={[16, 16]}>
{sponsors.map((sponsor) => (
<Col key={sponsor.id} xs={24} sm={12} xl={8}>
<SponsorCard sponsor={sponsor} variant="card" />
</Col>
))}
{data.contact && (
<Col xs={24} sm={12} xl={8}>
<YourBrandCard contact={data.contact} />
</Col>
)}
</Row>
)}
</Spin>
<Typography.Title level={5} className="sponsors-section-title">
{t('pages.sponsors.placements')}
</Typography.Title>
<Row gutter={[16, 16]}>
{placements.map((p) => {
const status = placementStatus(data.sponsors, p.slot);
return (
<Col key={p.slot} xs={24} sm={12} xl={6}>
<div className="sponsors-placement">
<span className="sponsors-placement-icon">{p.icon}</span>
<div className="sponsors-placement-body">
<div className="sponsors-placement-title">{p.title}</div>
<div className="sponsors-placement-desc">{p.desc}</div>
<div className="sponsors-placement-status">
{status.takenUntil ? (
<Tag color="orange">
{t('pages.sponsors.takenUntil', {
date: IntlUtil.formatDate(status.takenUntil, datepicker),
})}
</Tag>
) : (
<Tag color="green">{t('pages.sponsors.available')}</Tag>
)}
{status.count > 0 && !status.takenUntil && (
<Tag>
{t('pages.sponsors.activeCount', {
count:
status.capacity && status.capacity > 1
? `${status.count}/${status.capacity}`
: status.count,
})}
</Tag>
)}
</div>
</div>
</div>
</Col>
);
})}
</Row>
</div>
</Layout.Content>
</Layout>
</Layout>
</ConfigProvider>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Segmented } from 'antd';
import { AndroidOutlined, AppleOutlined } from '@ant-design/icons';
import { APP_ICONS } from './appIcons';
import type { AppPlatform, SubApp } from './subPageModel';
interface SubAppsTabProps {
apps: Record<AppPlatform, SubApp[]>;
initialPlatform: AppPlatform;
onOpen: (url: string) => void;
}
const PLATFORM_OPTIONS = [
{ value: 'android' as const, label: 'Android', icon: <AndroidOutlined /> },
{ value: 'ios' as const, label: 'iOS', icon: <AppleOutlined /> },
];
function AppIcon({ name }: { name: string }) {
const icon = APP_ICONS[name];
if (!icon) {
return (
<span className="sub-app-mark" aria-hidden="true">
{name.charAt(0)}
</span>
);
}
if (icon.tinted) {
const mask = `url("${icon.src}")`;
return (
<span className="sub-app-mark" aria-hidden="true">
<span className="sub-app-glyph" style={{ maskImage: mask, WebkitMaskImage: mask }} />
</span>
);
}
return <img className="sub-app-logo" src={icon.src} alt="" width={32} height={32} />;
}
export default function SubAppsTab({ apps, initialPlatform, onOpen }: SubAppsTabProps) {
const { t } = useTranslation();
const [platform, setPlatform] = useState<AppPlatform>(initialPlatform);
return (
<div className="sub-apps">
<Segmented<AppPlatform> value={platform} onChange={setPlatform} options={PLATFORM_OPTIONS} />
<div className="sub-app-grid">
{apps[platform].map((app) => (
<div key={app.name} className="sub-row">
<AppIcon name={app.name} />
<span className="sub-app-name">{app.name}</span>
<Button type="primary" size="small" onClick={() => onOpen(app.url)}>
{t('add')}
</Button>
</div>
))}
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Tag } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import ConfigBlock from '@/components/clients/ConfigBlock';
import {
amneziawgConfigFromLink,
isPostQuantumLink,
wireguardConfigFromLink,
} from '@/lib/xray/inbound-link';
import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
import SubQrButton from './SubQrButton';
interface SubConfigsTabProps {
links: string[];
onCopy: (value: string, toast?: string) => void;
}
export default function SubConfigsTab({ links, onCopy }: SubConfigsTabProps) {
const { t } = useTranslation();
return (
<div className="sub-rows">
<div className="sub-configs-bar">
<Button
icon={<CopyOutlined />}
onClick={() => onCopy(links.join('\n'), t('subscription.copyAllConfigsCopied'))}
>
{t('subscription.copyAllConfigs')}
</Button>
</div>
{links.map((link, idx) => {
const parts = parseLinkParts(link);
const rowTitle = parts?.remark || `Link ${idx + 1}`;
const isWireguardLink = link.startsWith('wireguard://') || link.startsWith('wg://');
const isAmneziawgLink = link.startsWith('vpn://');
return (
<Fragment key={link}>
<div className="sub-row">
{parts ? <LinkTags parts={parts} /> : <Tag className="sub-row-tag">LINK</Tag>}
<span className="sub-row-title" dir="auto" title={rowTitle}>
{rowTitle}
</span>
<div className="sub-row-actions">
<Button
icon={<CopyOutlined />}
onClick={() => onCopy(link)}
aria-label={t('copy')}
title={t('copy')}
/>
{!isPostQuantumLink(link) && (
<SubQrButton value={link} label={rowTitle} onCopy={onCopy} />
)}
</div>
</div>
{isWireguardLink && (
<ConfigBlock
label={t('pages.clients.wireguardConfig')}
text={wireguardConfigFromLink(link, rowTitle)}
fileName={`${rowTitle || 'peer'}.conf`}
qrRemark={rowTitle}
tagColor="cyan"
/>
)}
{isAmneziawgLink && (
<ConfigBlock
label={t('pages.clients.amneziaWgConfig')}
text={amneziawgConfigFromLink(link)}
fileName={`${rowTitle || 'peer'}.conf`}
qrRemark={rowTitle}
tagColor="purple"
/>
)}
</Fragment>
);
})}
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Menu, Popover, Space } from 'antd';
import {
MoonFilled,
MoonOutlined,
SunOutlined,
TranslationOutlined,
WifiOutlined,
} from '@ant-design/icons';
import { LanguageManager } from '@/utils';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
interface SubHeaderProps {
title: string;
sId: string;
email: string;
lang: string;
onLangChange: (lang: string) => void;
}
export default function SubHeader({ title, sId, email, lang, onLangChange }: SubHeaderProps) {
const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
const cycleTheme = () => {
pauseAnimationsUntilLeave('sub-theme-cycle');
if (!isDark) {
toggleTheme();
if (isUltra) toggleUltra();
} else if (!isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
};
const langMenuItems = useMemo(
() =>
(LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
(l) => ({
key: l.value,
label: (
<Space size={8}>
<span aria-hidden="true">{l.icon}</span>
<span>{l.name}</span>
</Space>
),
}),
),
[],
);
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
const initial = Array.from(title)[0]?.toUpperCase();
return (
<header className="sub-header">
<div className="sub-brand">
<span className="sub-brand-mark" aria-hidden="true">
{initial ?? <WifiOutlined />}
</span>
<div className="sub-brand-text">
<div className="sub-brand-title" dir="auto">
{title || t('subscription.title')}
</div>
<div className="sub-brand-id">
<bdi>{email ? `${sId} - ${email}` : sId}</bdi>
</div>
</div>
</div>
<div className="sub-toolbar">
<Button
id="sub-theme-cycle"
shape="circle"
size="large"
className="toolbar-btn"
aria-label={t('menu.theme')}
title={t('menu.theme')}
icon={themeIcon}
onClick={cycleTheme}
/>
<Popover
rootClassName={isDark ? 'dark' : 'light'}
placement="bottomRight"
trigger="click"
styles={{ content: { padding: 4 } }}
content={
<Menu
mode="vertical"
selectable
selectedKeys={[lang]}
items={langMenuItems}
onClick={({ key }) => onLangChange(key)}
style={{ border: 'none', minWidth: 160 }}
/>
}
>
<Button
shape="circle"
size="large"
className="toolbar-btn"
aria-label={t('pages.settings.language')}
icon={<TranslationOutlined />}
/>
</Popover>
</div>
</header>
);
}
+127
View File
@@ -0,0 +1,127 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Progress, Tag, theme } from 'antd';
import { IntlUtil } from '@/utils';
import type { CalendarKind } from '@/utils';
import { usagePercent } from './subPageModel';
import type { SubStatus } from './subPageModel';
interface SubHeroProps {
status: SubStatus;
daysLeft: number | null;
usedByte: number;
totalByte: number;
expireMs: number;
lastOnlineMs: number;
download: string;
upload: string;
used: string;
total: string;
remained: string;
datepicker: CalendarKind;
lang: string;
}
const STATUS_TAGS: Record<SubStatus, { color: string; label: string }> = {
active: { color: 'green', label: 'subscription.active' },
unlimited: { color: 'purple', label: 'subscription.unlimited' },
expired: { color: 'red', label: 'subscription.expired' },
depleted: { color: 'red', label: 'subscription.depleted' },
disabled: { color: 'red', label: 'subscription.inactive' },
};
// FormatTraffic renders "37.60GB"; the amount and unit are sized apart.
function splitSize(label: string): [string, string] {
const match = /^([\d.,]+)\s*(\D*)$/.exec(label.trim());
return match ? [match[1], match[2]] : [label, ''];
}
export default function SubHero({
status,
daysLeft,
usedByte,
totalByte,
expireMs,
lastOnlineMs,
download,
upload,
used,
total,
remained,
datepicker,
lang,
}: SubHeroProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const hasQuota = totalByte > 0;
const healthy = status === 'active' || status === 'unlimited';
const pct = usagePercent(usedByte, totalByte);
const ringColor =
!healthy || pct >= 90 ? token.colorError : pct >= 75 ? token.colorWarning : token.colorPrimary;
const [amount, unit] = splitSize(hasQuota ? remained : used);
const formatDate = (ms: number) => IntlUtil.formatDate(ms, datepicker, lang);
const statusTag = STATUS_TAGS[status];
const stats: { key: string; label: string; value: ReactNode }[] = [
{ key: 'days', label: t('subscription.daysLeft'), value: daysLeft ?? '∞' },
{
key: 'expiry',
label: t('subscription.expiry'),
value: expireMs > 0 ? formatDate(expireMs) : t('subscription.noExpiry'),
},
{
key: 'status',
label: t('subscription.status'),
value: <Tag color={statusTag.color}>{t(statusTag.label)}</Tag>,
},
{ key: 'down', label: t('subscription.downloaded'), value: <bdi>{download}</bdi> },
{ key: 'up', label: t('subscription.uploaded'), value: <bdi>{upload}</bdi> },
{ key: 'total', label: t('subscription.totalQuota'), value: <bdi>{total}</bdi> },
{
key: 'lastOnline',
label: t('lastOnline'),
value: lastOnlineMs > 0 ? formatDate(lastOnlineMs) : '-',
},
];
return (
<section className={healthy ? 'sub-hero' : 'sub-hero is-alert'}>
<Progress
type="circle"
className="sub-ring"
percent={pct}
status="normal"
size={156}
strokeColor={ringColor}
format={() => (
<span className="sub-ring-center">
<span className="sub-ring-value">{hasQuota ? `${pct.toFixed(1)}%` : '∞'}</span>
<span className="sub-ring-label">
{hasQuota ? t('usage') : t('subscription.unlimited')}
</span>
</span>
)}
/>
<div className="sub-hero-summary">
<div className="sub-label">{hasQuota ? t('remained') : t('usage')}</div>
<bdi className="sub-big">
<span className="sub-big-num">{amount}</span>
{unit && <span className="sub-big-unit">{unit}</span>}
</bdi>
<div className="sub-muted">
{hasQuota ? t('subscription.ofTotal', { total }) : t('subscription.unlimited')}
</div>
<dl className="sub-stats">
{stats.map((stat) => (
<div key={stat.key} className="sub-stat">
<dt className="sub-label">{stat.label}</dt>
<dd className="sub-stat-value">{stat.value}</dd>
</div>
))}
</dl>
</div>
</section>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useTranslation } from 'react-i18next';
import { Button, QRCode, Tag } from 'antd';
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
import SubQrButton from './SubQrButton';
interface SubLinksTabProps {
subUrl: string;
subJsonUrl: string;
subClashUrl: string;
onCopy: (value: string) => void;
}
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
export default function SubLinksTab({ subUrl, subJsonUrl, subClashUrl, onCopy }: SubLinksTabProps) {
const { t } = useTranslation();
const subLabel = t('pages.settings.subSettings');
const rows = [
{ kind: 'SUB', color: 'green', url: subUrl, title: subLabel, downloadable: false },
{
kind: 'JSON',
color: 'purple',
url: subJsonUrl,
title: `${subLabel} JSON`,
downloadable: true,
},
{ kind: 'CLASH', color: 'gold', url: subClashUrl, title: 'Clash / Mihomo', downloadable: true },
].filter((row) => row.url);
return (
<div className="sub-rows">
{rows.map((row) => (
<div key={row.kind} className="sub-row">
<Tag color={row.color} className="sub-row-tag">
{row.kind}
</Tag>
<div className="sub-row-main">
<a href={row.url} target="_blank" rel="noopener noreferrer" className="sub-row-title">
{row.title}
</a>
<div className="sub-row-url" dir="ltr" title={row.url}>
{row.url}
</div>
</div>
<div className="sub-row-actions">
{row.downloadable && (
<Button
href={appendRawView(row.url)}
target="_blank"
rel="noopener noreferrer"
icon={<DownloadOutlined />}
aria-label={t('download')}
title={t('download')}
/>
)}
<Button
icon={<CopyOutlined />}
onClick={() => onCopy(row.url)}
aria-label={t('copy')}
title={t('copy')}
/>
<SubQrButton value={row.url} label={row.title} onCopy={onCopy} />
</div>
</div>
))}
{subUrl && (
<div className="sub-qr-card">
<div className="sub-qr-code">
<QRCode
value={subUrl}
size={112}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
/>
</div>
<div>
<div className="sub-qr-title">{t('subscription.scanTitle')}</div>
<div className="sub-muted">{t('subscription.scanHint')}</div>
</div>
</div>
)}
</div>
);
}
+667 -74
View File
@@ -1,18 +1,78 @@
.subscription-page { .subscription-page {
--bg-page: #e6e8ec; /* --sub-grad-* paints graphics, --sub-ink-* paints text: the cyan end darkens
--bg-card: #ffffff; so text clears 4.5:1. --sub-accent is ACCENT.primary in SubPage.tsx. */
--sub-grad-from: #8b5cf6;
--sub-grad-to: #06b6d4;
--sub-ink-from: #6d28d9;
--sub-ink-to: #0e7490;
--sub-accent: #7c3aed;
--bg-page: linear-gradient(135deg, #e9e4ff 0%, #ddeefc 52%, #e2f7f2 100%);
--sub-card-bg: rgba(255, 255, 255, 0.72);
--sub-card-border: rgba(255, 255, 255, 0.7);
--sub-card-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 20px 56px rgba(124, 58, 237, 0.16);
--sub-card-sheen: linear-gradient(
135deg,
rgba(255, 255, 255, 0.75),
rgba(255, 255, 255, 0) 42%,
rgba(124, 58, 237, 0.22) 88%
);
--sub-blob-1: rgba(139, 92, 246, 0.55);
--sub-blob-2: rgba(6, 182, 212, 0.45);
--sub-grid: rgba(124, 58, 237, 0.06);
--sub-hairline: linear-gradient(90deg, rgba(139, 92, 246, 0.4), rgba(6, 182, 212, 0.4));
--sub-tile-bg: rgba(124, 58, 237, 0.05);
--sub-tile-border: rgba(124, 58, 237, 0.12);
--sub-row-bg: rgba(124, 58, 237, 0.045);
--sub-row-border: rgba(124, 58, 237, 0.11);
--sub-row-bg-hover: rgba(124, 58, 237, 0.09);
--sub-row-border-hover: rgba(124, 58, 237, 0.3);
--sub-row-glow: rgba(124, 58, 237, 0.28);
--sub-glass-bg: rgba(255, 255, 255, 0.6);
position: relative;
min-height: 100vh; min-height: 100vh;
background: var(--bg-page); background: var(--bg-page);
} }
.subscription-page.is-dark { .subscription-page.is-dark {
--bg-page: #1a1b1f; --sub-grad-from: #a78bfa;
--bg-card: #23252b; --sub-grad-to: #22d3ee;
--sub-ink-from: #c4b5fd;
--sub-ink-to: #67e8f9;
--sub-accent: #a78bfa;
--bg-page: radial-gradient(ellipse 120% 90% at 18% -10%, #1f1740 0%, #16171d 52%, #101116 100%);
--sub-card-bg: rgba(35, 37, 43, 0.62);
--sub-card-border: rgba(255, 255, 255, 0.08);
--sub-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 24px 64px rgba(109, 40, 217, 0.24);
--sub-card-sheen: linear-gradient(
135deg,
rgba(255, 255, 255, 0.16),
rgba(255, 255, 255, 0) 42%,
rgba(167, 139, 250, 0.4) 88%
);
--sub-blob-1: rgba(139, 92, 246, 0.4);
--sub-blob-2: rgba(34, 211, 238, 0.26);
--sub-grid: rgba(255, 255, 255, 0.035);
--sub-hairline: linear-gradient(90deg, rgba(167, 139, 250, 0.45), rgba(34, 211, 238, 0.45));
--sub-tile-bg: rgba(167, 139, 250, 0.07);
--sub-tile-border: rgba(167, 139, 250, 0.14);
--sub-row-bg: rgba(167, 139, 250, 0.06);
--sub-row-border: rgba(167, 139, 250, 0.12);
--sub-row-bg-hover: rgba(167, 139, 250, 0.12);
--sub-row-border-hover: rgba(167, 139, 250, 0.35);
--sub-row-glow: rgba(139, 92, 246, 0.45);
--sub-glass-bg: rgba(255, 255, 255, 0.06);
} }
.subscription-page.is-dark.is-ultra { .subscription-page.is-dark.is-ultra {
--bg-page: #000; --bg-page: radial-gradient(ellipse 120% 90% at 18% -10%, #120a2b 0%, #050509 55%, #000 100%);
--bg-card: #101013; --sub-card-bg: rgba(16, 16, 19, 0.68);
--sub-card-border: rgba(255, 255, 255, 0.055);
--sub-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.6), 0 24px 64px rgba(88, 28, 135, 0.3);
--sub-blob-1: rgba(139, 92, 246, 0.22);
--sub-blob-2: rgba(34, 211, 238, 0.14);
--sub-grid: rgba(255, 255, 255, 0.022);
--sub-glass-bg: rgba(255, 255, 255, 0.04);
} }
.subscription-page .ant-layout, .subscription-page .ant-layout,
@@ -20,104 +80,204 @@
background: transparent; background: transparent;
} }
.subscription-page .content { /* aurora backdrop */
padding: 24px 12px; .sub-aurora {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
} }
.subscription-card { .sub-aurora-grid {
margin-top: 8px; position: absolute;
inset: 0;
background-image:
linear-gradient(var(--sub-grid) 1px, transparent 1px),
linear-gradient(90deg, var(--sub-grid) 1px, transparent 1px);
background-size: 48px 48px;
background-position: center;
-webkit-mask-image: radial-gradient(ellipse at 50% 30%, black 20%, transparent 72%);
mask-image: radial-gradient(ellipse at 50% 30%, black 20%, transparent 72%);
} }
.qr-tag { .sub-aurora::before,
width: 100%; .sub-aurora::after {
text-align: center; content: '';
margin: 0; position: absolute;
width: 70vmax;
height: 70vmax;
max-width: 820px;
max-height: 820px;
border-radius: 50%;
filter: blur(80px);
will-change: transform;
} }
.info-table { .sub-aurora::before {
margin-top: 4px; top: -22vmax;
left: -16vmax;
background: radial-gradient(circle, var(--sub-blob-1) 0%, transparent 65%);
animation: sub-blob-a 28s ease-in-out infinite alternate;
} }
.links-section { .sub-aurora::after {
display: flex; bottom: -24vmax;
flex-direction: column; right: -18vmax;
gap: 8px; background: radial-gradient(circle, var(--sub-blob-2) 0%, transparent 65%);
animation: sub-blob-b 34s ease-in-out infinite alternate;
} }
.sub-link-anchor { @keyframes sub-blob-a {
color: inherit; 0% {
text-decoration: none; transform: translate(0, 0) scale(1);
}
100% {
transform: translate(16vw, 14vh) scale(1.18);
}
} }
.sub-link-anchor:hover { @keyframes sub-blob-b {
text-decoration: underline; 0% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(-14vw, -12vh) scale(1.15);
}
} }
.sub-link-row { .sub-content {
position: relative;
z-index: 1;
padding: 32px 16px;
}
.sub-card {
max-width: 880px;
margin: 0 auto;
}
.subscription-page .sub-card {
position: relative;
border-radius: 20px;
border: 1px solid var(--sub-card-border);
background: var(--sub-card-bg);
box-shadow: var(--sub-card-shadow);
-webkit-backdrop-filter: blur(24px) saturate(180%);
backdrop-filter: blur(24px) saturate(180%);
}
/* Hairline gradient rim: a padded sheen layer with its own middle masked out. */
.subscription-page .sub-card::before {
content: '';
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
padding: 1px;
background: var(--sub-card-sheen);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.sub-card > .ant-card-body {
position: relative;
z-index: 1;
padding: 28px;
}
.sub-label,
.sub-muted {
font-size: 12px;
color: var(--ant-color-text-tertiary);
}
.sub-muted {
font-size: 13px;
}
/* Gradient hairline shared by the header, the stats grid and the footer. */
.sub-header::after,
.sub-stats::before,
.sub-footer::before {
content: '';
position: absolute;
inset-inline: 0;
height: 1px;
background: var(--sub-hairline);
opacity: 0.7;
}
/* header */
.sub-header {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; justify-content: space-between;
padding: 8px 12px; gap: 12px;
border-radius: 10px; padding-bottom: 20px;
background: rgba(0, 0, 0, 0.03); margin-bottom: 24px;
border: 1px solid rgba(0, 0, 0, 0.08);
transition:
background 120ms ease,
border-color 120ms ease;
} }
.sub-link-row:hover { .sub-header::after {
background: rgba(0, 0, 0, 0.05); bottom: 0;
border-color: rgba(0, 0, 0, 0.14);
} }
.is-dark .sub-link-row { .sub-brand {
background: rgba(0, 0, 0, 0.2); display: flex;
border-color: rgba(255, 255, 255, 0.1); align-items: center;
} gap: 12px;
.is-dark .sub-link-row:hover {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.2);
}
.sub-link-tag {
margin: 0;
flex-shrink: 0;
font-weight: 600;
letter-spacing: 0.3px;
}
.sub-link-title {
flex: 1;
min-width: 0; min-width: 0;
font-size: 13px; }
.sub-brand-mark {
width: 44px;
height: 44px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 13px;
background: linear-gradient(135deg, var(--sub-grad-from), var(--sub-grad-to));
box-shadow: 0 8px 20px -8px var(--sub-row-glow);
color: #fff;
font-size: 20px;
font-weight: 600;
}
.sub-brand-text {
min-width: 0;
}
.sub-brand-title,
.sub-brand-id {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.sub-link-actions { .sub-brand-title {
font-size: 18px;
font-weight: 600;
line-height: 1.3;
color: var(--ant-color-text);
}
.sub-brand-id {
font-size: 12px;
color: var(--ant-color-text-tertiary);
}
.sub-toolbar {
display: flex; display: flex;
gap: 4px; gap: 8px;
flex-shrink: 0; flex-shrink: 0;
} }
.sub-link-qr-popover {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.apps-row {
margin-top: 24px;
}
.app-col {
text-align: center;
}
.toolbar-btn { .toolbar-btn {
width: 40px; width: 40px;
height: 40px; height: 40px;
@@ -129,3 +289,436 @@
.toolbar-btn .anticon { .toolbar-btn .anticon {
font-size: 18px; font-size: 18px;
} }
.subscription-page .toolbar-btn {
border-color: var(--sub-row-border);
background: var(--sub-glass-bg);
color: var(--sub-accent);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.subscription-page .toolbar-btn:hover {
border-color: var(--sub-row-border-hover);
background: var(--sub-row-bg-hover);
color: var(--sub-accent);
}
.sub-announce {
margin-bottom: 24px;
}
/* usage hero */
.sub-hero {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 32px;
align-items: center;
}
.sub-ring .ant-progress-text {
color: var(--ant-color-text);
}
.sub-ring-center {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
line-height: 1.1;
}
.sub-ring-value {
font-size: 26px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.sub-ring-label {
font-size: 12px;
color: var(--ant-color-text-tertiary);
}
.sub-big {
display: inline-flex;
align-items: baseline;
gap: 6px;
margin: 2px 0;
line-height: 1.1;
}
.sub-big-num {
font-size: 44px;
font-weight: 700;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
background: linear-gradient(135deg, var(--sub-ink-from), var(--sub-ink-to));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: var(--sub-ink-from);
}
.sub-big-unit {
font-size: 18px;
color: var(--ant-color-text-secondary);
}
.sub-hero.is-alert .sub-big-num {
background: none;
-webkit-text-fill-color: var(--ant-color-error);
color: var(--ant-color-error);
}
.sub-stats {
position: relative;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 20px 0 0;
padding-top: 20px;
}
.sub-stats::before {
top: 0;
}
.sub-stat {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--sub-tile-border);
background: var(--sub-tile-bg);
}
.sub-stat-value {
margin: 2px 0 0;
font-size: 14px;
font-weight: 600;
color: var(--ant-color-text);
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.sub-stat-value .ant-tag {
margin: 0;
}
/* tabs */
.sub-tabs {
margin-top: 28px;
}
.sub-tabs.ant-tabs .ant-tabs-ink-bar {
height: 3px;
border-radius: 2px;
background: linear-gradient(90deg, var(--sub-grad-from), var(--sub-grad-to));
}
.sub-tab-count {
margin-inline-start: 6px;
padding: 0 7px;
border-radius: 10px;
font-size: 12px;
background: var(--sub-tile-bg);
border: 1px solid var(--sub-tile-border);
color: var(--sub-accent);
}
.sub-rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.sub-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
padding: 10px 12px;
border-radius: 12px;
background: var(--sub-row-bg);
border: 1px solid var(--sub-row-border);
transition:
background 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.sub-row:hover {
background: var(--sub-row-bg-hover);
border-color: var(--sub-row-border-hover);
box-shadow: 0 8px 20px -14px var(--sub-row-glow);
transform: translateY(-1px);
}
.sub-row-tag {
margin: 0;
flex-shrink: 0;
font-weight: 600;
letter-spacing: 0.3px;
}
.sub-row-main {
flex: 1;
min-width: 0;
}
.sub-row-title {
display: block;
font-size: 14px;
color: var(--ant-color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub-row > .sub-row-title {
flex: 1;
min-width: 0;
font-size: 13px;
}
a.sub-row-title:hover {
color: var(--sub-accent);
}
.sub-row-url {
font-size: 12px;
color: var(--ant-color-text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
[dir='rtl'] .sub-row-url,
[dir='rtl'] .sub-row > .sub-row-title {
text-align: right;
}
.sub-row-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.sub-qr-modal .ant-modal-title {
font-size: 20px;
font-weight: 600;
}
.sub-qr-modal .ant-modal-close {
width: 36px;
height: 36px;
border: 1px solid var(--ant-color-border-secondary);
border-radius: 50%;
}
.sub-qr-modal-hint {
margin: 2px 0 20px;
}
.sub-qr-modal-code {
width: fit-content;
margin: 0 auto 20px;
border-radius: 8px;
background: #fff;
line-height: 0;
}
.sub-qr-modal-code canvas {
display: block;
}
.sub-qr-modal-link {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
}
.sub-qr-modal-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.sub-qr-modal-actions > .ant-btn:first-child {
flex: 1;
}
.sub-qr-card {
display: flex;
align-items: center;
gap: 16px;
margin-top: 8px;
padding: 16px;
border-radius: 14px;
border: 1px dashed var(--sub-row-border-hover);
background: var(--sub-row-bg);
}
.sub-qr-code {
flex-shrink: 0;
padding: 6px;
border-radius: 8px;
background: #fff;
line-height: 0;
}
.sub-qr-title {
margin-bottom: 4px;
font-size: 15px;
font-weight: 600;
color: var(--ant-color-text);
}
/* apps */
.sub-apps {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.sub-app-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
width: 100%;
}
.sub-app-mark {
width: 32px;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9px;
border: 1px solid var(--sub-tile-border);
background: linear-gradient(135deg, var(--sub-row-bg-hover), var(--sub-tile-bg));
color: var(--sub-accent);
font-weight: 600;
}
.sub-app-glyph {
width: 22px;
height: 22px;
background: currentColor;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: contain;
mask-size: contain;
}
.sub-app-logo {
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: 9px;
object-fit: cover;
box-shadow: 0 0 0 1px var(--sub-tile-border);
}
.sub-app-name {
flex: 1;
min-width: 0;
font-size: 14px;
color: var(--ant-color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* configs */
.sub-configs-bar {
display: flex;
justify-content: flex-end;
margin-bottom: 4px;
}
/* footer */
.sub-footer {
position: relative;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px 16px;
margin-top: 28px;
padding-top: 16px;
font-size: 12px;
color: var(--ant-color-text-tertiary);
}
.sub-footer::before {
top: 0;
}
.sub-footer > span,
.sub-footer > a {
display: inline-flex;
align-items: center;
gap: 6px;
}
@media (prefers-reduced-motion: reduce) {
.sub-aurora::before,
.sub-aurora::after {
animation: none;
}
.sub-row:hover {
transform: none;
}
}
@media (max-width: 576px) {
.sub-content {
padding: 16px 8px;
}
.sub-card > .ant-card-body {
padding: 16px;
}
/* One static blob: two animated 70vmax blurs drop frames on low-end phones. */
.sub-aurora::before {
animation: none;
}
.sub-aurora::after {
display: none;
}
.sub-hero {
grid-template-columns: minmax(0, 1fr);
gap: 20px;
}
.sub-ring {
justify-self: center;
}
.sub-big-num {
font-size: 36px;
}
.sub-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sub-app-grid {
grid-template-columns: minmax(0, 1fr);
}
.sub-tabs .ant-tabs-tab-icon {
display: none;
}
.sub-qr-card {
display: none;
}
}
+163 -586
View File
@@ -1,98 +1,89 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Card, ConfigProvider, Layout, Tabs, message } from 'antd';
import type { TabsProps } from 'antd';
import { import {
Alert, AppstoreOutlined,
Button, ClockCircleOutlined,
Card, CustomerServiceOutlined,
Col, LinkOutlined,
ConfigProvider, UnorderedListOutlined,
Descriptions,
Divider,
Dropdown,
Layout,
Menu,
message,
Popover,
QRCode,
Row,
Space,
Tag,
Tooltip,
} from 'antd';
import {
AndroidOutlined,
AppleOutlined,
CopyOutlined,
DownOutlined,
DownloadOutlined,
MoonFilled,
MoonOutlined,
QrcodeOutlined,
SunOutlined,
TranslationOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils'; import { ClipboardManager, LanguageManager } from '@/utils';
import {
amneziawgConfigFromLink,
isPostQuantumLink,
wireguardConfigFromLink,
} from '@/lib/xray/inbound-link';
import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
import ConfigBlock from '@/components/clients/ConfigBlock';
import { setMessageInstance } from '@/utils/messageBus'; import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import SubAppsTab from './SubAppsTab';
import SubUsageSummary from './SubUsageSummary'; import SubConfigsTab from './SubConfigsTab';
import SubHeader from './SubHeader';
import SubHero from './SubHero';
import SubLinksTab from './SubLinksTab';
import { buildSubApps, daysUntil, detectPlatform, resolveSubStatus } from './subPageModel';
import './SubPage.css'; import './SubPage.css';
const QR_SIZE = 240;
const subData = window.__SUB_PAGE_DATA__ || {}; const subData = window.__SUB_PAGE_DATA__ || {};
const sId = subData.sId || ''; const sId = subData.sId || '';
const enabled = !!subData.enabled;
const download = subData.download || '0';
const upload = subData.upload || '0';
const total = subData.total || '∞';
const used = subData.used || '0';
const remained = subData.remained || '';
const totalByte = Number(subData.totalByte || 0);
const expireMs = Number(subData.expire || 0) * 1000;
const lastOnlineMs = Number(subData.lastOnline || 0);
const subUrl = subData.subUrl || ''; const subUrl = subData.subUrl || '';
const subJsonUrl = subData.subJsonUrl || ''; const subJsonUrl = subData.subJsonUrl || '';
const subClashUrl = subData.subClashUrl || ''; const subClashUrl = subData.subClashUrl || '';
const subTitle = subData.subTitle || ''; const subTitle = subData.subTitle || '';
const subSupportUrl = subData.subSupportUrl || '';
const updateHours = Number(subData.subUpdates || 0);
const announce = subData.announce || '';
const links: string[] = Array.isArray(subData.links) ? subData.links : []; const links: string[] = Array.isArray(subData.links) ? subData.links : [];
const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : []; const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', '); const totalByte = Number(subData.totalByte || 0);
const datepicker = subData.datepicker || 'gregorian'; const usedByte =
const announce = subData.announce || ''; Number(subData.usedByte || 0) ||
Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0);
const expireMs = Number(subData.expire || 0) * 1000;
const clientEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
const loadedAt = Date.now();
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`; const heroData = {
status: resolveSubStatus({ enabled: !!subData.enabled, usedByte, totalByte, expireMs }, loadedAt),
daysLeft: daysUntil(expireMs, loadedAt),
usedByte,
totalByte,
expireMs,
lastOnlineMs: Number(subData.lastOnline || 0),
download: subData.download || '0',
upload: subData.upload || '0',
used: subData.used || '0',
total: subData.total || '∞',
remained: subData.remained || '',
datepicker: subData.datepicker || 'gregorian',
};
const isUnlimited = totalByte <= 0 && expireMs === 0; const apps = buildSubApps({ subUrl, sId, subTitle });
const isActive = (() => { const initialPlatform = detectPlatform(navigator.userAgent);
if (!enabled) return false; const RTL_LANGUAGES = new Set(['fa-IR', 'ar-EG']);
if (totalByte > 0) {
const usedByteCalc = // The sub page runs its own violet accent, so every antd control on it picks the
Number(subData.usedByte || 0) || // hue up instead of the panel blue useTheme pins. Mirrored in SubPage.css.
Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0); const ACCENT = {
if (usedByteCalc >= totalByte) return false; light: {
} primary: '#7c3aed',
if (expireMs > 0 && Date.now() >= expireMs) return false; hover: '#8b5cf6',
return true; active: '#6d28d9',
})(); rail: 'rgba(124, 58, 237, 0.16)',
},
dark: {
primary: '#a78bfa',
hover: '#c4b5fd',
active: '#8b5cf6',
rail: 'rgba(167, 139, 250, 0.18)',
},
};
export default function SubPage() { export default function SubPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, antdThemeConfig } = useTheme();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { useEffect(() => {
setMessageInstance(messageApi); setMessageInstance(messageApi);
}, [messageApi]); }, [messageApi]);
const { isMobile } = useMediaQuery(576);
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage('subscription')); const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage('subscription'));
const onLangChange = useCallback((next: string) => { const onLangChange = useCallback((next: string) => {
@@ -100,538 +91,124 @@ export default function SubPage() {
LanguageManager.setLanguage(next, 'subscription'); LanguageManager.setLanguage(next, 'subscription');
}, []); }, []);
const cycleTheme = useCallback(() => {
pauseAnimationsUntilLeave('sub-theme-cycle');
if (!isDark) {
toggleTheme();
if (isUltra) toggleUltra();
} else if (!isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
}, [isDark, isUltra, toggleTheme, toggleUltra]);
const copy = useCallback( const copy = useCallback(
async (value: string) => { async (value: string, toast?: string) => {
if (!value) return; if (!value) return;
const ok = await ClipboardManager.copyText(value); const ok = await ClipboardManager.copyText(value);
if (ok) messageApi.success(t('copied')); if (ok) messageApi.success(toast ?? t('copied'));
}, },
[t, messageApi], [t, messageApi],
); );
const copyAll = useCallback(async () => {
if (links.length === 0) return;
const allLinks = links.join('\n');
const ok = await ClipboardManager.copyText(allLinks);
if (ok) messageApi.success(t('subscription.copyAllConfigsCopied'));
}, [t, messageApi]);
const open = useCallback((url: string) => { const open = useCallback((url: string) => {
if (!url) return; if (url) window.open(url, '_blank');
window.open(url, '_blank');
}, []); }, []);
const shadowrocketUrl = useMemo(() => { const tabs = useMemo(() => {
if (!subUrl) return ''; const items: NonNullable<TabsProps['items']> = [];
const separator = subUrl.includes('?') ? '&' : '?'; if (subUrl || subJsonUrl || subClashUrl) {
const rawUrl = subUrl + separator + 'flag=shadowrocket'; items.push({
const base64Url = btoa(rawUrl); key: 'subscription',
const remark = encodeURIComponent(subTitle || sId || 'Subscription'); icon: <LinkOutlined />,
return `shadowrocket://add/sub://${base64Url}?remark=${remark}`; label: t('subscription.tabLinks'),
}, []); children: (
<SubLinksTab
const v2boxUrl = useMemo( subUrl={subUrl}
() => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`, subJsonUrl={subJsonUrl}
[], subClashUrl={subClashUrl}
); onCopy={copy}
const streisandUrl = useMemo(() => `streisand://import/${encodeURIComponent(subUrl)}`, []);
const happUrl = useMemo(() => `happ://add/${subUrl}`, []);
const incyUrl = useMemo(() => `incy://add/${subUrl}`, []);
const pageClass = useMemo(() => {
const classes = ['subscription-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const descriptionsItems = useMemo(() => {
const items = [
{ key: 'subId', label: t('subscription.subId'), children: sId },
...(subEmail ? [{ key: 'email', label: t('subscription.email'), children: subEmail }] : []),
{
key: 'status',
label: t('subscription.status'),
children: !enabled ? (
<Tag color="red">{t('subscription.inactive')}</Tag>
) : isUnlimited ? (
<Tag color="purple">{t('subscription.unlimited')}</Tag>
) : (
<Tag color={isActive ? 'green' : 'red'}>
{isActive ? t('subscription.active') : t('subscription.inactive')}
</Tag>
),
},
{ key: 'down', label: t('subscription.downloaded'), children: download },
{ key: 'up', label: t('subscription.uploaded'), children: upload },
{ key: 'used', label: t('usage'), children: used },
{ key: 'total', label: t('subscription.totalQuota'), children: total },
];
if (totalByte > 0) {
items.push({ key: 'remained', label: t('remained'), children: remained });
}
items.push({
key: 'lastOnline',
label: t('lastOnline'),
children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker, lang) : '-',
});
items.push({
key: 'expiry',
label: t('subscription.expiry'),
children:
expireMs === 0
? t('subscription.noExpiry')
: IntlUtil.formatDate(expireMs, datepicker, lang),
});
return items;
}, [t, lang]);
const androidMenuItems = useMemo(
() => [
{
key: 'android-v2box',
label: 'V2Box',
onClick: () =>
open(
`v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`,
),
},
{
key: 'android-v2rayng',
label: 'V2RayNG',
onClick: () => open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`),
},
{ key: 'android-singbox', label: 'Sing-box', onClick: () => copy(subUrl) },
{ key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
{ key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
{ key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) },
{ key: 'android-incy', label: 'Incy', onClick: () => open(`incy://add/${subUrl}`) },
],
[copy, open],
);
const iosMenuItems = useMemo(
() => [
{ key: 'ios-shadowrocket', label: 'Shadowrocket', onClick: () => open(shadowrocketUrl) },
{ key: 'ios-v2box', label: 'V2Box', onClick: () => open(v2boxUrl) },
{ key: 'ios-streisand', label: 'Streisand', onClick: () => open(streisandUrl) },
{ key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
{ key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
{ key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) },
{ key: 'ios-incy', label: 'Incy', onClick: () => open(incyUrl) },
],
[copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl, incyUrl],
);
const langMenuItems = useMemo(
() =>
(LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
(l) => ({
key: l.value,
label: (
<Space size={8}>
<span aria-hidden="true">{l.icon}</span>
<span>{l.name}</span>
</Space>
),
}),
),
[],
);
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
const cardTitle = (
<Space>
<span>{t('subscription.title')}</span>
<Tag>{sId}</Tag>
</Space>
);
const cardExtra = (
<Space size={8} align="center">
<Button
shape="circle"
size="large"
className="toolbar-btn"
aria-label={t('menu.theme')}
title={t('menu.theme')}
icon={themeIcon}
onClick={cycleTheme}
/>
<Popover
rootClassName={isDark ? 'dark' : 'light'}
placement="bottomRight"
trigger="click"
styles={{ content: { padding: 4 } }}
content={
<Menu
mode="vertical"
selectable
selectedKeys={[lang]}
items={langMenuItems}
onClick={({ key }) => onLangChange(key)}
style={{ border: 'none', minWidth: 160 }}
/> />
} ),
> });
<Button }
shape="circle" if (subUrl) {
size="large" items.push({
className="toolbar-btn" key: 'apps',
aria-label={t('pages.settings.language')} icon: <AppstoreOutlined />,
icon={<TranslationOutlined />} label: t('subscription.tabApps'),
/> children: <SubAppsTab apps={apps} initialPlatform={initialPlatform} onOpen={open} />,
</Popover> });
</Space> }
); if (links.length > 0) {
items.push({
key: 'configs',
icon: <UnorderedListOutlined />,
label: (
<>
{t('subscription.tabConfigs')}
<span className="sub-tab-count">{links.length}</span>
</>
),
children: <SubConfigsTab links={links} onCopy={copy} />,
});
}
return items;
}, [t, copy, open]);
const direction = RTL_LANGUAGES.has(lang) ? 'rtl' : 'ltr';
const pageClass = ['subscription-page', isDark && 'is-dark', isUltra && 'is-ultra']
.filter(Boolean)
.join(' ');
const themeConfig = useMemo(() => {
const accent = isDark ? ACCENT.dark : ACCENT.light;
const primary = {
colorPrimary: accent.primary,
colorPrimaryHover: accent.hover,
colorPrimaryActive: accent.active,
};
return {
...antdThemeConfig,
token: {
...antdThemeConfig.token,
...primary,
colorLink: accent.primary,
colorInfo: accent.primary,
},
components: {
...antdThemeConfig.components,
Button: { ...antdThemeConfig.components?.Button, ...primary },
Progress: { ...antdThemeConfig.components?.Progress, remainingColor: accent.rail },
},
};
}, [antdThemeConfig, isDark]);
return ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={themeConfig} direction={direction}>
{messageContextHolder} {messageContextHolder}
<Layout className={pageClass}> <Layout className={pageClass} dir={direction}>
<Layout.Content className="content"> <div className="sub-aurora" aria-hidden="true">
<Row justify="center"> <span className="sub-aurora-grid" />
<Col xs={24} sm={22} md={18} lg={14} xl={12}> </div>
<Card hoverable className="subscription-card" title={cardTitle} extra={cardExtra}> <Layout.Content className="sub-content">
{announce && ( <Card className="sub-card">
<Alert type="info" showIcon title={announce} style={{ marginBottom: 16 }} /> <SubHeader
title={subTitle}
sId={sId}
email={clientEmail}
lang={lang}
onLangChange={onLangChange}
/>
{announce && <Alert type="info" showIcon title={announce} className="sub-announce" />}
<SubHero {...heroData} lang={lang} />
{tabs.length > 0 && <Tabs className="sub-tabs" tabBarGutter={24} items={tabs} />}
{(updateHours > 0 || subSupportUrl) && (
<footer className="sub-footer">
{updateHours > 0 && (
<span>
<ClockCircleOutlined />
{t('subscription.updateInterval', { hours: updateHours })}
</span>
)} )}
<Descriptions {subSupportUrl && (
bordered <a href={subSupportUrl} target="_blank" rel="noopener noreferrer">
column={1} <CustomerServiceOutlined />
size="small" {t('subscription.support')}
className="info-table" </a>
items={descriptionsItems}
/>
<SubUsageSummary
usedByte={
Number(subData.usedByte || 0) ||
Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0)
}
totalByte={totalByte}
usedLabel={used}
totalLabel={total}
remainedLabel={remained}
expireMs={expireMs}
isActive={isActive}
/>
{(subUrl || subJsonUrl || subClashUrl) && (
<>
<Divider>{t('subscription.title')}</Divider>
<div className="links-section">
{subUrl && (
<div className="sub-link-row">
<Tag color="green" className="sub-link-tag">
SUB
</Tag>
<a
href={subUrl}
target="_blank"
rel="noopener noreferrer"
className="sub-link-title sub-link-anchor"
title={subUrl}
>
{sId}
</a>
<div className="sub-link-actions">
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => copy(subUrl)}
aria-label={t('copy')}
title={t('copy')}
/>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={
<div className="sub-link-qr-popover">
<Tag color="green" className="qr-tag">
{t('pages.settings.subSettings')}
</Tag>
<QRCode
value={subUrl}
size={QR_SIZE}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
/>
</div>
}
>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label="QR"
title="QR"
/>
</Popover>
</div>
</div>
)}
{subJsonUrl && (
<div className="sub-link-row">
<Tag color="purple" className="sub-link-tag">
JSON
</Tag>
<a
href={subJsonUrl}
target="_blank"
rel="noopener noreferrer"
className="sub-link-title sub-link-anchor"
title={subJsonUrl}
>
{sId}
</a>
<div className="sub-link-actions">
<Button
size="small"
href={appendRawView(subJsonUrl)}
target="_blank"
rel="noopener noreferrer"
icon={<DownloadOutlined />}
aria-label={t('download')}
title={t('download')}
/>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => copy(subJsonUrl)}
aria-label={t('copy')}
title={t('copy')}
/>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={
<div className="sub-link-qr-popover">
<Tag color="purple" className="qr-tag">
{t('pages.settings.subSettings')} JSON
</Tag>
<QRCode
value={subJsonUrl}
size={QR_SIZE}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
/>
</div>
}
>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label="QR"
title="QR"
/>
</Popover>
</div>
</div>
)}
{subClashUrl && (
<div className="sub-link-row">
<Tooltip title="Clash / Mihomo">
<Tag color="gold" className="sub-link-tag">
CLASH
</Tag>
</Tooltip>
<a
href={subClashUrl}
target="_blank"
rel="noopener noreferrer"
className="sub-link-title sub-link-anchor"
title={subClashUrl}
>
{sId}
</a>
<div className="sub-link-actions">
<Button
size="small"
href={appendRawView(subClashUrl)}
target="_blank"
rel="noopener noreferrer"
icon={<DownloadOutlined />}
aria-label={t('download')}
title={t('download')}
/>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => copy(subClashUrl)}
aria-label={t('copy')}
title={t('copy')}
/>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={
<div className="sub-link-qr-popover">
<Tag color="gold" className="qr-tag">
Clash / Mihomo
</Tag>
<QRCode
value={subClashUrl}
size={QR_SIZE}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
/>
</div>
}
>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label="QR"
title="QR"
/>
</Popover>
</div>
</div>
)}
</div>
</>
)} )}
</footer>
{links.length > 0 && ( )}
<> </Card>
<Divider>{t('pages.inbounds.copyLink')}</Divider>
<div className="links-section">
<div className="sub-link-row">
<span className="sub-link-title">{t('subscription.copyAllConfigs')}</span>
<div className="sub-link-actions">
<Button
size="small"
icon={<CopyOutlined />}
onClick={copyAll}
aria-label={t('subscription.copyAllConfigs')}
title={t('subscription.copyAllConfigs')}
/>
</div>
</div>
{links.map((link, idx) => {
const parts = parseLinkParts(link);
const fallback = `Link ${idx + 1}`;
const rowTitle = parts?.remark || fallback;
const qrLabel = parts?.remark || rowTitle;
const canQr = !isPostQuantumLink(link);
const isWireguardLink =
link.startsWith('wireguard://') || link.startsWith('wg://');
const isAmneziawgLink = link.startsWith('vpn://');
return (
<Fragment key={link}>
<div className="sub-link-row">
{parts ? (
<LinkTags parts={parts} />
) : (
<Tag className="sub-link-tag">LINK</Tag>
)}
<span className="sub-link-title" title={rowTitle}>
{rowTitle}
</span>
<div className="sub-link-actions">
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => copy(link)}
aria-label={t('copy')}
title={t('copy')}
/>
{canQr && (
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={
<div className="sub-link-qr-popover">
<Tag className="qr-tag">{qrLabel}</Tag>
<QRCode
value={link}
size={220}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
/>
</div>
}
>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label="QR"
title="QR"
/>
</Popover>
)}
</div>
</div>
{isWireguardLink && (
<ConfigBlock
label={t('pages.clients.wireguardConfig')}
text={wireguardConfigFromLink(link, rowTitle)}
fileName={`${rowTitle || 'peer'}.conf`}
qrRemark={rowTitle}
tagColor="cyan"
/>
)}
{isAmneziawgLink && (
<ConfigBlock
label={t('pages.clients.amneziaWgConfig')}
text={amneziawgConfigFromLink(link)}
fileName={`${rowTitle || 'peer'}.conf`}
qrRemark={rowTitle}
tagColor="purple"
/>
)}
</Fragment>
);
})}
</div>
</>
)}
<Row gutter={[8, 8]} justify="center" className="apps-row">
<Col xs={24} sm={12} className="app-col">
<Dropdown trigger={['click']} menu={{ items: androidMenuItems }}>
<Button block={isMobile} size="large" type="primary">
<AndroidOutlined /> Android <DownOutlined />
</Button>
</Dropdown>
</Col>
<Col xs={24} sm={12} className="app-col">
<Dropdown trigger={['click']} menu={{ items: iosMenuItems }}>
<Button block={isMobile} size="large" type="primary">
<AppleOutlined /> iOS <DownOutlined />
</Button>
</Dropdown>
</Col>
</Row>
</Card>
</Col>
</Row>
</Layout.Content> </Layout.Content>
</Layout> </Layout>
</ConfigProvider> </ConfigProvider>

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