Compare commits

...

58 Commits

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

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

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

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

* fix(frontend): retain Xray server projections

* fix(frontend): keep draft controls internal

* fix(frontend): rehydrate saved redacted settings

* fix(frontend): order saved draft hydration

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

---------

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

* fix(frontend): retain theme body classes

* fix(storybook): mirror panel theme attributes

* test(storybook): cover theme switches

* test(storybook): strengthen theme DOM coverage

* fix(frontend): preserve message container classes

---------

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

* fix(frontend): preserve incomplete DNS hosts

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

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

---------

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

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

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

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

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

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

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

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

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

* style(database): group SQLite driver imports

* fix(database): bound online backup retries

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

* test(database): cover existing backup destinations

* fix(database): harden SQLite snapshot lifecycle

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

---------

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

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

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

* fix(frontend): meet config text contrast requirements

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

---------

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

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

* test(xray): cover concurrent lifecycle reads

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

* fix(xray): guard process config snapshots

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

---------

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

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

* fix(sub): preserve shared stale refresh results

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

---------

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

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

* test(mtproto): cover concurrent process exit

* test(mtproto): cover lifecycle field synchronization

---------

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

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

* fix(frontend): keep invalid settings recoverable

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

---------

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

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

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

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

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

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

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

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

From review, in order of severity:

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

* chore(build): gate production sourcemaps behind XUI_SOURCEMAP

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

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

---------

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

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

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

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

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

Three things from the review, in severity order.

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:59:25 +02:00
Shichao Song 17e6b5a460 inbounds: allow custom monthly traffic reset days (#6071) 2026-07-28 23:27:09 +02:00
Intervence 34d2591e50 fix (install.sh): use realpath instead of script name (#6075)
* fix (install.sh): use realpath instead of script name

###Description:
During arch() sctipt tries to delete itself in case no compatible arch found. This may lead to unexpected file deletion if executed outside root dir; also cur_dir is declared but doesn't seem to be used anywhere

###Way to reproduce:
```bash
cd "/some/other_dir_with_install_sh"
/3x-ui/project/dir/install.sh
```

* fix(install): quote the script path before the self-delete

realpath was handed an unquoted $0, so a script living under a path that
contains spaces was split into several arguments: realpath printed a
partial path plus an error, and rm -f then targeted a name matching
nothing at all. The unsupported-arch branch silently kept the script it
means to remove — the very case the surrounding fix exists for.
2026-07-28 23:11:27 +02:00
PathGao ca6955d88b feat(ui): validate the REALITY client version range at save time (#6126)
* feat(ui): validate the REALITY client version range at save time

The impossible range from PR #6125 — a max below the effective minimum
— could still be saved; the tooltip only helps a user who hovers it.
Add save-time validation mirroring xray-core's parser (up to three
dot-separated parts, each 0-255) on both fields, plus a cross-field
check that a non-empty max is not below a non-empty min. Errors are
field-level i18n keys following the REALITY target precedent, so the
modal stays open and points at the offending field instead of storing
a config that rejects every client.

A malformed min is reported by its own field and skipped by the max
comparison, so the user sees one precise error per field.

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

* fix(ui): reject untrimmed client versions and revalidate max on min edits

From review: the validators trimmed but the save path ships the value
verbatim, and xray-core's part parser accepts no surrounding
whitespace — so a green form could still save a config the core
refuses to load. Reject any value that differs from its trimmed form.

Also revalidate the max field after a min edit when max already
shows an error, so correcting the min clears the stale cross-field
message without waiting for the next submit.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:04:03 +02:00
PathGao 411271b454 refactor(ui): share one onNumber handler for numeric setting inputs (#6127)
* refactor(ui): share one onNumber handler for numeric setting inputs

The Number(v) || 0 idiom in InputNumber onChange handlers is the root
pattern behind the cleared-port bug (#6121): AntD reports a cleared
field as null, and || 0 turns that into a stored zero or a min-clamp.
The port fields got an inline null-guard; the other sixteen numeric
settings kept the idiom, so every new field is a chance to
reintroduce the bug.

Extract the guard into onNumber(apply): null, empty and NaN change
events are ignored so a cleared field snaps back to its stored value
on blur, and numeric events pass through unchanged. Convert all
sixteen sites in the settings and xray pages. Two sites keep their
deliberate different semantics: smtpPort falls back to 587 on clear,
and the Telegram notify interval clamps through Math.max.

For the non-port fields this changes clearing from storing 0 to
keeping the stored value; zero remains reachable by typing it.

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

* refactor(ui): fold the remaining hand-rolled numeric guards into onNumber

From review: ObservatorySettingsTab's sampling field hand-rolled the
same ignore-null semantic and smtpPort kept a fallback-to-587 on
clear that nothing documents as intentional and that silently
overwrites a configured non-standard port — both now go through the
shared helper, leaving the Telegram interval clamp as the one
deliberate exception.

Also from review: narrow the helper to numbers only (no stringMode
input exists in the repo, and the string branch codified a guarantee
the number-typed callback cannot honour), soften the docblock to
describe behavior rather than promise prevention, add a GeneralTab
component test covering the clear-vs-typed-zero semantics, and assert
the blur snap-back in both settings tests so a display/state desync
cannot ship unnoticed.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:03:13 +02:00
Tosd e862d81c60 fix(sub): omit hyphen for empty remark variables (#6101)
* fix(sub): omit hyphen for empty remark variables

The default INBOUND-EMAIL template left a leading hyphen when an inbound had no remark after display remarks became template-driven in b0c1156dd. Treat a hyphen between adjacent variables as their separator and drop it when it would lead the output or when the value after it is empty, so an empty variable in the middle of a template still leaves a single separator between its neighbours. Literal leading hyphens written into the template are preserved.

* fix(sub): elide the remark separator after leading decoration

The separator between two adjacent tokens was kept as soon as any text had
reached the segment, so a template opening with decoration still rendered
"🌐-john" for an inbound with no remark. Track whether a token has produced
a value rather than testing the accumulated output, so the hyphen is elided
for any prefix that carries no token value of its own, and the builder is no
longer rescanned once per token.
2026-07-28 23:02:00 +02:00
Kim Fom 6af2995930 feat(api): add GET endpoint to look up clients by Telegram ID (#5945)
* feat(api): add GET endpoint to look up clients by Telegram ID

GET /panel/api/clients/getByTgId/:tgId returns all clients matching the given Telegram user ID. tgId is not unique, so the response is an array of {client, inboundIds, externalLinks, usedTraffic} objects.

* fix: guard tgId=0 sentinel, index tg_id, deduplicate enrichment in getByTgId

Three issues from the code review on the new GET /panel/api/clients/getByTgId/:tgId
endpoint: the lookup did not short-circuit tgId <= 0 (this codebase's sentinel
for 'no Telegram ID'), had no index on clients.tg_id causing a full table scan
on every call, and duplicated the per-record enrichment (inbound IDs, external
links, effective flow, traffic) identically between get and getByTgId.

- Reject tgId <= 0 in GetRecordsByTgId with a clear error, matching the
  '0 = none' convention used elsewhere in the codebase.
- Add index:idx_clients_tg_id to ClientRecord.TgID (struct tag + idempotent
  startup migration for existing databases).
- Extract buildClientPayload helper used by both get and getByTgId.
- Update client_lookup_test.go to verify sentinel rejection instead of
  expecting tgId=0 to be a valid lookup.

* refactor(api): move Telegram client lookup under /get/tgId/:tgId

Nest the Telegram-ID lookup beside the email lookup as /get/tgId/:tgId
instead of the flat /getByTgId/:tgId, so both client fetch routes share the
/get prefix. Gin resolves the static tgId segment ahead of the :email
wildcard, so /get/:email keeps matching plain email lookups, including a
literal 'tgId' email. The endpoint is unreleased, so no compatibility
concern.
2026-07-28 22:38:44 +02:00
Maksim Alekseev 041476a317 feat(sub): Add XHTTP session field compatibility in share links and subscriptions (#5929)
* ✨ Add sessionKey and sessionPlacement compatability for previous clients

* ✨ Add sessionKey and sessionPlacement compatability for previous clients on backend
2026-07-28 22:15:28 +02:00
Mr. Nickson ff954ec48c fix: stop deleting client_traffics for detached-but-alive clients (#6110)
* fix: stop deleting client_traffics for detached-but-alive clients

MigrationRemoveOrphanedTraffics keyed "orphaned" off presence in some
inbound's settings.clients[] JSON, a definition that predates #4469's
standalone clients table. ClientService.Detach intentionally keeps a
client's traffic row when it drops its last inbound attachment (so it
can be re-attached later without losing stats/expiry), but that client
has no entry in any inbound's JSON anymore - so every x-ui migrate run
or backup restore deleted its traffic row anyway, even though the
client itself was untouched and still listed. Scope the query to the
clients table instead, which is the function's actual intent.

Separately, frontend/src/hooks/useClients.ts recomputed the clients
summary from the client_stats WS snapshot as soon as it arrived, even
when that snapshot held fewer rows than the server's own total (e.g.
exactly the gap above, or any other client with no client_traffics
row). The recompute can only bucket the clients it was given, so the
missing ones silently fell out of every bucket while the headline
total still counted them - the Ended/Disabled cards read 0 and their
hover lists were empty even though the table below listed those rows,
leaving the Filter drawer as the only way to reach them. Extracted the
decision into pickClientsSummary and added the guard: fall back to the
server summary (built from the clients table, always sums to total)
whenever the snapshot doesn't cover every client.

Fixes #6102.

* fix: union both keep-sets instead of replacing (review feedback)

Address the automated review on this PR: switching
MigrationRemoveOrphanedTraffics to key solely off the clients table
traded the original bug for a worse one. The one-shot ClientsTable
seeder (internal/database/db.go) skips a client it fails to unmarshal
and never retries, so a client still live in an inbound's
settings.clients[] JSON can have no clients row at all - the new
predicate deleted its traffic row too, and an empty clients table
would have emptied client_traffics outright. Union both keep-sets: a
row survives if it's referenced by either the clients table or any
inbound's JSON, and is removed only when it's in neither.

Log the delete's outcome instead of discarding it silently, since a
whole-table wipe would otherwise leave no trace.

Rewrote the migration test as a table of all four combinations, driven
through real ClientService calls (SyncInbound, Detach) rather than
hand-built rows wherever a real path produces the state, so it tracks
actual behavior instead of an assumption about it. Added the missing
case the review flagged: a client live in JSON only, with no clients
row, must survive.

Also stripped the // comments this PR had added - CLAUDE.md states
committed Go/TS carries none, which the review separately flagged.
2026-07-28 22:14:01 +02:00
H-TTTTT 8f49327efb feat(sub): allow identity tokens on every subscription link (#5935)
Keep usage tokens first-link-only while adding an opt-in setting for repeating EMAIL and USERNAME in subscription-body remarks.

Co-authored-by: x06579 <x06579@ai-dashboard>
2026-07-28 22:12:52 +02:00
n0liu 8bbca76bdd fix(sub): drop duplicated fingerprint in external-proxy tlsSettings (#6096)
applyExternalProxyTLSToStream wrote the external proxy fingerprint both to
tlsSettings.fingerprint and to tlsSettings.settings.fingerprint, so the
generated JSON subscription for an XHTTP Host group carried the same
fingerprint twice. Every other field in this function writes a single
location, and tlsData already emits fingerprint at the top level, so keep
only tlsSettings.fingerprint.
2026-07-28 22:10:47 +02:00
PathGao a2774bf212 fix(ui): explain the REALITY client version gate and drop the impossible placeholder (#6125)
* fix(ui): explain the REALITY client version gate and drop the impossible placeholder

An empty Min Client Ver looks unrestricted, but Xray-core silently
falls back to a built-in minimum (currently 26.3.27) that rejects
third-party cores such as Mihomo and sing-box with a bare REALITY
verification failure, and nothing in the panel points at the field.
Add tooltips to both version fields explaining the fallback and its
TLS-fingerprint-freshness rationale.

The Max Client Ver placeholder (25.9.11) sat below the built-in
minimum, so filling in both placeholders produced a range that
rejects every client. Remove it; empty genuinely means no upper
limit for that field.

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

* docs(reality): warn that an empty min client version rejects old cores

Common pitfalls covered bad targets, SNI mismatches, leaked keys and
wrong flow, but not the client version gate that currently bites
Mihomo and sing-box users. Add it to all four doc languages.

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

* fix(ui): word the version hints against the effective minimum

Address the automated review: the Max Client Ver hint said only 'not
lower than Min Client Ver', which re-establishes the empty-means-unset
mental model when the effective floor is the core's built-in minimum.
Both hints now name the effective minimum and tie the quoted 26.3.27
to the core build the panel runs, since operators can install any
Xray-core version.

Also from review: full-width quotes and a missing verb in the zh doc
bullet, the idiomatic Arabic opening, and a format-only x.y.z
placeholder on Max Client Ver so the field still conveys its shape.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:08:39 +02:00
Jingyue Yao 48675ff197 style(i18n): normalize Chinese-English spacing (#6076)
Add consistent spacing between Chinese text and Latin terms in the Simplified and Traditional Chinese translations to improve readability without changing keys or placeholders.
2026-07-28 21:01:21 +02:00
PathGao 604986598f fix(ui): commit date-picker selections immediately instead of on confirm (#6122)
* fix(ui): commit date-picker selections immediately instead of on confirm

With showTime, Ant Design's DatePicker stages a clicked date until the
OK button confirms it. Closing the dropdown any other way - clicking
elsewhere in the form or hitting Create/Save directly - discarded the
staged date without a hint, so an inbound saved this way ended up with
expiryTime=0 (never expires). The Now shortcut commits in one click,
which made it look like only the current time could ever be set.

Drop the confirm step (needConfirm=false) and propagate every calendar
selection through onCalendarChange, so the picked date reaches the form
state the moment it is clicked and can no longer be lost to a race with
the submit button.

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

* test(ui): pin calendar clicks committing without a confirm press

A clicked day cell must reach onChange with the exact selected
timestamp while the dropdown is still open, and the footer must not
render a confirm button. Pins the needConfirm-free behavior so a picker
dependency bump cannot silently bring the staged-value discard back.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:57:46 +02:00
Sanaei b6473004ac fix(ci): harden the conflict resolver against the branch it checks out
resolve-conflicts is the one job that puts a pull request's own tree in
the working directory while holding CLAUDE_CODE_OAUTH_TOKEN,
CLAUDE_BOT_PAT and a write-scoped token, which is what CodeQL alert 101
(actions/untrusted-checkout) points at. Nothing in the job executes that
tree and the trigger is gated on the repository owner, so the alert is
not reachable as written, but two of its guards were weaker than they
read.

Git hooks were neutered only after gh pr checkout had already run, so
the guard sat one step behind the checkout it exists to cover; it now
precedes it. The conflicted paths are concatenated into the
--allowedTools value handed to the model, so a path carrying a comma or
a parenthesis would widen that allowlist. Only both-modified paths reach
that code today, which means they already exist in the base repository,
but the merge is now handed back to the maintainer unless every
conflicted path is plain [A-Za-z0-9._/-].

The file's header comment block is dropped.
2026-07-28 20:11:22 +02:00
PathGao 579acbc669 fix(settings): keep the stored port when a port field is cleared (#6121)
* fix(settings): keep the stored port when a port field is cleared

Clearing the panel-port, subscription-port or LDAP-port InputNumber
fired onChange(null), which the handlers coerced to 0; on blur Ant
Design clamped the empty field to min=1 and the next save silently
persisted port 1. For subPort that breaks the generated subscription
links; for webPort it moves the panel itself to port 1 and locks the
admin out until the port is fixed via the x-ui CLI.

Ignore null changes so clearing a port field snaps back to the last
valid value instead of committing a bogus port.

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

* test(settings): pin cleared port fields to the stored value

Clearing the subscription-port field must not reach updateSetting at
all, while typed ports still pass through unchanged. Pins the fix so a
handler refactor cannot silently reintroduce the clamped port 1.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:10:21 +02:00
Sanaei 4605f00a15 fix(nodes): keep the credential-presence flag on the node heartbeat push
The Nodes page cache is overwritten wholesale by the heartbeat websocket
push, but the job broadcast a raw []*model.Node while the REST list returns
[]*service.NodeView. model.Node tags the api token json:"-" and carries no
hasApiToken field, so every push stripped the flag the edit form reads to
decide whether a token is already stored.

One 5s tick after the page loaded, editing any non-mTLS node then failed
with "Name, address, port and API token are required" — and stayed failed,
because setQueryData refreshes dataUpdatedAt, so the query never goes stale
and never refetches the intact REST payload.

Broadcast the NodeView read contract instead.
2026-07-28 17:38:46 +02:00
Sanaei dc6a16019e fix(xray): reject configs xray-core refuses, and check the fixtures against it
The frontend's golden fixtures are the panel's model of an xray config, but
nothing ever asked xray-core whether it would accept them: the snapshots only
prove the Zod schemas agree with themselves. Building every fixture through the
same config builders the panel hands its config to — conf.InboundDetourConfig
for the full-config and AddInbound paths, conf.RouterConfig for
ApplyRoutingConfig, conf.DNSConfig for the dns section — found seven the core
refuses, three of them reachable from the panel's own UI. A refusal is not
scoped to one inbound: the config fails to load and every inbound stays down.

Hysteria: xray-core builds version 2 only, in both the protocol settings and
the transport settings, but the inbound settings schema accepted any version
from 1 up and its comment claimed upstream still supported v1. Both fixtures
carried version 1. The schema now pins 2, GenXrayInboundConfig heals stored
rows on the way out the way it already heals shadowsocks ciphers and wireguard
peers, and the share link drops the dead hysteria:// scheme — the subscription
server already emitted hysteria2:// for the same inbound.

XHTTP uplinkDataPlacement: both transport forms offered "query", which the core
has never accepted for that field (auto and body always, cookie and header in
packet-up mode). Replaced with auto, which was missing, and the default label
now names auto rather than body.

FinalMask items: switching an item to the rand-driven array kind wrote
packet:[] next to the rand. xray-core counts an empty array as a packet and
every item kind is exclusive, so noise answers "len(item.Packet) > 0 &&
item.Rand.To > 0" and header-custom "exactly one item kind must be set". The
editor now clears the packet, and GetXrayConfig strips the residue from rows
already saved with it.

The remaining four were stale fixtures: an xmc mask still on the usernames
shape v26.7.28 replaced with profiles, a fragment mask with no length, and
header-custom and noise items passing an array to the string packet kind — all
shapes the panel's own editors cannot produce.

golden_fixtures_xray_test.go keeps this from drifting again: every fixture in
every category is built through xray-core on each run, with a self-signed pair
standing in for the deployment certificate paths, so the next core bump reports
which fixture it broke.
2026-07-28 14:43:55 +02:00
Sanaei fea6a20f7c fix(xray): stop the runtime user API from crashing xray-core
Exercising the whole XrayAPI surface against a real xray-core 26.7.28 (the
version go.mod pins) turned up a way for ordinary panel activity to kill the
core process, plus two smaller mismatches with what the core actually does.

buildUserAccount picked the shadowsocks account type by falling through to a
2022 account whenever the cipher was not one of six hardcoded names. xray's
legacy and 2022 inbounds cast the account they are handed without checking
(proxy/shadowsocks/validator.go, proxy/shadowsocks_2022/inbound_multi.go), so
the wrong type is not an error — it panics the core and drops every connection
on the server. The fallback was reachable without any misconfiguration:
autoRenewClients hands AddUser the client object straight out of the inbound's
settings, where the cipher lives under "method", never "cipher", so every
auto-renewed client on a legacy-cipher shadowsocks inbound took xray down. The
xray-valid aead_* aliases hit it too. The cipher is now read from either key,
matched with the same table (and case-insensitivity) the core's own conf
package uses, and an unrecognized one is an error instead of a guess.

The legacy shadowsocks validator is also the only one that accepts a second
user under an email it already holds, and RemoveUser then drops just one of
them — a disabled or expired client kept connecting. AddUser now drops the
email first on that account type so a single removal fully revokes the client.

GetTraffic skipped every stat the first time it saw it. xray creates a
counter on a user's first use, so that dropped a new client's traffic for a
whole polling interval, as did the counter reset after a core restart. Only
the first poll of a process is a baseline now; later, unseen and rewound
counters both count from zero.

Also fixes three unchecked settings["method"].(string) assertions that panic
the panel on a shadowsocks inbound whose settings carry no method, and bounds
TestRoute's port so an out-of-range value cannot wrap into the uint32 the
core is asked about.

Tests: api_users_e2e_test.go drives add/remove for every protocol against a
real core and asserts it survives each one (skipped unless XRAY_E2E_BINARY is
set); the account-type, traffic-delta and renew paths get unit coverage.
2026-07-28 13:52:10 +02:00
Sanaei 7f7b7e16a4 feat(xray): update xray-core to v26.7.28 and adapt panel
Bump xtls/xray-core to 5ca6f4b7d4dc (v26.7.28) and move the three binary
pins (DockerInit.sh, the Linux and Windows URLs in release.yml) in lockstep
so the in-process conf.Build() validation and the child binary agree.

XMC finalmask (#6487) is the breaking change. The mask's `usernames` string
list is gone, replaced by a required `profiles` array whose entries each need
a 3-16 character [A-Za-z0-9_] username, a parseable UUID and both Mojang
texture fields; the "default to Dream when empty" fallback was removed, so an
xmc mask saved by an older panel now fails to build and takes the whole
config down with it rather than degrading one inbound.

The textures are a signed blob only Mojang's session server can issue, so a
legacy username cannot be upgraded automatically. The panel now:

- rejects an incomplete xmc mask at save time (AddInbound/UpdateInbound),
  pointing at the specific field that is missing;
- drops only the offending mask when generating the core config, for rows
  that never went through the form (upgrade, node sync, restored backup,
  direct DB edit), warning which inbound lost its obfuscation instead of
  leaving every inbound offline;
- carries legacy usernames into profile stubs in the finalmask form so the
  operator keeps their player names and sees exactly what still needs
  filling in, and edits profiles through a list editor.

No destructive DB migration: unlike the removed shadowsocks ciphers there is
no valid replacement to rewrite to, and dropping the mask from stored rows
would discard the operator's hostname and password for config they can still
repair. The generation-time strip already prevents the startup failure.

Also track the core's xmux maxConnections fallback, lowered from 6 to 3 for
anti-TSPU, in the fresh-XMUX seed so a new panel config matches what the core
would pick on its own.

TUN gained a `desc` key and random utunN naming, but the Go validator no
longer accepts TUN inbounds and the panel only renders legacy saved rows, so
nothing there needs adapting. The remaining commits are REALITY log-warning
wording, gRPC/XHTTP localAddr accuracy and a routing tweak, none of which
change the JSON config surface.

Tests cross-check the panel's profile predicate against conf.XMCProfile.Build()
so a future core release that tightens or relaxes the rules fails loudly
rather than silently emitting configs the core refuses to start on.
2026-07-28 13:14:06 +02:00
Sanaei fd17255f1d Revert "fix(sub): keep the client identity on every subscription link (#6098)"
This reverts commit c004c18d90.

Showing {{EMAIL}}/{{USERNAME}} on the first subscription-body link only is
intentional, not an oversight in 876d55f2. Restoring the behaviour and the
tests that pin it.

Making the identity tokens configurable is the sanctioned route for the
operators asking for them on every link (#5935), rather than flipping the
default for everyone.
2026-07-27 19:43:01 +02:00
Sanaei 8bc00d1e90 style: drop the line comments added with the triage fixes
CLAUDE.md rules out // line comments in committed Go. The rationale they
carried is in the commit messages for each fix; doc comments that already
existed are kept, updated where the code they describe changed.

Also replaces reflect.Ptr with reflect.Pointer and rewrites the YAML keyword
alternation as a lookup table, both flagged by golangci-lint.
2026-07-27 14:37:57 +02:00
Sanaei 6f4cc1e53c fix(xray): emit an empty client array instead of null in the generated config (#6117)
finalClients was a nil slice, so an inbound that has a clients key but whose
clients are all filtered out — disabled by an admin, or cut by the traffic
job for quota or expiry — was handed to xray-core as "clients": null.

The panel already treats a stored null client list as invalid data and
coerces it to [] at startup, and null is what reporters see in bin/config.json
when they go looking for a connectivity problem, which sends the diagnosis
after a serialization bug that is not there. Build the slice empty so the
same state serializes as [].

The reported inbound also needs the clients table to be in sync, which is a
separate question still open on the issue.
2026-07-27 14:34:09 +02:00
Sanaei 0e69f64e56 fix(job): bound the traffic-notify POST so a stalled receiver can't wedge it (#6115)
informTrafficToExternalAPI posted through the package-level fasthttp.Do,
which carries no read or write deadline. Run() is scheduled @every 5s under
cron.SkipIfStillRunning, so a receiver that accepts the connection and then
neither answers nor closes did not just delay one notification — it held the
job, and every following tick was skipped for the duration.

What stops with it is more than counters: AddTraffic runs autoRenewClients
and disableInvalidClients in the same call, so quota and expiry enforcement
stall too, and an over-quota client keeps transiting for the whole hang. The
online-client refresh and the websocket broadcasts sit later in the same tick.

Give the endpoint its own client with read/write deadlines and a DoTimeout
budget under the poll cadence, close the connection rather than pooling it
for a call this infrequent, and skip the POST outright when there is nothing
to report. Retries stay off: the payload carries per-tick deltas, so a resend
after a failed response leg would double-count on the receiver.

Verified against a listener that accepts and stalls: fasthttp.Do was still
blocked after 8s, the new client returns at its 3s budget.
2026-07-27 14:30:48 +02:00
Sanaei 7fe9932d7b fix(sub): quote Clash scalars a YAML parser would read as numbers (#6104)
A REALITY short-id like 2351e1 is valid hex, but as a bare YAML scalar the
resolution rules read it as the float 23510. mihomo hex-decodes the resulting
five-digit string, fails with "invalid REALITY short ID", and the whole
provider loads zero nodes — one proxy takes the entire subscription down.

The encoder quotes the forms it recognises (plain integers, hex, booleans)
but not the exponent-float form, and its own parser reads that token back as
a string, so nothing in a round-trip through it reveals the problem. Check
the values against the resolution rules instead, and force quotes on any
plain scalar that would resolve to a non-string.

Applied to every string in the document rather than to short-id alone: the
panel's own short-id generator emits random hex, and passwords, obfs-
passwords and pre-shared keys reach the output the same way. Unambiguous
values are untouched, so the document is otherwise byte-identical.

The existing Clash tests assert on the config map, never on the serialized
text, which is why this survived; the new tests assert on the output.
2026-07-27 14:28:34 +02:00
Sanaei c004c18d90 fix(sub): keep the client identity on every subscription link (#6098)
876d55f2 put EMAIL/USERNAME in the same first-link-only bucket as the usage
tokens, so a client attached to several inbounds got its email on whichever
inbound sorted first and bare inbound names on all the rest. With the shipped
default template ({{INBOUND}}-{{EMAIL}}|...) that makes every profile after
the first indistinguishable between clients — the point of the token.

The two are not alike: the usage block repeats identical numbers on every
link, while the identity is what tells one imported profile from another.
Restore identity on all body links and leave usage first-link-only.

Reported again in #6029 and #5659, which asked for the same revert.
2026-07-27 14:24:28 +02:00
Sanaei f8e9f2f087 fix(node): stop a departed master's frozen traffic from disabling clients (#6113)
client_global_traffics rows are keyed by (master_guid, email) and are only
ever overwritten by a push from that same master. A master that stops
pushing — decommissioned, reinstalled under a fresh GUID, or detached from
the node — therefore leaves its last snapshot behind permanently.

depletedClientsCond's cross-panel EXISTS branch matched any such row, so a
node kept comparing a client's quota against counters frozen weeks earlier.
Once they exceeded the quota the node disabled the client on every traffic
poll, and the node -> master enable merge latched that off on the master too,
where nothing sets it back. The reported symptom is exactly this: a client at
11 GB of a 24 GB quota, enabled on two nodes, disabled on the third, which
still held a 27-day-old row from a previous master reporting 30 GB.

Bound both the enforcement predicate and the display overlay to rows a master
refreshed within globalTrafficFreshWindow. Masters push every 30s, so a live
master is never affected; a master that is merely unreachable for a while
keeps enforcing for a full day before its numbers are set aside.

The one-way enable merge that makes such a disable permanent on the master is
deliberate (12d84c2a, #4917) and is left alone.
2026-07-27 14:21:56 +02:00
Sanaei 5accd8a611 fix(ci): stop the conflict job trusting the branch it is merging
A second audit of the hardened workflow found the "no shell at all"
claim in resolve-conflicts was still false, by two routes that live
outside this file.

The job runs the model in the workspace right after `gh pr checkout`,
so for a fork pull request the working directory is attacker-controlled.
claude-code-action writes `enableAllProjectMcpServers = true` into
~/.claude/settings.json before starting Claude Code
(base-action/src/setup-claude-code-settings.ts), and the CLI honours a
project `.mcp.json` unless `strictMcpConfig` is set, which the action
never sets. A contributor branch carrying an `.mcp.json` therefore got
its command spawned at session start, with --allowedTools gating tool
calls but not server startup. The same tree also supplied CLAUDE.md and
.claude/ as project instructions. The job now passes
`--strict-mcp-config` and `--setting-sources user`, so nothing in the
merged tree configures the session.

The second route was `Edit` with no path scope, the only unscoped file
grant left. Editing `.git/config` to set `core.fsmonitor` or a
`credential.helper` gets a command run by the next step's git calls,
which hold CLAUDE_BOT_PAT, and the stray-file guard could never see it
because `git diff --name-only` lists tracked paths only. The merge step
now emits one `Edit(//<workspace>/<file>)` rule per conflicted path and
the model gets exactly those plus /tmp, with `.git/**` denied outright
and Bash, WebFetch, WebSearch and Task denied by name. Hooks are
disabled for the run (`core.hooksPath=/dev/null`, `commit --no-verify`).

Conflict handling gets three real gaps closed: modify/delete, rename and
both-added conflicts (git status DD/AU/UD/DU/AA/UA) leave no markers, so
they used to sail through the marker check and get committed unresolved
- they are now detected up front and handed back untouched; the marker
scan covers `=======` and `|||||||`, not just the outer pair; and after
staging, `git diff --diff-filter=U` must come back empty or nothing is
committed. A `=======` markdown underline of exactly seven characters in
a conflicted file will now hand the merge back rather than commit it,
which is the safe direction.

Smaller things the audit was right about:
- the mutating gh rules are prefix rules, so `Bash(gh issue close:*)`
  reached every issue in the repository. They now carry the triggering
  number: `Bash(gh issue close ${{ github.event.issue.number }}:*)`.
- `Write(//tmp/**)` is granted alongside `Edit(//tmp/**)`: the docs say a
  Write(path) rule is never matched by the file checks, so the Edit rule
  is what authorises it, but the tool has to be listed to exist at all.
  Without this the model could not create /tmp/comment.md.
- the mention prompt lost its thread context when it moved to agent mode
  and referred to "<number>" literally; it now gets repo, number, title
  and whether the thread is a pull request.
- `git log`/`git show` are gone from mention: `--output=<file>` makes
  them a file-write primitive.
- `@claude resolve pr conflicts` on a plain issue matched no job at all.
- the commit step gated on `skip != 'true'`, so it also ran when the
  merge step died before writing any output; it now needs `skip ==
  'false'`.
- bot-authored pull requests (dependabot opens three ecosystems' worth)
  no longer start a review run that the action refuses to serve.
- resolve-conflicts drops to `contents: read`, since the push is the
  PAT's job, and fails with a comment when that PAT is missing.
2026-07-25 22:27:09 +02:00
Sanaei f46b1726cf fix(ci): close the write paths an audit found still open in the bot
Making the jobs read-only in the previous commit was not enough: two of
the mechanisms that grant write access were invisible in the workflow
file itself.

Every job now passes a `prompt:` input. Without one, claude-code-action
picks tag mode for a mention, and src/modes/tag/index.ts then appends
`--permission-mode acceptEdits`, its own allowedTools including
`Bash(git commit:*)` and a push wrapper, and calls setupBranch. So the
mention job could edit files and commit them no matter what its own
allowedTools said, and its system prompt claiming otherwise was simply
wrong. A `prompt:` selects agent mode, which adds nothing. It also
removes tag mode's hidden requirement that the comment contain the
trigger phrase, which would have made resolve-conflicts a no-op for a
comment that said only "resolve pr conflicts".

resolve-conflicts no longer hands git to the model. `Bash(git:*)` is a
prefix rule, so it permitted `git push origin HEAD:main`, `--force`,
`git remote set-url`, and shell execution through `git config alias.x
'!sh -c ...'` - the action ships scripts/git-push.sh precisely because
`git push:*` allows `--receive-pack='sh -c ...'`. The job now splits in
three: a step checks out the PR branch, merges the base and collects the
conflicted paths; the model gets Read/Glob/Grep/Edit and no shell at all;
a final step verifies and pushes. That step refuses to commit if a
conflict marker survives, if the model wrote /tmp/ABORT, or if anything
outside the conflicted set was touched, and it stages those paths
individually instead of `git add -A`. The PAT is now written to the push
URL only in that last step, after the model's session has ended, instead
of sitting in .git/config while untrusted branch content is read.

The bare `Write` grant in the three answering jobs becomes
`Edit(//tmp/**)`, since only prose kept it out of the checkout and out of
$GITHUB_ACTION_PATH, whose scripts run after the model step. Each prompt
now says to fall back to an inline --body if the write is refused, so a
denied write cannot silently cost a reply. mention gains the transcript
upload and the no-reply guard the other jobs already have, keyed to the
triggering comment's timestamp.

Restores the header note about the 21000-character expression cap, with
the current block sizes.
2026-07-25 22:03:10 +02:00
Sanaei acbb879f80 refactor(ci): make the bot read-only except for PR conflict resolution
The bot is meant to investigate and explain, not to write code. It could
do considerably more than that: handle-pr-fix applied fixes and pushed
them to any trusted author's PR, an @claude mention on a pull request
could edit files, and an @claude mention on an issue opened a pull
request against main. All of it is gone.

Now every job that answers automatically runs with a contents: read
token, so pushing is impossible rather than merely forbidden:

- handle-pr-fix is deleted. handle-pr-review takes every pull request
  instead of only the ones from outside contributors, and it comments.
- mention drops contents: write, the push-URL routing step, and the
  Edit tool. Its Bash allowlist is now an explicit read-only set - the
  gh subcommands it needs plus git log/show/diff/blame - so gh api,
  gh pr merge and gh pr create are no longer reachable. Asked for a
  fix, it now writes the change out in full instead of applying it.

One narrow exception replaces all of that: resolve-conflicts. It runs
only when the repository owner comments "resolve pr conflicts" on a
pull request, and it may merge the base branch into that PR's head
branch and resolve the conflicts, nothing else. It keeps both sides of
every conflict, takes the base version of generated artifacts it cannot
regenerate here, and aborts the merge rather than guess when a hunk
needs a human. It never force-pushes, merges, or closes.

Also removes the pull-request-opening step whose guard never worked:
gh api prints the 404 body on stdout, so `ahead=$(gh api ... || echo 0)`
became `{"message":"Not Found",...}0`, never equal to "0", and every
reply-only mention run ended red on `gh pr create`. Uploads the
handle-pr-review transcript the way handle-issue already does, so a run
that dies inside the sandbox leaves evidence.
2026-07-25 21:39:28 +02:00
Sanaei 1358f65bec fix(ci): unbreak the issue-triage bot, which answered nothing
Since 2026-07-20 every `issues` run reported success while posting no
comment at all - #6094 through #6103 carry zero replies. The cause is
the sandbox, not the prompt or the model.

`handle-issue` and `handle-pr-review` pass allowed_non_write_users,
which is what lets the bot run for reporters who have no write access.
claude-code-action reacts to that input by turning subprocess isolation
on and installing bubblewrap, and that sandbox cannot start on the
runner: every Bash call dies during setup, before the command itself
runs, with

    bwrap: Can't create file at /home/.mcp.json: Permission denied

`gh` is reachable only through Bash, so the triage investigated the
issue, wrote its reply to /tmp/comment.md, and could never post it.
The action itself did not crash, so the job stayed green.

Opt both jobs out with CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0. The scrub is
a best-effort wipe of secrets from subprocess environments, not an
access control; what actually bounds these jobs is unchanged - a
contents: read token that cannot push, and a Bash allowlist holding
only specific `gh issue`, `gh label`, `gh search` and `gh release`
subcommands. Code changes stay confined to handle-pr-fix and mention,
which only trusted actors and the owner can trigger.

Add a step to each job that fails the run when no bot comment landed on
the issue or pull request, so the next silent breakage shows up red
instead of green, and lower retention-days to the repository maximum of
7 so the artifact upload stops warning.
2026-07-25 20:14:53 +02:00
Sanaei f4e79e70ea chore: refresh dependencies, fix Linux tool tasks, modernize Go idioms
Frontend deps: @hookform/resolvers 5.4.0 -> 5.4.3 and react-hook-form
7.82.0 -> 7.83.0. The @typeschema/valibot override is what makes this
installable at all. Resolvers 5.4.3 re-declares 25 optional peers for its
validator matrix, and npm resolves them into the ideal tree even though none
are used here; two of them contradict, since resolvers wants valibot ^1 while
@typeschema/main -> @typeschema/valibot pins valibot ^0.39. Both target the
same node_modules/valibot, so a plain npm update dies with ERESOLVE. The
override settles that one edge and nothing extra lands in node_modules.

Backend deps: telego 1.10.0 -> 1.11.1 (Telegram Bot API v10.2, additive
only), klauspost/compress 1.19.1, plus the indirect bumps that came with them.

VS Code tasks: the golangci-lint and modernize tasks assumed Windows PATH
semantics, where PATH is a persistent user variable that every process
inherits, so ~/go/bin was always visible. On Linux that directory is exported
from ~/.bashrc, which the non-interactive `bash -c` behind a task never
sources, and both tasks failed with exit 127. Adds linux/osx option blocks
that prepend the Go bin directories and leaves the Windows path untouched,
plus tasks to install the two tools; those are split because go install
rejects packages from different modules in one invocation.

Go sources: modernize -fix output, covering range-over-int, slices.Backward,
maps.Copy, strings.CutPrefix and strings.SplitSeq. Behaviour is unchanged.
2026-07-25 16:08:09 +02:00
Sanaei edb487a005 chore(deps): migrate to react-router 8 and refresh frontend dependencies
react-router-dom 7 is superseded by react-router 8, which folds the DOM
bindings back into the core package. RouterProvider now comes from
`react-router/dom`, while the hooks and `createBrowserRouter` move to
`react-router`. Updates the nine importing modules and the router line in
docs/architecture.md to match.

Also refreshes antd, react-i18next, storybook, eslint, lint-staged and
playwright to current patch/minor releases, and restores alphabetical order
in devDependencies for the @vitest/browser-playwright and playwright entries.

Bumps brace-expansion to 5.0.8, the only release outside the affected range
of GHSA-mh99-v99m-4gvg (unbounded expansion length causing an OOM crash).
`npm audit fix` could not apply this on its own: the lockfile pinned 5.0.7
and npm will not re-resolve a transitive-only dependency in place, so the
entry was updated directly and reinstalled.
2026-07-25 02:07:15 +02:00
Sanaei 35cf6be6f9 fix(ci): keep the triage prompt under the 21000-char expression cap
The previous commit pushed handle-issue's prompt to 21587 characters and
GitHub stopped parsing the file: "(Line: 39, Col: 19): Exceeded max
expression length 21000". Because the prompt interpolates ${{ }}, GitHub
treats the whole block scalar as a single expression, and the cap applies
per expression. The failure mode is quiet and total - no job fails,
the workflow itself disappears, its registered name reverts from "Claude
Bot" to the file path, and the only signal is a run attributed to the
push with no jobs in it.

Drop the hand-written stack description, repository map and runtime-fact
list from that prompt and point at CLAUDE.md and docs/architecture.md
instead. Both are maintained, both are already in the checkout, and the
copy in the prompt had drifted from them anyway - it still described the
mtg worker, omitted internal/tunnelmonitor/ and memory.high, and filed
internal/web/runtime/ under "wiring". Only the support-facing facts that
live in neither file are kept: the install one-liner, the random initial
credentials, the distro-dependent env file, the Docker image and the
capabilities fail2ban needs.

handle-issue is now 15069 characters, and a header comment records the
limit so the next edit does not rediscover it in production.
2026-07-25 01:31:40 +02:00
Sanaei 0f7329c3ce fix(ci): repair the Claude bot and narrow what it can reach
Three problems, all in .github/workflows/claude-bot.yml.

It was silently dead. No comment had been posted since 2026-07-20 while
every run reported success: roughly twenty issues and pull requests each
burned 18-56 turns and up to $2.59, ended with permission denials, and
published nothing. Comment bodies are markdown, markdown is full of
backticks, and inside a quoted `--body "..."` backticks are command
substitution, so the write was rejected and a failed triage looked
exactly like a clean one. The body now goes to /tmp through Write and out
through --body-file, in every branch of both jobs, and each job re-reads
the thread afterwards so a rejected write fails loudly instead of
reporting success. The run transcript is kept as an artifact.

It could reach much further than it claimed. Both jobs that any GitHub
user can trigger declared themselves READ-ONLY in prose while holding
Bash(gh:*), which is not a GitHub-scoped allowlist: `gh alias set --shell`
runs its argument through sh -c and `gh extension install` fetches and
executes code, both as single commands whose first token is gh. That is a
general shell on a runner holding CLAUDE_CODE_OAUTH_TOKEN, which does not
expire with the job. `gh api` accepted any method, issues: write is
repo-scoped rather than issue-scoped, and `gh pr review --approve`,
`gh pr close` and `gh pr checkout` were forbidden in prose only. Those two
jobs now list the subcommands they actually run. The untrusted title and
body are fenced in tags carrying github.run_id, unguessable at the time
the issue is written, and the invariants an allowlist cannot express -
one issue number, labels and title only, /tmp as the sole writable path,
never $GITHUB_ENV - are stated explicitly. Both checkouts get
persist-credentials: false. handle-pr-fix and mention keep their
wildcards: only owners, members and collaborators can trigger them, and
narrowing the maintainer's own path risks more than it protects.

Its review hid findings and its triage quoted stale facts. "Prefer a few
high-signal findings over many low-value ones" is read literally by
Opus - it finds the bug, judges it below the stated bar and says
nothing - while the Severity and Confidence tiers already existed to do
that filtering. The review also never said that the working directory is
the base revision, so it could assert that a case was unhandled in code
the pull request had already rewritten, and label it confirmed, on an
outside contributor's first patch. Four CLAUDE.md conventions were
missing, each a guaranteed miss: openapigen's StructAllow allowlist, the
layering rules including the runtime.Runtime dispatch requirement that
silently breaks multi-node when bypassed, the assertion standard, and
golden share-link fixtures regenerated to turn a red test green. On the
triage side the invalid and duplicate branches were gated three times
over and so never fired, leaving spam to collect a full investigation and
a courteous reply; /etc/default/x-ui was given as the env file when it is
distro-dependent, making the PostgreSQL migration advice a silent no-op
on RHEL and Arch; an env list labelled "full" omitted XUI_PORT and the
XUI_TUNNEL_HEALTH_* family; XTLS was offered as a security option the
panel does not have. docs/architecture.md was invisible to both prompts
despite being maintained and already in the checkout. From the bot's own
output: it published a trigger only the maintainer can use, retitled
issues without saying so, asked for screenshots it cannot open, and once
invented a reason for a number it had miscounted.

All four jobs move to Opus 5, at xhigh effort rather than max - the
recommended tier for agentic work, and one below the overthinking that
max invites on routine triage.
2026-07-25 01:22:08 +02:00
Sanaei 29557e2153 fix(sub): gate the VLESS flow in JSON subscriptions like raw and Clash links
genVless emitted client.Flow unconditionally, while the raw link
(service.go:806) and the Clash proxy (clash_service.go:251) both gate it
behind vlessFlowAllowed. A flow_override left on client_inbounds after its
inbound moved to a transport Vision cannot use -- ws, grpc, httpupgrade --
therefore survived only into the JSON subscription, handing that client an
outbound xray-core rejects while its other two formats were correct.

Apply the same gate at the call site, reading the network from the
per-host stream so a host that rewrites the transport is judged on what it
actually emits. Verified by seeding a flow_override on a ws+tls inbound:
before, raw and Clash dropped the flow and JSON kept it.
2026-07-25 00:51:48 +02:00
Sanaei 0b60154383 fix(docs): force transitive sharp up to patched 0.35.3
sharp <0.35.0 inherits four libvips CVEs (GHSA-f88m-g3jw-g9cj). It comes
in as an optional dependency of next, which still declares ^0.34.5 on its
current release, so only an override reaches the fixed line. Brings
libvips 8.18.3 via @img/sharp-libvips-* 1.3.2.
2026-07-25 00:51:48 +02:00
Sanaei c3967e57dc perf(clients): take one email snapshot per client fan-out, not one per inbound (#6091)
Create and Attach called the exported AddInboundClient once per target
inbound, and that wrapper passes a nil email→subId map, so every iteration
re-ran getAllEmailSubIDs -- a JSON_EACH expansion over the settings blob of
every inbound in the panel. Adding one client to 24 inbounds on a panel with
~300 users meant 24 full expansions of ~7k rows to answer the same question.

Hoist the snapshot above the loop and call the unexported addInboundClient
with it, exactly as BulkAttach (client_bulk.go:63) and BulkCreate
(client_bulk.go:1151) already do. The snapshot goes stale from the second
inbound onward, but the identity being added is the same on every iteration,
so its own entry can only ever match itself -- checkEmailsExistForClients
accepts an email whose stored subId equals the incoming one, and an absent
entry is accepted too.

This is the database half of #6091. The dominant cost there is the other
half -- one synchronous 10s-capped node round-trip per remote inbound,
which multiplies again on chained nodes -- and that needs the push batched
per node rather than per inbound; left for a separate change.
2026-07-25 00:51:48 +02:00
Sanaei aa60d54ea5 fix(wireguard): widen the client address pool past a full /24 (#6089)
allocateWireguardAddress scanned exactly one /24, so a WireGuard inbound
was hard-capped at 254 clients with no way out -- the pool is not
configurable anywhere in the UI or API.

Fill the inbound's own /24 first, then widen to the enclosing /16 instead
of failing. A wireguard inbound carries no interface subnet and xray
routes purely by each peer's allowedIPs, so nothing constrains the wider
address. Capped at /16 to keep the worst-case scan bounded; IPv4 only.
2026-07-24 22:21:18 +02:00
Sanaei a652cb8cea fix(clients): keep a client editable when its subId is already shared (#6065)
The subId collision check in Update ran on every save, unlike the email
check above it. Because Update defaults an omitted subId to the stored
one, any client already sharing a subId was rejected on every later edit
-- even a pure totalGB or expiry change that never mentions subId.

Gate the check on an actual change. Pre-existing duplicates are reachable
because SyncInbound has no such check, and 88a36773 meant to leave them
untouched. Editing a client onto another subscriber's subId is still
rejected, so the typo guard is intact.
2026-07-24 22:18:39 +02:00
240 changed files with 12387 additions and 6070 deletions
+1 -1
View File
@@ -181,5 +181,5 @@ jobs:
run: npm run build-storybook
working-directory: frontend
- name: Audit
run: npm audit --audit-level=high
run: npm audit --omit=dev --audit-level=high
working-directory: frontend
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -124,7 +124,7 @@ jobs:
cd x-ui/bin
# Download dependencies
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.28/"
if [ "${{ matrix.platform }}" == "amd64" ]; then
fetch ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip
@@ -282,7 +282,7 @@ jobs:
cd x-ui\bin
# Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.28/"
Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip"
+2 -1
View File
@@ -2,7 +2,8 @@
.idea/
.vscode/
.cursor/
.claude/*
.specify/
.claude/
.cache/
.sync*
+131
View File
@@ -96,6 +96,22 @@
"options": {
"cwd": "${workspaceFolder}"
},
"linux": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"osx": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"problemMatcher": [
"$go"
]
@@ -111,6 +127,22 @@
"options": {
"cwd": "${workspaceFolder}"
},
"linux": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"osx": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"problemMatcher": [
"$go"
]
@@ -125,6 +157,22 @@
"options": {
"cwd": "${workspaceFolder}"
},
"linux": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"osx": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"problemMatcher": [
"$go"
]
@@ -140,10 +188,93 @@
"options": {
"cwd": "${workspaceFolder}"
},
"linux": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"osx": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"problemMatcher": [
"$go"
]
},
{
"label": "go: install golangci-lint",
"type": "shell",
"command": "go",
"args": [
"install",
"github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest"
],
"options": {
"cwd": "${workspaceFolder}"
},
"linux": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"osx": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"problemMatcher": []
},
{
"label": "go: install modernize",
"type": "shell",
"command": "go",
"args": [
"install",
"golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest"
],
"options": {
"cwd": "${workspaceFolder}"
},
"linux": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"osx": {
"options": {
"cwd": "${workspaceFolder}",
"env": {
"PATH": "${userHome}/go/bin:/usr/local/go/bin:${env:PATH}"
}
}
},
"problemMatcher": []
},
{
"label": "go: install tools",
"dependsOrder": "sequence",
"dependsOn": [
"go: install golangci-lint",
"go: install modernize"
],
"problemMatcher": []
},
{
"label": "frontend: ncu -u",
"type": "shell",
+1 -1
View File
@@ -32,7 +32,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
fi
mkdir -p build/bin
cd build/bin
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/Xray-linux-${ARCH}.zip"
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.7.28/Xray-linux-${ARCH}.zip"
unzip "Xray-linux-${ARCH}.zip"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
mv xray "xray-linux-${FNAME}"
+3 -3
View File
@@ -63,7 +63,7 @@ Two key ideas that explain most of the complexity:
**Frontend (`frontend/`):**
- **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**.
- Data layer: **TanStack Query** (`@tanstack/react-query`) over the native **Fetch API**; **Zod 4** schemas.
- Router: **react-router-dom 7**. Charts: **uPlot** (`frontend/src/components/viz/Sparkline.tsx`). Editor: **CodeMirror 6**.
- Router: **react-router 8**. Charts: **uPlot** (`frontend/src/components/viz/Sparkline.tsx`). Editor: **CodeMirror 6**.
- **Build output goes to `internal/web/dist/`** (see `vite.config.js` → `outDir`) and is
embedded into the Go binary with `go:embed`. Three HTML entries: `index.html` (panel SPA),
`login.html`, `subpage.html`. The Go server serves the SPA; there is no separate frontend
@@ -369,8 +369,8 @@ All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` me
| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
| `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB |
| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets |
| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")` | IP-limit and Xray access/error log cleanup; traffic resets |
| `@weekly` / `@monthly` | `periodic_traffic_reset_job(...)` | Weekly/monthly traffic resets |
| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")`, `periodic_traffic_reset_job("monthly")` | IP-limit and Xray access/error log cleanup; daily resets and due monthly resets |
| `@weekly` | `periodic_traffic_reset_job("weekly")` | Weekly traffic resets |
| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
| default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable |
| `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes |
+3
View File
@@ -40,6 +40,9 @@ See [Clients](/docs/config/clients).
Optionally cap total traffic and set an expiry date for the inbound, and choose a
periodic **traffic reset** schedule: `never` (default), `hourly`, `daily`,
`weekly`, or `monthly`.
For `monthly` resets, select a day from 1 to 31. If the selected day does not
exist in a shorter month, the reset runs on that month's last day.
</Step>
</Steps>
+1 -1
View File
@@ -19,7 +19,7 @@ browser in full.
| `webBasePath` | `/` | URL path the panel is served under (always normalized to `/…/`). |
| `webCertFile` / `webKeyFile` | _(none)_ | TLS certificate + key. When both are set, the panel serves **HTTPS**. |
| `sessionMaxAge` | `360` | Session lifetime in **minutes** (default 6 hours). |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPs/CIDRs whose forwarded headers (real client IP) are trusted. |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPs/CIDRs whose forwarded headers (real client IP) are trusted. A custom value also controls forwarded host and scheme in subscription links; include the subscription proxy or set `subURI` to override those links. |
| `panelOutbound` | _(none)_ | Route the panel's own egress (update checks, Telegram, geo/sub fetches) through a named Xray outbound. |
After changing the port or base path, the panel URL becomes
+7
View File
@@ -118,6 +118,13 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
- **Leaked private key.** Only ever distribute the **public** key to clients.
- **Wrong flow.** REALITY + XTLS-Vision needs `flow = xtls-rprx-vision` on both
the inbound client entry and the share link.
- **Old client cores rejected by default.** An empty **Min Client Ver** is not
"no limit": Xray-core falls back to the built-in minimum of the core build you
run (26.3.27 in current releases) that keeps client TLS fingerprints fresh, so
third-party cores such as Mihomo and sing-box fail REALITY verification even
with a correct config — clients see timeouts while only Xray-core based apps
connect. Set it to `1.0.0` only if you must support them; that also re-admits
outdated fingerprints.
</Callout>
+3
View File
@@ -40,6 +40,9 @@ TLS یا REALITY) را انتخاب کنید. به [انتقال‌ها](/docs/c
به‌صورت اختیاری می‌توانید کل ترافیک را محدود کنید و یک تاریخ انقضا برای ورودی
تعیین کنید، و یک زمان‌بندی **بازنشانی ترافیک** دوره‌ای انتخاب کنید: `never`
(پیش‌فرض)، `hourly`، `daily`، `weekly` یا `monthly`.
برای بازنشانی `monthly`، روزی از ۱ تا ۳۱ انتخاب کنید. اگر آن روز در ماهی کوتاه‌تر
وجود نداشته باشد، بازنشانی در آخرین روز همان ماه انجام می‌شود.
</Step>
</Steps>
+1 -1
View File
@@ -19,7 +19,7 @@ icon: SlidersHorizontal
| `webBasePath` | `/` | مسیر URLی که پنل زیر آن ارائه می‌شود (همیشه به شکل `/…/` نرمال‌سازی می‌شود). |
| `webCertFile` / `webKeyFile` | _(هیچ‌کدام)_ | گواهی + کلید TLS. وقتی هر دو تنظیم شوند، پنل با **HTTPS** ارائه می‌شود. |
| `sessionMaxAge` | `360` | طول عمر نشست بر حسب **دقیقه** (پیش‌فرض ۶ ساعت). |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPها/CIDRهایی که هدرهای فورواردشده‌شان (IP واقعی کلاینت) مورد اعتماد است. |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IPها/CIDRهایی که هدرهای فورواردشده‌شان (IP واقعی کلاینت) مورد اعتماد است. مقدار سفارشی همچنین میزبان و طرحِ لینک‌های اشتراک را کنترل می‌کند؛ پراکسی اشتراک را اضافه کنید یا برای بازنویسی این لینک‌ها `subURI` را تنظیم کنید. |
| `panelOutbound` | _(هیچ‌کدام)_ | مسیریابی خروجیِ خود پنل (بررسی به‌روزرسانی‌ها، Telegram، واکشی geo/sub) از طریق یک خروجی Xray با نام مشخص. |
پس از تغییر پورت یا مسیر پایه، آدرس پنل به‌صورت
+7
View File
@@ -118,6 +118,13 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
- **نشت کلید خصوصی.** فقط و فقط **کلید عمومی** را میان کلاینت‌ها توزیع کنید.
- **جریان نادرست.** REALITY + XTLS-Vision به `flow = xtls-rprx-vision` هم در ورودیِ
مدخل کلاینت و هم در لینک اشتراک‌گذاری نیاز دارد.
- **هسته‌های قدیمی کلاینت به‌طور پیش‌فرض رد می‌شوند.** خالی گذاشتن
**حداقل نسخه کلاینت** به معنای «بدون محدودیت» نیست: Xray-core به حداقل داخلیِ
نسخهٔ هسته‌ای که اجرا می‌کنید (در نسخه‌های فعلی 26.3.27) بازمی‌گردد تا اثر انگشت‌های TLS کلاینت‌ها تازه
بمانند؛ در نتیجه هسته‌های شخص ثالث مانند Mihomo و sing-box حتی با پیکربندی
کاملاً درست در تأیید REALITY شکست می‌خورند — کلاینت‌ها تایم‌اوت می‌بینند و فقط
اپلیکیشن‌های مبتنی بر Xray-core وصل می‌شوند. تنها در صورت نیاز به پشتیبانی از
آن‌ها مقدار `1.0.0` را تنظیم کنید؛ این کار اثر انگشت‌های قدیمی را هم می‌پذیرد.
</Callout>
+3
View File
@@ -41,6 +41,9 @@ icon: ArrowDownToLine
При необходимости ограничьте общий объём трафика и установите дату истечения для
входящего подключения, а также выберите расписание периодического **сброса трафика**:
`never` (по умолчанию), `hourly`, `daily`, `weekly` или `monthly`.
Для сброса `monthly` выберите день от 1 до 31. Если выбранного дня нет в более
коротком месяце, сброс выполняется в последний день этого месяца.
</Step>
</Steps>
+1 -1
View File
@@ -19,7 +19,7 @@ icon: SlidersHorizontal
| `webBasePath` | `/` | URL-путь, по которому обслуживается панель (всегда нормализуется к `/…/`). |
| `webCertFile` / `webKeyFile` | _(нет)_ | Сертификат TLS + ключ. Когда заданы оба, панель обслуживается по **HTTPS**. |
| `sessionMaxAge` | `360` | Время жизни сессии в **минутах** (по умолчанию 6 часов). |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IP-адреса/CIDR, чьим переадресованным заголовкам (реальный IP клиента) можно доверять. |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | IP-адреса/CIDR, чьим переадресованным заголовкам (реальный IP клиента) можно доверять. Пользовательское значение также управляет пересылаемыми хостом и схемой в ссылках подписки; добавьте прокси подписки или задайте `subURI`, чтобы переопределить эти ссылки. |
| `panelOutbound` | _(нет)_ | Маршрутизация собственного исходящего трафика панели (проверка обновлений, Telegram, запросы geo/подписок) через именованный исходящий канал Xray. |
После изменения порта или базового пути URL панели становится
+8
View File
@@ -123,6 +123,14 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
ключ.
- **Неправильный поток.** Для REALITY + XTLS-Vision нужен `flow = xtls-rprx-vision`
как в записи клиента входящего подключения, так и в ссылке для подключения.
- **Старые ядра клиентов отклоняются по умолчанию.** Пустое поле
**Мин. версия клиента** не означает «без ограничений»: Xray-core использует
встроенный минимум используемой сборки ядра (26.3.27 в текущих релизах),
который поддерживает свежесть
TLS-отпечатков клиентов, поэтому сторонние ядра, такие как Mihomo и sing-box,
не проходят проверку REALITY даже при корректной конфигурации — клиенты видят
таймауты, а подключаются только приложения на базе Xray-core. Ставьте `1.0.0`,
только если они вам необходимы; это также допустит устаревшие отпечатки.
</Callout>
+3
View File
@@ -37,6 +37,9 @@ icon: ArrowDownToLine
可选地为入站设置总流量上限和到期日期,并选择一个周期性的**流量重置**计划:
`never`(默认)、`hourly`、`daily`、`weekly` 或 `monthly`。
选择 `monthly` 时,可以指定每月 1 至 31 日重置。如果当月没有指定日期,
则在该月最后一天重置。
</Step>
</Steps>
+1 -1
View File
@@ -15,7 +15,7 @@ icon: SlidersHorizontal
| `webBasePath` | `/` | 面板对外提供服务所使用的 URL 路径(始终规范化为 `/…/`)。 |
| `webCertFile` / `webKeyFile` | _(无)_ | TLS 证书 + 密钥。两者都设置后,面板将以 **HTTPS** 提供服务。 |
| `sessionMaxAge` | `360` | 会话有效期,单位为**分钟**(默认 6 小时)。 |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | 其转发头(真实客户端 IP)受信任的 IP/CIDR。 |
| `trustedProxyCIDRs` | `127.0.0.1/32,::1/128` | 其转发头(真实客户端 IP)受信任的 IP/CIDR。自定义值还会控制订阅链接中转发的主机和协议;请将订阅代理加入列表,或设置 `subURI` 覆盖这些链接。 |
| `panelOutbound` | _(无)_ | 通过一个命名的 Xray 出站来路由面板自身的出口流量(更新检查、Telegram、地理/订阅拉取)。 |
更改端口或基础路径后,面板 URL 将变为
+1
View File
@@ -105,6 +105,7 @@ vless://<uuid>@<server>:443?security=reality&pbk=<public-key>&sid=<short-id>&sni
- **SNI 不匹配。** SNI / server names 必须与目标站点的真实证书匹配,否则握手会暴露伪装。
- **私钥泄露。** 永远只把**公钥**分发给客户端。
- **流控设置错误。** REALITY + XTLS-Vision 要求在入站的客户端条目和分享链接上都设置 `flow = xtls-rprx-vision`。
- **旧客户端内核默认被拒。** **最小客户端版本**留空并不是“不限制”:Xray-core 会退回到所运行内核版本的内置最低值(当前版本为 26.3.27)以保证客户端 TLS 指纹的新鲜度,因此 Mihomo、sing-box 等第三方内核即使配置完全正确也会导致 REALITY 验证失败——表现为客户端超时,只有基于 Xray-core 的应用能连上。只有在必须支持它们时才填 `1.0.0`;这同时也会放行过时的指纹。
</Callout>
+177 -149
View File
@@ -6,6 +6,7 @@ settings:
overrides:
postcss@<8.5.10: ^8.5.15
sharp@<0.35.0: ^0.35.3
importers:
@@ -16,19 +17,19 @@ importers:
version: 3.1.18
fumadocs-core:
specifier: ^16.11.5
version: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
version: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-docgen:
specifier: ^3.1.0
version: 3.1.0(@types/estree@1.0.9)(@types/hast@3.0.5)(@types/mdast@4.0.4)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(mdast-util-mdx@3.0.0(supports-color@7.2.0))
version: 3.1.0(@types/estree@1.0.9)(@types/hast@3.0.5)(@types/mdast@4.0.4)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(mdast-util-mdx@3.0.0(supports-color@7.2.0))
fumadocs-mdx:
specifier: ^15.2.0
version: 15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(supports-color@7.2.0)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))
version: 15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(supports-color@7.2.0)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))
fumadocs-openapi:
specifier: ^11.2.2
version: 11.2.2(7c1fd77811020e629e283908335462bb)
version: 11.2.2(905c31216873909f632674fe18e3aac7)
fumadocs-ui:
specifier: ^16.11.5
version: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
version: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
lucide-react:
specifier: ^1.25.0
version: 1.25.0(react@19.2.8)
@@ -37,7 +38,7 @@ importers:
version: 11.16.0
next:
specifier: 16.2.11
version: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
version: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -518,152 +519,161 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-darwin-arm64@0.35.3':
resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-darwin-x64@0.35.3':
resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
'@img/sharp-freebsd-wasm32@0.35.3':
resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==}
engines: {node: '>=20.9.0'}
os: [freebsd]
'@img/sharp-libvips-darwin-arm64@1.3.2':
resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
'@img/sharp-libvips-darwin-x64@1.3.2':
resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
'@img/sharp-libvips-linux-arm64@1.3.2':
resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
'@img/sharp-libvips-linux-arm@1.3.2':
resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
'@img/sharp-libvips-linux-ppc64@1.3.2':
resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
'@img/sharp-libvips-linux-riscv64@1.3.2':
resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
'@img/sharp-libvips-linux-s390x@1.3.2':
resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
'@img/sharp-libvips-linux-x64@1.3.2':
resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linux-arm64@0.35.3':
resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linux-arm@0.35.3':
resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linux-ppc64@0.35.3':
resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linux-riscv64@0.35.3':
resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linux-s390x@0.35.3':
resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linux-x64@0.35.3':
resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linuxmusl-arm64@0.35.3':
resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-linuxmusl-x64@0.35.3':
resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-wasm32@0.35.3':
resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
engines: {node: '>=20.9.0'}
'@img/sharp-webcontainers-wasm32@0.35.3':
resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==}
engines: {node: '>=20.9.0'}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-win32-arm64@0.35.3':
resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-win32-ia32@0.35.3':
resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==}
engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
'@img/sharp-win32-x64@0.35.3':
resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
@@ -3968,9 +3978,14 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
sharp@0.35.3:
resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
engines: {node: '>=20.9.0'}
peerDependencies:
'@types/node': '*'
peerDependenciesMeta:
'@types/node':
optional: true
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
@@ -4725,7 +4740,7 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@fumadocs/api-docs@0.2.1(4cf0c3492febbe4240fa9ee6953191d3)':
'@fumadocs/api-docs@0.2.1(4e04a1f3fe664854324546db6fca6427)':
dependencies:
'@base-ui/react': 1.6.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -4733,8 +4748,8 @@ snapshots:
'@scalar/json-magic': 0.12.19
class-variance-authority: 0.7.1
cnfast: 0.0.8
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-ui: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-ui: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
github-slugger: 2.0.0
lucide-react: 1.25.0(react@19.2.8)
react: 19.2.8
@@ -4785,98 +4800,108 @@ snapshots:
'@img/colour@1.1.0':
optional: true
'@img/sharp-darwin-arm64@0.34.5':
'@img/sharp-darwin-arm64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-arm64': 1.3.2
optional: true
'@img/sharp-darwin-x64@0.34.5':
'@img/sharp-darwin-x64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.3.2
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
'@img/sharp-freebsd-wasm32@0.35.3':
dependencies:
'@img/sharp-wasm32': 0.35.3
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
'@img/sharp-libvips-darwin-arm64@1.3.2':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
'@img/sharp-libvips-darwin-x64@1.3.2':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
'@img/sharp-libvips-linux-arm64@1.3.2':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
'@img/sharp-libvips-linux-arm@1.3.2':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
'@img/sharp-libvips-linux-ppc64@1.3.2':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
'@img/sharp-libvips-linux-riscv64@1.3.2':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
'@img/sharp-libvips-linux-s390x@1.3.2':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
'@img/sharp-libvips-linux-x64@1.3.2':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
optional: true
'@img/sharp-linux-arm64@0.34.5':
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
optional: true
'@img/sharp-linux-arm64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.3.2
optional: true
'@img/sharp-linux-arm@0.34.5':
'@img/sharp-linux-arm@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm': 1.3.2
optional: true
'@img/sharp-linux-ppc64@0.34.5':
'@img/sharp-linux-ppc64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.3.2
optional: true
'@img/sharp-linux-riscv64@0.34.5':
'@img/sharp-linux-riscv64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.3.2
optional: true
'@img/sharp-linux-s390x@0.34.5':
'@img/sharp-linux-s390x@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.3.2
optional: true
'@img/sharp-linux-x64@0.34.5':
'@img/sharp-linux-x64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linux-x64': 1.3.2
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
'@img/sharp-linuxmusl-arm64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
'@img/sharp-linuxmusl-x64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
optional: true
'@img/sharp-wasm32@0.34.5':
'@img/sharp-wasm32@0.35.3':
dependencies:
'@emnapi/runtime': 1.11.2
optional: true
'@img/sharp-win32-arm64@0.34.5':
'@img/sharp-webcontainers-wasm32@0.35.3':
dependencies:
'@img/sharp-wasm32': 0.35.3
optional: true
'@img/sharp-win32-ia32@0.34.5':
'@img/sharp-win32-arm64@0.35.3':
optional: true
'@img/sharp-win32-x64@0.34.5':
'@img/sharp-win32-ia32@0.35.3':
optional: true
'@img/sharp-win32-x64@0.35.3':
optional: true
'@jridgewell/gen-mapping@0.3.13':
@@ -6975,7 +7000,7 @@ snapshots:
fsevents@2.3.3:
optional: true
fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3):
fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3):
dependencies:
'@orama/orama': 3.1.18
estree-util-value-to-estree: 3.5.0
@@ -7001,18 +7026,18 @@ snapshots:
'@types/mdast': 4.0.4
'@types/react': 19.2.17
lucide-react: 1.25.0(react@19.2.8)
next: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
zod: 4.4.3
transitivePeerDependencies:
- supports-color
fumadocs-docgen@3.1.0(@types/estree@1.0.9)(@types/hast@3.0.5)(@types/mdast@4.0.4)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(mdast-util-mdx@3.0.0(supports-color@7.2.0)):
fumadocs-docgen@3.1.0(@types/estree@1.0.9)(@types/hast@3.0.5)(@types/mdast@4.0.4)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(mdast-util-mdx@3.0.0(supports-color@7.2.0)):
dependencies:
estree-util-to-js: 2.0.0
estree-util-value-to-estree: 3.5.0
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
npm-to-yarn: 3.1.0
oxc-transform: 0.138.0
unified: 11.0.5
@@ -7025,14 +7050,14 @@ snapshots:
'@types/mdast': 4.0.4
mdast-util-mdx: 3.0.0(supports-color@7.2.0)
fumadocs-mdx@15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(supports-color@7.2.0)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)):
fumadocs-mdx@15.2.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(supports-color@7.2.0)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)):
dependencies:
'@mdx-js/mdx': 3.1.1(supports-color@7.2.0)
'@standard-schema/spec': 1.1.0
chokidar: 5.0.0
esbuild: 0.28.1
estree-util-value-to-estree: 3.5.0
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
github-slugger: 2.0.0
magic-string: 0.30.21
mdast-util-mdx: 3.0.0(supports-color@7.2.0)
@@ -7051,25 +7076,25 @@ snapshots:
'@types/mdast': 4.0.4
'@types/mdx': 2.0.14
'@types/react': 19.2.17
next: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
rolldown: 1.1.5
vite: 8.1.0(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
fumadocs-openapi@11.2.2(7c1fd77811020e629e283908335462bb):
fumadocs-openapi@11.2.2(905c31216873909f632674fe18e3aac7):
dependencies:
'@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@fumadocs/api-docs': 0.2.1(4cf0c3492febbe4240fa9ee6953191d3)
'@fumadocs/api-docs': 0.2.1(4e04a1f3fe664854324546db6fca6427)
'@fumari/json-schema-ts': 1.0.2
'@fumari/stf': 1.1.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@scalar/json-magic': 0.12.19
chokidar: 5.0.0
class-variance-authority: 0.7.1
cnfast: 0.0.8
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-ui: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-ui: 16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3)
github-slugger: 2.0.0
hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0)
lucide-react: 1.25.0(react@19.2.8)
@@ -7086,7 +7111,7 @@ snapshots:
- date-fns
- supports-color
fumadocs-ui@16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3):
fumadocs-ui@16.11.5(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3):
dependencies:
'@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3)
@@ -7102,7 +7127,7 @@ snapshots:
'@radix-ui/react-tabs': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
class-variance-authority: 0.7.1
cnfast: 0.0.8
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
fumadocs-core: 16.11.5(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.25.0(react@19.2.8))(next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(zod@4.4.3)
lucide-react: 1.25.0(react@19.2.8)
motion: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -7116,7 +7141,7 @@ snapshots:
optionalDependencies:
'@types/mdx': 2.0.14
'@types/react': 19.2.17
next: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
transitivePeerDependencies:
- '@emotion/is-prop-valid'
- '@types/react-dom'
@@ -8187,7 +8212,7 @@ snapshots:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@26.1.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@next/env': 16.2.11
'@swc/helpers': 0.5.15
@@ -8206,9 +8231,10 @@ snapshots:
'@next/swc-linux-x64-musl': 16.2.11
'@next/swc-win32-arm64-msvc': 16.2.11
'@next/swc-win32-x64-msvc': 16.2.11
sharp: 0.34.5
sharp: 0.35.3(@types/node@26.1.1)
transitivePeerDependencies:
- '@babel/core'
- '@types/node'
- babel-plugin-macros
node-exports-info@1.6.2:
@@ -8663,36 +8689,38 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.2
sharp@0.34.5:
sharp@0.35.3(@types/node@26.1.1):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
'@img/sharp-darwin-arm64': 0.35.3
'@img/sharp-darwin-x64': 0.35.3
'@img/sharp-freebsd-wasm32': 0.35.3
'@img/sharp-libvips-darwin-arm64': 1.3.2
'@img/sharp-libvips-darwin-x64': 1.3.2
'@img/sharp-libvips-linux-arm': 1.3.2
'@img/sharp-libvips-linux-arm64': 1.3.2
'@img/sharp-libvips-linux-ppc64': 1.3.2
'@img/sharp-libvips-linux-riscv64': 1.3.2
'@img/sharp-libvips-linux-s390x': 1.3.2
'@img/sharp-libvips-linux-x64': 1.3.2
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
'@img/sharp-linux-arm': 0.35.3
'@img/sharp-linux-arm64': 0.35.3
'@img/sharp-linux-ppc64': 0.35.3
'@img/sharp-linux-riscv64': 0.35.3
'@img/sharp-linux-s390x': 0.35.3
'@img/sharp-linux-x64': 0.35.3
'@img/sharp-linuxmusl-arm64': 0.35.3
'@img/sharp-linuxmusl-x64': 0.35.3
'@img/sharp-webcontainers-wasm32': 0.35.3
'@img/sharp-win32-arm64': 0.35.3
'@img/sharp-win32-ia32': 0.35.3
'@img/sharp-win32-x64': 0.35.3
'@types/node': 26.1.1
optional: true
shebang-command@2.0.0:
+1
View File
@@ -6,6 +6,7 @@ allowBuilds:
# release — fixes GHSA-qx2v-qp2m-jg93 / CVE-2026-41305 (vulnerable < 8.5.10).
overrides:
'postcss@<8.5.10': '^8.5.15'
'sharp@<0.35.0': '^0.35.3'
minimumReleaseAgeExclude:
- '@mermaid-js/parser@1.2.0'
- mermaid@11.16.0
+8
View File
@@ -411,6 +411,9 @@
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
}
},
"required": [
@@ -479,6 +482,7 @@
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
@@ -916,6 +920,9 @@
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
}
},
"required": [
@@ -991,6 +998,7 @@
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
+6 -5
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useLayoutEffect } from 'react';
import type { Decorator, Preview } from '@storybook/react-vite';
import { ConfigProvider } from 'antd';
import i18next from 'i18next';
@@ -17,11 +17,12 @@ if (!i18next.isInitialized) {
});
}
const withTheme: Decorator = (Story, context) => {
export const withTheme: Decorator = (Story, context) => {
const dark = context.globals.theme === 'dark';
useEffect(() => {
document.body.setAttribute('class', dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
useLayoutEffect(() => {
document.body.classList.remove('dark', 'light');
document.body.classList.add(dark ? 'dark' : 'light');
document.documentElement.removeAttribute('data-theme');
}, [dark]);
return (
<ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
+33
View File
@@ -53,4 +53,37 @@ export default [
'jsx-a11y/no-autofocus': 'off',
},
},
{
// The settings and xray pages write numeric InputNumber changes straight
// into state, so a null-collapsing handler (`Number(v) || N`, or the
// ternary `typeof v === 'number' ? v : N`) turns a cleared field into a
// stored N — the cleared-port bug, #6121. Handlers here go through
// onNumber() (src/utils/onNumber.ts) instead. Known limit: a handler
// extracted into a variable and passed as onChange={handler} is not
// matched; the inline shapes below are the ones that drift in practice.
files: ['src/pages/settings/**/*.tsx', 'src/pages/xray/**/*.tsx'],
rules: {
'no-restricted-syntax': ['error', {
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="||"] > CallExpression[callee.name="Number"]',
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
}, {
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] ConditionalExpression[test.left.operator="typeof"][alternate.type="Literal"]',
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
}, {
selector: 'JSXElement[openingElement.name.name="InputNumber"] JSXAttribute[name.name="onChange"] LogicalExpression[operator="??"][right.type="Literal"]',
message: 'A cleared InputNumber must not write a synthetic value; wrap the handler with onNumber() from @/utils/onNumber (see #6127).',
}],
},
},
{
// The xray form modals (OutboundFormModal, BalancerFormModal,
// DnsServerModal, WarpModal, …) stage values behind Zod validation like
// the clients/inbounds modals do, and some of their fields carry a
// deliberate clear-means-zero semantic — the direct-write rule above
// does not apply to them.
files: ['src/pages/xray/**/*Modal.tsx'],
rules: {
'no-restricted-syntax': 'off',
},
},
];
+498 -452
View File
File diff suppressed because it is too large Load Diff
+22 -19
View File
@@ -1,7 +1,7 @@
{
"name": "3x-ui-frontend",
"private": true,
"version": "0.4.3",
"version": "0.6.0",
"type": "module",
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
"engines": {
@@ -30,11 +30,11 @@
"@ant-design/icons": "^6.3.2",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^5.4.0",
"@hookform/resolvers": "^5.5.7",
"@noble/hashes": "^2.2.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query-devtools": "^5.101.4",
"antd": "^6.5.1",
"antd": "^6.5.2",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.6",
@@ -42,41 +42,41 @@
"persian-calendar-suite": "^1.5.5",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.82.0",
"react-i18next": "^17.0.10",
"react-router-dom": "^7.18.1",
"react-hook-form": "^7.83.0",
"react-i18next": "^17.0.11",
"react-router": "^8.3.0",
"swagger-ui-react": "^5.32.11",
"uplot": "^1.6.32",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@storybook/addon-a11y": "^10.5.3",
"@storybook/addon-docs": "^10.5.3",
"@storybook/addon-vitest": "^10.5.3",
"@storybook/react-vite": "^10.5.3",
"@storybook/addon-a11y": "^10.5.5",
"@storybook/addon-docs": "^10.5.5",
"@storybook/addon-vitest": "^10.5.5",
"@storybook/react-vite": "^10.5.5",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.0.4",
"@vitest/browser-playwright": "4.1.10",
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.7.0",
"eslint": "^10.8.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.7.0",
"globals": "^17.8.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.1.1",
"jsdom": "^30.0.1",
"lint-staged": "^17.2.0",
"msw": "^2.15.0",
"storybook": "^10.5.3",
"typescript": "^6.0.3",
"playwright": "^1.62.0",
"storybook": "^10.5.5",
"typescript": "6.0.3",
"typescript-eslint": "^8.65.0",
"vite": "8.1.5",
"vitest": "^4.1.10",
"@vitest/browser-playwright": "4.1.10",
"playwright": "^1.61.1"
"vitest": "^4.1.10"
},
"overrides": {
"eslint-plugin-jsx-a11y": {
@@ -90,6 +90,9 @@
},
"swagger-ui-react": {
"js-yaml": "^4.2.0"
},
"@typeschema/valibot": {
"valibot": "^1.1.0"
}
},
"allowScripts": {
+126 -2
View File
@@ -270,6 +270,9 @@
"subRoutingRules": {
"type": "string"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
},
"subSupportUrl": {
"type": "string"
},
@@ -441,6 +444,7 @@
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
@@ -737,6 +741,9 @@
"subRoutingRules": {
"type": "string"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
},
"subSupportUrl": {
"type": "string"
},
@@ -915,6 +922,7 @@
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
@@ -1860,6 +1868,13 @@
],
"type": "string"
},
"trafficResetDay": {
"description": "Day of month for monthly traffic resets",
"example": 1,
"maximum": 31,
"minimum": 1,
"type": "integer"
},
"up": {
"description": "Upload traffic in bytes",
"format": "int64",
@@ -1886,6 +1901,7 @@
"tag",
"total",
"trafficReset",
"trafficResetDay",
"up"
],
"type": "object"
@@ -3151,6 +3167,7 @@
"tag": "in-443-tcp",
"total": 0,
"trafficReset": "never",
"trafficResetDay": 1,
"up": 0
}
]
@@ -3963,6 +3980,36 @@
}
}
},
"/panel/api/openapi.json": {
"get": {
"tags": [
"Server"
],
"summary": "Serve this API description as an OpenAPI 3 document — the same file that powers the API Docs page. Requires a session or Bearer token like the rest of /panel/api. Useful for generating clients or importing into API tooling.",
"operationId": "get_panel_api_openapi_json",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/server/status": {
"get": {
"tags": [
@@ -5644,7 +5691,7 @@
"tags": [
"Clients"
],
"summary": "Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.",
"summary": "Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters: the *Count fields are exact, while the email arrays beside them stop at 200 entries so the payload does not grow with the panel. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.",
"operationId": "get_panel_api_clients_list_paged",
"parameters": [
{
@@ -5760,12 +5807,18 @@
"summary": {
"total": 2000,
"active": 1850,
"onlineCount": 1,
"depletedCount": 0,
"expiringCount": 0,
"deactiveCount": 150,
"online": [
"alice@example.com"
],
"depleted": [],
"expiring": [],
"deactive": []
"deactive": [
"bob@example.com"
]
}
}
}
@@ -5816,6 +5869,47 @@
}
}
},
"/panel/api/clients/get/tgId/{tgId}": {
"get": {
"tags": [
"Clients"
],
"summary": "Fetch clients by Telegram user ID. Returns an array since multiple clients can share the same Telegram ID.",
"operationId": "get_panel_api_clients_get_tgId_tgId",
"parameters": [
{
"name": "tgId",
"in": "path",
"required": true,
"description": "Telegram user ID (numeric).",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/clients/add": {
"post": {
"tags": [
@@ -9681,6 +9775,36 @@
}
}
},
"/panel/api/setting/factoryDefaults": {
"post": {
"tags": [
"Settings"
],
"summary": "Return the shipped (factory) default value per browser-safe setting key, so clients can tell a stored value apart from the default it would fall back to. Per-install material (secret, panelGuid, mTLS keys) and credential fields are never included.",
"operationId": "post_panel_api_setting_factoryDefaults",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/setting/update": {
"post": {
"tags": [
+24 -2
View File
@@ -88,6 +88,28 @@ function encodeForm(data: unknown): string {
return parts.join('&');
}
function appendQuery(url: string, query: string): string {
if (query === '') return url;
const hashIndex = url.indexOf('#');
const path = hashIndex === -1 ? url : url.slice(0, hashIndex);
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
const hasQuery = path.includes('?');
const separator = !hasQuery ? '?' : path.endsWith('?') || path.endsWith('&') ? '' : '&';
return `${path}${separator}${query}${hash}`;
}
function requestSignal(options: HttpRequestOptions): AbortSignal | undefined {
if (!options.timeout) return options.signal;
const timeout = AbortSignal.timeout(options.timeout);
if (!options.signal) return timeout;
if (typeof AbortSignal.any === 'function') return AbortSignal.any([options.signal, timeout]);
const controller = new AbortController();
const abort = () => controller.abort();
options.signal.addEventListener('abort', abort, { once: true });
timeout.addEventListener('abort', abort, { once: true });
return controller.signal;
}
async function performFetch(
method: string,
url: string,
@@ -121,8 +143,8 @@ async function performFetch(
}
const query = encodeForm(options.params);
const fullUrl = basePathPrefix + url + (query ? `?${query}` : '');
const signal = options.timeout ? AbortSignal.timeout(options.timeout) : options.signal;
const fullUrl = basePathPrefix + appendQuery(url, query);
const signal = requestSignal(options);
return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
}
+37 -20
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil, Msg } from '@/utils';
@@ -6,8 +6,13 @@ import { parseMsg } from '@/utils/zodValidate';
import { AllSetting } from '@/models/setting';
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
import { keys } from '@/api/queryKeys';
import { useServerDraft } from '@/hooks/useServerDraft';
type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
type SettingSaveResult = {
msg: Msg<unknown>;
saved?: AllSetting;
};
async function fetchAllSetting(): Promise<AllSettingInput | null> {
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
@@ -18,7 +23,6 @@ async function fetchAllSetting(): Promise<AllSettingInput | null> {
export function useAllSettings() {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<AllSetting>(() => new AllSetting());
const [extraSpinning, setExtraSpinning] = useState(false);
const query = useQuery({
@@ -28,41 +32,54 @@ export function useAllSettings() {
});
const server = useMemo(() => new AllSetting(query.data), [query.data]);
useEffect(() => {
if (query.data !== undefined) {
setDraft(new AllSetting(query.data));
}
}, [query.data]);
const { draft, setDraft, isDirty, markSaved } = useServerDraft(
query.data === undefined ? undefined : server,
(setting) => new AllSetting(setting),
(left, right) => left.equals(right),
);
const allSetting = draft ?? server;
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
setDraft((prev) => {
const next = new AllSetting(prev);
const next = new AllSetting(prev ?? server);
Object.assign(next, patch);
return next;
});
}, []);
}, [server, setDraft]);
const saveMut = useMutation({
mutationFn: async (next: SettingSavePayload): Promise<Msg<unknown>> => {
const payload = { ...next };
const body = AllSettingSchema.partial().safeParse(payload);
mutationFn: async ({ payload, saved }: { payload: SettingSavePayload; saved?: AllSetting }): Promise<SettingSaveResult> => {
const next = { ...payload };
const body = AllSettingSchema.partial().safeParse(next);
if (!body.success) {
console.warn('[zod] setting/update body failed validation', body.error.issues);
}
return HttpUtil.post('/panel/api/setting/update', body.success ? { ...payload, ...body.data } : payload);
const msg = await HttpUtil.post('/panel/api/setting/update', body.success ? { ...next, ...body.data } : next);
return { msg, saved };
},
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
onSuccess: ({ msg, saved }) => {
if (!msg?.success) return;
if (saved) markSaved(saved);
queryClient.invalidateQueries({ queryKey: keys.settings.all() });
},
});
const saveAll = useCallback(() => saveMut.mutateAsync({ ...draft }), [saveMut, draft]);
const savePayload = useCallback((payload: SettingSavePayload) => saveMut.mutateAsync(payload), [saveMut]);
const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
const saveAll = useCallback(async () => {
const saved = new AllSetting(allSetting);
return (await saveMut.mutateAsync({ payload: { ...saved }, saved })).msg;
}, [allSetting, saveMut]);
const savePayload = useCallback(
async (payload: SettingSavePayload) => {
const saved = new AllSetting(allSetting);
Object.assign(saved, payload);
return (await saveMut.mutateAsync({ payload, saved })).msg;
},
[allSetting, saveMut],
);
const saveDisabled = !isDirty;
return {
allSetting: draft,
allSetting,
updateSetting,
fetched: query.data !== undefined,
spinning: extraSpinning || saveMut.isPending,
@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { FactoryDefaultsSchema, type FactoryDefaults } from '@/schemas/setting';
import { keys } from '@/api/queryKeys';
async function fetchFactoryDefaults(): Promise<FactoryDefaults> {
const msg = await HttpUtil.post('/panel/api/setting/factoryDefaults', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch factory defaults');
const validated = parseMsg(msg, FactoryDefaultsSchema, 'setting/factoryDefaults');
const parsed = FactoryDefaultsSchema.safeParse(validated.obj);
return parsed.success ? parsed.data : {};
}
export function useFactoryDefaults() {
return useQuery({
queryKey: keys.settings.factoryDefaults(),
queryFn: fetchFactoryDefaults,
staleTime: Infinity,
});
}
+1
View File
@@ -17,6 +17,7 @@ export const keys = {
root: () => ['settings'] as const,
all: () => ['settings', 'all'] as const,
defaults: () => ['settings', 'defaults'] as const,
factoryDefaults: () => ['settings', 'factoryDefaults'] as const,
},
inbounds: {
root: () => ['inbounds'] as const,
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Popover, Progress } from 'antd';
@@ -17,7 +17,11 @@ export interface ClientTrafficCellProps {
compact?: boolean;
}
export default function ClientTrafficCell({
// Every prop is a primitive and the component is pure, so the memo bails out
// whenever a client's counters did not move — which is most of them on most
// pushes. Each skipped instance is one antd Popover (rc-trigger), one Progress,
// a useTranslation subscription and a theme context read, times up to 200 rows.
const ClientTrafficCell = memo(function ClientTrafficCell({
up = 0,
down = 0,
total = 0,
@@ -83,4 +87,6 @@ export default function ClientTrafficCell({
</div>
</Popover>
);
}
});
export default ClientTrafficCell;
@@ -16,6 +16,10 @@ body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold {
color: #874d00;
}
body.light .config-block-text {
color: #595959;
}
.config-block .ant-collapse-extra {
display: flex;
align-items: center;
@@ -121,7 +121,9 @@ export default function DateTimePicker({
<DatePicker
value={value}
onChange={(next) => onChange(next || null)}
onCalendarChange={(next) => onChange((Array.isArray(next) ? next[0] : next) || null)}
showTime={showTime ? { format: 'HH:mm:ss' } : false}
needConfirm={false}
format={format}
placeholder={placeholder}
disabled={disabled}
@@ -0,0 +1,37 @@
import { Tag } from 'antd';
import { useTranslation } from 'react-i18next';
import { useFactoryDefaults } from '@/api/queries/useFactoryDefaults';
/**
* Value semantics on purpose: the tag answers "does this equal the shipped
* default?", not "has the user ever saved this key?" — a stored 2096 and a
* fallback 2096 behave identically, so they read identically.
*/
export function matchesFactoryDefault(current: unknown, factoryDefault: string | undefined): boolean {
if (factoryDefault === undefined) return false;
if (typeof current === 'number') {
const parsed = Number(factoryDefault);
return factoryDefault.trim() !== '' && !Number.isNaN(parsed) && parsed === current;
}
if (typeof current === 'boolean') {
if (factoryDefault !== 'true' && factoryDefault !== 'false') return false;
return (factoryDefault === 'true') === current;
}
if (typeof current === 'string') return factoryDefault === current;
return false;
}
interface DefaultSettingTagProps {
settingKey: string;
value: unknown;
}
export default function DefaultSettingTag({ settingKey, value }: DefaultSettingTagProps) {
const { t } = useTranslation();
const defaults = useFactoryDefaults();
if (!matchesFactoryDefault(value, defaults.data?.[settingKey])) return null;
return <Tag style={{ marginLeft: 8 }}>{t('pages.settings.defaultTag')}</Tag>;
}
@@ -5,6 +5,7 @@ import './SettingListItem.css';
interface SettingListItemProps {
paddings?: 'small' | 'default';
title?: ReactNode;
badge?: ReactNode;
description?: ReactNode;
children?: ReactNode;
control?: ReactNode;
@@ -13,6 +14,7 @@ interface SettingListItemProps {
export default function SettingListItem({
paddings = 'default',
title,
badge,
description,
children,
control,
@@ -28,7 +30,12 @@ export default function SettingListItem({
<Row gutter={[8, 16]} style={{ width: '100%' }}>
<Col xs={24} lg={12}>
<div className="setting-list-meta">
{title && <div className="setting-list-title" id={titleId}>{title}</div>}
{title && (
<div className="setting-list-title">
<span id={titleId}>{title}</span>
{badge}
</div>
)}
{description && <div className="setting-list-description">{description}</div>}
</div>
</Col>
+1
View File
@@ -1,3 +1,4 @@
export { default as InputAddon } from './InputAddon';
export { default as InfinityIcon } from './InfinityIcon';
export { default as SettingListItem } from './SettingListItem';
export { default as DefaultSettingTag } from './DefaultSettingTag';
+17 -9
View File
@@ -48,6 +48,7 @@ interface SparklineProps {
yTickStep?: number;
tickCountX?: number;
showTooltip?: boolean;
showLegend?: boolean;
valueMin?: number;
valueMax?: number | null;
yFormatter?: (v: number) => string;
@@ -80,13 +81,23 @@ interface SparklineView {
extremaPoints: ExtremaResult | null;
}
function hexToRgba(hex: string, alpha: number): string {
let h = hex.trim();
function hexToRgba(color: string, alpha: number): string {
const trimmed = color.trim();
const fn = trimmed.match(/^rgba?\(([^)]+)\)$/i);
if (fn) {
const parts = fn[1].split(/[,/]\s*|\s+/).filter(Boolean).map(Number);
if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) {
const baseAlpha = parts.length > 3 && Number.isFinite(parts[3]) ? parts[3] : 1;
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${baseAlpha * alpha})`;
}
return trimmed;
}
let h = trimmed;
if (h.startsWith('#')) h = h.slice(1);
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
if (h.length !== 6) return hex;
if (h.length !== 6) return trimmed;
const int = Number.parseInt(h, 16);
if (Number.isNaN(int)) return hex;
if (Number.isNaN(int)) return trimmed;
const r = (int >> 16) & 255;
const g = (int >> 8) & 255;
const b = int & 255;
@@ -129,6 +140,7 @@ export default function Sparkline(props: SparklineProps) {
yTickStep = 25,
tickCountX = 4,
showTooltip = false,
showLegend = true,
valueMin = 0,
valueMax = 100,
yFormatter = (v: number) => `${Math.round(v)}%`,
@@ -542,10 +554,6 @@ export default function Sparkline(props: SparklineProps) {
);
}, [points, hasSeries2, hasSeries3, valueMin, valueMax]);
useEffect(() => {
plotRef.current?.redraw(false);
});
useEffect(() => {
const redraw = () => plotRef.current?.redraw(false);
const moBody = new MutationObserver(redraw);
@@ -570,7 +578,7 @@ export default function Sparkline(props: SparklineProps) {
</span>
</div>
)}
{legendItems.length > 0 && (
{showLegend && legendItems.length > 0 && (
<div className="sparkline-legend" aria-hidden="true">
{legendItems.map((s) => (
<span key={s.name} className="extrema-item" style={{ color: s.color }}>● {s.name}</span>
+3
View File
@@ -75,6 +75,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subPort": 1,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowIdentityOnAllLinks": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
@@ -186,6 +187,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subPort": 1,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowIdentityOnAllLinks": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
@@ -448,6 +450,7 @@ export const EXAMPLES: Record<string, unknown> = {
"tag": "in-443-tcp",
"total": 0,
"trafficReset": "never",
"trafficResetDay": 1,
"up": 0
},
"InboundClientIps": {
+16
View File
@@ -244,6 +244,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subRoutingRules": {
"type": "string"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
},
"subSupportUrl": {
"type": "string"
},
@@ -415,6 +418,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
@@ -711,6 +715,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subRoutingRules": {
"type": "string"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
},
"subSupportUrl": {
"type": "string"
},
@@ -889,6 +896,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl",
"subThemeDir",
"subTitle",
@@ -1834,6 +1842,13 @@ export const SCHEMAS: Record<string, unknown> = {
],
"type": "string"
},
"trafficResetDay": {
"description": "Day of month for monthly traffic resets",
"example": 1,
"maximum": 31,
"minimum": 1,
"type": "integer"
},
"up": {
"description": "Upload traffic in bytes",
"format": "int64",
@@ -1860,6 +1875,7 @@ export const SCHEMAS: Record<string, unknown> = {
"tag",
"total",
"trafficReset",
"trafficResetDay",
"up"
],
"type": "object"
+3
View File
@@ -81,6 +81,7 @@ export interface AllSetting {
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowIdentityOnAllLinks: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
@@ -193,6 +194,7 @@ export interface AllSettingView {
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowIdentityOnAllLinks: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
@@ -426,6 +428,7 @@ export interface Inbound {
tag: string;
total: number;
trafficReset: string;
trafficResetDay: number;
up: number;
}
+3
View File
@@ -93,6 +93,7 @@ export const AllSettingSchema = z.object({
subPort: z.number().int().min(1).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(),
subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(),
@@ -206,6 +207,7 @@ export const AllSettingViewSchema = z.object({
subPort: z.number().int().min(1).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(),
subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(),
@@ -451,6 +453,7 @@ export const InboundSchema = z.object({
tag: z.string(),
total: z.number().int(),
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
trafficResetDay: z.number().int().min(1).max(31),
up: z.number().int(),
});
export type Inbound = z.infer<typeof InboundSchema>;
+116 -25
View File
@@ -73,7 +73,9 @@ export interface ClientQueryParams {
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
const DEFAULT_SUMMARY: ClientsSummary = {
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
total: 0, active: 0,
onlineCount: 0, depletedCount: 0, expiringCount: 0, deactiveCount: 0,
online: [], depleted: [], expiring: [], deactive: [],
};
export interface ClientSpeedEntry {
@@ -114,7 +116,63 @@ export function computeClientsSummary(
if (nearExpiry || nearLimit) expiring.push(email);
else active += 1;
}
return { total: stats.length, active, online, depleted, expiring, deactive };
return {
total: stats.length,
active,
onlineCount: online.length,
depletedCount: depleted.length,
expiringCount: expiring.length,
deactiveCount: deactive.length,
online,
depleted,
expiring,
deactive,
};
}
export function sameSpeedMap(
a: Record<string, ClientSpeedEntry>,
b: Record<string, ClientSpeedEntry>,
): boolean {
const aKeys = Object.keys(a);
if (aKeys.length !== Object.keys(b).length) return false;
for (const key of aKeys) {
const left = a[key];
const right = b[key];
if (!right || left.up !== right.up || left.down !== right.down) return false;
}
return true;
}
// The field list computeClientsSummary reads, and deliberately nothing else.
// lastOnline in particular churns for every online client on every push and no
// counter depends on it, so including it here would defeat the comparison.
export function sameSummaryInputs(a: ClientStatRow[], b: ClientStatRow[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const left = a[i];
const right = b[i];
if (left.email !== right.email
|| left.up !== right.up
|| left.down !== right.down
|| left.total !== right.total
|| left.enable !== right.enable
|| left.expiryTime !== right.expiryTime) return false;
}
return true;
}
export function pickClientsSummary(
serverSummary: ClientsSummary,
allClientStats: ClientStatRow[],
onlineSet: Set<string>,
expireDiffMs: number,
trafficDiffBytes: number,
): ClientsSummary {
if (allClientStats.length === 0) return serverSummary;
if (serverSummary.total > allClientStats.length) return serverSummary;
const live = computeClientsSummary(allClientStats, onlineSet, expireDiffMs, trafficDiffBytes);
return { ...live, total: serverSummary.total || live.total };
}
function buildQS(p: ClientQueryParams): string {
@@ -142,7 +200,7 @@ async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageRes
const qs = buildQS(params);
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true });
if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients');
const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged');
const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged', { strict: true });
if (!validated.obj) throw new Error('Empty clients response');
return validated.obj;
}
@@ -161,17 +219,31 @@ async function fetchDefaults(): Promise<Record<string, unknown>> {
return validated.obj || {};
}
export function useClients() {
export interface UseClientsOptions {
// Callers that only need the mutations — the bulk modals, the groups page —
// pass false. Mounting them used to start a second 5-second poll of the paged
// list whose result they never read, which on a large panel means a full
// summary aggregate every 5 seconds for nothing.
list?: boolean;
}
export function useClients(options: UseClientsOptions = {}) {
const withList = options.list ?? true;
const queryClient = useQueryClient();
const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
// Null until the page has settled on a query. The clients page cannot build
// one until the persisted sort and the panel's configured page size are both
// known, and fetching before then cost three sequential requests per load —
// the first two thrown away (#trace).
const [query, setQueryState] = useState<ClientQueryParams | null>(null);
// setQuery shallow-compares so callers can pass a fresh object every render
// (the common React pattern) without triggering a re-fetch when nothing
// actually changed.
const setQuery = useCallback((next: ClientQueryParams) => {
setQueryState((prev) => {
if (
prev.page === next.page
prev
&& prev.page === next.page
&& prev.pageSize === next.pageSize
&& (prev.search ?? '') === (next.search ?? '')
&& (prev.filter ?? '') === (next.filter ?? '')
@@ -193,8 +265,9 @@ export function useClients() {
}, []);
const listQuery = useQuery({
queryKey: keys.clients.list(query),
queryFn: () => fetchClientPage(query),
queryKey: keys.clients.list(query ?? DEFAULT_QUERY),
queryFn: () => fetchClientPage(query ?? DEFAULT_QUERY),
enabled: withList && query !== null,
staleTime: Infinity,
// List is sorted/paged server-side, so the WS patch can't add new or
// re-sort rows; poll the current page to keep it live (pauses when hidden).
@@ -205,6 +278,7 @@ export function useClients() {
const inboundOptionsQuery = useQuery({
queryKey: keys.inbounds.options(),
queryFn: fetchInboundOptions,
enabled: withList,
staleTime: Infinity,
});
@@ -222,6 +296,7 @@ export function useClients() {
const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
return Array.isArray(validated.obj) ? validated.obj : [];
},
enabled: withList,
staleTime: Infinity,
});
@@ -231,7 +306,11 @@ export function useClients() {
const allGroups = listQuery.data?.groups ?? [];
const fetched = listQuery.data !== undefined || listQuery.isError;
const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
const loading = listQuery.isFetching;
// isFetching is deliberately NOT read here. Touching it makes it a tracked
// property, so the 5s refetchInterval notifies twice per cycle — two whole
// page renders even when structural sharing leaves the data identical, and
// each one bumps rc-table's immutable mark and re-runs every cell renderer.
// Callers that want a spinner for an explicit refresh drive it locally.
// Showing kept-previous data for a new key (filter/sort/page) — drives the
// table overlay so the 5s background poll doesn't flash it.
const transitioning = listQuery.isPlaceholderData;
@@ -264,19 +343,18 @@ export function useClients() {
const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
const pageSize = (defaults.pageSize as number) ?? 0;
// pageSize 0 means "one long page", which is indistinguishable from "the
// settings have not arrived yet" — so callers need this flag to know when the
// configured page size is real. isFetched (not isSuccess) so a failed
// settings request still lets the page fall back and render.
const settingsReady = defaultsQuery.isFetched;
// Live summary: the client_stats WS event refreshes allClientStats every few
// seconds, so the top counters track reality without a page refresh. Falls
// back to the server-computed summary until the first event lands, and keeps
// the server's authoritative total for the headline count.
const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
const summary = useMemo<ClientsSummary>(() => {
const serverSummary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
if (allClientStats.length === 0) return serverSummary;
const live = computeClientsSummary(allClientStats, new Set(onlines), expireDiff, trafficDiff);
return { ...live, total: serverSummary.total || live.total };
}, [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary]);
const summary = useMemo<ClientsSummary>(
() => pickClientsSummary(listQuery.data?.summary ?? DEFAULT_SUMMARY, allClientStats, new Set(onlines), expireDiff, trafficDiff),
[allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary],
);
const invalidateAll = useCallback(
() => {
@@ -558,15 +636,23 @@ export function useClients() {
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
}
if (Array.isArray(p.clientTraffics)) {
// Xray reports a row per client whether or not it moved a byte, so most of
// this map used to be zeros. A missing entry and a zero entry render
// identically (isActiveSpeed treats both as inactive), so the zeros are
// dropped and an unchanged result returns the previous object — which lets
// React bail out of the update instead of re-rendering the table.
const next: Record<string, ClientSpeedEntry> = {};
for (const ct of p.clientTraffics) {
if (!ct || !ct.email) continue;
const up = ct.up || 0;
const down = ct.down || 0;
if (up === 0 && down === 0) continue;
next[ct.email] = {
up: (ct.up || 0) / TRAFFIC_POLL_INTERVAL_S,
down: (ct.down || 0) / TRAFFIC_POLL_INTERVAL_S,
up: up / TRAFFIC_POLL_INTERVAL_S,
down: down / TRAFFIC_POLL_INTERVAL_S,
};
}
setClientSpeed(next);
setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
}
}, [queryClient]);
@@ -574,12 +660,17 @@ export function useClients() {
if (!payload || typeof payload !== 'object') return;
const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
if (p.snapshot !== false) setAllClientStats(p.clients);
if (p.snapshot !== false) {
const rows = p.clients;
setAllClientStats((prev) => (sameSummaryInputs(prev, rows) ? prev : rows));
}
const active = queryRef.current;
if (!active) return;
const byEmail = new Map<string, ClientTraffic>();
for (const row of p.clients) {
if (row && row.email) byEmail.set(row.email, row);
}
queryClient.setQueryData<ClientPageResponse>(keys.clients.list(queryRef.current), (prev) => {
queryClient.setQueryData<ClientPageResponse>(keys.clients.list(active), (prev) => {
if (!prev) return prev;
let touched = false;
const next = prev.items.slice();
@@ -617,7 +708,6 @@ export function useClients() {
setQuery,
inbounds,
onlines,
loading,
transitioning,
fetched,
fetchError,
@@ -627,6 +717,7 @@ export function useClients() {
expireDiff,
trafficDiff,
pageSize,
settingsReady,
refresh,
create,
bulkCreate,
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
const TITLE_KEYS: Record<string, string> = {
+37
View File
@@ -0,0 +1,37 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
export function useServerDraft<T>(server: T | undefined, clone: (value: T) => T, equals: (left: T, right: T) => boolean) {
const cloneRef = useRef(clone);
const equalsRef = useRef(equals);
cloneRef.current = clone;
equalsRef.current = equals;
const [draft, setDraft] = useState<T | undefined>();
const [baseline, setBaseline] = useState<T | undefined>();
const draftRef = useRef(draft);
const baselineRef = useRef(baseline);
draftRef.current = draft;
baselineRef.current = baseline;
useEffect(() => {
if (server === undefined) return;
const currentDraft = draftRef.current;
const currentBaseline = baselineRef.current;
const isDirty = currentDraft !== undefined
&& (currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline));
setBaseline(server);
if (isDirty && !equalsRef.current(currentDraft, server)) return;
setDraft(cloneRef.current(server));
}, [server]);
const markSaved = useCallback((value: T) => {
setBaseline(cloneRef.current(value));
}, []);
const isDirty = useMemo(
() => draft !== undefined && (baseline === undefined || !equalsRef.current(draft, baseline)),
[baseline, draft],
);
return { draft, setDraft, isDirty, markSaved };
}
+24 -4
View File
@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { theme as antdTheme } from 'antd';
import type { ThemeConfig } from 'antd';
@@ -13,14 +13,18 @@ function readBool(key: string, fallback: boolean): boolean {
}
function applyDom(isDark: boolean, isUltra: boolean) {
document.body.setAttribute('class', isDark ? 'dark' : 'light');
document.body.classList.remove('dark', 'light');
document.body.classList.add(isDark ? 'dark' : 'light');
if (isUltra) {
document.documentElement.setAttribute('data-theme', 'ultra-dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
const msg = document.getElementById('message');
if (msg) msg.className = isDark ? 'dark' : 'light';
if (msg) {
msg.classList.remove('dark', 'light');
msg.classList.add(isDark ? 'dark' : 'light');
}
}
// module load so the document is in the right theme before React mounts.
@@ -92,9 +96,24 @@ const LIGHT_BUTTON_TOKENS = {
colorPrimaryActive: '#073ea8',
};
// hashed:false drops the `:where(.css-<hash>)` wrapper antd puts around every
// rule. It costs nothing in specificity — `:where()` contributes zero, so the
// panel's own `.ant-*` overrides still win — and it removes roughly 5,700
// wrappers, 16% of the generated stylesheet, from what the browser has to parse.
//
// cssVar.key pins the CSS-variable scope. Every panel page mounts its own
// ConfigProvider (there is no root one), and without a fixed key each mints a
// fresh useId-derived scope, so navigating re-serialises and re-injects the whole
// token block under a new class instead of reusing the one already in the head.
const SHARED_STYLE_CONFIG = {
hashed: false,
cssVar: { key: 'xui' },
} as const;
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
if (!isDark) {
return {
...SHARED_STYLE_CONFIG,
algorithm: antdTheme.defaultAlgorithm,
token: LIGHT_CONTRAST_TOKENS,
components: {
@@ -104,6 +123,7 @@ export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeCo
};
}
return {
...SHARED_STYLE_CONFIG,
algorithm: antdTheme.darkAlgorithm,
token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
components: {
@@ -142,7 +162,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const [isDark, setIsDark] = useState<boolean>(initialDark);
const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
useEffect(() => {
useLayoutEffect(() => {
applyDom(isDark, isUltra);
localStorage.setItem(STORAGE_DARK, String(isDark));
localStorage.setItem(STORAGE_ULTRA, String(isUltra));
+25 -26
View File
@@ -14,7 +14,6 @@ import {
type OutboundTrafficRow,
} from '@/schemas/xray';
const DIRTY_POLL_MS = 1000;
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
// One HTTP-mode batch request tests this many outbounds through a single
// shared temp xray instance; chunking keeps responses bounded (~30s worst
@@ -22,6 +21,10 @@ const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
// results progressively.
const HTTP_BATCH_CHUNK = 16;
function normalizeOutboundTestUrl(url: string) {
return url || DEFAULT_TEST_URL;
}
export function isUdpOutbound(outbound: unknown): boolean {
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
const p = o?.protocol;
@@ -125,10 +128,11 @@ export function useXraySetting(): UseXraySettingResult {
staleTime: Infinity,
});
const [saveDisabled, setSaveDisabled] = useState(true);
const [xraySetting, setXraySettingState] = useState('');
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
const [savedXraySetting, setSavedXraySetting] = useState('');
const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
const [inboundTags, setInboundTags] = useState<string[]>([]);
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
@@ -139,38 +143,40 @@ export function useXraySetting(): UseXraySettingResult {
const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
const [testingAll, setTestingAll] = useState(false);
const oldXraySettingRef = useRef('');
const oldOutboundTestUrlRef = useRef('');
const syncingRef = useRef(false);
const xraySettingRef = useRef('');
const outboundTestUrlRef = useRef(outboundTestUrl);
const savedXraySettingRef = useRef(savedXraySetting);
const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
const subscriptionOutboundsRef = useRef<unknown[]>([]);
xraySettingRef.current = xraySetting;
outboundTestUrlRef.current = outboundTestUrl;
savedXraySettingRef.current = savedXraySetting;
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
templateSettingsRef.current = templateSettings;
subscriptionOutboundsRef.current = subscriptionOutbounds;
// Seed local editor state from the config query. Runs on first fetch and
// every time the query refetches (e.g. after a successful save).
useEffect(() => {
if (!configQuery.data) return;
const obj = configQuery.data;
const pretty = JSON.stringify(obj.xraySetting, null, 2);
syncingRef.current = true;
setXraySettingState(pretty);
setTemplateSettingsState(obj.xraySetting);
oldXraySettingRef.current = pretty;
syncingRef.current = false;
const nextUrl = normalizeOutboundTestUrl(obj.outboundTestUrl || '');
setInboundTags(obj.inboundTags || []);
setClientReverseTags(obj.clientReverseTags || []);
setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
const isDirty = savedXraySettingRef.current !== xraySettingRef.current
|| savedOutboundTestUrlRef.current !== normalizeOutboundTestUrl(outboundTestUrlRef.current);
if (isDirty) return;
syncingRef.current = true;
setXraySettingState(pretty);
setTemplateSettingsState(obj.xraySetting);
setSavedXraySetting(pretty);
syncingRef.current = false;
setOutboundTestUrlState(nextUrl);
oldOutboundTestUrlRef.current = nextUrl;
setSaveDisabled(true);
setSavedOutboundTestUrl(nextUrl);
}, [configQuery.data]);
const fetched = configQuery.data !== undefined || configQuery.isError;
@@ -220,7 +226,7 @@ export function useXraySetting(): UseXraySettingResult {
const saveMut = useMutation({
mutationFn: async () => {
const sentXraySetting = xraySettingRef.current;
const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current);
const msg = await HttpUtil.post('/panel/api/xray/update', {
xraySetting: sentXraySetting,
outboundTestUrl: sentTestUrl,
@@ -229,9 +235,8 @@ export function useXraySetting(): UseXraySettingResult {
},
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
if (!msg?.success) return;
oldXraySettingRef.current = sentXraySetting;
oldOutboundTestUrlRef.current = sentTestUrl;
setSaveDisabled(true);
setSavedXraySetting(sentXraySetting);
setSavedOutboundTestUrl(sentTestUrl);
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
},
});
@@ -425,14 +430,8 @@ export function useXraySetting(): UseXraySettingResult {
}
}, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
useEffect(() => {
const timer = window.setInterval(() => {
const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
setSaveDisabled(!(dirtyXray || dirtyUrl));
}, DIRTY_POLL_MS);
return () => window.clearInterval(timer);
}, []);
const saveDisabled = savedXraySetting === xraySetting
&& savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
+27 -19
View File
@@ -1,3 +1,10 @@
.ant-sidebar {
flex: 0 0 var(--sider-rail, 72px);
width: var(--sider-rail, 72px);
position: relative;
z-index: 210;
}
.ant-sidebar > .ant-layout-sider {
position: sticky;
top: 0;
@@ -5,6 +12,16 @@
align-self: flex-start;
}
.ant-sidebar > .ant-layout-sider:not(.ant-layout-sider-collapsed) {
box-shadow: 0 0 32px rgba(0, 0, 0, 0.22);
}
.sider-nav .ant-menu-item .anticon,
.sider-nav .ant-menu-submenu-title .anticon,
.sider-utility .ant-menu-item .anticon {
font-size: 16px;
}
.sider-brand,
.drawer-brand {
font-weight: 600;
@@ -18,16 +35,12 @@
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 16px 14px 24px;
height: 58px;
padding: 0 16px 0 24px;
border-bottom: 1px solid var(--ant-color-border-secondary);
user-select: none;
}
.sider-brand-collapsed {
justify-content: center;
font-size: 16px;
padding: 14px 4px;
letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
}
.brand-block {
@@ -37,10 +50,6 @@
line-height: 1.1;
}
.sider-brand-collapsed .brand-block {
flex: 0 0 auto;
}
.brand-actions {
display: inline-flex;
align-items: center;
@@ -246,11 +255,6 @@
outline: none;
}
.sider-version.is-collapsed {
justify-content: center;
padding: 8px 0;
}
.drawer-footer {
flex: 0 0 auto;
padding: 8px 8px 12px;
@@ -261,8 +265,7 @@
display: inline-flex;
}
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children,
.ant-sidebar > .ant-layout-sider .ant-layout-sider-trigger {
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
display: none;
}
@@ -272,6 +275,11 @@
min-width: 0 !important;
width: 0 !important;
}
.ant-sidebar {
flex: 0 0 0;
width: 0;
}
}
body.dark .ant-drawer-content,
+39 -32
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ComponentType } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ComponentType, CSSProperties } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Drawer, Layout, Menu } from 'antd';
import type { MenuProps } from 'antd';
@@ -39,11 +39,14 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { useAllSettings } from '@/api/queries/useAllSettings';
import './AppSidebar.css';
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const DONATE_URL = 'https://donate.sanaei.dev/';
const DOCS_URL = 'https://docs.sanaei.dev/';
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
const LOGOUT_KEY = '__logout__';
const RAIL_WIDTH = 72;
const railStyle = { '--sider-rail': `${RAIL_WIDTH}px` } as CSSProperties;
let hoveredAcrossRemounts = false;
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound' | 'routing';
@@ -62,14 +65,6 @@ const iconByName: Record<IconName, ComponentType> = {
routing: SwapOutlined,
};
function readCollapsed(): boolean {
try {
return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
} catch {
return false;
}
}
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
return (
<a
@@ -108,7 +103,7 @@ function VersionBadge({ version, collapsed }: { version: string; collapsed?: boo
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className={`sider-version${collapsed ? ' is-collapsed' : ''}`}
className="sider-version"
aria-label={`GitHub ${label}`}
title={label}
>
@@ -148,8 +143,23 @@ export default function AppSidebar() {
const { allSetting } = useAllSettings();
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
const [drawerOpen, setDrawerOpen] = useState(false);
const railCollapsed = !hovered;
const rootRef = useRef<HTMLDivElement>(null);
const updateHovered = useCallback((value: boolean) => {
hoveredAcrossRemounts = value;
setHovered(value);
}, []);
useEffect(() => {
const timer = window.setTimeout(() => {
const el = rootRef.current;
if (el) updateHovered(el.matches(':hover'));
}, 150);
return () => window.clearTimeout(timer);
}, [updateHovered]);
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
const panelVersion = window.X_UI_CUR_VER || '';
@@ -218,7 +228,7 @@ export default function AppSidebar() {
if (tab.key === '/xray') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
}
return { key: tab.key, icon: <Icon />, label: tab.title };
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
}),
[settingsChildren, xrayChildren]);
@@ -235,13 +245,6 @@ export default function AppSidebar() {
openLink(String(key));
}, [openLink]);
const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
if (type === 'clickTrigger') {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
setCollapsed(isCollapsed);
}
}, []);
const cycleTheme = useCallback((id: string) => {
pauseAnimationsUntilLeave(id);
if (!isDark) {
@@ -256,20 +259,24 @@ export default function AppSidebar() {
}, [isDark, isUltra, toggleTheme, toggleUltra]);
return (
<div className="ant-sidebar">
<div
ref={rootRef}
className="ant-sidebar"
style={railStyle}
onMouseEnter={() => updateHovered(true)}
onMouseLeave={() => updateHovered(false)}
>
<Layout.Sider
theme={currentTheme}
width={220}
collapsible
collapsed={collapsed}
breakpoint="md"
onCollapse={onSiderCollapse}
collapsedWidth={RAIL_WIDTH}
collapsed={railCollapsed}
>
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
<div className="sider-brand">
<div className="brand-block">
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
<span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span>
</div>
{!collapsed && (
{!railCollapsed && (
<div className="brand-actions">
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
@@ -287,7 +294,7 @@ export default function AppSidebar() {
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
openKeys={collapsed ? undefined : openKeys}
openKeys={railCollapsed ? undefined : openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
className="sider-nav"
items={toMenuItems(navItems)}
@@ -302,7 +309,7 @@ export default function AppSidebar() {
onClick={onMenuClick}
/>
<div className="sider-footer">
<VersionBadge version={panelVersion} collapsed={collapsed} />
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
</div>
</Layout.Sider>
+1 -1
View File
@@ -1,4 +1,4 @@
import { Outlet } from 'react-router-dom';
import { Outlet } from 'react-router';
import { useWebSocketBridge } from '@/api/websocketBridge';
import { usePageTitle } from '@/hooks/usePageTitle';
@@ -82,12 +82,43 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
case 'header-custom':
return { clients: [], servers: [] };
case 'xmc':
return { hostname: '', usernames: [], password: RandomUtil.randomLowerAndNum(16) };
return { hostname: '', profiles: [defaultXmcProfile()], password: RandomUtil.randomLowerAndNum(16) };
default:
return {};
}
}
function defaultXmcProfile(): Record<string, unknown> {
return { username: '', uuid: '', texturesValue: '', texturesSignature: '' };
}
// xray-core #6487 replaced the xmc mask's `usernames` string list with
// `profiles` objects carrying a Mojang-signed session profile, and dropped the
// "default to Dream" fallback so at least one complete profile is now
// mandatory. The signature can only come from Mojang's session server, so a
// legacy username cannot be upgraded automatically — carry it into a profile
// stub instead, which keeps the operator's player names visible and leaves the
// per-field validators pointing at exactly what still has to be filled in.
export function migrateXmcSettings(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
const out: Record<string, unknown> = { ...settings };
let changed = false;
if (!Array.isArray(out.profiles) && Array.isArray(out.usernames)) {
out.profiles = out.usernames
.filter((name): name is string => typeof name === 'string' && name.trim() !== '')
.map((name) => ({ ...defaultXmcProfile(), username: name }));
changed = true;
}
if ('usernames' in out) {
delete out.usernames;
changed = true;
}
if (!Array.isArray(out.profiles)) {
out.profiles = [];
changed = true;
}
return { next: out, changed };
}
// xray-core #6334 replaced a fragment mask's single `length`/`delay` ranges
// with `lengths`/`delays` arrays (the singular keys remain in core only as a
// fallback). Lift any legacy singular value into a one-element array so the
@@ -171,8 +202,8 @@ function defaultUdpHop(): Record<string, unknown> {
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
const base = asPath(name);
// Migrate legacy single-range fragment masks to the per-segment arrays once
// on mount so configs saved before #6334 render in the list UI.
// Migrate legacy TCP mask shapes once on mount so configs saved before
// #6334 (fragment ranges) and #6487 (xmc profiles) render in the list UI.
const migratedRef = useRef(false);
useEffect(() => {
if (migratedRef.current) return;
@@ -183,8 +214,12 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
const next = tcp.map((mask) => {
if (!mask || typeof mask !== 'object') return mask;
const m = mask as Record<string, unknown>;
if (m.type !== 'fragment' || !m.settings || typeof m.settings !== 'object') return mask;
const { next: migrated, changed } = migrateFragmentSettings(m.settings as Record<string, unknown>);
if (m.type !== 'fragment' && m.type !== 'xmc') return mask;
if (!m.settings || typeof m.settings !== 'object') return mask;
const settings = m.settings as Record<string, unknown>;
const { next: migrated, changed } = m.type === 'fragment'
? migrateFragmentSettings(settings)
: migrateXmcSettings(settings);
if (!changed) return mask;
anyChanged = true;
return { ...m, settings: migrated };
@@ -380,13 +415,7 @@ function TcpMaskItem({
<Form.Item label="Hostname" name={[fieldName, 'settings', 'hostname']}>
<Input placeholder="Server address mimicked in the handshake" />
</Form.Item>
<Form.Item
label="Usernames"
name={[fieldName, 'settings', 'usernames']}
extra="Player names offered to probes; core defaults to Dream when empty."
>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
</Form.Item>
<XmcProfilesList tcpFieldName={fieldName} />
<Form.Item label="Password" required>
<Space.Compact block>
<Form.Item
@@ -528,6 +557,92 @@ function getDeep(obj: unknown, path: (string | number)[]): unknown {
return cur;
}
// Mojang hands the profile UUID back undashed from the session server and
// dashed from most other endpoints; xray-core parses either, so accept both
// rather than forcing the operator to reformat what they pasted.
const XMC_UUID_PATTERN = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
const XMC_USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
function validateXmcUsername(_rule: unknown, value: unknown): Promise<void> {
if (typeof value === 'string' && XMC_USERNAME_PATTERN.test(value)) return Promise.resolve();
return Promise.reject(new Error('3-16 characters, letters/digits/underscore only'));
}
function validateXmcUuid(_rule: unknown, value: unknown): Promise<void> {
if (typeof value === 'string' && XMC_UUID_PATTERN.test(value.trim())) return Promise.resolve();
return Promise.reject(new Error('Enter the profile UUID (dashed or 32 hex characters)'));
}
// Each mask needs at least one fully signed profile since xray-core #6487 —
// an empty or partial list makes the core reject the whole config, so the
// panel blocks the save here rather than letting the backend drop the mask.
function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
const { t } = useTranslation();
return (
<Form.List name={[tcpFieldName, 'settings', 'profiles']}>
{(profiles, { add, remove }) => (
<>
<Form.Item
label="Profiles"
extra="Signed Minecraft session profiles; resolve the UUID by username, then fetch the profile with unsigned=false."
>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add(defaultXmcProfile())}
/>
</Form.Item>
{profiles.map((profile, idx) => (
<div key={profile.key}>
<Divider style={{ margin: 0 }}>
Profile {idx + 1}
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(profile.name)}
onKeyDown={activateOnKey(() => remove(profile.name))}
/>
</Divider>
<Form.Item
label="Username"
name={[profile.name, 'username']}
rules={[{ validator: validateXmcUsername }]}
>
<Input placeholder="Notch" />
</Form.Item>
<Form.Item
label="UUID"
name={[profile.name, 'uuid']}
rules={[{ validator: validateXmcUuid }]}
>
<Input placeholder="069a79f4-44e9-4726-a5be-fca90e38aaf5" />
</Form.Item>
<Form.Item
label="Textures Value"
name={[profile.name, 'texturesValue']}
rules={[{ required: true, message: 'Textures value is required' }]}
>
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 value from the session profile" />
</Form.Item>
<Form.Item
label="Textures Signature"
name={[profile.name, 'texturesSignature']}
rules={[{ required: true, message: 'Textures signature is required' }]}
>
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 signature from the session profile" />
</Form.Item>
</div>
))}
</>
)}
</Form.List>
);
}
function HeaderCustomGroups({
tcpFieldName, form, absoluteSettingsPath,
}: {
@@ -1031,12 +1146,18 @@ function ItemEditor({
onRemove?: () => void;
}) {
const { t } = useTranslation();
/**
* Switching to `array` clears the packet instead of emptying it to `[]`:
* that branch is rand-driven, and xray-core counts even an empty array as a
* packet, rejecting an item that carries both a packet and a rand. That
* error fails the whole config, so one such item keeps every inbound offline.
*/
const onTypeChange = (v: string) => {
if (v === 'base64') {
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
} else if (v === 'array') {
form.setFieldValue([...absoluteItemPath, 'rand'], delayMode === 'string' ? '1-8192' : 0);
form.setFieldValue([...absoluteItemPath, 'packet'], []);
form.setFieldValue([...absoluteItemPath, 'packet'], undefined);
} else {
form.setFieldValue([...absoluteItemPath, 'packet'], '');
}
+1 -1
View File
@@ -174,7 +174,7 @@ export function createDefaultShadowsocksInboundSettings(
// constructor — the field discriminates v1 vs v2 inside the same settings
// shape. Callers that explicitly want v1 pass `{ version: 1 }`.
export interface HysteriaInboundSeed {
version?: number;
version?: 2;
}
export function createDefaultHysteriaInboundSettings(
@@ -41,6 +41,7 @@ export interface RawInboundRow {
enable?: boolean;
expiryTime?: number;
trafficReset?: string;
trafficResetDay?: number;
lastTrafficResetTime?: number;
nodeId?: number | null;
shareAddrStrategy?: string;
@@ -60,6 +61,7 @@ export interface WireInboundPayload {
enable: boolean;
expiryTime: number;
trafficReset: TrafficReset;
trafficResetDay: number;
lastTrafficResetTime: number;
listen: string;
port: number;
@@ -202,6 +204,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
down: row.down ?? 0,
total: row.total ?? 0,
trafficReset: coerceTrafficReset(row.trafficReset),
trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
nodeId: row.nodeId ?? null,
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
@@ -344,6 +347,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
enable: values.enable,
expiryTime: values.expiryTime,
trafficReset: values.trafficReset,
trafficResetDay: values.trafficResetDay,
lastTrafficResetTime: values.lastTrafficResetTime,
listen: values.listen,
port: values.port,
+17 -9
View File
@@ -42,8 +42,7 @@ function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
// Pull the bidirectional SplitHTTPConfig fields out of xhttp into a
// compact extra payload. Server-only fields (noSSEHeader, scMaxBufferedPosts,
// scStreamUpServerSecs, serverMaxHeaderBytes) are excluded — the client
// reading the share link wouldn't honor them. Mirrors the legacy
// Inbound.buildXhttpExtra exactly so the shadow link snapshots line up.
// reading the share link wouldn't honor them.
function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string, unknown> | null {
if (!xhttp) return null;
const extra: Record<string, unknown> = {};
@@ -85,6 +84,15 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
const v = xhttp[k];
if (typeof v === 'string' && v.length > 0 && v !== coreDefaults[k]) extra[k] = v;
}
// xray-core #6258 renamed these fields, but older clients still read the
// legacy names from share-link extra. Emit both names so one link works
// across old and new clients while the stored panel config stays canonical.
if (typeof extra.sessionIDPlacement === 'string') {
extra.sessionPlacement = extra.sessionIDPlacement;
}
if (typeof extra.sessionIDKey === 'string') {
extra.sessionKey = extra.sessionIDKey;
}
// Headers on the wire are a record; emit them as a map upstream's
// SplitHTTPConfig.headers expects, dropping Host (already on the URL).
@@ -704,11 +712,12 @@ function hysteriaPinHex(pin: string): string {
}
}
// Hysteria share link: hysteria://<auth>@<host>:<port>?<query>#<remark>.
// The URL scheme is "hysteria2" when settings.version === 2 (hysteria v2
// AKA hysteria2), "hysteria" otherwise. Salamander obfuscation pulls its
// password from finalmask.udp[type=salamander] when present; the broader
// finalmask payload still rides under `fm` like the other links.
// Hysteria share link: hysteria2://<auth>@<host>:<port>?<query>#<remark>.
// The scheme is always hysteria2 — xray-core builds version 2 only, so the
// settings schema pins it there and the subscription server emits the same
// scheme. Salamander obfuscation pulls its password from
// finalmask.udp[type=salamander] when present; the broader finalmask payload
// still rides under `fm` like the other links.
//
// Note: legacy genHysteriaLink reads stream.tls.settings.allowInsecure,
// which isn't a field on TlsStreamSettings.Settings — the guard is always
@@ -727,8 +736,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
const stream = inbound.streamSettings;
if (!stream || stream.security !== 'tls') return '';
const settings = inbound.settings;
const scheme = settings.version === 2 ? 'hysteria2' : 'hysteria';
const scheme = 'hysteria2';
const params = new URLSearchParams();
params.set('security', 'tls');
@@ -104,6 +104,63 @@ export function validateRealityTarget(target: string): string | undefined {
return undefined;
}
/**
* Parses a REALITY client-version string the way xray-core's config loader
* does: one to three dot-separated numeric parts, each 0-255. Returns the
* parts padded to three entries, or undefined when the string is not a valid
* version.
*/
export function parseRealityClientVer(value: string): [number, number, number] | undefined {
const trimmed = value.trim();
if (!trimmed) return undefined;
const parts = trimmed.split('.');
if (parts.length > 3) return undefined;
const nums: number[] = [];
for (const part of parts) {
if (!/^\d+$/.test(part)) return undefined;
const n = Number(part);
if (n > 255) return undefined;
nums.push(n);
}
while (nums.length < 3) nums.push(0);
return nums as [number, number, number];
}
/**
* Validates a REALITY client-version field; empty means "not set" and is
* valid. The value is saved exactly as typed and xray-core's part parser
* accepts no surrounding whitespace, so a value that differs from its
* trimmed form is rejected rather than silently passed to the wire.
*/
export function validateRealityClientVer(value: string): string | undefined {
if (!value) return undefined;
if (value !== value.trim() || !parseRealityClientVer(value)) {
return 'pages.inbounds.form.clientVerInvalid';
}
return undefined;
}
/**
* Validates the max client-version field: format first, then that a non-empty
* max is not below a non-empty min (an inverted range rejects every client).
* An empty or malformed min is left to the min field's own validation.
*/
export function validateRealityMaxClientVer(max: string, min: string): string | undefined {
const formatError = validateRealityClientVer(max);
if (formatError) return formatError;
const maxParts = parseRealityClientVer(max);
const minParts = parseRealityClientVer(min);
if (!maxParts || !minParts) return undefined;
for (let i = 0; i < 3; i++) {
if (maxParts[i] !== minParts[i]) {
return maxParts[i] < minParts[i]
? 'pages.inbounds.form.maxClientVerBelowMin'
: undefined;
}
}
return undefined;
}
function liftLegacyXhttpSessionKeys(obj: Record<string, unknown>): void {
const lift = (legacy: string, renamed: string) => {
const v = obj[legacy];
+1 -1
View File
@@ -1,5 +1,5 @@
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { RouterProvider } from 'react-router/dom';
import { message } from 'antd';
import 'antd/dist/reset.css';
import '@/styles/utils.css';
+3
View File
@@ -30,6 +30,7 @@ export type DBInboundInit = Partial<{
enable: boolean;
expiryTime: number;
trafficReset: string;
trafficResetDay: number;
lastTrafficResetTime: number;
listen: string;
port: number;
@@ -76,6 +77,7 @@ export class DBInbound {
enable: boolean;
expiryTime: number;
trafficReset: string;
trafficResetDay: number;
lastTrafficResetTime: number;
listen: string;
@@ -105,6 +107,7 @@ export class DBInbound {
this.enable = true;
this.expiryTime = 0;
this.trafficReset = "never";
this.trafficResetDay = 1;
this.lastTrafficResetTime = 0;
this.listen = "";
+3 -2
View File
@@ -14,6 +14,7 @@ export class AllSetting {
expireDiff = 0;
trafficDiff = 0;
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
subShowIdentityOnAllLinks = false;
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
tgBotEnable = false;
tgBotToken = '';
@@ -90,7 +91,7 @@ export class AllSetting {
ldapDefaultTotalGB = 0;
ldapDefaultExpiryDays = 0;
ldapDefaultLimitIP = 0;
tgEnabledEvents = '';
tgEnabledEvents = 'login.attempt,cpu.high';
smtpEnable = false;
smtpHost = '';
smtpPort = 587;
@@ -100,7 +101,7 @@ export class AllSetting {
smtpFromName = '';
smtpTo = '';
smtpEncryptionType = 'starttls';
smtpEnabledEvents = '';
smtpEnabledEvents = 'login.attempt,cpu.high';
smtpCpu = 80;
smtpMemory = 80;
outboundDownThreshold = 3;
+8 -3
View File
@@ -1,5 +1,10 @@
import { NumberFormatter } from '@/utils';
export const USAGE_WARN_PERCENT = 80;
export const USAGE_CRIT_PERCENT = 90;
export const USAGE_WARN_COLOR = '#faad14';
export const USAGE_CRIT_COLOR = '#ff4d4f';
export class CurTotal {
current: number;
total: number;
@@ -16,9 +21,9 @@ export class CurTotal {
get color(): string {
const p = this.percent;
if (p < 80) return '#1677ff';
if (p < 90) return '#faad14';
return '#ff4d4f';
if (p < USAGE_WARN_PERCENT) return '#1677ff';
if (p < USAGE_CRIT_PERCENT) return USAGE_WARN_COLOR;
return USAGE_CRIT_COLOR;
}
}
+22 -2
View File
@@ -254,6 +254,11 @@ export const sections: readonly Section[] = [
description:
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
endpoints: [
{
method: 'GET',
path: '/panel/api/openapi.json',
summary: 'Serve this API description as an OpenAPI 3 document — the same file that powers the API Docs page. Requires a session or Bearer token like the rest of /panel/api. Useful for generating clients or importing into API tooling.',
},
{
method: 'GET',
path: '/panel/api/server/status',
@@ -559,7 +564,7 @@ export const sections: readonly Section[] = [
{
method: 'GET',
path: '/panel/api/clients/list/paged',
summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.',
summary: 'Filter, sort, and paginate clients on the server. Each item is a slim row (no uuid/password/auth/flow/security/reverse/tgId) so the clients page can ship 25-ish rows in a few KB instead of the full table. The response also includes a summary computed across the full DB row set so dashboard counters stay stable as the user paginates or filters: the *Count fields are exact, while the email arrays beside them stop at 200 entries so the payload does not grow with the panel. Page size capped at 200; fetch /get/:email to obtain the full per-client payload for an edit/info modal.',
params: [
{ name: 'page', in: 'query', type: 'number', desc: '1-indexed page number. Defaults to 1.' },
{ name: 'pageSize', in: 'query', type: 'number', desc: 'Rows per page. Defaults to 25, capped at 200.' },
@@ -570,7 +575,7 @@ export const sections: readonly Section[] = [
{ name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' },
],
response:
'{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": []\n }\n }\n}',
'{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "onlineCount": 1,\n "depletedCount": 0,\n "expiringCount": 0,\n "deactiveCount": 150,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": ["bob@example.com"]\n }\n }\n}',
},
{
method: 'GET',
@@ -582,6 +587,16 @@ export const sections: readonly Section[] = [
response:
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n }\n}',
},
{
method: 'GET',
path: '/panel/api/clients/get/tgId/:tgId',
summary: 'Fetch clients by Telegram user ID. Returns an array since multiple clients can share the same Telegram ID.',
params: [
{ name: 'tgId', in: 'path', type: 'integer', desc: 'Telegram user ID (numeric).' },
],
response:
'{\n "success": true,\n "obj": [\n {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [],\n "usedTraffic": 1048576\n }\n ]\n}',
},
{
method: 'POST',
path: '/panel/api/clients/add',
@@ -1154,6 +1169,11 @@ export const sections: readonly Section[] = [
path: '/panel/api/setting/defaultSettings',
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
},
{
method: 'POST',
path: '/panel/api/setting/factoryDefaults',
summary: 'Return the shipped (factory) default value per browser-safe setting key, so clients can tell a stored value apart from the default it would fall back to. Per-install material (secret, panelGuid, mTLS keys) and credential fields are never included.',
},
{
method: 'POST',
path: '/panel/api/setting/update',
@@ -56,7 +56,7 @@ export default function ClientBulkAddModal({
}: ClientBulkAddModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const { bulkCreate } = useClients();
const { bulkCreate } = useClients({ list: false });
const methods = useForm<ClientBulkAddFormValues>({ defaultValues: EMPTY });
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
@@ -16,6 +16,13 @@
white-space: nowrap;
}
.client-email-more {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--ant-color-border-secondary, rgba(128, 128, 128, 0.2));
opacity: 0.65;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
+128 -99
View File
@@ -1,4 +1,4 @@
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
@@ -16,7 +16,6 @@ import {
Result,
Row,
Select,
Space,
Spin,
Statistic,
Switch,
@@ -80,12 +79,14 @@ const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
import { ClientInboundChips, ClientRowActions } from './RowCells';
import { emptyFilters, activeFilterCount } from './filters';
import type { ClientFilters } from './filters';
import './ClientsPage.css';
const FILTER_STATE_KEY = 'clientsFilterState';
const DISABLED_PAGE_SIZE = 200;
const DEFAULT_TABLE_PAGE_SIZE = 25;
function UngroupIcon() {
return (
@@ -126,12 +127,29 @@ function UngroupIcon() {
);
}
// The server sends exact counters but caps the email arrays behind them, so a
// panel with thousands of depleted clients neither ships nor renders them all.
// The trailing chip reports what the popover left out.
function ClientEmailList({ emails, total }: { emails: string[]; total: number }) {
const hidden = total - emails.length;
return (
<div className="client-email-list">
{emails.map((e) => <div key={e}>{e}</div>)}
{hidden > 0 && <div className="client-email-more">+{hidden}</div>}
</div>
);
}
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
interface PersistedFilterState {
searchKey: string;
filters: ClientFilters;
sort: string;
// The page size resolved on the previous visit. Without it the first list
// request has to wait for /setting/defaultSettings just to learn how many rows
// to ask for, which serialises two round trips on every load.
pageSize: number | null;
}
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
@@ -147,6 +165,9 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
tunnel: 'orange',
};
const INBOUND_CHIP_LIMIT = 1;
// A shared empty array keeps the memoised chip cell from seeing a fresh prop for
// every unattached client on every render.
const EMPTY_INBOUND_IDS: number[] = [];
function readFilterState(): PersistedFilterState {
try {
@@ -164,9 +185,10 @@ function readFilterState(): PersistedFilterState {
groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
},
sort: typeof raw.sort === 'string' ? raw.sort : '',
pageSize: typeof raw.pageSize === 'number' && raw.pageSize > 0 ? raw.pageSize : null,
};
} catch {
return { searchKey: '', filters: emptyFilters(), sort: '' };
return { searchKey: '', filters: emptyFilters(), sort: '', pageSize: null };
}
}
@@ -205,11 +227,11 @@ export default function ClientsPage() {
const {
clients, total, filtered,
summary: serverSummary,
summary,
allGroups,
setQuery,
inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
tgBotEnable, expireDiff, trafficDiff, pageSize,
inbounds, onlines, transitioning, fetched, fetchError, subSettings,
tgBotEnable, expireDiff, trafficDiff, pageSize, settingsReady,
create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
clientSpeed,
@@ -265,14 +287,31 @@ export default function ClientsPage() {
const [sortColumn, setSortColumn] = useState<string | null>(initialSort.column);
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(initialSort.order);
const [currentPage, setCurrentPage] = useState(1);
const [tablePageSize, setTablePageSize] = useState(25);
// Derived, not mirrored into state by an effect: an effect lags one render
// behind the settings arriving, and that lag is what made the page fetch the
// list once with the placeholder size and again with the real one.
const [pageSizeChoice, setPageSizeChoice] = useState<number | null>(null);
const settingsPageSize = settingsReady ? (pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE) : null;
// Last visit's resolved size stands in until the settings land, so the list
// request goes out with the page mount instead of queueing behind them. If the
// admin has since changed the setting the authoritative value replaces it and
// costs one refetch — only on the load that follows the change. Null means
// nothing is known yet, which is the one case worth waiting for.
const resolvedPageSize = pageSizeChoice ?? settingsPageSize ?? initial.pageSize;
const tablePageSize = resolvedPageSize ?? DEFAULT_TABLE_PAGE_SIZE;
// debouncedSearch lags behind the input so we don't spam the server on every
// keystroke; the search box still feels instant locally.
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
useEffect(() => {
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters, sort: sortValueFor(sortColumn, sortOrder) }));
}, [searchKey, filters, sortColumn, sortOrder]);
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
searchKey,
filters,
sort: sortValueFor(sortColumn, sortOrder),
// Only ever persist a size we actually resolved, never the render fallback.
pageSize: resolvedPageSize,
}));
}, [searchKey, filters, sortColumn, sortOrder, resolvedPageSize]);
useEffect(() => {
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
@@ -303,6 +342,10 @@ export default function ClientsPage() {
}, [filters.nodeIds, filters.inboundIds, inbounds]);
useEffect(() => {
// With no remembered size and no settings yet, any query we build would be a
// guess, and issuing it costs a full server round trip that is thrown away as
// soon as the real size arrives.
if (resolvedPageSize === null) return;
setQuery({
page: currentPage,
pageSize: tablePageSize,
@@ -321,13 +364,21 @@ export default function ClientsPage() {
sort: sortColumn || undefined,
order: sortOrder || undefined,
});
}, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
}, [setQuery, resolvedPageSize, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
const activeCount = activeFilterCount(filters);
useEffect(() => {
setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
}, [pageSize]);
// Row handlers take an email and look the row up here at call time. Keying
// them on the record object instead would defeat the memoised cells: every
// traffic push replaces the row object of every client whose counters moved,
// so the memo would miss on exactly the rows that are busy. Reading through
// the ref also means a modal opened mid-poll shows current usage.
const rowsByEmail = useRef(new Map<string, ClientRecord>());
rowsByEmail.current = useMemo(() => {
const map = new Map<string, ClientRecord>();
for (const c of clients) map.set(c.email, c);
return map;
}, [clients]);
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
const inboundsById = useMemo(() => {
@@ -385,9 +436,6 @@ export default function ClientsPage() {
// a rename.
const filteredClients = clients;
// Server-computed counts that stay stable as the user paginates/filters.
const summary = serverSummary;
// Sort is server-side now; the page already arrives in the requested
// order, so we just hand it through.
const sortedClients = filteredClients;
@@ -457,7 +505,9 @@ export default function ClientsPage() {
setFormOpen(true);
}
async function onEdit(row: ClientRecord) {
const onEdit = useCallback(async (email: string) => {
const row = rowsByEmail.current.get(email);
if (!row) return;
setFormMode('edit');
// Paged list omits per-client secrets to keep the row payload tiny;
// edit needs them, so fetch the full record first.
@@ -468,9 +518,11 @@ export default function ClientsPage() {
setEditingAttachedIds([...ids]);
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
setFormOpen(true);
}
}, [hydrate]);
function onDelete(row: ClientRecord) {
const onDelete = useCallback((email: string) => {
const row = rowsByEmail.current.get(email);
if (!row) return;
modal.confirm({
title: t('pages.clients.deleteConfirmTitle', { email: row.email }),
content: t('pages.clients.deleteConfirmContent'),
@@ -482,9 +534,10 @@ export default function ClientsPage() {
if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
},
});
}
}, [modal, t, remove, messageApi]);
function onResetTraffic(row: ClientRecord) {
const onResetTraffic = useCallback((email: string) => {
const row = rowsByEmail.current.get(email);
if (!row?.email) {
messageApi.warning(t('pages.clients.resetNotPossible'));
return;
@@ -499,19 +552,33 @@ export default function ClientsPage() {
if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
},
});
}
}, [modal, t, resetTraffic, messageApi]);
async function onShowInfo(row: ClientRecord) {
const onShowInfo = useCallback(async (email: string) => {
const row = rowsByEmail.current.get(email);
if (!row) return;
const full = await hydrate(row.email);
setInfoClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
setInfoOpen(true);
}
}, [hydrate]);
async function onShowQr(row: ClientRecord) {
const onShowQr = useCallback(async (email: string) => {
const row = rowsByEmail.current.get(email);
if (!row) return;
const full = await hydrate(row.email);
setQrClient(full ? { ...row, ...full.client, inboundIds: full.inboundIds } : row);
setQrOpen(true);
}
}, [hydrate]);
const [refreshing, setRefreshing] = useState(false);
const onRefreshClick = useCallback(async () => {
setRefreshing(true);
try {
await refresh();
} finally {
setRefreshing(false);
}
}, [refresh]);
const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
setTextTitle(opts.title);
@@ -746,7 +813,7 @@ export default function ClientsPage() {
const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
if (pag?.current) setCurrentPage(pag.current);
if (pag?.pageSize) setTablePageSize(pag.pageSize);
if (pag?.pageSize) setPageSizeChoice(pag.pageSize);
};
const columns = useMemo<ColumnsType<ClientRecord>>(() => [
@@ -755,23 +822,14 @@ export default function ClientsPage() {
key: 'actions',
width: 200,
render: (_v, record) => (
<Space size={4}>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} onClick={() => onShowQr(record)} />
</Tooltip>
<Tooltip title={t('pages.clients.clientInfo')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} aria-label={t('pages.clients.clientInfo')} onClick={() => onShowInfo(record)} />
</Tooltip>
<Tooltip title={t('pages.inbounds.resetTraffic')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} aria-label={t('pages.inbounds.resetTraffic')} onClick={() => onResetTraffic(record)} />
</Tooltip>
<Tooltip title={t('edit')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
<ClientRowActions
email={record.email}
onShowQr={onShowQr}
onShowInfo={onShowInfo}
onResetTraffic={onResetTraffic}
onEdit={onEdit}
onDelete={onDelete}
/>
),
},
{
@@ -853,42 +911,13 @@ export default function ClientsPage() {
key: 'inboundIds',
width: 170,
render: (_v, record) => {
const ids = record.inboundIds || [];
if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
const overflow = ids.slice(INBOUND_CHIP_LIMIT);
const chip = (id: number, compact: boolean) => {
const ib = inboundsById[id];
const proto = (ib?.protocol || '').toLowerCase();
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
const compactLabel = formatInboundLabel(ib?.tag, ib?.remark);
return (
<Tooltip key={id} title={inboundLabel(id)}>
<Tag color={color} style={{ margin: 2 }}>
{compact ? compactLabel : inboundLabel(id)}
</Tag>
</Tooltip>
);
};
return (
<>
{visible.map((id) => chip(id, true))}
{overflow.length > 0 && (
<Popover
trigger="click"
placement="bottomRight"
content={
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
{overflow.map((id) => chip(id, false))}
</div>
}
>
<Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
+{overflow.length}
</Tag>
</Popover>
)}
</>
<ClientInboundChips
ids={record.inboundIds || EMPTY_INBOUND_IDS}
inboundsById={inboundsById}
protocolColors={INBOUND_PROTOCOL_COLORS}
chipLimit={INBOUND_CHIP_LIMIT}
/>
);
},
},
@@ -997,7 +1026,7 @@ export default function ClientsPage() {
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError}
extra={<Button type="primary" loading={loading} onClick={refresh}>{t('refresh')}</Button>}
extra={<Button type="primary" loading={refreshing} onClick={onRefreshClick}>{t('refresh')}</Button>}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
@@ -1010,37 +1039,37 @@ export default function ClientsPage() {
<Col xs={12} sm={8} md={4}>
<Popover
title={t('online')}
open={summary.online.length ? undefined : false}
content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
open={summary.onlineCount ? undefined : false}
content={<ClientEmailList emails={summary.online} total={summary.onlineCount} />}
>
<Statistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
<Statistic title={t('online')} value={String(summary.onlineCount)} prefix={<span className="dot dot-blue" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('depleted')}
open={summary.depleted.length ? undefined : false}
content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
open={summary.depletedCount ? undefined : false}
content={<ClientEmailList emails={summary.depleted} total={summary.depletedCount} />}
>
<Statistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
<Statistic title={t('depleted')} value={String(summary.depletedCount)} prefix={<span className="dot dot-red" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('depletingSoon')}
open={summary.expiring.length ? undefined : false}
content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
open={summary.expiringCount ? undefined : false}
content={<ClientEmailList emails={summary.expiring} total={summary.expiringCount} />}
>
<Statistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
<Statistic title={t('depletingSoon')} value={String(summary.expiringCount)} prefix={<span className="dot dot-orange" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
<Popover
title={t('disabled')}
open={summary.deactive.length ? undefined : false}
content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
open={summary.deactiveCount ? undefined : false}
content={<ClientEmailList emails={summary.deactive} total={summary.deactiveCount} />}
>
<Statistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
<Statistic title={t('disabled')} value={String(summary.deactiveCount)} prefix={<span className="dot dot-gray" />} />
</Popover>
</Col>
<Col xs={12} sm={8} md={4}>
@@ -1367,7 +1396,7 @@ export default function ClientsPage() {
showTotal={(n) => `${n}`}
onChange={(p, s) => {
setCurrentPage(p);
if (s && s !== tablePageSize) setTablePageSize(s);
if (s && s !== tablePageSize) setPageSizeChoice(s);
}}
/>
</div>
@@ -1394,8 +1423,8 @@ export default function ClientsPage() {
role="button"
tabIndex={0}
aria-label={t('pages.clients.clientInfo')}
onClick={() => onShowInfo(row)}
onKeyDown={activateOnKey(() => onShowInfo(row))}
onClick={() => onShowInfo(row.email)}
onKeyDown={activateOnKey(() => onShowInfo(row.email))}
/>
</Tooltip>
<Switch
@@ -1412,23 +1441,23 @@ export default function ClientsPage() {
{
key: 'qr',
label: <><QrcodeOutlined /> {t('pages.clients.qrCode')}</>,
onClick: () => onShowQr(row),
onClick: () => onShowQr(row.email),
},
{
key: 'reset',
label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>,
onClick: () => onResetTraffic(row),
onClick: () => onResetTraffic(row.email),
},
{
key: 'edit',
label: <><EditOutlined /> {t('edit')}</>,
onClick: () => onEdit(row),
onClick: () => onEdit(row.email),
},
{
key: 'delete',
danger: true,
label: <><DeleteOutlined /> {t('delete')}</>,
onClick: () => onDelete(row),
onClick: () => onDelete(row.email),
},
],
}}
+154
View File
@@ -0,0 +1,154 @@
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Popover, Space, Tag, Tooltip } from 'antd';
import {
DeleteOutlined,
EditOutlined,
InfoCircleOutlined,
QrcodeOutlined,
RetweetOutlined,
} from '@ant-design/icons';
import { formatInboundLabel } from '@/lib/inbounds/label';
import type { InboundOption } from '@/hooks/useClients';
const ICON_BUTTON_STYLE = { fontSize: 16 } as const;
interface ClientRowActionsProps {
email: string;
onShowQr: (email: string) => void;
onShowInfo: (email: string) => void;
onResetTraffic: (email: string) => void;
onEdit: (email: string) => void;
onDelete: (email: string) => void;
}
// Five Tooltip-wrapped buttons per row, none of which depend on traffic. Left
// inline they re-ran rc-tooltip's alignment machinery for every visible row on
// every traffic push — 125 Tooltips on a 25-row page, five seconds apart.
// Keyed on the email rather than the row object, because a push replaces the row
// object of every client whose counters moved; the page resolves the live row.
export const ClientRowActions = memo(function ClientRowActions({
email,
onShowQr,
onShowInfo,
onResetTraffic,
onEdit,
onDelete,
}: ClientRowActionsProps) {
const { t } = useTranslation();
return (
<Space size={4}>
<Tooltip title={t('pages.clients.qrCode')}>
<Button
size="small"
type="text"
style={ICON_BUTTON_STYLE}
icon={<QrcodeOutlined />}
aria-label={t('pages.clients.qrCode')}
onClick={() => onShowQr(email)}
/>
</Tooltip>
<Tooltip title={t('pages.clients.clientInfo')}>
<Button
size="small"
type="text"
style={ICON_BUTTON_STYLE}
icon={<InfoCircleOutlined />}
aria-label={t('pages.clients.clientInfo')}
onClick={() => onShowInfo(email)}
/>
</Tooltip>
<Tooltip title={t('pages.inbounds.resetTraffic')}>
<Button
size="small"
type="text"
style={ICON_BUTTON_STYLE}
icon={<RetweetOutlined />}
aria-label={t('pages.inbounds.resetTraffic')}
onClick={() => onResetTraffic(email)}
/>
</Tooltip>
<Tooltip title={t('edit')}>
<Button
size="small"
type="text"
style={ICON_BUTTON_STYLE}
icon={<EditOutlined />}
aria-label={t('edit')}
onClick={() => onEdit(email)}
/>
</Tooltip>
<Tooltip title={t('delete')}>
<Button
size="small"
type="text"
danger
style={ICON_BUTTON_STYLE}
icon={<DeleteOutlined />}
aria-label={t('delete')}
onClick={() => onDelete(email)}
/>
</Tooltip>
</Space>
);
});
const CHIP_STYLE = { margin: 2 } as const;
const OVERFLOW_CHIP_STYLE = { margin: 2, cursor: 'pointer' } as const;
const OVERFLOW_LIST_STYLE = {
display: 'flex',
flexDirection: 'column' as const,
gap: 4,
maxWidth: 280,
maxHeight: 280,
overflowY: 'auto' as const,
};
interface ClientInboundChipsProps {
ids: number[];
inboundsById: Record<number, InboundOption>;
protocolColors: Record<string, string>;
chipLimit: number;
}
// Attachments never change on a traffic push either, so the same memoisation
// applies: one Tooltip per visible chip plus a Popover for the overflow.
export const ClientInboundChips = memo(function ClientInboundChips({
ids,
inboundsById,
protocolColors,
chipLimit,
}: ClientInboundChipsProps) {
if (ids.length === 0) return <span className="cell-empty">—</span>;
const label = (id: number) => {
const ib = inboundsById[id];
return formatInboundLabel(ib?.tag, ib?.remark);
};
const chip = (id: number) => {
const proto = (inboundsById[id]?.protocol || '').toLowerCase();
return (
<Tooltip key={id} title={label(id)}>
<Tag color={protocolColors[proto] ?? 'default'} style={CHIP_STYLE}>{label(id)}</Tag>
</Tooltip>
);
};
const visible = ids.slice(0, chipLimit);
const overflow = ids.slice(chipLimit);
return (
<>
{visible.map(chip)}
{overflow.length > 0 && (
<Popover
trigger="click"
placement="bottomRight"
content={<div style={OVERFLOW_LIST_STYLE}>{overflow.map(chip)}</div>}
>
<Tag color="default" style={OVERFLOW_CHIP_STYLE}>+{overflow.length}</Tag>
</Popover>
)}
</>
);
});
+1 -1
View File
@@ -93,7 +93,7 @@ export default function GroupsPage() {
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const queryClient = useQueryClient();
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients();
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({ list: false });
const groupsQuery = useQuery({
queryKey: keys.clients.groups(),
@@ -33,6 +33,7 @@ import {
isSS2022,
} from '@/lib/xray/protocol-capabilities';
import {
InboundDbFieldsSchema,
InboundFormBaseSchema,
InboundFormSchema,
type InboundFormValues,
@@ -255,6 +256,7 @@ export default function InboundFormModal({
const wTunnelNetwork = useWatch({ control, name: 'settings.allowedNetwork' });
const wTotal = (useWatch({ control, name: 'total' }) as number | undefined) ?? 0;
const wExpiry = (useWatch({ control, name: 'expiryTime' }) as number | undefined) ?? 0;
const trafficReset = useWatch({ control, name: 'trafficReset' }) ?? 'never';
const autoTagRef = useRef(true);
const lastWrittenTagRef = useRef('');
const currentTagInput = (): InboundTagInput => ({
@@ -619,6 +621,16 @@ export default function InboundFormModal({
/>
</FormField>
{trafficReset === 'monthly' && (
<FormField
name="trafficResetDay"
label={t('pages.inbounds.periodicTrafficResetDay')}
rules={{ validate: rhfZodValidate(InboundDbFieldsSchema.shape.trafficResetDay) }}
>
<InputNumber min={1} max={31} />
</FormField>
)}
<Form.Item
label={
<Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>
@@ -1,11 +1,16 @@
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { FormField } from '@/components/form/rhf';
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
import {
validateRealityClientVer,
validateRealityMaxClientVer,
validateRealityTarget,
} from '@/lib/xray/stream-wire-normalize';
import type { RealityScanResult } from '@/generated/types';
import RealityTargetScannerModal from './RealityTargetScannerModal';
@@ -39,7 +44,14 @@ export default function RealityForm({
clearMldsa65,
}: RealityFormProps) {
const { t } = useTranslation();
const { getFieldState, trigger } = useFormContext();
const [scannerOpen, setScannerOpen] = useState(false);
const maxClientVerPath = 'streamSettings.realitySettings.maxClientVer';
const revalidateMaxClientVer = () => {
if (getFieldState(maxClientVerPath).error) {
void trigger(maxClientVerPath);
}
};
return (
<>
<FormField
@@ -127,14 +139,31 @@ export default function RealityForm({
<FormField
name={['streamSettings', 'realitySettings', 'minClientVer']}
label={t('pages.inbounds.form.minClientVer')}
tooltip={t('pages.inbounds.form.minClientVerHint')}
onAfterChange={revalidateMaxClientVer}
rules={{
validate: (value) => {
const errKey = validateRealityClientVer(typeof value === 'string' ? value : '');
return errKey ? errKey : true;
},
}}
>
<Input placeholder="26.3.27" />
</FormField>
<FormField
name={['streamSettings', 'realitySettings', 'maxClientVer']}
label={t('pages.inbounds.form.maxClientVer')}
tooltip={t('pages.inbounds.form.maxClientVerHint')}
rules={{
validate: (value, formValues) => {
const max = typeof value === 'string' ? value : '';
const min = formValues?.streamSettings?.realitySettings?.minClientVer;
const errKey = validateRealityMaxClientVer(max, typeof min === 'string' ? min : '');
return errKey ? errKey : true;
},
}}
>
<Input placeholder="25.9.11" />
<Input placeholder="x.y.z" />
</FormField>
<Form.Item label={t('pages.inbounds.form.shortIds')}>
<Space.Compact block style={{ display: 'flex' }}>
@@ -265,11 +265,11 @@ export default function XhttpForm() {
>
<Select
options={[
{ value: '', label: 'Default (body)' },
{ value: '', label: 'Default (auto)' },
{ value: 'auto', label: 'auto' },
{ value: 'body', label: 'body' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</FormField>
@@ -0,0 +1,74 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, theme } from 'antd';
import { Sparkline } from '@/components/viz';
import type { Status } from '@/models/status';
interface ConnectionsCardProps {
status: Status;
tcp: number[];
udp: number[];
labels: string[];
isMobile: boolean;
}
export default function ConnectionsCard({ status, tcp, udp, labels, isMobile }: ConnectionsCardProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const accent = token.colorPrimary;
const udpColor = token.colorTextTertiary;
const referenceLines = useMemo(
() => [
{ y: status.udpCount, color: udpColor, dash: '2 4' },
{ y: status.tcpCount, color: accent, dash: '2 4' },
],
[status.tcpCount, status.udpCount, accent, udpColor],
);
return (
<Card hoverable styles={{ body: { padding: 0 } }}>
<div className="ov-wide-head ov-wide-head-stack">
<div className="ov-kicker">{t('pages.index.connectionCount')}</div>
<div className="ov-conn-total">
<span className="ov-tile-number">{status.tcpCount + status.udpCount}</span>
<span className="ov-tile-unit">{t('pages.index.openSockets')}</span>
</div>
</div>
<div className="ov-conn-legend">
<div className="ov-legend-label">
<span className="ov-swatch" style={{ background: accent }} />
TCP
<span className="ov-legend-num">{status.tcpCount.toLocaleString()}</span>
</div>
<div className="ov-legend-label">
<span className="ov-swatch" style={{ background: udpColor }} />
UDP
<span className="ov-legend-num">{status.udpCount.toLocaleString()}</span>
</div>
</div>
<div className="ov-wide-chart">
<Sparkline
data={tcp}
data2={udp}
labels={labels}
height={isMobile ? 120 : 170}
strokeWidth={1.5}
fillOpacity={0.24}
showTooltip
showLegend={false}
valueMax={null}
stroke={accent}
stroke2={udpColor}
name1="TCP"
name2="UDP"
yFormatter={(v) => Math.round(v).toLocaleString()}
referenceLines={referenceLines}
/>
</div>
</Card>
);
}
+448 -29
View File
@@ -1,51 +1,470 @@
/* Overview page — trend-first layout. Every colour comes from the AntD theme
tokens so light / dark / ultra-dark keep working and the sidebar is untouched.
Set --ov-accent once here if you want a fixed accent instead of the primary. */
.index-page {
--ov-accent: var(--ant-color-primary);
--ov-line: var(--ant-color-border);
--ov-label: var(--ant-color-text-secondary);
--ov-faint: var(--ant-color-text-tertiary);
--ov-gap: 12px;
--ov-pad: 20px;
}
@media (max-width: 768px) {
.index-page .content-area {
padding: 12px;
padding-top: 64px;
}
.index-page {
--ov-gap: 8px;
--ov-pad: 14px;
}
}
.index-page .action {
cursor: pointer;
justify-content: center;
max-width: 100%;
flex-wrap: nowrap;
.ov-page {
display: flex;
flex-direction: column;
gap: var(--ov-gap);
}
.index-page .action > span:not(.anticon) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
/* — action bar — */
.ov-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.index-page .action-update {
color: var(--ant-color-warning);
font-weight: 600;
}
.index-page .action-update .anticon {
color: var(--ant-color-warning);
}
.index-page .history-tag {
cursor: pointer;
.ov-state {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 8px;
padding: 4px 12px;
border: 1px solid var(--ov-line);
border-radius: 999px;
font-size: 13px;
color: var(--ant-color-text);
}
.ov-state-dot {
position: relative;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
flex: none;
}
.ov-state[data-state='running'] .ov-state-dot::after {
content: '';
position: absolute;
inset: -1px;
border-radius: 50%;
border: 1px solid currentColor;
animation: ovPulse 1.6s infinite ease-out;
}
@keyframes ovPulse {
0% { transform: scale(0.9); opacity: 0.5; }
100% { transform: scale(2.4); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ov-state[data-state='running'] .ov-state-dot::after {
animation: none;
}
}
.ov-state-version,
.ov-panel-version {
padding: 0;
border: 0;
background: transparent;
font: inherit;
cursor: pointer;
color: var(--ov-label);
transition: color 0.2s;
}
.ov-state-version:hover,
.ov-state-version:focus-visible,
.ov-panel-version:hover,
.ov-panel-version:focus-visible {
color: var(--ant-color-primary);
}
.ov-panel-version {
font-size: 12px;
color: var(--ov-faint);
}
.ov-update-tag {
cursor: pointer;
margin-inline-end: 0;
}
.index-page .ip-toggle-icon {
cursor: pointer;
font-size: 16px;
.ov-error-detail {
white-space: pre-wrap;
word-break: break-word;
}
.index-page .ip-hidden .ant-statistic-content-value {
filter: blur(6px);
.ov-bar-actions {
margin-inline-start: auto;
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.ov-bar-actions {
margin-inline-start: 0;
width: 100%;
justify-content: space-between;
}
}
.ov-bar-sep {
width: 1px;
height: 20px;
background: var(--ov-line);
margin: 0 4px;
}
.ov-health {
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
}
.ov-health-mark {
width: 14px;
height: 1px;
background: currentColor;
flex: none;
}
.ov-rule {
height: 1px;
border: 0;
margin: 0;
background: linear-gradient(
to right,
transparent,
var(--ov-line) 48px,
var(--ov-line) calc(100% - 48px),
transparent
);
}
/* — shared type — */
.ov-kicker {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ov-label);
}
.ov-kicker-icon {
display: flex;
align-items: center;
gap: 7px;
}
.ov-sub {
font-size: 12.5px;
margin-top: 4px;
color: var(--ov-faint);
}
.ov-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* — vitals tiles — */
.ov-vitals {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--ov-gap);
}
@media (max-width: 1100px) {
.ov-vitals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.ov-vitals { grid-template-columns: minmax(0, 1fr); }
}
.ov-tile {
overflow: hidden;
}
.ov-tile-head {
display: flex;
align-items: center;
gap: 8px;
padding: 14px var(--ov-pad) 0;
color: var(--ov-accent);
}
.ov-tile-icon {
display: inline-flex;
font-size: 15px;
}
.ov-tile-value {
display: flex;
align-items: baseline;
gap: 4px;
padding: 12px var(--ov-pad) 0;
}
.ov-tile-number {
font-size: 34px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.02em;
color: var(--ant-color-text);
font-variant-numeric: tabular-nums;
}
.ov-tile-unit {
font-size: 14px;
color: var(--ov-label);
}
.ov-tile-detail {
padding: 5px var(--ov-pad) 0;
font-size: 12px;
color: var(--ov-label);
}
.ov-tile-foot {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 14px var(--ov-pad) 0;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ov-faint);
}
.ov-tile-chart {
margin-top: 6px;
}
/* — throughput + connections — */
.ov-mid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: var(--ov-gap);
}
@media (max-width: 1100px) {
.ov-mid { grid-template-columns: minmax(0, 1fr); }
}
.ov-wide-head {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 16px;
padding: var(--ov-pad) var(--ov-pad) 0;
}
.ov-wide-head-stack {
flex-direction: column;
gap: 0;
}
.ov-wide-legend {
margin-inline-start: auto;
display: flex;
gap: 22px;
text-align: end;
}
.ov-legend-label {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
font-size: 11px;
color: var(--ov-label);
}
.ov-legend-num {
font-size: 13px;
font-weight: 600;
color: var(--ant-color-text);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ov-conn-total {
display: flex;
align-items: baseline;
gap: 6px;
margin-top: 12px;
}
.ov-conn-legend {
display: flex;
gap: 16px;
padding: 16px var(--ov-pad) 0;
}
.ov-conn-legend > div {
flex: 1 1 0;
}
.ov-conn-legend .ov-legend-label {
justify-content: flex-start;
}
.ov-swatch {
width: 14px;
height: 2px;
flex: none;
}
.ov-wide-chart {
padding: 12px 8px 0;
}
.ov-wide-foot {
display: flex;
gap: 16px;
margin: 12px var(--ov-pad) 0;
padding: 14px 0 var(--ov-pad);
border-top: 1px solid var(--ov-line);
}
.ov-wide-foot > div {
flex: 1 1 0;
}
.ov-foot-sep {
width: 1px;
background: var(--ov-line);
}
.ov-foot-value {
font-size: 18px;
font-weight: 600;
margin-top: 4px;
color: var(--ant-color-text);
font-variant-numeric: tabular-nums;
}
.ov-foot-part {
display: inline-block;
white-space: nowrap;
}
@media (max-width: 560px) {
.ov-wide-foot {
flex-direction: column;
gap: 10px;
}
.ov-wide-foot .ov-foot-sep {
display: none;
}
}
/* — system strip — */
/* tracks follow SystemStrip's cell order:
uptime (xray | os) · panel (memory | threads) · ip addresses */
.ov-strip-grid {
display: grid;
grid-template-columns:
minmax(max-content, 1.2fr) minmax(max-content, 1.2fr) minmax(0, 1.6fr);
gap: 16px;
padding: var(--ov-pad);
}
@media (max-width: 1439px) {
.ov-strip-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.ov-strip-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.ov-strip-grid { grid-template-columns: minmax(0, 1fr); }
}
@media (min-width: 1440px) {
.ov-strip-cell + .ov-strip-cell {
border-inline-start: 1px solid var(--ov-line);
padding-inline-start: 16px;
}
}
.ov-strip-value {
font-size: 19px;
font-weight: 600;
margin-top: 6px;
color: var(--ant-color-text);
font-variant-numeric: tabular-nums;
}
.ov-strip-split {
display: flex;
align-items: stretch;
gap: 14px;
}
.ov-strip-split-sep {
width: 1px;
background: var(--ov-line);
margin-top: 8px;
}
.ov-strip-sub {
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-top: 8px;
color: var(--ov-faint);
}
.ov-strip-sub + .ov-strip-value {
margin-top: 2px;
}
.ov-ip {
margin-top: 7px;
font-size: 13px;
overflow-wrap: anywhere;
transition: filter 0.2s ease;
}
.index-page .ip-visible .ant-statistic-content-value {
filter: none;
.ov-ip-v6 {
margin-top: 3px;
color: var(--ov-label);
}
/* — preserved from the previous overview — */
.index-page .ip-toggle-icon {
cursor: pointer;
font-size: 15px;
margin-inline-start: auto;
}
.index-page .ip-hidden {
filter: blur(6px);
}
+124 -309
View File
@@ -1,53 +1,29 @@
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, ConfigProvider, Layout, Modal, Result, Spin, message } from 'antd';
import {
Button,
Card,
Col,
ConfigProvider,
Layout,
message,
Modal,
Result,
Row,
Space,
Spin,
Statistic,
Tag,
Tooltip,
} from 'antd';
import {
BarsOutlined,
ControlOutlined,
CloudServerOutlined,
CloudDownloadOutlined,
CloudUploadOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
AreaChartOutlined,
GlobalOutlined,
SwapOutlined,
EyeOutlined,
EyeInvisibleOutlined,
ThunderboltOutlined,
DesktopOutlined,
DatabaseOutlined,
ForkOutlined,
CopyOutlined,
TelegramFilled,
CloudDownloadOutlined,
DashboardOutlined,
DatabaseOutlined,
HddOutlined,
SwapOutlined,
} from '@ant-design/icons';
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version';
import { activateOnKey } from '@/utils/a11y';
import { HttpUtil, CPUFormatter, SizeFormatter, ClipboardManager, FileManager } from '@/utils';
import { USAGE_CRIT_COLOR, USAGE_CRIT_PERCENT, USAGE_WARN_COLOR, USAGE_WARN_PERCENT } from '@/models/status';
import { useTheme } from '@/hooks/useTheme';
import { useStatusQuery } from '@/api/queries/useStatusQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import AppSidebar from '@/layouts/AppSidebar';
import { LazyMount } from '@/components/utility';
import { setMessageInstance } from '@/utils/messageBus';
import StatusCard from './StatusCard';
import XrayStatusCard from './XrayStatusCard';
import OverviewActionBar from './OverviewActionBar';
import VitalTile from './VitalTile';
import ThroughputCard from './ThroughputCard';
import ConnectionsCard from './ConnectionsCard';
import SystemStrip from './SystemStrip';
import { mean, peak, useOverviewHistory } from './useOverviewHistory';
import type { PanelUpdateInfo } from './PanelUpdateModal';
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
@@ -90,6 +66,8 @@ export default function IndexPage() {
const [loading, setLoading] = useState(false);
const [loadingTip, setLoadingTip] = useState(t('loading'));
const history = useOverviewHistory(status, fetched && !fetchError);
useEffect(() => {
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
'/panel/api/setting/defaultSettings',
@@ -127,10 +105,6 @@ export default function IndexPage() {
await refresh();
}, [refresh]);
function openPanelVersion() {
setPanelUpdateOpen(true);
}
async function handleChannelChange(dev: boolean) {
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
if (!res?.success) return;
@@ -139,10 +113,6 @@ export default function IndexPage() {
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
}
function openTelegram() {
window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
}
async function openConfig() {
setLoading(true);
try {
@@ -165,6 +135,23 @@ export default function IndexPage() {
}
const pageClass = `index-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
const totalDisk = status.disk.total;
const freeDisk = Math.max(0, totalDisk - status.disk.current);
const health = useMemo(() => {
const items = [
{ name: t('pages.index.cpu'), value: status.cpu.percent },
{ name: t('pages.index.memory'), value: status.mem.percent },
{ name: t('pages.index.swap'), value: status.swap.percent },
{ name: t('pages.index.storage'), value: status.disk.percent },
];
const list = (xs: typeof items) => xs.map((i) => `${i.name} ${i.value.toFixed(0)}%`).join(', ');
const crit = items.filter((i) => i.value >= USAGE_CRIT_PERCENT);
if (crit.length) return { text: t('pages.index.healthCritical', { list: list(crit) }), color: USAGE_CRIT_COLOR };
const warm = items.filter((i) => i.value >= USAGE_WARN_PERCENT);
if (warm.length) return { text: t('pages.index.healthWarm', { list: list(warm) }), color: USAGE_WARN_COLOR };
return null;
}, [status, t]);
return (
<ConfigProvider theme={antdThemeConfig}>
@@ -190,277 +177,105 @@ export default function IndexPage() {
extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, 12]}>
<Col span={24}>
<StatusCard status={status} isMobile={isMobile} />
</Col>
<div className="ov-page">
<OverviewActionBar
status={status}
isMobile={isMobile}
accessLogEnable={accessLogEnable}
panelVersion={displayVersion}
latestVersion={panelUpdateInfo.latestVersion}
updateAvailable={panelUpdateInfo.updateAvailable}
onStopXray={stopXray}
onRestartXray={restartXray}
onOpenLogs={() => setLogsOpen(true)}
onOpenXrayLogs={() => setXrayLogsOpen(true)}
onOpenConfig={openConfig}
onOpenBackup={() => setBackupOpen(true)}
onOpenSystemHistory={() => setSysHistoryOpen(true)}
onOpenXrayMetrics={() => setXrayMetricsOpen(true)}
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
onOpenVersionSwitch={() => setVersionOpen(true)}
/>
<Col xs={24} lg={12}>
<XrayStatusCard
status={status}
{health && (
<div className="ov-health" style={{ color: health.color }}>
<span className="ov-health-mark" />
{health.text}
</div>
)}
<hr className="ov-rule" />
<div className="ov-vitals">
<VitalTile
icon={<DashboardOutlined />}
label={t('pages.index.cpu')}
percent={status.cpu.percent}
statusColor={status.cpu.color}
detail={`${CPUFormatter.cpuCoreFormat(status.cpuCores)} / ${status.logicalPro}T · ${CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.cpu).toFixed(0)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.cpu).toFixed(0)}%`}
data={history.series.cpu}
isMobile={isMobile}
accessLogEnable={accessLogEnable}
onStopXray={stopXray}
onRestartXray={restartXray}
onOpenXrayLogs={() => setXrayLogsOpen(true)}
onOpenLogs={() => setLogsOpen(true)}
onOpenVersionSwitch={() => setVersionOpen(true)}
/>
</Col>
<Col xs={24} lg={12}>
<Card
title={t('menu.link')}
hoverable
actions={[
<Space className="action" key="logs" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={() => setLogsOpen(true)} onKeyDown={activateOnKey(() => setLogsOpen(true))}>
<BarsOutlined />
{!isMobile && <span>{t('pages.index.logs')}</span>}
</Space>,
<Space className="action" key="config" role="button" tabIndex={0} aria-label={t('pages.index.config')} onClick={openConfig} onKeyDown={activateOnKey(openConfig)}>
<ControlOutlined />
{!isMobile && <span>{t('pages.index.config')}</span>}
</Space>,
<Space className="action" key="backup" role="button" tabIndex={0} aria-label={t('pages.index.backupTitle')} onClick={() => setBackupOpen(true)} onKeyDown={activateOnKey(() => setBackupOpen(true))}>
<CloudServerOutlined />
{!isMobile && <span>{t('pages.index.backupTitle')}</span>}
</Space>,
]}
<VitalTile
icon={<DatabaseOutlined />}
label={t('pages.index.memory')}
percent={status.mem.percent}
statusColor={status.mem.color}
detail={`${SizeFormatter.sizeFormat(status.mem.current)} / ${SizeFormatter.sizeFormat(status.mem.total)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.mem).toFixed(0)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.mem).toFixed(0)}%`}
data={history.series.mem}
isMobile={isMobile}
/>
</Col>
<Col xs={24} lg={12}>
<Card
title={
<Space>
<span>3X-UI</span>
{isMobile && displayVersion && (
<Tag color={panelUpdateInfo.updateAvailable ? 'orange' : 'green'}>
{panelUpdateInfo.updateAvailable
? formatPanelVersion(panelUpdateInfo.latestVersion)
: formatPanelVersion(displayVersion)}
</Tag>
)}
</Space>
}
hoverable
actions={[
<Space className="action" key="tg" role="button" tabIndex={0} aria-label="@XrayUI" onClick={openTelegram} onKeyDown={activateOnKey(openTelegram)}>
<TelegramFilled aria-hidden="true" />
{!isMobile && <span>@XrayUI</span>}
</Space>,
<Space
key="panel-version"
className={`action ${panelUpdateInfo.updateAvailable ? 'action-update' : ''}`}
role="button"
tabIndex={0}
aria-label={t('pages.index.updatePanel')}
onClick={openPanelVersion}
onKeyDown={activateOnKey(openPanelVersion)}
>
<CloudDownloadOutlined />
{!isMobile && (
<span>
{panelUpdateInfo.updateAvailable
? `${t('update')} ${formatPanelVersion(panelUpdateInfo.latestVersion)}`
: formatPanelVersion(displayVersion)}
</span>
)}
</Space>,
]}
<VitalTile
icon={<SwapOutlined />}
label={t('pages.index.swap')}
percent={status.swap.percent}
statusColor={status.swap.color}
detail={`${SizeFormatter.sizeFormat(status.swap.current)} / ${SizeFormatter.sizeFormat(status.swap.total)}`}
footLeft={`${t('pages.index.avg')} ${mean(history.series.swap).toFixed(1)}%`}
footRight={`${t('pages.index.peak')} ${peak(history.series.swap).toFixed(0)}%`}
data={history.series.swap}
isMobile={isMobile}
/>
</Col>
<Col xs={24} lg={12}>
<Card
title={t('pages.index.charts')}
hoverable
actions={[
<Space
className="action"
key="sys-history"
role="button"
tabIndex={0}
aria-label={t('pages.index.systemHistoryTitle')}
onClick={() => setSysHistoryOpen(true)}
onKeyDown={activateOnKey(() => setSysHistoryOpen(true))}
>
<AreaChartOutlined />
{!isMobile && <span>{t('pages.index.systemHistoryTitle')}</span>}
</Space>,
<Space
className="action"
key="xray-metrics"
role="button"
tabIndex={0}
aria-label={t('pages.index.xrayMetricsTitle')}
onClick={() => setXrayMetricsOpen(true)}
onKeyDown={activateOnKey(() => setXrayMetricsOpen(true))}
>
<AreaChartOutlined />
{!isMobile && <span>{t('pages.index.xrayMetricsTitle')}</span>}
</Space>,
]}
<VitalTile
icon={<HddOutlined />}
label={t('pages.index.storage')}
percent={status.disk.percent}
statusColor={status.disk.color}
detail={`${SizeFormatter.sizeFormat(status.disk.current)} / ${SizeFormatter.sizeFormat(totalDisk)}`}
footLeft={`${t('pages.index.free')} ${SizeFormatter.sizeFormat(freeDisk)}`}
footRight={`${t('pages.index.avg')} ${mean(history.series.diskUsage).toFixed(1)}%`}
data={history.series.diskUsage}
isMobile={isMobile}
/>
</Col>
</div>
<Col xs={24} lg={12}>
<Card title={t('pages.index.operationHours')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title="Xray"
value={TimeFormatter.formatSecond(status.appStats.uptime)}
prefix={<ThunderboltOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title="OS"
value={TimeFormatter.formatSecond(status.uptime)}
prefix={<DesktopOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<div className="ov-mid">
<ThroughputCard
status={status}
up={history.series.netUp}
down={history.series.netDown}
labels={history.labels}
isMobile={isMobile}
/>
<ConnectionsCard
status={status}
tcp={history.series.tcpCount}
udp={history.series.udpCount}
labels={history.labels}
isMobile={isMobile}
/>
</div>
<Col xs={24} lg={12}>
<Card title={t('usage')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title={t('pages.index.memory')}
value={SizeFormatter.sizeFormat(status.appStats.mem)}
prefix={<DatabaseOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title={t('pages.index.threads')}
value={status.appStats.threads}
prefix={<ForkOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={t('pages.index.overallSpeed')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title={t('pages.index.upload')}
value={SizeFormatter.sizeFormat(status.netIO.up)}
prefix={<ArrowUpOutlined />}
suffix="/s"
/>
</Col>
<Col span={12}>
<Statistic
title={t('pages.index.download')}
value={SizeFormatter.sizeFormat(status.netIO.down)}
prefix={<ArrowDownOutlined />}
suffix="/s"
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={t('pages.index.totalData')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title={t('pages.index.sent')}
value={SizeFormatter.sizeFormat(status.netTraffic.sent)}
prefix={<CloudUploadOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title={t('pages.index.received')}
value={SizeFormatter.sizeFormat(status.netTraffic.recv)}
prefix={<CloudDownloadOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title={t('pages.index.ipAddresses')}
hoverable
extra={
<Tooltip
title={t('pages.index.toggleIpVisibility')}
placement={isMobile ? 'topRight' : 'top'}
>
{showIp ? (
<EyeOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowIp(false)}
onKeyDown={activateOnKey(() => setShowIp(false))}
/>
) : (
<EyeInvisibleOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowIp(true)}
onKeyDown={activateOnKey(() => setShowIp(true))}
/>
)}
</Tooltip>
}
>
<Row className={showIp ? 'ip-visible' : 'ip-hidden'} gutter={isMobile ? [8, 8] : 0}>
<Col span={isMobile ? 24 : 12}>
<Statistic
title="IPv4"
value={status.publicIP.ipv4}
prefix={<GlobalOutlined />}
/>
</Col>
<Col span={isMobile ? 24 : 12}>
<Statistic
title="IPv6"
value={status.publicIP.ipv6}
prefix={<GlobalOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={t('pages.index.connectionCount')} hoverable>
<Row gutter={isMobile ? [8, 8] : 0}>
<Col span={12}>
<Statistic
title="TCP"
value={status.tcpCount}
prefix={<SwapOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title="UDP"
value={status.udpCount}
prefix={<SwapOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
</Row>
<SystemStrip
status={status}
showIp={showIp}
onToggleIp={() => setShowIp((v) => !v)}
/>
</div>
)}
</Spin>
</Layout.Content>
@@ -0,0 +1,163 @@
import { Fragment } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Tag, Tooltip } from 'antd';
import {
ArrowUpOutlined,
AreaChartOutlined,
BarsOutlined,
CloudDownloadOutlined,
CloudServerOutlined,
ControlOutlined,
FileTextOutlined,
PoweroffOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { formatPanelVersion } from '@/lib/panel-version';
import type { Status } from '@/models/status';
interface OverviewActionBarProps {
status: Status;
isMobile: boolean;
accessLogEnable: boolean;
panelVersion: string;
latestVersion: string;
updateAvailable: boolean;
onStopXray: () => void;
onRestartXray: () => void;
onOpenLogs: () => void;
onOpenXrayLogs: () => void;
onOpenConfig: () => void;
onOpenBackup: () => void;
onOpenSystemHistory: () => void;
onOpenXrayMetrics: () => void;
onOpenPanelUpdate: () => void;
onOpenVersionSwitch: () => void;
}
interface BarAction {
key: string;
icon: ReactNode;
text: string;
onClick: () => void;
primary?: boolean;
}
const XRAY_STATE_KEYS: Record<string, string> = {
running: 'pages.index.xrayStatusRunning',
stop: 'pages.index.xrayStatusStop',
error: 'pages.index.xrayStatusError',
};
export default function OverviewActionBar({
status,
isMobile,
accessLogEnable,
panelVersion,
latestVersion,
updateAvailable,
onStopXray,
onRestartXray,
onOpenLogs,
onOpenXrayLogs,
onOpenConfig,
onOpenBackup,
onOpenSystemHistory,
onOpenXrayMetrics,
onOpenPanelUpdate,
onOpenVersionSwitch,
}: OverviewActionBarProps) {
const { t } = useTranslation();
const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
const hasVersion = !!status.xray.version && status.xray.version !== 'Unknown';
const size = isMobile ? ('small' as const) : ('middle' as const);
const actionGroups: BarAction[][] = [
[
{ key: 'restart', icon: <ReloadOutlined />, text: t('pages.index.restartXray'), onClick: onRestartXray, primary: true },
{ key: 'stop', icon: <PoweroffOutlined />, text: t('pages.index.stopXray'), onClick: onStopXray },
],
[
{ key: 'logs', icon: <BarsOutlined />, text: t('pages.index.logs'), onClick: onOpenLogs },
...(accessLogEnable
? [{ key: 'accessLogs', icon: <FileTextOutlined />, text: t('pages.index.accessLogs'), onClick: onOpenXrayLogs }]
: []),
{ key: 'config', icon: <ControlOutlined />, text: t('pages.index.config'), onClick: onOpenConfig },
{ key: 'backup', icon: <CloudServerOutlined />, text: t('pages.index.backupTitle'), onClick: onOpenBackup },
],
[
{ key: 'history', icon: <AreaChartOutlined />, text: t('pages.index.systemHistoryTitle'), onClick: onOpenSystemHistory },
{ key: 'metrics', icon: <ArrowUpOutlined />, text: t('pages.index.xrayMetricsTitle'), onClick: onOpenXrayMetrics },
],
];
const statePill = (
<span className="ov-state" data-state={status.xray.state}>
<span className="ov-state-dot" style={{ color: status.xray.color }} />
<span>{`${t('pages.index.xrayStatus')} · ${stateText}`}</span>
{hasVersion && (
<Tooltip title={t('pages.index.xraySwitch')}>
<button
type="button"
className="ov-state-version"
onClick={onOpenVersionSwitch}
>
{`v${status.xray.version}`}
</button>
</Tooltip>
)}
</span>
);
return (
<div className="ov-bar">
{status.xray.state === 'error' && status.xray.errorMsg ? (
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
{statePill}
</Tooltip>
) : (
statePill
)}
{updateAvailable ? (
<Tag
className="ov-update-tag"
color="warning"
icon={<CloudDownloadOutlined />}
onClick={onOpenPanelUpdate}
>
{`${t('update')} ${formatPanelVersion(latestVersion)}`}
</Tag>
) : (
<Tooltip title={t('pages.index.updatePanel')}>
<button type="button" className="ov-panel-version ov-mono" onClick={onOpenPanelUpdate}>
{formatPanelVersion(panelVersion)}
</button>
</Tooltip>
)}
<div className="ov-bar-actions">
{actionGroups.map((group, groupIndex) => (
<Fragment key={group[0].key}>
{groupIndex > 0 && <span className="ov-bar-sep" />}
{group.map((action) => (
<Button
key={action.key}
type={action.primary ? undefined : 'text'}
color={action.primary ? 'primary' : undefined}
variant={action.primary ? 'outlined' : undefined}
size={size}
icon={action.icon}
aria-label={action.text}
onClick={action.onClick}
>
{isMobile ? undefined : action.text}
</Button>
))}
</Fragment>
))}
</div>
</div>
);
}
-9
View File
@@ -1,9 +0,0 @@
.status-card .text-center {
text-align: center;
}
.status-card .ant-progress-text,
.status-card .ant-progress-indicator {
font-size: 12px !important;
font-weight: 500;
}
-115
View File
@@ -1,115 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Card, Col, Progress, Row, Tooltip } from 'antd';
import { AreaChartOutlined } from '@ant-design/icons';
import { CPUFormatter, SizeFormatter } from '@/utils';
import { useTheme } from '@/hooks/useTheme';
import type { Status } from '@/models/status';
import './StatusCard.css';
interface StatusCardProps {
status: Status;
isMobile: boolean;
}
export default function StatusCard({ status, isMobile }: StatusCardProps) {
const { t } = useTranslation();
const { isDark, isUltra } = useTheme();
const gaugeSize = isMobile ? 60 : 90;
const strokeWidth = isMobile ? 7 : 5;
const railColor = isDark
? isUltra ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.16)'
: 'rgba(0, 0, 0, 0.08)';
return (
<Card hoverable className="status-card">
<Row gutter={[0, isMobile ? 16 : 0]}>
<Col xs={24} md={12}>
<Row>
<Col span={12} className="text-center">
<Progress
type="dashboard"
status="normal"
strokeColor={status.cpu.color}
railColor={railColor}
strokeWidth={strokeWidth}
percent={status.cpu.percent}
size={gaugeSize}
/>
<div>
<b>{t('pages.index.cpu')}:</b> {CPUFormatter.cpuCoreFormat(status.cpuCores)}
<Tooltip
title={
<>
<div>
<b>{t('pages.index.logicalProcessors')}:</b> {status.logicalPro}
</div>
<div>
<b>{t('pages.index.frequency')}:</b>{' '}
{CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}
</div>
</>
}
>
<AreaChartOutlined />
</Tooltip>
</div>
</Col>
<Col span={12} className="text-center">
<Progress
type="dashboard"
status="normal"
strokeColor={status.mem.color}
railColor={railColor}
strokeWidth={strokeWidth}
percent={status.mem.percent}
size={gaugeSize}
/>
<div>
<b>{t('pages.index.memory')}:</b> {SizeFormatter.sizeFormat(status.mem.current)} /{' '}
{SizeFormatter.sizeFormat(status.mem.total)}
</div>
</Col>
</Row>
</Col>
<Col xs={24} md={12}>
<Row>
<Col span={12} className="text-center">
<Progress
type="dashboard"
status="normal"
strokeColor={status.swap.color}
railColor={railColor}
strokeWidth={strokeWidth}
percent={status.swap.percent}
size={gaugeSize}
/>
<div>
<b>{t('pages.index.swap')}:</b> {SizeFormatter.sizeFormat(status.swap.current)} /{' '}
{SizeFormatter.sizeFormat(status.swap.total)}
</div>
</Col>
<Col span={12} className="text-center">
<Progress
type="dashboard"
status="normal"
strokeColor={status.disk.color}
railColor={railColor}
strokeWidth={strokeWidth}
percent={status.disk.percent}
size={gaugeSize}
/>
<div>
<b>{t('pages.index.storage')}:</b> {SizeFormatter.sizeFormat(status.disk.current)} /{' '}
{SizeFormatter.sizeFormat(status.disk.total)}
</div>
</Col>
</Row>
</Col>
</Row>
</Card>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useTranslation } from 'react-i18next';
import { Card, Tooltip } from 'antd';
import {
ClockCircleOutlined,
DatabaseOutlined,
EyeInvisibleOutlined,
EyeOutlined,
GlobalOutlined,
} from '@ant-design/icons';
import { SizeFormatter, TimeFormatter } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import type { Status } from '@/models/status';
interface SystemStripProps {
status: Status;
showIp: boolean;
onToggleIp: () => void;
}
export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripProps) {
const { t } = useTranslation();
return (
<Card hoverable styles={{ body: { padding: 0 } }}>
<div className="ov-strip-grid">
<div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon">
<ClockCircleOutlined />
{t('pages.index.uptime')}
</div>
<div className="ov-strip-split">
<div>
<div className="ov-strip-sub">Xray</div>
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.appStats.uptime)}</div>
</div>
<span className="ov-strip-split-sep" />
<div>
<div className="ov-strip-sub">OS</div>
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
</div>
</div>
</div>
<div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon">
<DatabaseOutlined />
{t('pages.index.panel')}
</div>
<div className="ov-strip-split">
<div>
<div className="ov-strip-sub">{t('pages.index.memory')}</div>
<div className="ov-strip-value">{SizeFormatter.sizeFormat(status.appStats.mem)}</div>
</div>
<span className="ov-strip-split-sep" />
<div>
<div className="ov-strip-sub">{t('pages.index.threads')}</div>
<div className="ov-strip-value">{status.appStats.threads}</div>
</div>
</div>
</div>
<div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon">
<GlobalOutlined />
{t('pages.index.ipAddresses')}
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{showIp ? (
<EyeOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={onToggleIp}
onKeyDown={activateOnKey(onToggleIp)}
/>
) : (
<EyeInvisibleOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={onToggleIp}
onKeyDown={activateOnKey(onToggleIp)}
/>
)}
</Tooltip>
</div>
<div className={`ov-ip${showIp ? '' : ' ip-hidden'}`}>
<div className="ov-mono">{status.publicIP.ipv4}</div>
<div className="ov-mono ov-ip-v6">{status.publicIP.ipv6}</div>
</div>
</div>
</div>
</Card>
);
}
@@ -0,0 +1,97 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, theme } from 'antd';
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons';
import { SizeFormatter } from '@/utils';
import { Sparkline } from '@/components/viz';
import type { Status } from '@/models/status';
import { mean, peak } from './useOverviewHistory';
interface ThroughputCardProps {
status: Status;
up: number[];
down: number[];
labels: string[];
isMobile: boolean;
}
export default function ThroughputCard({ status, up, down, labels, isMobile }: ThroughputCardProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const accent = token.colorPrimary;
const downColor = token.colorTextTertiary;
const referenceLines = useMemo(
() => [
{ y: status.netIO.down, color: downColor, dash: '2 4' },
{ y: status.netIO.up, color: accent, dash: '2 4' },
],
[status.netIO.up, status.netIO.down, accent, downColor],
);
return (
<Card hoverable styles={{ body: { padding: 0 } }}>
<div className="ov-wide-head">
<div>
<div className="ov-kicker">{t('pages.index.overallSpeed')}</div>
<div className="ov-sub">
{`${t('pages.index.throughputSub')} · ${t('pages.index.peak')} ${SizeFormatter.speedFormat(peak(down))}`}
</div>
</div>
<div className="ov-wide-legend">
<div className="ov-legend-label">
<ArrowUpOutlined style={{ color: accent }} />
{t('pages.index.upload')}
<span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.up)}</span>
</div>
<div className="ov-legend-label">
<ArrowDownOutlined style={{ color: downColor }} />
{t('pages.index.download')}
<span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.down)}</span>
</div>
</div>
</div>
<div className="ov-wide-chart">
<Sparkline
data={up}
data2={down}
labels={labels}
height={isMobile ? 140 : 186}
strokeWidth={1.75}
fillOpacity={0.24}
showTooltip
showLegend={false}
valueMax={null}
stroke={accent}
stroke2={downColor}
name1={t('pages.index.upload')}
name2={t('pages.index.download')}
yFormatter={SizeFormatter.speedFormat}
referenceLines={referenceLines}
/>
</div>
<div className="ov-wide-foot">
<div>
<div className="ov-kicker">{t('pages.index.sent')}</div>
<div className="ov-foot-value">{SizeFormatter.sizeFormat(status.netTraffic.sent)}</div>
</div>
<span className="ov-foot-sep" />
<div>
<div className="ov-kicker">{t('pages.index.received')}</div>
<div className="ov-foot-value">{SizeFormatter.sizeFormat(status.netTraffic.recv)}</div>
</div>
<span className="ov-foot-sep" />
<div>
<div className="ov-kicker">{t('pages.index.avgWindow')}</div>
<div className="ov-foot-value">
<span className="ov-foot-part">{`↑ ${SizeFormatter.speedFormat(mean(up))}`}</span>{' '}
<span className="ov-foot-part">{`↓ ${SizeFormatter.speedFormat(mean(down))}`}</span>
</div>
</div>
</div>
</Card>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { Card, theme } from 'antd';
import { Sparkline } from '@/components/viz';
import { mean, peak } from './useOverviewHistory';
interface VitalTileProps {
icon: ReactNode;
label: string;
percent: number;
statusColor: string;
detail: string;
footLeft: string;
footRight: string;
data: number[];
isMobile: boolean;
}
export default function VitalTile({
icon,
label,
percent,
statusColor,
detail,
footLeft,
footRight,
data,
isMobile,
}: VitalTileProps) {
const { token } = theme.useToken();
const meanColor = token.colorTextTertiary;
const referenceLines = useMemo(
() => (data.length > 1 ? [{ y: mean(data), dash: '3 4', color: meanColor }] : []),
[data, meanColor],
);
return (
<Card hoverable className="ov-tile" styles={{ body: { padding: 0 } }}>
<div className="ov-tile-head">
<span className="ov-tile-icon">{icon}</span>
<span className="ov-kicker">{label}</span>
</div>
<div className="ov-tile-value">
<span className="ov-tile-number">{percent.toFixed(1)}</span>
<span className="ov-tile-unit">%</span>
</div>
<div className="ov-tile-detail">{detail}</div>
<div className="ov-tile-foot">
<span>{footLeft}</span>
<span>{footRight}</span>
</div>
<div className="ov-tile-chart">
<Sparkline
data={data}
height={isMobile ? 48 : 62}
strokeWidth={1.5}
fillOpacity={0.3}
showGrid={false}
showMarker={false}
valueMax={peak(data) > 0 ? null : 100}
stroke={statusColor}
referenceLines={referenceLines}
yFormatter={(v) => `${v.toFixed(0)}%`}
name1={label}
/>
</div>
</Card>
);
}
@@ -1,14 +0,0 @@
.xray-status-card .action {
cursor: pointer;
justify-content: center;
}
.error-line {
display: block;
max-width: 400px;
white-space: pre-wrap;
}
.cursor-pointer {
cursor: pointer;
}
-123
View File
@@ -1,123 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge, Card, Col, Popover, Row, Space, Tag } from 'antd';
import {
BarsOutlined,
PoweroffOutlined,
ReloadOutlined,
ToolOutlined,
} from '@ant-design/icons';
import type { Status } from '@/models/status';
import { activateOnKey } from '@/utils/a11y';
import './XrayStatusCard.css';
interface XrayStatusCardProps {
status: Status;
isMobile: boolean;
accessLogEnable: boolean;
onStopXray: () => void;
onRestartXray: () => void;
onOpenLogs: () => void;
onOpenXrayLogs: () => void;
onOpenVersionSwitch: () => void;
}
const XRAY_STATE_KEYS: Record<string, string> = {
running: 'pages.index.xrayStatusRunning',
stop: 'pages.index.xrayStatusStop',
error: 'pages.index.xrayStatusError',
};
export default function XrayStatusCard({
status,
isMobile,
accessLogEnable,
onStopXray,
onRestartXray,
onOpenLogs,
onOpenXrayLogs,
onOpenVersionSwitch,
}: XrayStatusCardProps) {
const { t } = useTranslation();
const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
const title = (
<Space>
<span>{t('pages.index.xrayStatus')}</span>
{isMobile && status.xray.version && status.xray.version !== 'Unknown' && (
<Tag color="green">v{status.xray.version}</Tag>
)}
</Space>
);
const errorLines = useMemo(
() => (status.xray.errorMsg || '').split('\n'),
[status.xray.errorMsg],
);
const extra =
status.xray.state !== 'error' ? (
<Badge status="processing" text={stateText} color={status.xray.color} />
) : (
<Popover
title={
<Row align="middle" justify="space-between">
<Col>
<span>{t('pages.index.xrayStatusError')}</span>
</Col>
<Col>
<BarsOutlined className="cursor-pointer" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={onOpenLogs} onKeyDown={activateOnKey(onOpenLogs)} />
</Col>
</Row>
}
content={
<>
{errorLines.map((line, i) => (
<span key={i} className="error-line">
{line}
</span>
))}
</>
}
>
<Badge status="processing" text={stateText} color={status.xray.color} />
</Popover>
);
const actions = [
// the xray log viewer reads the access log file, so the button only makes
// sense when one is configured (unlike IP limit, which no longer needs it)
...(accessLogEnable
? [
<Space className="action" key="xraylogs" role="button" tabIndex={0} aria-label={t('pages.index.accessLogs')} onClick={onOpenXrayLogs} onKeyDown={activateOnKey(onOpenXrayLogs)}>
<BarsOutlined />
{!isMobile && <span>{t('pages.index.accessLogs')}</span>}
</Space>,
]
: []),
<Space className="action" key="stop" role="button" tabIndex={0} aria-label={t('pages.index.stopXray')} onClick={onStopXray} onKeyDown={activateOnKey(onStopXray)}>
<PoweroffOutlined />
{!isMobile && <span>{t('pages.index.stopXray')}</span>}
</Space>,
<Space className="action" key="restart" role="button" tabIndex={0} aria-label={t('pages.index.restartXray')} onClick={onRestartXray} onKeyDown={activateOnKey(onRestartXray)}>
<ReloadOutlined />
{!isMobile && <span>{t('pages.index.restartXray')}</span>}
</Space>,
<Space className="action" key="switch" role="button" tabIndex={0} aria-label={t('pages.index.xraySwitch')} onClick={onOpenVersionSwitch} onKeyDown={activateOnKey(onOpenVersionSwitch)}>
<ToolOutlined />
{!isMobile && (
<span>
{status.xray.version && status.xray.version !== 'Unknown'
? `v${status.xray.version}`
: t('pages.index.xraySwitch')}
</span>
)}
</Space>,
];
return (
<Card hoverable title={title} extra={extra} actions={actions} className="xray-status-card" />
);
}
@@ -0,0 +1,135 @@
import { useEffect, useMemo, useState } from 'react';
import { HttpUtil, TimeFormatter } from '@/utils';
import type { Status } from '@/models/status';
const OVERVIEW_WINDOW = 72;
const SEED_BUCKET_SECONDS = 2;
const SERIES_KEYS = ['cpu', 'mem', 'swap', 'diskUsage', 'netUp', 'netDown', 'tcpCount', 'udpCount'] as const;
export type OverviewSeriesKey = (typeof SERIES_KEYS)[number];
export interface OverviewHistory {
series: Record<OverviewSeriesKey, number[]>;
labels: string[];
}
interface HistoryPoint {
t: number;
v: number;
}
interface HistoryWindow {
series: Record<OverviewSeriesKey, number[]>;
times: number[];
}
function emptySeries(): Record<OverviewSeriesKey, number[]> {
return Object.fromEntries(SERIES_KEYS.map((key) => [key, [] as number[]])) as Record<OverviewSeriesKey, number[]>;
}
function emptyWindow(): HistoryWindow {
return { series: emptySeries(), times: [] };
}
function sampleOf(status: Status): Record<OverviewSeriesKey, number> {
return {
cpu: status.cpu.percent,
mem: status.mem.percent,
swap: status.swap.percent,
diskUsage: status.disk.percent,
netUp: status.netIO.up,
netDown: status.netIO.down,
tcpCount: status.tcpCount,
udpCount: status.udpCount,
};
}
function tailWindow<T>(values: T[]): T[] {
return values.slice(-OVERVIEW_WINDOW);
}
export function mean(values: number[]): number {
if (values.length === 0) return 0;
let total = 0;
for (const v of values) total += v;
return total / values.length;
}
export function peak(values: number[]): number {
let max = 0;
for (const v of values) if (v > max) max = v;
return max;
}
/* the seed bucket must be in the backend's allowedHistoryBuckets whitelist;
2s is the smallest and matches the status poll cadence */
export function useOverviewHistory(status: Status, hasData: boolean): OverviewHistory {
const [trend, setTrend] = useState<HistoryWindow>(emptyWindow);
useEffect(() => {
let cancelled = false;
const seed = async () => {
const responses = new Map<OverviewSeriesKey, HistoryPoint[]>();
await Promise.all(
SERIES_KEYS.map(async (key) => {
const msg = await HttpUtil.get<HistoryPoint[]>(
`/panel/api/server/history/${key}/${SEED_BUCKET_SECONDS}`,
undefined,
{ silent: true },
);
if (msg?.success && Array.isArray(msg.obj)) responses.set(key, msg.obj);
}),
);
if (cancelled || responses.size === 0) return;
let axis: HistoryPoint[] = [];
for (const points of responses.values()) {
if (points.length > axis.length) axis = points;
}
axis = tailWindow(axis);
if (axis.length === 0) return;
const seedTimes = axis.map((p) => Number(p.t) || 0);
const seedSeries = emptySeries();
for (const key of SERIES_KEYS) {
const byTs = new Map<number, number>();
for (const p of responses.get(key) ?? []) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
seedSeries[key] = seedTimes.map((ts) => byTs.get(ts) ?? 0);
}
setTrend((prev) => {
const merged = emptyWindow();
merged.times = tailWindow(seedTimes.concat(prev.times));
for (const key of SERIES_KEYS) {
merged.series[key] = tailWindow(seedSeries[key].concat(prev.series[key]));
}
return merged;
});
};
seed().catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!hasData) return;
setTrend((prev) => {
const point = sampleOf(status);
const next = emptyWindow();
next.times = tailWindow(prev.times.concat(Math.floor(Date.now() / 1000)));
for (const key of SERIES_KEYS) {
next.series[key] = tailWindow(prev.series[key].concat(point[key]));
}
return next;
});
}, [status, hasData]);
const labels = useMemo(() => trend.times.map(TimeFormatter.formatClock), [trend.times]);
return useMemo(() => ({ series: trend.series, labels }), [trend.series, labels]);
}
+4 -3
View File
@@ -3,8 +3,9 @@ import { useTranslation } from 'react-i18next';
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
@@ -62,9 +63,9 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
onChange={(e) => updateSetting({ smtpHost: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} description={t('pages.settings.smtpPortDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} badge={<DefaultSettingTag settingKey="smtpPort" value={allSetting.smtpPort} />} description={t('pages.settings.smtpPortDesc')}>
<InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
onChange={(v) => updateSetting({ smtpPort: Number(v) || 587 })} />
onChange={onNumber((v) => updateSetting({ smtpPort: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpUsername')} description={t('pages.settings.smtpUsernameDesc')}>
+20 -19
View File
@@ -17,7 +17,8 @@ import {
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { HttpUtil, LanguageManager } from '@/utils';
import { SettingListItem } from '@/components/ui';
import { onNumber } from '@/utils/onNumber';
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath } from './uriPath';
@@ -168,18 +169,18 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
<Input value={allSetting.webDomain} onChange={(e) => updateSetting({ webDomain: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} description={t('pages.settings.panelPortDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.panelPort')} badge={<DefaultSettingTag settingKey="webPort" value={allSetting.webPort} />} description={t('pages.settings.panelPortDesc')}>
<InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
onChange={(v) => updateSetting({ webPort: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ webPort: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelUrlPath')} description={t('pages.settings.panelUrlPathDesc')}>
<Input value={allSetting.webBasePath} onChange={(e) => updateSetting({ webBasePath: sanitizePath(e.target.value) })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} description={t('pages.settings.sessionMaxAgeDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} badge={<DefaultSettingTag settingKey="sessionMaxAge" value={allSetting.sessionMaxAge} />} description={t('pages.settings.sessionMaxAgeDesc')}>
<InputNumber value={allSetting.sessionMaxAge} min={60} max={525600} style={{ width: '100%' }}
onChange={(v) => updateSetting({ sessionMaxAge: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))} />
</SettingListItem>
<SettingListItem
@@ -206,9 +207,9 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.pageSize')} badge={<DefaultSettingTag settingKey="pageSize" value={allSetting.pageSize} />} description={t('pages.settings.pageSizeDesc')}>
<InputNumber value={allSetting.pageSize} min={0} max={1000} step={5} style={{ width: '100%' }}
onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ pageSize: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
@@ -232,13 +233,13 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} badge={<DefaultSettingTag settingKey="expireDiff" value={allSetting.expireDiff} />} description={t('pages.settings.expireTimeDiffDesc')}>
<InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
onChange={(v) => updateSetting({ expireDiff: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ expireDiff: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} badge={<DefaultSettingTag settingKey="trafficDiff" value={allSetting.trafficDiff} />} description={t('pages.settings.trafficDiffDesc')}>
<InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
onChange={(v) => updateSetting({ trafficDiff: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ trafficDiff: v }))} />
</SettingListItem>
</>
),
@@ -306,9 +307,9 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
<SettingListItem paddings="small" title={t('pages.settings.ldap.host')}>
<Input value={allSetting.ldapHost} onChange={(e) => updateSetting({ ldapHost: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.port')}>
<SettingListItem paddings="small" title={t('pages.settings.ldap.port')} badge={<DefaultSettingTag settingKey="ldapPort" value={allSetting.ldapPort} />}>
<InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
onChange={(v) => updateSetting({ ldapPort: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ ldapPort: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
<Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
@@ -385,17 +386,17 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoDelete')}>
<Switch checked={allSetting.ldapAutoDelete} onChange={(v) => updateSetting({ ldapAutoDelete: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')}>
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')} badge={<DefaultSettingTag settingKey="ldapDefaultTotalGB" value={allSetting.ldapDefaultTotalGB} />}>
<InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
onChange={(v) => updateSetting({ ldapDefaultTotalGB: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')}>
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')} badge={<DefaultSettingTag settingKey="ldapDefaultExpiryDays" value={allSetting.ldapDefaultExpiryDays} />}>
<InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
onChange={(v) => updateSetting({ ldapDefaultExpiryDays: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')}>
<SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')} badge={<DefaultSettingTag settingKey="ldapDefaultLimitIP" value={allSetting.ldapDefaultLimitIP} />}>
<InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
onChange={(v) => updateSetting({ ldapDefaultLimitIP: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))} />
</SettingListItem>
</>
),
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { useLocation } from 'react-router';
import {
Alert,
Button,
@@ -17,6 +17,7 @@ import {
SettingOutlined,
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { GoRegexInput } from '@/components/form';
import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -279,11 +280,11 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
<div className="format-settings">
<SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
<InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
onChange={(v) => setMuxField('concurrency', Number(v) || 0)} />
onChange={onNumber((v) => setMuxField('concurrency', v))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpConcurrency')}>
<InputNumber value={muxObj.xudpConcurrency} min={-1} max={1024} style={{ width: '100%' }}
onChange={(v) => setMuxField('xudpConcurrency', Number(v) || 0)} />
onChange={onNumber((v) => setMuxField('xudpConcurrency', v))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpUdp443')}>
<Select
@@ -1,9 +1,10 @@
import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { onNumber } from '@/utils/onNumber';
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { RemarkTemplateField } from '@/components/form';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
@@ -55,9 +56,9 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
<SettingListItem paddings="small" title={t('pages.settings.subDomain')} description={t('pages.settings.subDomainDesc')}>
<Input value={allSetting.subDomain} onChange={(e) => updateSetting({ subDomain: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subPort')} description={t('pages.settings.subPortDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.subPort')} badge={<DefaultSettingTag settingKey="subPort" value={allSetting.subPort} />} description={t('pages.settings.subPortDesc')}>
<InputNumber value={allSetting.subPort} min={1} max={65535} style={{ width: '100%' }}
onChange={(v) => updateSetting({ subPort: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ subPort: v }))} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subPath')} description={t('pages.settings.subPathDesc')}>
<Input
@@ -93,10 +94,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
maxLength={256}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subShowIdentityOnAllLinks')}
description={t('pages.settings.subShowIdentityOnAllLinksDesc')}
>
<Switch
checked={allSetting.subShowIdentityOnAllLinks}
onChange={(v) => updateSetting({ subShowIdentityOnAllLinks: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} badge={<DefaultSettingTag settingKey="subUpdates" value={allSetting.subUpdates} />} description={t('pages.settings.subUpdatesDesc')}>
<InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}
onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
onChange={onNumber((v) => updateSetting({ subUpdates: v }))} />
</SettingListItem>
</>
),
+3 -1
View File
@@ -4,6 +4,7 @@ import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from '
import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { LanguageManager } from '@/utils';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
@@ -122,9 +123,10 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
<Space.Compact style={{ width: '100%' }}>
<InputNumber
min={1}
precision={0}
style={{ width: '50%' }}
value={state.num}
onChange={(v) => update({ num: Math.max(1, Number(v) || 1) })}
onChange={onNumber((v) => update({ num: Math.max(1, v) }))}
aria-label={t('pages.settings.notifyTime.interval')}
/>
<Select<Unit>
+1 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router';
import {
Alert,
Button,
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import {
BurstObservatorySchema,
@@ -195,7 +196,7 @@ export default function ObservatorySettingsTab({
<InputNumber
min={1}
value={burst.pingConfig.sampling}
onChange={(v) => patchPingConfig({ sampling: typeof v === 'number' ? v : burst.pingConfig.sampling })}
onChange={onNumber((v) => patchPingConfig({ sampling: v }))}
style={{ width: '100%' }}
/>
</SettingListItem>
+4 -3
View File
@@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { onNumber } from '@/utils/onNumber';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
import {
@@ -216,10 +217,10 @@ export default function BasicsTab({
style={{ width: '100%' }}
value={directHappyEyeballs.tryDelayMs}
placeholder="150"
onChange={(v) => setDirectHappyEyeballs({
onChange={onNumber((v) => setDirectHappyEyeballs({
...directHappyEyeballs,
tryDelayMs: typeof v === 'number' ? v : 0,
})}
tryDelayMs: v,
}))}
/>
}
/>
+28 -24
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
import {
@@ -11,6 +11,7 @@ import {
SettingOutlined,
} from '@ant-design/icons';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
@@ -41,6 +42,23 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
const dnsEnabled = !!dns;
const sourceHosts = dns?.hosts;
const incomingHosts = JSON.stringify(sourceHosts ?? {});
const lastWrittenHostsRef = useRef<string | null>(null);
useEffect(() => {
if (!dnsEnabled) {
lastWrittenHostsRef.current = '{}';
setHostsList([]);
return;
}
if (incomingHosts === lastWrittenHostsRef.current) return;
lastWrittenHostsRef.current = incomingHosts;
setHostsList(Object.entries(sourceHosts ?? {}).map(([domain, values]) => ({
domain,
values: Array.isArray(values) ? [...values] : [String(values)],
})));
}, [dnsEnabled, incomingHosts, sourceHosts]);
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
@@ -78,32 +96,18 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
});
}
useEffect(() => {
if (!dns) {
setHostsList([]);
return;
}
const src = dns.hosts || {};
setHostsList(
Object.entries(src).map(([domain, val]) => ({
domain,
values: Array.isArray(val) ? [...val] : [String(val)],
})),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dnsEnabled]);
function syncHosts(next: HostRow[]) {
const obj: Record<string, string | string[]> = {};
for (const row of next) {
if (!row.domain) continue;
const vals = (row.values || []).filter(Boolean);
if (vals.length === 0) continue;
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
}
lastWrittenHostsRef.current = JSON.stringify(obj);
setHostsList(next);
mutate((tt) => {
if (!tt.dns) return;
const obj: Record<string, string | string[]> = {};
for (const row of next) {
if (!row.domain) continue;
const vals = (row.values || []).filter(Boolean);
if (vals.length === 0) continue;
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
}
if (Object.keys(obj).length > 0) {
(tt.dns as DnsConfig).hosts = obj;
} else if ('hosts' in (tt.dns as DnsConfig)) {
@@ -311,7 +315,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
min={0}
step={60}
style={{ width: '100%' }}
onChange={(v) => setDnsField('serveExpiredTTL', Number(v) || 0)}
onChange={onNumber((v) => setDnsField('serveExpiredTTL', v))}
/>
}
/>
@@ -4,6 +4,8 @@ import { Button, Dropdown, Input, InputNumber, Space } from 'antd';
import { MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { onNumber } from '@/utils/onNumber';
import { addrFor, domainsFor, expectedIPsFor } from './helpers';
import type { DnsServerValue } from './DnsServerModal';
@@ -113,7 +115,7 @@ export function useFakednsColumns({
aria-label={t('pages.xray.fakedns.poolSize')}
min={1}
size="small"
onChange={(v) => updateFakednsField(index, 'poolSize', Number(v) || 0)}
onChange={onNumber((v) => updateFakednsField(index, 'poolSize', v))}
/>
),
},
@@ -38,6 +38,7 @@ import {
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import PromptModal from '@/components/feedback/PromptModal';
import TextModal from '@/components/feedback/TextModal';
@@ -626,14 +627,14 @@ export default function OutboundsTab({
<InputNumber
min={0}
value={intervalHours}
onChange={(v) => setIntervalHM(Number(v) || 0, intervalMinutes)}
onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))}
style={{ width: 80 }}
/> {t('pages.xray.outboundSub.hours')}
<InputNumber
min={0}
max={59}
value={intervalMinutes}
onChange={(v) => setIntervalHM(intervalHours, Number(v) || 0)}
onChange={onNumber((v) => setIntervalHM(intervalHours, v))}
style={{ width: 80 }}
/> {t('pages.xray.outboundSub.minutes')}
</Space>
@@ -240,11 +240,11 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
>
<Select
options={[
{ value: '', label: 'Default (body)' },
{ value: '', label: 'Default (auto)' },
{ value: 'auto', label: 'auto' },
{ value: 'body', label: 'body' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</FormField>
+1 -1
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense } from 'react';
import { createBrowserRouter, type RouteObject } from 'react-router-dom';
import { createBrowserRouter, type RouteObject } from 'react-router';
import PanelLayout from '@/layouts/PanelLayout';
+6
View File
@@ -68,9 +68,15 @@ export const InboundOptionSchema = z.object({
export const InboundOptionsSchema = z.array(InboundOptionSchema);
// The *Count fields are exact; the email arrays stop at the server's cap and
// only feed the hover popovers, so never derive a counter from their length.
export const ClientsSummarySchema = z.object({
total: z.number(),
active: z.number(),
onlineCount: z.number().optional().default(0),
depletedCount: z.number().optional().default(0),
expiringCount: z.number().optional().default(0),
deactiveCount: z.number().optional().default(0),
online: nullableStringArray,
depleted: nullableStringArray,
expiring: nullableStringArray,
@@ -22,6 +22,7 @@ export const InboundDbFieldsSchema = z.object({
down: z.number().int().min(0).default(0),
total: z.number().int().min(0).default(0),
trafficReset: TrafficResetSchema.default('never'),
trafficResetDay: z.number().int().min(1).max(31).default(1),
lastTrafficResetTime: z.number().int().default(0),
nodeId: z.number().int().nullable().optional(),
shareAddrStrategy: ShareAddrStrategySchema.default('node'),
@@ -1,8 +1,9 @@
import { z } from 'zod';
// Hysteria v1 inbound (legacy — upstream xray-core kept v1 support but the
// panel defaults to v2). Each client supplies an `auth` token instead of a
// UUID/password.
// Hysteria inbound. Each client supplies an `auth` token instead of a
// UUID/password. xray-core builds version 2 only — it answers anything else
// with "version != 2" and rejects the entire config, so a legacy row is
// coerced rather than carried through.
export const HysteriaClientSchema = z.object({
auth: z.string().min(1),
email: z.string().min(1),
@@ -20,7 +21,7 @@ export const HysteriaClientSchema = z.object({
export type HysteriaClient = z.infer<typeof HysteriaClientSchema>;
export const HysteriaInboundSettingsSchema = z.object({
version: z.number().int().min(1).default(2),
version: z.preprocess(() => 2, z.literal(2)).default(2),
clients: z.array(HysteriaClientSchema).default([]),
});
export type HysteriaInboundSettings = z.infer<typeof HysteriaInboundSettingsSchema>;
@@ -31,12 +31,14 @@ export const XHttpXmuxSchema = z.object({
export type XHttpXmux = z.infer<typeof XHttpXmuxSchema>;
// Seed for freshly enabling XMUX on a config that had no xmux block:
// mirrors xray-core v26.6.27's own anti-RKN maxConnections=6 fallback
// rather than the concurrency strategy.
// mirrors xray-core's own maxConnections fallback rather than the
// concurrency strategy. v26.7.28 lowered that fallback from 6 to 3 for
// anti-TSPU, so track it here to keep a fresh panel config matching what
// the core would have picked on its own.
export const XMUX_FRESH_DEFAULTS: XHttpXmux = {
...XHttpXmuxSchema.parse({}),
maxConcurrency: '',
maxConnections: 6,
maxConnections: 3,
};
// Predefined sessionIDTable names xray-core accepts as a shorthand for a
+5
View File
@@ -18,6 +18,7 @@ export const AllSettingSchema = z.object({
expireDiff: nonNegativeInt.optional(),
trafficDiff: nonNegativeInt.max(100).optional(),
remarkTemplate: z.string().optional(),
subShowIdentityOnAllLinks: z.boolean().optional(),
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
tgBotEnable: z.boolean().optional(),
tgBotToken: z.string().optional(),
@@ -102,3 +103,7 @@ export const AllSettingSchema = z.object({
}).loose();
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
export const FactoryDefaultsSchema = z.record(z.string(), z.string());
export type FactoryDefaults = z.infer<typeof FactoryDefaultsSchema>;
@@ -14,6 +14,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses combined byte-stably 1`
"tcp": [
{
"settings": {
"length": "10-20",
"packets": "1-3",
},
"type": "fragment",
@@ -145,9 +146,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
[
{
"delay": 0,
"packet": [
"GET / HTTP/1.1",
],
"packet": "GET / HTTP/1.1",
"type": "str",
},
],
@@ -157,9 +156,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
[
{
"delay": 0,
"packet": [
"HTTP/1.1 200 OK",
],
"packet": "HTTP/1.1 200 OK",
"type": "str",
},
],
@@ -171,8 +168,13 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
"settings": {
"hostname": "mc.example.com",
"password": "s3cr3t",
"usernames": [
"Dream",
"profiles": [
{
"texturesSignature": "Zm9yLWZpeHR1cmUtdXNlLW9ubHktbm90LWEtcmVhbC1tb2phbmctc2lnbmF0dXJl",
"texturesValue": "eyJ0aW1lc3RhbXAiOjE3MDAwMDAwMDAwMDAsInByb2ZpbGVJZCI6ImVjNzBiY2FmNzAyZjRiYjhiNDhkMjc2ZmE1MmE3ODBjIn0=",
"username": "Dream",
"uuid": "ec70bcaf-702f-4bb8-b48d-276fa52a780c",
},
],
},
"type": "xmc",
@@ -219,13 +221,11 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
{
"delay": "10-16",
"rand": "10-20",
"type": "rand",
"type": "array",
},
{
"delay": "5",
"packet": [
"ping",
],
"packet": "ping",
"type": "str",
},
],

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