Compare commits

...

103 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
Sanaei cd674c8d4f feat(sub): expose live online status and add ?format=info endpoint
Custom subscription templates only received the lastOnline timestamp, so
template authors had to fake an online indicator by comparing it against
the current time, and the page was a one-shot server render with no way
to refresh usage without reloading the whole HTML.

The template context (and window.__SUB_PAGE_DATA__) now carries isOnline,
computed from the panel's own online-client tracking (local xray plus
remote nodes) at render time. The subscription URL also answers
?format=info with the page view-model as JSON — minus the links, with
emails deduplicated — so templates can poll live status cheaply. The
shared view-model construction moved into buildSubPageData/subPageContext
so the HTML page, the SPA payload and the info JSON cannot drift apart.

Also documents the previously injected but undocumented announce
template variable.
2026-07-23 23:57:29 +02:00
Sanaei b319dd0c3a fix(panel): align telegram icon with its label in home card actions
The .tg-icon override (display: inline-block; vertical-align: -2px)
defeated the default .anticon flex centering that every other card
action icon relies on, so the icon rendered ~2px below the @XrayUI
text. Dropping the override lets AntD center it like its neighbors.
2026-07-23 20:20:32 +02:00
Sanaei 8ef2eec3d1 fix(hosts): assign group ids to imported hosts and repair empty ones
Host rows created from a legacy streamSettings.externalProxy during
inbound import got an empty group_id, and the one-time HostGroupIds
seeder had already been gated off, so the UI rendered them under a
synthetic fallback_<id> group the update/delete API could not resolve,
failing every edit with "host group not found".

Assign a real group id in externalProxyEntryToHost at creation, and
replace the seeder with backfillEmptyHostGroupIds, an idempotent
startup repair that runs on every boot so rows from older builds and
restored backups are healed too. Also rename the leaked internal
error "host group not found" to "host not found" since groups are not
a user-facing concept.
2026-07-23 18:52:42 +02:00
Sanaei 941c6116a9 chore(openapi): regenerate schemas with int64 formats on node fields
Output of make gen: the generator now stamps format int64 on the node
status schema's 64-bit integer fields (timestamps, net counters,
uptime), syncing the committed OpenAPI doc and generated schemas with
the Go structs.
2026-07-23 16:28:49 +02:00
n0ctal c77608bc47 fix(nodes): make node API tokens write-only (#5613)
* fix(nodes): make node API tokens write-only

* fix(nodes): keep token optional on edit for write-only API tokens

NodeView no longer returns apiToken, so the edit form must consume hasApiToken and not require re-entering the token. Relaxes the form validation on edit, adds a keep-current placeholder, and adds the i18n key to all 13 locales.
2026-07-23 15:35:11 +02:00
Sanaei 892c06c8bc Bug-label issue sweep: 16 fixes (#6083)
* fix(xray): block private-range egress in default freedom finalRules (#6037)

With domainStrategy AsIs the router never resolves domains, so a domain
with a private A record (e.g. 127-0-0-1.nip.io) sails past the
geoip:private routing block and freedom's allow-all finalRules let it
reach loopback services such as the xray gRPC API and metrics listener.

Prepend a block rule for geoip:private to the default template and add
the FreedomFinalRulesPrivateEgressBlock seeder so existing installs
still carrying the stock allow-only (or legacy private-only-allow)
finalRules are upgraded in place; customized rules are left untouched.

* fix(sub): version-gate unencrypted-outbound drops in outbound subscriptions (#6033)

Commit d38c912d taught CheckXrayConfig to keep unencrypted vless/trojan
outbounds when the running core predates the v26.7.11 rejection, but
filterOutboundsRejectedByCore still consulted the embedded validator
unconditionally, so outbound subscriptions kept silently dropping those
outbounds even on downgraded cores.

Apply the same shouldSkipLegacyUnencryptedOutboundRejection gate when
filtering fetched subscription outbounds.

* fix(xray): resolve geodata assets before building outbound configs (#5928)

Saving routing or template settings validates each outbound through the
embedded config loader, and a freedom outbound whose finalRules
reference geoip:private opens geoip.dat during that build. Unlike
ApplyRoutingConfig, ValidateOutboundConfig and AddOutbound never pointed
the in-process loader at the bin folder, so xray-core resolved the file
relative to the panel executable and saving failed with
'stat /usr/local/x-ui/geoip.dat: no such file or directory'.

Call ensureXrayAssetLocation before both build paths.

* fix(api): use a real i18n key in the client get handler (#5911)

The client fetch endpoint localized its error prefix with the bare key
'get', which exists in no translation file, so every lookup of a deleted
client's email logged 'message "get" not found in language ...' noise
alongside the expected record-not-found warning. Reuse the same
pages.inbounds.toasts.obtain key the sibling list handler uses.

* fix(sub): carry host record Host header and path into Clash/JSON output (#5944)

The raw-link path overrides the host/path share params from a Host
record via applyEndpointHostPath, but the Clash and JSON renderers read
the transport settings object, which applyHostStreamOverrides never
touched — so a Host record's WebSocket Host header (and path) silently
vanished from Clash/Mihomo and JSON subscriptions whenever the inbound's
own ws settings left them empty.

Inject hostHeader/path into the ws/httpupgrade/xhttp settings of the
per-host stream, mirroring the raw-link override.

* fix(metrics): accept Unicode outbound tags in the observatory (#5972)

The observatory validator whitelisted ASCII word characters, so any
outbound whose tag carries a flag emoji or other non-ASCII text was
silently dropped from the metrics snapshot, delay history, and health
notifications. The history store is an in-process map, so the strict
charset bought nothing.

Validate tags as non-empty, bounded, control-character-free UTF-8
instead, keeping spaces and emoji while still rejecting garbage input on
the query path.

* fix(database): default sqlite to WAL to stop background-job lock storms (#6057, #6068)

With journal_mode=DELETE every write serializes the whole database and
blocks readers, so under normal multi-job load (traffic sampling, node
sync, mtproto reconcile) transactions regularly outwaited the 10s busy
timeout and jobs failed with 'database is locked'.

Move to WAL by default: readers no longer block writers and vice versa,
which removes the observed contention while writer-writer access still
serializes safely. The single-file-at-rest property is preserved where
it matters — Checkpoint() now issues wal_checkpoint(TRUNCATE), so panel
and Telegram backups read a complete main file, and sqlite folds the WAL
back into the db on clean shutdown. XUI_DB_JOURNAL_MODE=DELETE restores
the previous behavior for setups that copy the live file directly.

* fix(database): strip finalmask.tcp from REALITY inbounds on upgrade (#6038)

validateFinalMaskRealityCombo blocks saving finalmask.tcp together with
REALITY because that combination crashes Xray-core 26.7.11 on the first
connection (XTLS/Xray-core#6453), but it only runs on add/update. An
inbound saved before the validator existed sailed through the upgrade
untouched and took the core down at boot.

Add the InboundRealityFinalmaskTcpStrip seeder: one-time scan that
removes finalmask.tcp from REALITY inbounds (other finalmask transports
survive), so upgraded panels start cleanly.

* fix(xray): stop deleting hand-written direct routing rules on save (#6056)

The DNS allow-rule sync recognized 'its' rules purely by shape
(type=field, ip, port, outboundTag=direct, nothing else), so any manual
rule of that shape — e.g. routing a LAN CIDR to a NAS port over direct —
was silently stripped on every settings save.

Mark managed rules with ruleTag=xui-dns-allow (round-tripped untouched
by both xray-core and the Routing tab editor) and only strip rules that
carry the tag. Untagged legacy managed rules are adopted when their
exact ip-set/port matches a currently configured private DNS endpoint;
anything else is left alone. A stale pre-tag managed rule whose DNS
server was removed now lingers until deleted manually — the safe side of
the trade against eating user rules.

* fix(clients): resolve email lookups through client_inbounds after a move (#6059)

GetClientInboundByEmail trusted the client_traffics.inbound_id pointer
whenever that inbound still existed, but a client moved between inbounds
leaves the row pointing at its old (still existing) inbound. The lookup
then searched the wrong inbound's clients and failed with 'Client Not
Found In Inbound For Email', which broke the Telegram bot's link and QR
generation for moved clients.

When the pointed-at inbound no longer carries the email, re-resolve
through the authoritative client_inbounds link to the inbound that
actually hosts the client.

* fix(nodes): replicate inbound fallbacks to nodes (#5963)

Fallbacks live in the inbound_fallbacks table and were only merged into
settings by the master's local config builder; the runtime inbound
pushed to nodes rebuilt settings without them, and the reconcile job
additionally fingerprinted the raw DB row, so fallback edits neither
reached nodes nor triggered a re-push.

Inject settings.fallbacks in buildRuntimeInboundForAPI (mirroring the
local builder, gated on inboundCanHostFallbacks) and make ReconcileNode
push and fingerprint that same runtime-built payload, aligning the
interactive and reconcile paths.

* fix(database): survive PostgreSQL outages without a runaway restart loop (#6023)

A PostgreSQL that was down or still starting made InitDB fail instantly;
the process exited with a generic startup error and systemd restarted it
every 5s forever, flooding the journal.

Retry the initial postgres connection with backoff (~70s total) and log
the real driver error on every attempt, and cap the systemd units with
StartLimitIntervalSec/StartLimitBurst so a persistently unreachable
database stops the unit instead of looping indefinitely.

* fix(xray): force a full restart when REALITY stream settings change (#6010)

A changed inbound is normally hot-swapped over gRPC as RemoveInbound +
AddInbound, but xray-core does not reliably rebuild a REALITY listener's
authenticator on a runtime re-add — key or shortId edits appeared
applied yet clients kept authenticating against the old parameters until
someone restarted the core manually, on nodes in particular.

Treat any non-client change to an inbound that uses (or starts using)
REALITY as not hot-appliable so the panel restarts the core instead.
Client-only edits on REALITY inbounds keep flowing through the per-user
AlterInbound path and still avoid restarts.

* feat(sub): allow insecure TLS for outbound subscription fetches (#6067)

An outbound subscription served over HTTPS with a self-signed or
private-CA certificate could never be fetched: the fetch client had no
TLS options, so refreshes died with 'x509: certificate signed by unknown
authority' and there was nothing the admin could toggle.

Add a per-subscription 'Allow insecure' switch (persisted as
allow_insecure, default off) that sets InsecureSkipVerify on the fetch
transport — including when the fetch is routed through the panel egress
proxy. The SSRF-guarded dialer and redirect re-validation stay in force
either way.

* fix(reality): send PROXY protocol header in the target scanner when xver is set (#6082)

The REALITY target scanner always probed with a plain TLS handshake, so
a target fronted by an Nginx listener that requires the PROXY protocol
(matching the inbound's xver>=1) reset the connection and the panel
reported a false 'TLS handshake failed'.

Thread the inbound's xver into the scan request and, when it is >=1,
lead with the matching PROXY protocol header (v1 for xver 1, binary v2
for xver 2) built from the dialed connection's own address pair. Batch
candidate scans against public sites are unaffected (xver 0).

* fix(frontend): default sockopt fields when editing a stored inbound (#5956)

Opening an existing inbound ran rawInboundToFormValues over the raw DB
row, and only xhttpSettings was re-parsed through its Zod schema to fill
defaults. A sockopt object saved before the TProxy control existed has
no tproxy key, so the Select rendered blank; picking Off didn't help
because the wire normalizer drops tproxy=off, recreating the missing
key on the next edit.

Re-parse streamSettings.sockopt through SockoptStreamSettingsSchema on
load, mirroring the xhttpSettings handling, so absent keys (tproxy,
tcpcongestion, …) get their schema defaults every time the form opens.
2026-07-23 15:34:42 +02:00
Sanaei 16b9b3ce1c chore(deps): bump docs and frontend dependencies
Routine minor/patch updates: Next.js 16.2.11 + eslint-config-next,
fumadocs, React 19.2.8, Storybook 10.5.3, and assorted tooling.

Docs stays on ESLint 9 (^9.39.5): eslint-config-next pulls in
eslint-plugin-react 7.37.5, whose newest release still calls the
context.getFilename API that ESLint 10 removed, so eslint crashes on
every file under ESLint 10. The frontend workspace already ran ESLint
10 without eslint-plugin-react and is unaffected.
2026-07-22 19:57:34 +02:00
Yuri Khachaturyan 2b1308ca29 feat(notifications): add a consecutive-failure threshold for outbound.down alerts (#5968)
Problem: a flaky outbound produces hundreds of false-positive "outbound down"
notifications overnight — each fires the moment xray's observatory reports a
single failed probe, and the next successful probe fires an "up".

applyObservatory forwarded every raw alive:true->false transition straight to
EventOutboundDown; xray's observatory has effectively no hysteresis, and nothing
on the panel side debounced it (the email/Telegram subscribers are pure
formatters).

Fix: debounce per outbound. outbound.down now fires only after
outboundDownThreshold consecutive FAILED probes (new setting, default 3);
outbound.up fires immediately on the first successful probe and only when a down
was actually notified. The threshold gates the event itself, so email and
Telegram share one knob (exposed next to the outbound.down toggle).

The streak counts genuinely new probes (last_try_time advancing), not sampler
polls — the sampler runs every 2s but the observatory re-probes per its
probeInterval, so counting samples would trip the threshold instantly.
outboundDownThreshold=1 reproduces the legacy notify-on-first-failure behaviour.

Tuning the observatory's probe interval/timeout is not a workaround: those
probes also drive the load balancer's outbound selection, so loosening them to
quiet notifications would slow real failover away from a genuinely dead
outbound. Notifications don't need observatory-grade latency, so the tolerance
belongs at the notification layer, leaving the observatory (and balancer)
untouched.

Adds TestApplyObservatoryDebounce covering the threshold, probe-vs-sample
counting, single-blip suppression and the legacy path.

Co-authored-by: Yuriy Khachaturian <y.khachaturian@souzmult.ru>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:59:07 +02:00
Blazethan 9e117bbdd3 fix(clients): keep VLESS xtls-rprx-vision flow when inbound options reload (#5971)
The client form cleared the `flow` field whenever `showFlow` was false, but
`showFlow` is derived from the inbound options list, which is transiently empty
while the options query (re)loads (`inboundOptionsQuery.data ?? []`). During
that window `showFlow` is a false negative, so the effect silently dropped a
valid `xtls-rprx-vision` flow the user had picked for a Reality/TLS inbound and
never restored it — the client was then saved with an empty flow and could not
connect with XTLS Vision.

Guard the clear so it only runs once the inbound options are actually available.

Adds a regression test that reproduces the drop across an options reload.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:58:25 +02:00
Mr. Nickson 8cd71e07ea fix: refresh stale client_traffics row when an inbound-deleted client's email is reused (#6003)
* fix: refresh stale client_traffics row when an inbound-deleted client's email is reused

AddClientStat's OnConflict was DoNothing on email, so once an inbound is
deleted (DelInbound only removes the client_inbounds link, matching
ClientService.Detach's intentional Detach-then-later-Attach behavior) the
orphaned client_traffics row for that email survives untouched. Re-creating
a client under the same email on a new inbound silently kept the old
enable/expiry_time/reset/total/inbound_id instead of adopting the new
client's config.

Switch the conflict path to DoUpdates on inbound_id/total/expiry_time/
enable/reset. up/down stay excluded on purpose: every call for an
already-attached identity carries the same config values (one call per
inbound), so the refresh is a no-op for that legitimate multi-inbound
share, while zeroing usage counters on each additional attach would erase
real traffic.

Fixes #5958

* fix: don't let AddClientStat clobber import's forced-enabled ClientStats rows

github-actions[bot] review on #6003 found that AddInbound writes client_traffics
twice for the same import payload: first inserting each ClientStats row
(DoNothing, with Enable forced true by controller.importInbound), then calling
AddClientStat once per Settings-derived client. With AddClientStat's OnConflict
now DoUpdates, that second call was unconditionally overwriting enable (and
total/expiry_time/reset/inbound_id) with the Settings.clients[].enable value —
which still holds whatever the client had at export time, silently undoing the
controller's "always import as enabled" behavior for any client disabled at
export.

Fix: track which emails were already seeded by the ClientStats loop and skip
the AddClientStat call for those emails, leaving the import path's forced
values as authoritative. Plain (non-import) creates are unaffected since
ClientStats is empty there, so every client still goes through AddClientStat's
refresh as before.

Also updated a stale comment in addClientTraffic that still described
AddClientStat as DoNothing.

Added TestAddInbound_ImportForcedEnableSurvivesDisabledSettingsClient, which
reproduces the exact regression (verified it fails without this fix) and
passes with it.
2026-07-21 15:57:55 +02:00
Seyed Ali Shahrokhi 79e65f63df fix(xray): validate generated egress targets (#5989)
* fix(xray): validate panel egress target

Avoid generating a loopback panel bridge and routing rule when a saved panel outbound disappears after an outbound subscription refresh. Preserve routing unchanged and log the missing target instead.

* fix(xray): guard node and mtproto egress

Apply the fail-closed target check to every generated egress bridge. Skip node and MTProto bridge injection when a selected outbound disappears or the relevant JSON cannot be parsed.

* test(xray): complete node egress coverage

Cover tag and port collisions plus absent and malformed routing. Clarify the fail-closed bridge behavior in the panel and MTProto egress documentation.
2026-07-21 15:57:34 +02:00
Alexey c87649d9f2 fix(sub) Happ profileEnableRouting button (#6008) 2026-07-21 15:56:54 +02:00
w3struk 123fac222b feat(sub): add raw subscription download actions (#6017)
* feat(sub): add raw subscription downloads

* fix(sub): address review feedback

* feat(sub): add download buttons to client subscriptions

* fix(sub): fetch subscription before download

---------

Co-authored-by: w3struk <w3struk@gmail.com>
2026-07-21 15:52:33 +02:00
Matt Van Horn d38c912dc1 fix: gate embedded unencrypted-outbound rejection on running Xray core version (#6028)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-07-21 15:51:46 +02:00
H-TTTTT fde66ba820 fix(sub): omit non-standard fm param from Hysteria2 URI (#6048)
Hysteria2 subscription links included a v2rayN-specific fm=<json>
finalmask dump alongside standard obfs/obfs-password. Clients such as
mihomo reject the unknown query param and fail to update the provider.

Emit only the standard salamander fields in genHysteriaLink, matching
the Clash generator.

Fixes #5982
2026-07-21 15:51:06 +02:00
H-TTTTT a0dec000b2 fix(clients): allow case-only email updates without duplicates (#6050)
Case-only email edits (test → Test) skipped the ClientRecord rename
because the gate used strings.EqualFold. SyncInbound then failed its
case-sensitive lookup and inserted a second row; the later fallback
rename hit UNIQUE constraint failed: clients.email.

Rename on any byte-level email difference so the same client is updated
in place.

Fixes #5951
2026-07-21 15:50:50 +02:00
H-TTTTT 11f602fe04 fix(sub): preserve external link names in Clash/JSON (#6049)
expandEntry cleared Name for external subscriptions and single links
with empty remark, so Clash/JSON fell back to the client email. Keep
each link's original name (#fragment / vmess ps); use the row remark
when set. Pass remark from the client form for single external links.

Fixes #6032
2026-07-21 15:50:33 +02:00
H-TTTTT c80e5e276b fix(wireguard): preserve all Allowed IPs in share link, .conf, and subscription (#6051)
The WireGuard client address is stored end-to-end as a string slice
(model.AllowedIPs []string, comma-split/joined in the DB), and the UI
hint documents comma-separated multi-value input. Three export paths
only read index [0], silently dropping every address after the first
(e.g. a dual-stack IPv4+IPv6 client never receives its second address):

- genWireguardLink share link address param (inbound-link.ts)
- genWireguardConfig .conf Address line (inbound-link.ts)
- SubService.genWireguardLink raw subscription address (service.go)

The sibling JSON and Clash subscriptions already emit the full slice,
and WireguardPeerFromClient passes the whole slice into the real Xray
peer config, so only the exported text was wrong. Join all entries,
matching the model's strings.Join(..., ",") convention for the link
params and the ', '-joined AllowedIPs line already used a few lines
below in genWireguardConfig.

Fixes #6031
2026-07-21 15:50:16 +02:00
H-TTTTT 22ff07b24b fix(warp): preserve outbound customization when rotating IP (#6052)
changeIp() rebuilt the warp outbound from a fixed default template via
collectConfig and replaced the whole in-memory object through
onResetOutbound, so only secretKey/address/reserved and the first peer's
publicKey/endpoint survived — a user's custom mtu, domainStrategy,
noKernelTun, the first peer's keepAlive / allowedIPs / preSharedKey, and
any extra peers were silently dropped from the editor. Because the page
keeps rendering that in-memory copy and a later page Save persists it to
/panel/api/xray/update, the loss could become permanent, even though the
backend ChangeWarpIP already patches only the rotated fields and leaves
everything else intact in the stored template.

Mirror the server-side UpdateWarpXraySetting: merge only the rotated
fields (secretKey, address, reserved, first peer publicKey/endpoint)
into the existing outbound, preserving the rest. register() (first-time
creation, nothing to preserve) still uses collectConfig's full build.

Extracted as a pure module-level mergeWarpRotation so it is unit-testable
without rendering the modal.

Fixes #6019
2026-07-21 15:50:00 +02:00
H-TTTTT d623410cf4 fix(clients): persist all editable fields for clients with no inbound (#6053)
ClientService.Update only wrote most editable fields to the clients table
inside the per-inbound loop (UpdateInboundClient -> SyncInbound), so a
client with no attached inbound — the external-links / remote-
subscription client — silently dropped subId, totalGB, expiryTime,
limitIp, tgId, comment, reset, flow, security and the credential fields
on edit. Update still returned success, so the panel showed a saved
toast while the row was untouched. email/enable/group_name/ad_tag/reverse
already had dedicated unconditional direct writes that covered the
no-inbound case; the rest did not.

This was a known failure shape: commit c4448f4e added the no-inbound
fallback for email only, and TestUpdate_PersistsRecordEnable_NoInbound
covered enable only — both stayed scoped to a single field.

Extract SyncInbound's per-row field merge into applyClientRecordMerge
(unconditional for scalar quota/lifecycle/subscription fields,
preserve-when-empty for credentials/identifiers, earliest CreatedAt)
and reuse it from Update. Update's new branch runs only when the client
has no inbound, so it fixes the gap without altering the inbound-attached
path (where flow is per-inbound gated via clientWithInboundFlow and
SyncInbound already persists every field). email/reverse/group/ad_tag/
enable keep their existing dedicated writes; the new write covers the
remaining columns.

Fixes #5981
2026-07-21 15:49:43 +02:00
H-TTTTT a9d5d9afdb fix(balancer): pin loopback routing rules ahead of general rules (#6054)
* fix(balancer): pin loopback routing rules ahead of general rules

ensureBalancerLoopback appended a balancer's fallback loopback rule
({ inboundTag: ['_bl_<target>'], balancerTag: <target> }) to the END of
routing.rules via Array.push, and only updated balancerTag in place when
the rule already existed. Xray evaluates routing rules top-to-bottom,
first match wins, and a general domain rule (no inboundTag restriction)
matches every inbound including the loopback one. So once such a general
rule targeting the parent balancer ended up earlier in the array — added
before the fallback was configured, or recreated later by an edit or by
ensureMissingBalancerLoopbacks — the fallback re-entered routing through
the _bl_<target> loopback outbound, the general rule matched again, and
traffic routed straight back into the parent balancer: an unbounded loop
and the CPU spike in the report. detectBalancerCycles only catches
mutual fallback cycles, so the plain acyclic Balancer1 -> Balancer2
chain passed silently.

Insert the loopback rule before the first general (no-inboundTag) rule
instead of pushing to the end, and reposition any existing loopback rule
that already landed after a general rule (preserving its other fields,
mirroring the EnsureStatsRouting hoist used for the same class of bug).
Rules with an inboundTag restriction (api, real inbounds) are left in
place — they can never match loopback traffic, so only the unrestricted
rules are dangerous. ensureMissingBalancerLoopbacks runs on every Save
All and calls ensureBalancerLoopback per fallback, so this also repairs
configs loaded from the DB with the wrong order.

Fixes #5960

* ci: re-trigger checks after flaky FuzzDecodeCertPin timeout

---------

Co-authored-by: x06579 <x06579@ai-dashboard>
2026-07-21 15:48:34 +02:00
MHSanaei 16b2bcf9aa fix(database): repair legacy string tgId in inbound settings on upgrade
Old panel builds and external tools writing directly to the DB store
tgId as a JSON string; parsing such settings into the strict int64
Client model fails with "json: cannot unmarshal string into Go struct
field Client.tgId of type int64" and blocks every client operation on
that inbound. The one-shot InboundClientTgIdFix seeder already ran on
existing installs, so the repair is re-registered as
InboundClientTgIdFix2 to run once more on upgrade, and it now preserves
numeric string ids instead of zeroing them. The Client model itself
stays number-only.
2026-07-18 13:06:05 +02:00
MHSanaei 2f156c8eb0 fix(ci): publish dev-latest edit-first instead of probing for existence
The dev-latest publish step probed for the release with gh release
view before choosing edit or create. During the api.github.com 503
storm the probe itself failed, mis-routing an existing release into
the create path, which then died on a permanent 422 already-exists
error that no amount of retrying can fix.

The release exists on every run but the very first, so edit first and
fall back to create only when the edit fails. The retry log line now
names the gh subcommand instead of just the binary.
2026-07-17 01:34:47 +02:00
MHSanaei 455d1cd0f7 fix(ci): resolve the mtg-multi tag from the release-page redirect
The api.github.com outage that failed the previous release run outlasted
the retry window, while asset downloads from github.com kept working the
whole time. The latest-release lookup was the only api.github.com
dependency left in the build jobs, so resolve the tag from the release
page redirect on github.com instead: tag resolution now shares exactly
the failure domain of the downloads it feeds, and an API-only outage can
no longer fail a build that could otherwise finish. No token needed for
the redirect, on either platform.
2026-07-17 01:24:36 +02:00
MHSanaei ab1a922806 fix(ci): survive transient GitHub 5xx outages in the release workflow
A GitHub API 503 storm failed the release run four attempts in a row:
the mtg-multi latest-release lookup died on every platform (even s390x,
which never packages the sidecar), and the matrix default fail-fast then
cancelled the six healthy builds alongside the one that hit the outage.

Retry every external fetch with backoff (curl --retry-all-errors, wget
--retry-on-http-error, Invoke-* -MaximumRetryCount), scope the mtg-multi
tag lookup to the platforms that actually package the sidecar, disable
fail-fast on the build matrix, and retry the idempotent dev-latest
publish commands. The workflow file itself now triggers the run's path
filters, so editing it exercises the release build immediately instead
of failing silently at the next code push.
2026-07-17 01:06:14 +02:00
Sanaei 5e1cb7693b Repo-wide self-correcting audit: 54 verified bug fixes (#5970)
* fix(email): resolve a name-addr smtpFrom into bare envelope address and display name

The save-time validator accepts any RFC 5322 address form, so a value
like '3x-ui Panel <panel(at)example.com>' passes validation, but Send and
TestConnection fed that raw string to MAIL FROM, which strict servers
reject with 501, and buildMessage mangled it into a quoted local part.
Parse the configured sender at the point of use: the envelope gets the
bare address and, when no explicit sender name is set, the display name
embedded in the setting is used for the From header.

* fix(email): report a missing sender address from the SMTP connection test

TestConnection skipped the empty-from guard that Send enforces, so with
no sender and no username configured the test issued the null reverse-path
and could report success against a lenient relay while every real
notification send kept failing with the missing-sender error. Guard the
test path the same way and surface a dedicated translated message.

* fix(sub): fall back to the raw subscription when an auto-detected format has no content

With format auto-detection enabled, a client whose User-Agent matched the
Clash or JSON regex was routed straight to that format handler. For a
subscription whose entries convert to neither format (an MTProto-only
subscription, for example) the handler returns an empty document and the
request ended as 404, breaking a URL that served the raw list before the
toggle. The auto-detect branches now serve the detected format only when
it produces content and otherwise continue to the raw response; the
explicit format endpoints keep answering 404 for empty documents.

* fix(node): match prefixed central tags when filtering a selected-mode node snapshot

FilterNodeSnapshot compared a node snapshot's inbound tags against the
raw selected-tag list with an exact match, while its two siblings
(SnapshotHasUnadoptedInbounds and the reconcile tagToCentral map) expand
each selected tag to both its bare node-side form and its n<id>- prefixed
central form. A panel-created node inbound is recorded in the selected
list under the central prefixed tag but reported by the node under the
bare tag, so the exact match dropped it from every snapshot and the
orphan sweep then deleted its central row one tick after creation. Expand
the allowed set with the same prefix flip the siblings use.

* fix(client): refuse a bulk quota reduction that would fall to or below zero

BulkAdjust clamped a client's new traffic limit with max(total+addBytes, 0).
Because 0 is the unlimited sentinel, reducing a client's quota by more than
it had left silently granted that client unlimited traffic. The sibling
expiry branch already refuses an over-reduction; mirror it for quota so the
adjustment is skipped with a clear reason instead of crossing the sentinel.

* fix(client): persist a bulk adjustment's applied field even when the sibling field is skipped

In a mixed BulkAdjust (both a days delta and a bytes delta), a per-field
planning skip such as "unlimited expiry" or "unlimited traffic" was recorded
in the same map that gated the client_traffics write. The applied field was
already written to the inbound JSON and the clients table, but the enforcement
row was left untouched, so the depletion job cut the client on the old limit
while the panel showed the new one. Gate the traffic-row write on an actual
inbound-processing failure rather than on any planning-phase skip note.

* fix(inbound): always create in AddInbound instead of overwriting a row whose id was posted

The add controller binds the inbound model's id form field and never clears
it, and AddInbound persisted with GORM Save, which updates in place when the
primary key is non-zero. A client that reused an existing id (for instance by
duplicating an inbound fetched from /get and changing the port) silently
overwrote that stored row instead of creating a new inbound. Zero the id at
the top of AddInbound, matching how it already zeroes the client-stat ids.

* fix(inbound): accept WireGuard clients when creating an inbound

AddInbound's per-client validation switch had cases for every protocol
except WireGuard, so a WireGuard client fell through to the default branch
that requires a non-empty id. WireGuard clients are keyed by their public
key and carry no id, so importing a WireGuard inbound or re-adding one to a
reconciling node was rejected with "empty client ID". Add a wireguard case
that validates the client key, mirroring addInboundClient.

* fix(client): stop holding the inbound-lock registry mutex while waiting on one inbound

lockInbound acquired the global registry mutex and then blocked on the
per-inbound mutex without releasing the registry first. A slow client
operation holding one inbound's mutex (for example a bulk delete pushing to
an unreachable node) made the next waiter park on that inbound while still
holding the registry mutex, which in turn blocked lockInbound for every
other inbound — freezing client mutations panel-wide. Release the registry
mutex before taking the per-inbound lock.

* fix(client): honor keepTraffic when deleting a client that is attached to inbounds

Delete, DeleteByEmail and BulkDelete all pass keepTraffic to their final
cleanup transaction, but each called the per-inbound delete helper with a
hardcoded false. That helper purges the client's traffic, IP and stat rows
before the gated cleanup runs, so keepTraffic=true still destroyed all
traffic history for any client actually attached to an inbound (the pinned
test only covered a record with no inbound mappings). Thread the caller's
keepTraffic through to the per-inbound helper at all three call sites.

* fix(inbound): defer a local MTProto inbound edit's sidecar push until after commit

UpdateInbound applied a local MTProto inbound change by calling the runtime
UpdateInbound (which stops/starts the mtg sidecar or talks to it) from inside
runSerializedTx. That runs process and network I/O on the single traffic-writer
goroutine while a DB transaction is open, so a slow sidecar stalls traffic
accounting and every concurrent client mutation, and a later step failing the
transaction leaves the sidecar ahead of the rolled-back row. Move the push into
the post-commit hook, matching the xray branch. Adds a SetLocalRuntimeOverride
test seam mirroring the existing node override so the deferral is regression
tested.

* fix(client): delete external-link rows when bulk-deleting clients

The single-client Delete path removes a client's client_external_links rows,
but BulkDelete (and the DelDepleted reaper that routes through it) deleted the
record, mappings and traffic while leaving the external-link rows keyed by the
now-dead client id, so they accumulated as orphans. Delete them in the same
cleanup transaction, keyed by client id like the single path.

* fix(inbound): request an xray restart when toggling a routed MTProto inbound

AddInbound, DelInbound and UpdateInbound all flag needRestart when an inbound
routes MTProto through xray, so the egress SOCKS bridge is regenerated. Only
SetInboundEnable's local path omitted it, so toggling a routed MTProto inbound
off then on left the bridge out of the running config while the sidecar dialed
its loopback port, blackholing that inbound until an unrelated restart. Flag the
restart on the local enable path too.

* fix(client): apply enable-by-email to every inbound a client is attached to

ToggleClientEnableByEmail (Telegram bot) and SetClientEnableByEmail (LDAP sync)
resolved a single inbound via the legacy client_traffics pointer and flipped
enable only there. A client attached to several inbounds kept connecting through
the siblings' running Xray after being disabled, and the next edit could
re-enable it everywhere from a stale sibling. Route both through the
applyClientFieldByEmail fan-out (the #5039 fix path) so the whole multi-inbound
identity is toggled at once, dropping the circular Set/Toggle dependency.

* fix(traffic): commit a traffic tick even when a best-effort maintenance helper fails

addTrafficLocked stages the inbound and client deltas, then runs three helpers
(auto-renew, disable depleted clients, disable depleted inbounds) that are meant
to log and continue. All three reused the function-scope err that the deferred
commit/rollback inspects, so the last helper's error decided the whole tick: a
failure in disableInvalidInbounds rolled back the already-staged traffic while
AddTraffic reported success, and because xray had already advanced its counter
baseline that traffic was lost for good. Give each best-effort helper its own
error variable so only a genuine staging failure rolls the tick back.

* fix(traffic): re-enable clients and serialize the write in Reset All Client Traffic

ClientService.ResetAllTraffics zeroed up/down but, unlike every sibling reset
path, never restored enable=true, so clients that had been auto-disabled for
exceeding their quota stayed cut with zero usage after a reset. It also wrote
client_traffics directly on the shared DB handle instead of through the serial
traffic writer, reintroducing the cross-transaction lock-order deadlock the
writer exists to prevent. Restore enable and run the reset inside
submitTrafficWrite within one transaction.

* fix(traffic): keep node reset propagation out of the serial traffic writer

ResetAllTraffics and ResetInboundTraffic performed their remote-node reset HTTP
calls inside submitTrafficWrite. Each call can block up to the remote timeout,
and Reset All Traffics loops every node serially, so the single traffic-writer
goroutine was held for seconds — long enough that the concurrent 5s traffic poll
timed out submitting its own write and dropped the deltas it had already drained
from xray. Do the DB reset inside the writer, then propagate to the nodes after
it returns, matching how the mtproto quota reset is already sequenced.

* fix(sub): stop the subscription from 500ing on valid-but-unusual stream settings

The raw share-link generators used unchecked type assertions and unguarded
array indexing: an empty Reality shortIds/serverNames array (random.Num(0)
panics), a tcp-http header with no request block or an empty request.path, a
grpc block missing its keys, empty stream settings, and a non-string Host
header all panicked mid-generation. Because getSubs loops every client's link
with no recover, one such client 500s the entire subscription for everyone. The
sibling JSON, Clash and frontend generators already guard these; make the raw
generators match with comma-ok assertions and length checks.

* fix(sub): tolerate a hysteria inbound without hysteriaSettings in the JSON subscription

genHy asserted stream["hysteriaSettings"].(map[string]any) without the comma-ok
form, so a hysteria inbound whose StreamSettings omit the hysteriaSettings key
(a valid, representable shape the raw generator renders fine) panicked and 500ed
the entire JSON subscription. Use comma-ok; the downstream reads already guard
each key, so a nil map degrades gracefully.

* fix(sub): emit the pinned peer cert sha256 in Clash subscriptions

The Clash stream builder computed tlsSettings["pin-sha256"] from the inbound's
pinnedPeerCertSha256, but applySecurity's tls case never copied it onto the
proxy, so it was written with no reader and silently dropped. Clash subscribers
lost certificate pinning while JSON subscribers kept it. Surface pin-sha256 on
the proxy in the tls case, matching the JSON emitter.

* fix(link): parse the snake_case and extra-blob xhttp fields when importing a share link

The panel's share-link emitters (Go and TS) carry advanced xhttp knobs as a
snake_case x_padding_bytes plus an extra=<json> payload, but the Go parser's
xhttp branch read only top-level camelCase params, so importing an xhttp link
via the outbound-subscription feature dropped xPaddingBytes, scMaxEachPostBytes
and the rest, silently reverting them to the stream defaults and producing a
non-working outbound. Mirror the TS parser: read the snake_case alias, merge the
extra JSON blob, then let explicit camelCase params win.

* fix(frontend): decode URL-safe base64 when parsing an imported share link

Base64.decode called window.atob directly, which rejects the base64url
alphabet (- and _) and unpadded input. But the panel's own share-link emitter
uses Base64.encode(x, true) (URL-safe, unpadded), and real SIP002 links do too,
so importing a Shadowsocks link whose method:password encodes with a - or _ threw,
fell back to the raw undecoded string, and produced a wrong method and garbage
password (the vmess parser shared the same limitation). Normalize base64url and
re-pad before atob so decode round-trips every emitted link.

* fix(link): honor the vmess ws path and hysteria2 vcn params on import

Two Go/TS parser parity gaps in the outbound share-link import path: parseVmess
only applied a ws link's path when the inner JSON also carried a host key, so a
generator that omits host dropped the path back to the default; and parseHysteria2
hardcoded verifyPeerCertByName to empty, ignoring the vcn param the panel emits,
so a hysteria2 outbound with a decoy SNI and a distinct cert name failed TLS
verification after import. The TS parser handles both; make the Go parser match.

* fix(ui): stop the sniffing form island from clobbering unrendered fields

antd's Form.useWatch only reports registered fields, so while the
sniffing toggle was off the island emitted { enabled: false } upward and
replaced the full Sniffing object in form state. Saving a VLESS reverse
outbound then crashed in sniffingToWire on the missing ipsExcluded
array; the loopback outbound and the inbound sniffing tab shared the
same hole. Watch the store with preserve: true so unrendered fields
keep their values, and seed a missing value from the schema defaults
instead of an empty cast.

* fix(sub): drop empty remark segments instead of leaving a stray separator

expandSegment dropped a "|" segment only when its tokens rendered the unlimited
mark, so a segment whose only token resolved to the empty string (a client with
no comment, an unlimited client's expiry date) was kept as bare decoration,
leaving a trailing "|" or a dangling emoji on every share link's remark. Drop a
token-bearing segment whenever none of its tokens produce a real value, while
still keeping pure-literal segments.

* fix(xray): keep source- and domains-scoped routing rules when an inbound is deleted

removeInboundTagFromRules drops a routing rule whose inboundTag list becomes
empty only if the rule has no other matcher, but routingMatcherKeys omitted
xray-core's canonical source and domains keys. A rule scoped by source or domains
(common in hand-authored or imported configs) therefore lost its whole body —
including a security-relevant block — when its single listed inbound was deleted,
instead of just having the tag trimmed. Recognize source and domains as live
matchers.

* fix(xray): guard RemoveUser against an uninitialized handler client

Every XrayAPI handler method returns an error when HandlerServiceClient is nil,
except RemoveUser, which dereferenced it directly. A depletion sweep runs Init
with the port ignored and, during a restart window where the fresh process's
api port is still 0, Init fails and leaves the client nil — so RemoveUser
panicked (recovered by the traffic writer, but re-thrown every poll) instead of
returning an error. Add the same nil guard the siblings have.

* fix(xray): do not revive a manually stopped Xray on a background restart

RestartXray cleared isManuallyStopped unconditionally at its top, so the @30s
pending-config cron (and warp/ldap/outbound reconcile jobs) that call
RestartXray(false) resurrected an Xray the admin had deliberately stopped —
unlike the crash-detector, which honors the manual-stop flag. Skip a non-forced
restart while the stop flag is set; only an explicit forced restart clears it.

* fix(xray): retry a failed pending-restart instead of dropping the config change

The 30s cron consumed the need-restart flag with IsNeedRestartAndSetFalse before
calling RestartXray and only logged a failure. If RestartXray failed early (a
transient GetXrayConfig DB error) the old process kept running the old config,
the crash detector saw a running process and never retried, and the flag stayed
cleared — so an admin's saved change silently never reached the core. Move the
consume/restart/retry into ApplyPendingRestart, which re-arms the flag on
failure so the next tick retries.

* fix(xray): synchronize the process version and apiPort fields

Start writes p.version and p.apiPort (via refreshVersion/refreshAPIPort) after
flipping the process to running, while GetXrayVersion and GetAPIPort read them
lock-free from the status and traffic poll goroutines. The struct mutex
deliberately excluded these fields, so a restart racing a poll was a real data
race — a torn read of the version string header can crash. Extend the mutex to
cover version and apiPort, doing the blocking version probe before taking the
lock.

* fix(settings): detect a wildcard listen collision between the web and sub ports

The web/sub same-port check compared the two listen addresses as raw strings, so
binding both on all interfaces with different spellings (webListen 0.0.0.0 vs an
empty subListen) slipped past validation and only failed at startup with an
opaque bind error. Treat any wildcard listen ('', 0.0.0.0, ::) as overlapping so
the clash is reported up front, while still allowing two distinct specific
addresses to share a port.

* fix(db): mark the IP-limit cleanup seeder done on a fresh install

ResetIpLimitNoFail2ban is a one-time migration that, on a host without fail2ban,
zeroes every existing client's limitIp because the limit can't be enforced. It
was missing from the fresh-install fast-path seeder list, so on a brand-new DB it
did not run on the first boot but fired on the second — wiping any IP limits the
admin had set in between. Add it to the fast-path so a truly fresh install marks
it done up front (there is nothing to clean), leaving later admin-set limits
intact.

* fix(security): dial outbound subscriptions through the SSRF guard

The outbound-subscription fetch validated the URL host once (resolving DNS and
rejecting private targets) but then fetched with a plain HTTP client that
re-resolves the host at dial time, so a subscription domain the attacker controls
could pass validation as a public IP and rebind to 127.0.0.1 / a cloud metadata
endpoint / an internal host for the actual dial — a blind SSRF into the panel's
network. Route the direct fetch (and its redirects) through
netsafe.SSRFGuardedDialContext, which resolves, checks and dials the same IP
atomically, carrying the subscription's AllowPrivate flag on the request context;
a configured egress proxy still dials its loopback bridge unguarded.

* fix(security): bound the login-limiter attempts map

The login rate limiter keys its records on the caller-supplied username and only
evicted a record when that exact key was revisited or the login succeeded. An
unauthenticated attacker replaying one CSRF token while rotating a fresh username
per request seeded a record that was never revisited, growing the map without
bound until the panel OOMs. Cap the map: before inserting a new record, reclaim
records whose block has lapsed and whose failures aged out, and if the map is
still at the ceiling under a broad flood, drop one so memory can never grow past
the cap.

* fix(tgbot): require admin for privileged callbacks, not just the first switch

answerCallback wraps only its first callback switch in an isAdmin guard; the
second switch (server usage, inbound/online enumeration, database backup export,
ban logs, mass traffic reset, client creation) ran for every caller. Telegram
delivers a callback with the tapping user's id, so a non-admin who can see an
admin's inline keyboard — as when the bot runs in a group — could tap Backup and
receive the full database and config, or reset all traffic. Default-deny before
the second switch: a non-admin may only run the per-user client_* callbacks that
resolve their own data from their Telegram id.

* fix(eventbus): dispatch each subscriber in its own goroutine

The fan-out loop called every subscriber's handler sequentially on the
single dispatch goroutine. The email and Telegram notifiers block on
network I/O for tens of seconds (or minutes when the remote is slow), so
one slow subscriber stalled the whole loop: the 256-slot channel then
filled and Publish silently dropped later events — including high-value
xray.crash and node.down notifications unrelated to the slow handler.

Hand each delivered event to every handler in its own goroutine so a
blocking subscriber can no longer stall delivery to the others. safeCall
already recovers panics, so a detached handler cannot take down the bus.

* fix(integration): cap WARP API response body size

doWarpRequest read the response with an unbounded io.ReadAll, unlike the
sibling NordVPN client which already caps every read at maxResponseSize.
A hostile panel egress proxy or a MITM on the Cloudflare WARP endpoint
could stream an arbitrarily large body and force the panel into an
unbounded allocation. Wrap the body in an io.LimitReader(maxResponseSize)
to match the NordVPN client.

* fix(email): bound every SMTP step with a connection deadline

The "starttls"/"none" transport delivered through net/smtp.SendMail, which
dials with an untimed net.Dial and never sets a socket deadline. When an
SMTP server accepted the TCP connection but then stalled (or was a
blackhole), the caller was released by Send's 30s select, but the sender
goroutine and its socket stayed blocked until the OS TCP timeout — minutes
per notification, leaking a goroutine and a connection each time.
sendWithTLS dialed with a timeout but likewise armed no deadline on the
protocol phase, and TestConnection (called synchronously from the settings
handler, with no select guard) could hang the request indefinitely.

Replace SendMail with sendPlain, which dials with smtpConnectTimeout and
arms conn.SetDeadline(smtpDeadline) before the greeting read, preserving
SendMail's opportunistic STARTTLS upgrade. Arm the same deadline in
sendWithTLS and TestConnection so every SMTP step is bounded.

* fix(server): guard access-log parser against malformed lines

GetXrayLogs split each Xray access-log line on whitespace and then read
fixed offsets — parts[1] for the timestamp and parts[i+1] after the "from",
"accepted" and "email:" markers — without checking the line had that many
fields. A truncated or malformed line (the logged destination is
attacker-influenced) indexed past the slice and panicked; the panel handler
returned a 500 via Gin's recovery.

Extract the per-line field parsing into parseAccessLogFields and length
guard every positional lookup so a short line yields a partial entry
instead of panicking.

* fix(server): guard xray key-generator output parsing

GetNewX25519Cert, GetNewmldsa65 and GetNewmlkem768 parsed xray's stdout by
reading lines[0], lines[1] and each line's second colon-separated field
without any length check — unlike GetNewEchCert, which already guards its
line count. If the xray binary printed fewer than two lines or reformatted
its labels (a version change, or a silent failure that emitted nothing),
the fixed slice index panicked and the handler 500'd.

Extract the shared parsing into parseXrayKeyPairOutput, which length guards
the line count and each label split and returns an error instead of
panicking, then route all three generators through it.

* fix(tgbot): stop auto-deleted messages from resetting wizard state

SendMsgToTgbotDeleteAfter spawns a goroutine that, after the display delay,
deleted the transient message and then unconditionally cleared the chat's
conversation state. Every caller that ends a wizard step already clears the
state synchronously, so that call was redundant — and harmful: if within
the delay the user advanced to the next step (a callback sets a fresh
awaiting_* state), the late goroutine wiped it, and the user's next message
fell through unrecognized, silently dropping their input.

Move the delayed deletion into deleteMessageAfterDelay, which only removes
the message and no longer touches the conversation state. Guard
deleteMessageTgBot against a nil bot so the deletion path is unit-testable.

* fix(frontend): refetch a fresh CSRF token on 403 instead of reusing the stale meta tag

On a 403 to an unsafe method the client cleared its cached CSRF token and
called ensureCsrfToken to retry. But ensureCsrfToken prefers the
<meta name="csrf-token"> tag baked into the page, which the production
panel always injects, so the "refresh" re-read the same stale token and the
/csrf-token refetch was never reached — the retry re-sent the token that had
just been rejected and the save failed with an error toast.

The token lives in the session and rotates when the session is regenerated
(for example re-login in another tab), leaving the tab's baked-in meta token
stale. Fetch the current token straight from /csrf-token in the 403 branch so
the retry uses the authoritative server value. The existing tests only passed
because they strip the meta tag; the new test keeps a stale tag present.

* fix(frontend): surface backend error text from failed requests

HttpUtil.get/post read the thrown HttpError body as response.data.message,
but the backend error envelope (entity.Msg) serializes its text as msg. On
any non-2xx JSON response the real reason was therefore dropped and the
operator saw only the generic "Request failed with status N" toast.

Read response.data.msg first (keeping message and the native error text as
fallbacks). The sibling test had pinned the wrong body shape ({ message });
correct it to the real backend shape ({ success:false, msg }) so it exercises
the actual envelope.

* fix(frontend): share one WebSocket connection across bridge and hooks

websocketBridge.ts and useWebSocket.ts each declared their own
module-scoped sharedClient plus an identical getSharedClient, so the
"shared" client was not shared between them: whenever a page using
useWebSocket (Clients/Inbounds) mounted alongside the always-mounted
bridge, the panel opened two sockets to /ws. The server then pushed every
traffic/stats/nodes/inbounds snapshot to both, doubling WebSocket bandwidth
and running two independent reconnect loops, and the hook's socket was never
disconnected on unmount.

Hoist a single getSharedWebSocketClient into api/websocket.ts and route both
the bridge and the hook through it, so exactly one connection is opened.

* fix(frontend): guard the outbounds WebSocket handler against non-array payloads

onOutbounds wrote the raw WebSocket payload straight into the
outboundsTraffic cache, unlike the sibling onNodes/onInbounds handlers which
first check Array.isArray. A malformed non-array push (for example an object)
would land in the cache with staleTime Infinity; consumers that call
.find()/.map() on the outbounds list would then throw and crash the Outbounds
tab. Add the same Array.isArray guard so a bad push is ignored.

* fix(frontend): key the node table by the computed row key, not id

The desktop node table used rowKey="id", but transitive sub-nodes (the
read-only rows surfaced from downstream nodes) all carry id 0, so a topology
with two or more transitive rows gave React duplicate keys. antd's rowKey
prop overrides the row object's own computed `key` (`t-${guid}` for
transitive rows, the numeric id otherwise), so the unique key the code
already builds was ignored — causing row-state/DOM mis-association on any
re-render (heartbeat refetch, address-eye toggle). The mobile card path
already keyed by record.key.

Key the table by "key" so transitive rows get their distinct t-${guid}
identity; direct nodes keep key === id, so row selection (filtered to numeric
keys) is unchanged.

* fix(frontend): map routing row actions through the rule's real index

The routing table hides balancer-loopback rules (`_bl_*`) but keeps each
visible row's original index in `key`, then handed antd's positional row
index straight to edit/delete/toggle/move/drag — all of which mutate the
full, unfiltered routing.rules array. Once a hidden loopback rule precedes a
visible one (e.g. a balancer whose fallback is another balancer, plus any
rule added afterwards), the positional index no longer matches the array
index, so deleting or editing a rule silently hit the wrong one — including
destroying the loopback rule that keeps the balancer alive.

Add originalRuleIndex to translate a positional row index back through the
row's `key`, and route every mutating handler (openEdit, confirmDelete,
toggleRule, moveUp/moveDown, drag) through it. When no loopback rows are
hidden the mapping is the identity, so ordinary configs are unaffected.

* fix(frontend): map outbound row actions through the outbound's real index

The outbounds table hides balancer-loopback outbounds (`_bl_*`) but keeps
each visible row's original index in `key`, then passed antd's positional
row index to edit/delete/move and to the per-row probe (onTest) and its
result lookup — all of which address the full, unfiltered outbounds array.
Once a hidden loopback outbound precedes a visible one, the positional index
diverges from the array index, so deleting or editing an outbound hit the
wrong one (its deletion-impact plan and removal targeting the wrong entry),
and the test button probed / showed results against the wrong outbound.

Add originalOutboundIndex and route the mutating handlers through it; key
the probe trigger and test-result columns by record.key. With no loopback
rows hidden the mapping is the identity, so ordinary configs are unaffected.

* fix(frontend): tolerate a malformed happyEyeballs value in the Xray Basics tab

BasicsTab derived directHappyEyeballs by calling HappyEyeballsSchema.parse
during render, guarding only against null/non-object. A wrong-typed field
(e.g. happyEyeballs.tryDelayMs as a string) or any other shape mismatch —
reachable via the Complete Template JSON editor or an imported config — threw
straight out of render, white-screening the default Xray landing tab.

Use safeParse and fall back to null so a bad value degrades to "no override"
instead of crashing the page.

* fix(frontend): preserve routing-rule fields the form does not surface

The rule form rebuilt the rule from a fixed literal of only the fields it
edits, and RoutingTab replaces the rule wholesale on confirm. Fields the
form never exposes — localPort, localIP, process, ruleTag, webhook — are in
the rule schema and can arrive via the advanced JSON editor or Import Rules;
opening such a rule in the form and saving silently dropped them.

Carry over every key of the original rule the form does not manage before
applying the form-derived fields, so an edit only touches what it surfaces.

* fix(frontend): re-sync the sniffing island when its value changes externally

The sniffing config editor froze its seed value at mount and only watched
its own inner AntD form, never reflecting a later change to the shared RHF
`sniffing` path. Because the inbound form mounts every tab with
forceRender, the friendly Sniffing tab and the Advanced JSON editor are live
at once: editing sniffing in the JSON editor updated the RHF value but not
the frozen island, so the next interaction with the friendly tab emitted the
stale value and silently discarded the JSON edit.

Add an effect that pushes an external value change into the inner form,
guarded by the same lastEmitted marker the emit path uses so the island
never re-seeds from its own echo and no update loop forms.

* fix(frontend): don't drift a client's byte quota on a no-op save

The quota field shows the total in GB rounded to two decimals; editing a
client and saving converted that display value straight back to bytes. A
byte total not aligned to 0.01 GB — one set via the API or an import — was
therefore rewritten to the rounded value on any save that never touched the
field, losing a few MB each time.

Add resolveTotalBytes: keep the original byte total when the displayed GB
still matches it, and only re-derive from GB when the user actually changed
the field.

* fix(eventbus): deliver events on a bounded per-subscriber worker

The previous fix dispatched each event to every subscriber with a bare
`go safeCall`. That unblocked the dispatch loop, but removed the bus's
backpressure: under a login-attempt flood (which both notifier subscribers
process without rate-limiting) with email/Telegram enabled, every attempt
spawned handler goroutines that each block on network I/O for up to ~30s,
with no bound — a goroutine and outbound-connection storm. It also let a
subscriber's handler run concurrently with itself, racing the Telegram
notifier's lazily-cached hostname.

Give each subscriber its own bounded queue drained by a single worker
goroutine. Dispatch does a non-blocking send per subscriber (dropping only
that subscriber's event when its queue is full), so a slow subscriber still
can't stall the others, concurrency is bounded to one in-flight handler per
subscriber, per-subscriber event order is preserved, and Stop again waits
for in-flight handlers to finish.

* fix(frontend): map outbound mobile-card actions through the real index too

The desktop outbounds table was keyed by the outbound's real index, but the
mobile card list was left keying the probe trigger and every test-state
lookup by the positional row index. With a hidden balancer-loopback outbound
present, tapping Check on a mobile card probed the wrong outbound and the
Test-All results landed on the wrong card. Key onTest and the
testResult/isTesting reads by record.key, matching the desktop columns.

* fix(frontend): meet WCAG AA contrast on the config-block link text

The Storybook accessibility test flagged the share-link <code> block: with no
explicit color it inherited a muted grey that renders as #888888 on the
#f8f8f8 tertiary-fill background in CI's Chromium — a 3.33:1 contrast, below
the 4.5:1 AA threshold. Set the text to the theme's primary text token so the
colour is explicit and high-contrast in both light and dark themes instead of
depending on an inherited value that varies by browser.

* style(sub): simplify a negated conjunction to satisfy staticcheck QF1001

golangci-lint (staticcheck QF1001) flagged the `!(a && b)` guard in
expandSegment. Rewrite it via De Morgan's law to the equivalent
`!a || !b` form so the linter passes; behavior is unchanged.

* fix: close panics and races the audit's own fixes left nearby

Second-pass review of the 54-commit self-correcting audit. Each item below
was confirmed by reading the surrounding source (and, where practical, the
pre-fix code) before being changed; regression tests are included for every
behavioral fix.

Concurrency:
- eventbus: Bus.Subscribe called wg.Add with no synchronization against a
  concurrent Bus.Stop's wg.Wait, a real "WaitGroup misuse" panic risk (e.g. a
  Telegram-bot settings save racing panel shutdown/restart). Stop now flips a
  mu-guarded `stopped` flag before waiting, and Subscribe checks it under the
  same lock, so Add and Wait can no longer race.

Security:
- login_limiter: evictForRoom's fallback eviction picked an arbitrary map
  key, including ones still under an active cooldown - an attacker flooding
  /login with fresh usernames could evict their own (or anyone's) blocked
  record and reset the lockout. The fallback now skips actively-blocked
  records, only falling back to an unconditional evict if the map is
  somehow entirely full of active blocks (preserves the hard memory cap).

Subscription-endpoint panics (reachable by any client hitting /sub):
- internal/sub/service.go: applyPathAndHostParams/Obj (ws/httpupgrade/xhttp
  with no path settings object) and the TLS alpn readers in three places
  used unchecked type assertions - exactly the bug class abab7cd0 patched
  elsewhere in the same switch statements, just not these call sites.
- internal/sub/json_service.go, clash_service.go: the externalProxy loops
  in the JSON and Clash generators used unchecked assertions on a
  legacy/admin-supplied field (missing "port", non-object entry, etc.).
- internal/sub/json_service.go: realityData's shortId/serverName selection
  could assert a non-string array element.

Other correctness:
- client_traffic.go: ResetAllTraffics (touched by 3eb214d0) still skipped
  clearing NodeClientTraffic node-sync baselines, unlike its sibling reset
  paths in the same file - a node's next sync would re-add pre-reset delta
  on top of the freshly-zeroed counter.
- inbound_traffic.go: the traffic-tick tx's Commit/Rollback errors were
  silently discarded; now logged so a backend-level commit failure (e.g. an
  aborted Postgres tx from a best-effort helper) doesn't masquerade as a
  successful tick.
- outbound_subscription.go: the new subscriptionFetchClient doc comment was
  wedged between fetchAndStore's existing comment and fetchAndStore itself,
  leaving fetchAndStore undocumented and the comment describing the wrong
  function.

Convention cleanup:
- Removed narrative // comments added by the audit that violate this repo's
  no-inline-comment rule (mostly narrating the specific bug/fix rather than
  a lasting contract, and mostly on new Test functions, which this repo's
  existing tests never comment) - calibrated against this exact codebase's
  own pre-existing comment style so legitimate godoc-style doc comments
  were left alone.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-07-17 00:33:06 +02:00
dependabot[bot] 28b360f2af chore(deps): bump google.golang.org/grpc from 1.82.0 to 1.82.1 (#5994)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.82.0 to 1.82.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.82.0...v1.82.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.82.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 13:16:32 +02:00
dependabot[bot] 4600771167 chore(deps): bump react-i18next from 17.0.9 to 17.0.10 in /frontend (#5996)
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 17.0.9 to 17.0.10.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.9...v17.0.10)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-version: 17.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 13:16:12 +02:00
dependabot[bot] b97504385f chore(deps-dev): bump vite from 8.1.4 to 8.1.5 in /frontend (#5997)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.1.4 to 8.1.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 13:15:50 +02:00
dependabot[bot] 8dfb639dcf chore(deps): bump github.com/go-ldap/ldap/v3 from 3.4.13 to 3.4.14 (#5993)
Bumps [github.com/go-ldap/ldap/v3](https://github.com/go-ldap/ldap) from 3.4.13 to 3.4.14.
- [Release notes](https://github.com/go-ldap/ldap/releases)
- [Commits](https://github.com/go-ldap/ldap/compare/v3.4.13...v3.4.14)

---
updated-dependencies:
- dependency-name: github.com/go-ldap/ldap/v3
  dependency-version: 3.4.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 13:15:13 +02:00
dependabot[bot] 444e1e5917 chore(deps): bump actions/setup-go from 6 to 7 (#5995)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 13:14:27 +02:00
dependabot[bot] 73b479e5a0 chore(deps): bump actions/setup-node from 6 to 7 (#5992)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 13:13:55 +02:00
Tomi lla 129f50d92a feat(sub): auto-detect subscription format by User-Agent (Updated) (#5826)
* feat(settings): add subscription format controls

* feat(sub): auto-detect subscription formats

* fix(xray): validate balancer regexes before save

* Revert "fix(xray): validate balancer regexes before save"

This reverts commit 8a208ce71b.

* doc(endpoints): align indent spaces

* doc(settings): improve error message formatting in validateSubUserAgentRegex

- Use NewErrorf with proper formatting instead of NewError with string concatenation
- Add comment explaining the rationale for returning original pattern value
- This preserves the intentional design where empty input is stored as empty
  in the DB and inherited as the runtime default at read time

---------

Co-authored-by: Tomilla <5007859+Tomilla@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-07-14 13:01:40 +02:00
H-TTTTT f2b17397f4 fix(frontend): stabilize speed tags on inbound and client pages (#5930)
* fix(frontend): add shared stable speed-tag style

Give live up/down rate tags a fixed width, centered layout, nowrap,
and tabular numerals so digit/unit changes cannot reflow the Speed column.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(frontend): stabilize InboundSpeedTag and ClientSpeedTag layout

Apply the shared speed-tag class/style to both live rate tags and lock
the behavior with a focused component test for small and large rates.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(frontend): align speed columns with stable tag width

Widen inbound/client Speed columns to match the fixed tag and apply the
same stable style to idle dash cells so active/idle swaps do not jitter.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(frontend): scope stable speed tags to table cells and fit content

---------

Co-authored-by: x06579 <x06579@ai-dashboard>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-14 13:00:25 +02:00
Sangeeth Thilakarathna 658e6ab3d3 feat(frontend): show client comments on mobile cards (#5942)
* feat(frontend): show client comments on mobile cards

* fix(frontend): bound mobile comment height

---------

Co-authored-by: sanmaxdev <sanmaxdev@users.noreply.github.com>
2026-07-14 12:59:55 +02:00
Yuri Khachaturyan 1cfd7b49b0 fix(email): build an RFC 5322 message with a proper From address and name (#5941)
The notification/test email carried only From/To/Subject/MIME headers, and
the From header was the raw SMTP username. Two problems:

- When the SMTP login is not a bare email address (common with relays and
  submission services), the From header has no valid address and strict
  receivers reject the message — e.g. Gmail returns "550-5.7.1 ... Messages
  missing a valid address in From: header".
- There was no Date (mandatory per RFC 5322 section 3.6) and no Message-ID,
  which also raises spam score.

Add smtpFrom (sender address) and smtpFromName (display name) settings and
assemble the message with net/mail: a name-addr From ("Name" <addr>), a
Date, a Message-ID, and an RFC 2047 encoded Subject, in a deterministic
header order. From falls back to the username when smtpFrom is empty, so
existing setups keep working. Wire the settings through the model, the SMTP
send and test paths, the Email settings UI, and all 13 locale files;
regenerate the Zod/OpenAPI artifacts.

Validate smtpFrom in AllSetting.CheckValid (reject anything net/mail cannot
parse), which surfaces a bad address at configuration time and prevents CRLF
header injection; strip CR/LF in buildMessage as defense in depth. Add
buildMessage and CheckValid tests.
2026-07-14 12:55:46 +02:00
Matt Van Horn ae0da4c51f fix: stop forcing port 53 on DoH/DoQ DNS server entries (#5950)
Object-form DNS server entries always received port: 53, because
DnsServerObjectInnerSchema defaulted the port unconditionally and the
DnsServerModal wire adapter always wrote it. Per Xray-core, encrypted
schemes must not carry a port field; a non-standard port is embedded in
the URL instead.

Default the port to 53 only for non-encrypted addresses and omit it for
the encrypted DNS schemes Xray dispatches without a port - https,
https+local, h2c, h2c+local and quic+local - both in the Zod schema and
in the modal's valuesToWire adapter. Schemes are matched
case-insensitively to mirror Xray-core's EqualFold comparison. A shared
isEncryptedDnsAddress helper backs both paths.

Fixes #5920

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-07-14 12:55:10 +02:00
Sangeeth Thilakarathna 65b5074b60 fix(script): remove release download time limit (#5952)
* fix(script): remove release download time limit

* fix(script): stop stalled release downloads

---------

Co-authored-by: sanmaxdev <sanmaxdev@users.noreply.github.com>
2026-07-14 12:44:55 +02:00
Mikhail Grigorev b18c87dc4b fix(script): Remove old mtg binary (#5955)
Co-authored-by: Mikhail Grigorev <grigorev_mm@magnit.ru>
2026-07-14 12:44:22 +02:00
MHSanaei b11ceac18e fix(ci): install the docs-pinned pnpm instead of floating on 11.x
pnpm/action-setup resolved 'version: 11' to the newest 11.x, and its self-installer crashes upgrading to 11.12.0 (Cannot use 'in' operator to search for 'integrity'), failing both docs workflows at setup. Reading docs/package.json instead installs the exact packageManager pin (pnpm@11.9.0), which also keeps the workflows and the lockfile toolchain on a single source of truth.
2026-07-14 03:50:16 +02:00
MHSanaei bbc4163768 chore: standardize the toolchain on Node 24 LTS
The repo now pins Node 24 everywhere instead of mixing 22 and hardcoded workflow versions. The docs workflows read .nvmrc like the main CI already did, so the Storybook bundle in the Pages deploy builds on the same runtime as the PR gate. The docs gen:api script runs its TypeScript entry natively, dropping the experimental type-stripping flag that Node 24 makes default; the matching frontend cleanup (engines and gen:api) landed with the Storybook commit.
2026-07-14 03:39:03 +02:00
MHSanaei ee9a6067c2 refactor(frontend): migrate off deprecated Ant Design 6 props
The repo's type-aware deprecation sweep (eslint.deprecated.config.js) reported fourteen findings; it now reports zero. Alert message becomes title and closable+onClose becomes closable.onClose; Select optionFilterProp moves into showSearch.optionFilterProp and suffixIcon becomes suffix; Drawer width becomes size; Progress trailColor becomes railColor. Behavior is unchanged apart from a few single-mode selects gaining type-to-filter, which the old prop already implied.
2026-07-14 03:38:41 +02:00
MHSanaei 60316c831f fix(frontend): resolve every axe accessibility violation in the component library
Running the stories under axe surfaced real panel defects, not just story cosmetics. FormField never associated its Form.Item label with the wrapped control, so no RHF form field in the panel had a programmatic label; it now generates an id and wires htmlFor. Unnamed controls get accessible names: the prompt and text modal inputs (from the modal title), the client traffic progress bar (used/limit values), the CPU and RAM threshold inputs in the notification groups (event label threaded through the extra renderer), and the JSON editor's contenteditable surface.

ConfigBlock's collapse header carried role=button around focusable action buttons; collapsible=header scopes the toggle to the label. Light theme gains contrast-safe tokens shared by the panel and Storybook: darker description, placeholder, error and success text, a darker primary button blue, and a readable gold tag, all meeting the WCAG AA 4.5:1 ratio. The infinity badge swaps a prohibited bare aria-label for role=img.
2026-07-14 03:38:14 +02:00
MHSanaei df3ba568d1 feat(docs): publish the component Storybook on the docs site
The docs site and the component workbench were entirely disconnected. The Pages deploy now builds the frontend Storybook and bundles it into the artifact under /storybook, so the live component reference ships with the documentation, and the navbar links to it. Story changes trigger a redeploy so the published workbench cannot go stale.
2026-07-14 03:37:55 +02:00
MHSanaei 7078abc14a feat(frontend): make Storybook a validated, fully covered component workbench
Storybook existed only as an undocumented local tool: 9 of 24 reusable components had stories, autodocs pages were bare prop tables, nothing built or tested the stories, and no contributor doc mentioned the workbench existed.

Every reusable component under src/components/ now has a co-located story with enriched autodocs (component descriptions plus per-prop argTypes, kept as string metadata since the repo bans line comments). Stories double as headless Chromium tests through the Storybook vitest addon, with axe accessibility checks enforced as errors and play-function interaction tests covering the modals, the RHF field bridge, the config block, and the select-all buttons. The preview now mirrors the panel's real theme DOM (body class, shared AntD theme config, seeded theme storage) so what stories render matches production.

CI and make verify gain a static Storybook build as a compile gate, and the frontend test job installs Chromium so story tests run on every PR. Contributor docs (frontend README, CONTRIBUTING, agent guides) document the workbench, the story conventions, and the Controls setup. Node engines move to 24 LTS and gen:api drops the type-stripping flags that Node 24 makes default.
2026-07-14 03:37:21 +02:00
432 changed files with 26553 additions and 9047 deletions
+17 -11
View File
@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
@@ -55,7 +55,7 @@ jobs:
--health-retries 5 --health-retries 5
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
@@ -74,11 +74,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version-file: .nvmrc node-version-file: .nvmrc
- name: Regenerate schemas, examples and OpenAPI - name: Regenerate schemas, examples and OpenAPI
@@ -91,7 +91,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
@@ -107,7 +107,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
@@ -124,7 +124,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
@@ -139,7 +139,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
@@ -154,7 +154,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version-file: .nvmrc node-version-file: .nvmrc
cache: npm cache: npm
@@ -168,12 +168,18 @@ jobs:
- name: Typecheck - name: Typecheck
run: npm run typecheck run: npm run typecheck
working-directory: frontend working-directory: frontend
- name: Install Playwright Chromium (Storybook story tests)
run: npx playwright install --with-deps chromium
working-directory: frontend
- name: Test - name: Test
run: npm test run: npm test
working-directory: frontend working-directory: frontend
- name: Build - name: Build
run: npm run build run: npm run build
working-directory: frontend working-directory: frontend
- name: Audit - name: Build Storybook
run: npm audit --audit-level=high run: npm run build-storybook
working-directory: frontend
- name: Audit
run: npm audit --omit=dev --audit-level=high
working-directory: frontend working-directory: frontend
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
if: matrix.language == 'go' if: matrix.language == 'go'
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version-file: .nvmrc node-version-file: .nvmrc
cache: 'npm' cache: 'npm'
+3 -3
View File
@@ -26,11 +26,11 @@ jobs:
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
with: with:
version: 11 package_json_file: docs/package.json
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 22 node-version-file: .nvmrc
cache: pnpm cache: pnpm
cache-dependency-path: docs/pnpm-lock.yaml cache-dependency-path: docs/pnpm-lock.yaml
+13 -3
View File
@@ -9,6 +9,9 @@ on:
branches: [main] branches: [main]
paths: paths:
- 'docs/**' - 'docs/**'
- 'frontend/src/components/**'
- 'frontend/.storybook/**'
- 'frontend/package-lock.json'
- '.github/workflows/docs-deploy.yml' - '.github/workflows/docs-deploy.yml'
workflow_dispatch: workflow_dispatch:
@@ -32,10 +35,10 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
with: with:
version: 11 package_json_file: docs/package.json
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 22 node-version-file: .nvmrc
cache: pnpm cache: pnpm
cache-dependency-path: docs/pnpm-lock.yaml cache-dependency-path: docs/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
@@ -52,6 +55,13 @@ jobs:
# in place. That way both /docs/... and /en/docs/... resolve. Other # in place. That way both /docs/... and /en/docs/... resolve. Other
# locales stay under /fa, /ru, /zh. # locales stay under /fa, /ru, /zh.
run: cp -a out/en/. out/ run: cp -a out/en/. out/
- name: Build the component Storybook (frontend/)
working-directory: frontend
run: |
npm ci
npm run build-storybook
- name: Bundle Storybook at /storybook
run: cp -a ../frontend/storybook-static out/storybook
- uses: actions/upload-pages-artifact@v5 - uses: actions/upload-pages-artifact@v5
with: with:
path: docs/out path: docs/out
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
exclude: 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go' exclude: 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go'
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
cache: true cache: true
+64 -46
View File
@@ -16,6 +16,7 @@ on:
- "x-ui.service.debian" - "x-ui.service.debian"
- "x-ui.service.arch" - "x-ui.service.arch"
- "x-ui.service.rhel" - "x-ui.service.rhel"
- ".github/workflows/release.yml"
pull_request: pull_request:
paths: paths:
- "**.go" - "**.go"
@@ -26,12 +27,15 @@ on:
- "x-ui.service.debian" - "x-ui.service.debian"
- "x-ui.service.arch" - "x-ui.service.arch"
- "x-ui.service.rhel" - "x-ui.service.rhel"
- ".github/workflows/release.yml"
jobs: jobs:
build: build:
permissions: permissions:
contents: write contents: write
strategy: strategy:
# One platform hitting a transient outage must not cancel the other six.
fail-fast: false
matrix: matrix:
platform: platform:
- amd64 - amd64
@@ -47,7 +51,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
check-latest: true check-latest: true
@@ -57,7 +61,7 @@ jobs:
# at compile time. internal/web/dist/ is .gitignored, so on a fresh CI # at compile time. internal/web/dist/ is .gitignored, so on a fresh CI
# checkout it doesn't exist until vite emits it. # checkout it doesn't exist until vite emits it.
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version-file: .nvmrc node-version-file: .nvmrc
cache: 'npm' cache: 'npm'
@@ -71,6 +75,8 @@ jobs:
- name: Build 3X-UI - name: Build 3X-UI
run: | run: |
CURL_RETRY="--retry 5 --retry-all-errors --retry-delay 3"
fetch() { wget -q --tries=5 --waitretry=10 --retry-on-http-error=429,500,502,503 "$@"; }
export CGO_ENABLED=1 export CGO_ENABLED=1
export GOOS=linux export GOOS=linux
export GOARCH=${{ matrix.platform }} export GOARCH=${{ matrix.platform }}
@@ -86,11 +92,11 @@ jobs:
esac esac
echo "Resolving Bootlin musl toolchain for arch=$BOOTLIN_ARCH (platform=${{ matrix.platform }})" echo "Resolving Bootlin musl toolchain for arch=$BOOTLIN_ARCH (platform=${{ matrix.platform }})"
TARBALL_BASE="https://toolchains.bootlin.com/downloads/releases/toolchains/$BOOTLIN_ARCH/tarballs/" TARBALL_BASE="https://toolchains.bootlin.com/downloads/releases/toolchains/$BOOTLIN_ARCH/tarballs/"
TARBALL_URL=$(curl -fsSL "$TARBALL_BASE" | grep -oE "${BOOTLIN_ARCH}--musl--stable-[^\"]+\\.tar\\.xz" | sort -r | head -n1) TARBALL_URL=$(curl -fsSL $CURL_RETRY "$TARBALL_BASE" | grep -oE "${BOOTLIN_ARCH}--musl--stable-[^\"]+\\.tar\\.xz" | sort -r | head -n1)
[ -z "$TARBALL_URL" ] && { echo "Failed to locate Bootlin musl toolchain for arch=$BOOTLIN_ARCH" >&2; exit 1; } [ -z "$TARBALL_URL" ] && { echo "Failed to locate Bootlin musl toolchain for arch=$BOOTLIN_ARCH" >&2; exit 1; }
echo "Downloading: $TARBALL_URL" echo "Downloading: $TARBALL_URL"
cd /tmp cd /tmp
curl -fL -sS -o "$(basename "$TARBALL_URL")" "$TARBALL_BASE/$TARBALL_URL" curl -fL -sS $CURL_RETRY -o "$(basename "$TARBALL_URL")" "$TARBALL_BASE/$TARBALL_URL"
tar -xf "$(basename "$TARBALL_URL")" tar -xf "$(basename "$TARBALL_URL")"
TOOLCHAIN_DIR=$(find . -maxdepth 1 -type d -name "${BOOTLIN_ARCH}--musl--stable-*" | head -n1) TOOLCHAIN_DIR=$(find . -maxdepth 1 -type d -name "${BOOTLIN_ARCH}--musl--stable-*" | head -n1)
export PATH="$(realpath "$TOOLCHAIN_DIR")/bin:$PATH" export PATH="$(realpath "$TOOLCHAIN_DIR")/bin:$PATH"
@@ -118,55 +124,57 @@ jobs:
cd x-ui/bin cd x-ui/bin
# Download dependencies # 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 if [ "${{ matrix.platform }}" == "amd64" ]; then
wget -q ${Xray_URL}Xray-linux-64.zip fetch ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip unzip Xray-linux-64.zip
rm -f Xray-linux-64.zip rm -f Xray-linux-64.zip
elif [ "${{ matrix.platform }}" == "arm64" ]; then elif [ "${{ matrix.platform }}" == "arm64" ]; then
wget -q ${Xray_URL}Xray-linux-arm64-v8a.zip fetch ${Xray_URL}Xray-linux-arm64-v8a.zip
unzip Xray-linux-arm64-v8a.zip unzip Xray-linux-arm64-v8a.zip
rm -f Xray-linux-arm64-v8a.zip rm -f Xray-linux-arm64-v8a.zip
elif [ "${{ matrix.platform }}" == "armv7" ]; then elif [ "${{ matrix.platform }}" == "armv7" ]; then
wget -q ${Xray_URL}Xray-linux-arm32-v7a.zip fetch ${Xray_URL}Xray-linux-arm32-v7a.zip
unzip Xray-linux-arm32-v7a.zip unzip Xray-linux-arm32-v7a.zip
rm -f Xray-linux-arm32-v7a.zip rm -f Xray-linux-arm32-v7a.zip
elif [ "${{ matrix.platform }}" == "armv6" ]; then elif [ "${{ matrix.platform }}" == "armv6" ]; then
wget -q ${Xray_URL}Xray-linux-arm32-v6.zip fetch ${Xray_URL}Xray-linux-arm32-v6.zip
unzip Xray-linux-arm32-v6.zip unzip Xray-linux-arm32-v6.zip
rm -f Xray-linux-arm32-v6.zip rm -f Xray-linux-arm32-v6.zip
elif [ "${{ matrix.platform }}" == "386" ]; then elif [ "${{ matrix.platform }}" == "386" ]; then
wget -q ${Xray_URL}Xray-linux-32.zip fetch ${Xray_URL}Xray-linux-32.zip
unzip Xray-linux-32.zip unzip Xray-linux-32.zip
rm -f Xray-linux-32.zip rm -f Xray-linux-32.zip
elif [ "${{ matrix.platform }}" == "armv5" ]; then elif [ "${{ matrix.platform }}" == "armv5" ]; then
wget -q ${Xray_URL}Xray-linux-arm32-v5.zip fetch ${Xray_URL}Xray-linux-arm32-v5.zip
unzip Xray-linux-arm32-v5.zip unzip Xray-linux-arm32-v5.zip
rm -f Xray-linux-arm32-v5.zip rm -f Xray-linux-arm32-v5.zip
elif [ "${{ matrix.platform }}" == "s390x" ]; then elif [ "${{ matrix.platform }}" == "s390x" ]; then
wget -q ${Xray_URL}Xray-linux-s390x.zip fetch ${Xray_URL}Xray-linux-s390x.zip
unzip Xray-linux-s390x.zip unzip Xray-linux-s390x.zip
rm -f Xray-linux-s390x.zip rm -f Xray-linux-s390x.zip
fi fi
rm -f geoip.dat geosite.dat rm -f geoip.dat geosite.dat
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat fetch https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat fetch https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
wget -q -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat fetch -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
wget -q -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat fetch -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat fetch -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat fetch -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
mv xray xray-linux-${{ matrix.platform }} mv xray xray-linux-${{ matrix.platform }}
# mtg-multi (MTProto sidecar) ships prebuilt release binaries whose # mtg-multi (MTProto sidecar) ships prebuilt release binaries whose
# platform labels match our matrix, so download and unpack the matching # platform labels match our matrix, so download and unpack the matching
# archive. Only the platforms the fork publishes are packaged. The tag # archive. Only the platforms the fork publishes are packaged — the tag
# is resolved from the fork's latest release so it never needs bumping # lookup lives inside that branch so unpackaged platforms (s390x) never
# here; the token only lifts the API rate limit for a public read. # depend on it. The tag comes from the release-page redirect on
MTG_MULTI_VER=$(curl -sfL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) # github.com — the host the downloads need anyway — because api.github.com
if [ -z "$MTG_MULTI_VER" ]; then echo "could not resolve the latest mtg-multi release tag"; exit 1; fi # has 503'd whole release runs while asset downloads kept working.
case "${{ matrix.platform }}" in case "${{ matrix.platform }}" in
amd64|arm64|armv7|armv6|386) amd64|arm64|armv7|armv6|386)
MTG_MULTI_VER=$(curl -sf $CURL_RETRY -o /dev/null -w '%{redirect_url}' "https://github.com/mhsanaei/mtg-multi/releases/latest" | sed -n 's#.*/releases/tag/##p')
if [ -z "$MTG_MULTI_VER" ]; then echo "could not resolve the latest mtg-multi release tag"; exit 1; fi
MTG_PKG="mtg-multi-${MTG_MULTI_VER#v}-linux-${{ matrix.platform }}" MTG_PKG="mtg-multi-${MTG_MULTI_VER#v}-linux-${{ matrix.platform }}"
curl -sfLRO "https://github.com/mhsanaei/mtg-multi/releases/download/${MTG_MULTI_VER}/${MTG_PKG}.tar.gz" curl -sfLRO $CURL_RETRY "https://github.com/mhsanaei/mtg-multi/releases/download/${MTG_MULTI_VER}/${MTG_PKG}.tar.gz"
tar -xzf "${MTG_PKG}.tar.gz" tar -xzf "${MTG_PKG}.tar.gz"
mv "${MTG_PKG}/mtg-multi" "mtg-linux-${{ matrix.platform }}" mv "${MTG_PKG}/mtg-multi" "mtg-linux-${{ matrix.platform }}"
rm -rf "${MTG_PKG}" "${MTG_PKG}.tar.gz" rm -rf "${MTG_PKG}" "${MTG_PKG}.tar.gz"
@@ -211,7 +219,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
check-latest: true check-latest: true
@@ -220,7 +228,7 @@ jobs:
# Linux job above. This step is identical except npm runs on the # Linux job above. This step is identical except npm runs on the
# Windows runner here. # Windows runner here.
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version-file: .nvmrc node-version-file: .nvmrc
cache: 'npm' cache: 'npm'
@@ -267,32 +275,34 @@ jobs:
- name: Copy and download resources - name: Copy and download resources
shell: pwsh shell: pwsh
run: | run: |
$retry = @{ MaximumRetryCount = 5; RetryIntervalSec = 10 }
mkdir x-ui mkdir x-ui
Copy-Item xui-release.exe x-ui\x-ui.exe Copy-Item xui-release.exe x-ui\x-ui.exe
mkdir x-ui\bin mkdir x-ui\bin
cd x-ui\bin cd x-ui\bin
# Download Xray for Windows # 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 -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip" Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath . Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip" Remove-Item "Xray-windows-64.zip"
Remove-Item geoip.dat, geosite.dat -ErrorAction SilentlyContinue Remove-Item geoip.dat, geosite.dat -ErrorAction SilentlyContinue
Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip.dat" Invoke-WebRequest @retry -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip.dat"
Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite.dat" Invoke-WebRequest @retry -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite.dat"
Invoke-WebRequest -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat" -OutFile "geoip_IR.dat" Invoke-WebRequest @retry -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat" -OutFile "geoip_IR.dat"
Invoke-WebRequest -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat" -OutFile "geosite_IR.dat" Invoke-WebRequest @retry -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat" -OutFile "geosite_IR.dat"
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat" Invoke-WebRequest @retry -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat"
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat" Invoke-WebRequest @retry -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
Rename-Item xray.exe xray-windows-amd64.exe Rename-Item xray.exe xray-windows-amd64.exe
# mtg-multi (MTProto sidecar) publishes a prebuilt Windows binary, so # mtg-multi (MTProto sidecar) publishes a prebuilt Windows binary, so
# download and unpack it instead of compiling. The tag tracks the # download and unpack it instead of compiling. The tag comes from the
# fork's latest release so it never needs bumping here. # release-page redirect on github.com — not api.github.com, whose
$MTG_MULTI_VER = (Invoke-RestMethod -Uri "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" -Headers @{ Authorization = "Bearer ${{ secrets.GITHUB_TOKEN }}"; "User-Agent" = "x-ui-release" }).tag_name # outages have failed release runs while asset downloads kept working.
if (-not $MTG_MULTI_VER) { throw "could not resolve the latest mtg-multi release tag" } $MTG_MULTI_VER = (curl.exe -sf --retry 5 --retry-all-errors --retry-delay 3 -o NUL -w '%{redirect_url}' "https://github.com/mhsanaei/mtg-multi/releases/latest") -replace '^.*/releases/tag/', ''
if (-not $MTG_MULTI_VER -or $MTG_MULTI_VER -notmatch '^v[\d.]+$') { throw "could not resolve the latest mtg-multi release tag" }
$MTG_PKG = "mtg-multi-$($MTG_MULTI_VER.TrimStart('v'))-windows-amd64" $MTG_PKG = "mtg-multi-$($MTG_MULTI_VER.TrimStart('v'))-windows-amd64"
curl.exe -sfLRO "https://github.com/mhsanaei/mtg-multi/releases/download/$MTG_MULTI_VER/$MTG_PKG.zip" curl.exe -sfLRO --retry 5 --retry-all-errors --retry-delay 3 "https://github.com/mhsanaei/mtg-multi/releases/download/$MTG_MULTI_VER/$MTG_PKG.zip"
Expand-Archive -Path "$MTG_PKG.zip" -DestinationPath "mtg-tmp" -Force Expand-Archive -Path "$MTG_PKG.zip" -DestinationPath "mtg-tmp" -Force
Move-Item "mtg-tmp/$MTG_PKG/mtg-multi.exe" "mtg-windows-amd64.exe" Move-Item "mtg-tmp/$MTG_PKG/mtg-multi.exe" "mtg-windows-amd64.exe"
Remove-Item -Recurse -Force "mtg-tmp", "$MTG_PKG.zip" Remove-Item -Recurse -Force "mtg-tmp", "$MTG_PKG.zip"
@@ -359,6 +369,14 @@ jobs:
COMMIT: ${{ github.sha }} COMMIT: ${{ github.sha }}
run: | run: |
set -e set -e
retry() {
for i in 1 2 3 4 5; do
"$@" && return 0
echo "attempt $i failed: ${*:1:3}" >&2
sleep $((i * 5))
done
return 1
}
short="${COMMIT::8}" short="${COMMIT::8}"
notes="Rolling development build — installs via the panel's Dev update channel. notes="Rolling development build — installs via the panel's Dev update channel.
@@ -369,14 +387,14 @@ jobs:
# Force-move the dev-latest tag to this commit so the release tracks it. # Force-move the dev-latest tag to this commit so the release tracks it.
git tag -f dev-latest "${COMMIT}" git tag -f dev-latest "${COMMIT}"
git push -f origin refs/tags/dev-latest retry git push -f origin refs/tags/dev-latest
if gh release view dev-latest >/dev/null 2>&1; then # The release exists on every run but the first; edit-first avoids an
gh release edit dev-latest --prerelease --latest=false \ # existence probe that can 503 and mis-route into create (422).
--title "Dev build ${short}" --notes "${notes}" if ! retry gh release edit dev-latest --prerelease --latest=false \
else --title "Dev build ${short}" --notes "${notes}"; then
gh release create dev-latest --prerelease --latest=false \ retry gh release create dev-latest --prerelease --latest=false \
--target "${COMMIT}" --title "Dev build ${short}" --notes "${notes}" --target "${COMMIT}" --title "Dev build ${short}" --notes "${notes}"
fi fi
gh release upload dev-latest dev-artifacts/*.tar.gz dev-artifacts/*.zip --clobber retry gh release upload dev-latest dev-artifacts/*.tar.gz dev-artifacts/*.zip --clobber
+2 -1
View File
@@ -2,7 +2,8 @@
.idea/ .idea/
.vscode/ .vscode/
.cursor/ .cursor/
.claude/* .specify/
.claude/
.cache/ .cache/
.sync* .sync*
+1 -1
View File
@@ -1 +1 @@
22 24
+131
View File
@@ -96,6 +96,22 @@
"options": { "options": {
"cwd": "${workspaceFolder}" "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": [ "problemMatcher": [
"$go" "$go"
] ]
@@ -111,6 +127,22 @@
"options": { "options": {
"cwd": "${workspaceFolder}" "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": [ "problemMatcher": [
"$go" "$go"
] ]
@@ -125,6 +157,22 @@
"options": { "options": {
"cwd": "${workspaceFolder}" "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": [ "problemMatcher": [
"$go" "$go"
] ]
@@ -140,10 +188,93 @@
"options": { "options": {
"cwd": "${workspaceFolder}" "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": [ "problemMatcher": [
"$go" "$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", "label": "frontend: ncu -u",
"type": "shell", "type": "shell",
+6 -4
View File
@@ -5,7 +5,7 @@ Thanks for taking the time to contribute to 3x-ui. This guide gets a development
## Prerequisites ## Prerequisites
- **Go 1.26+** (the version pinned in `go.mod`) - **Go 1.26+** (the version pinned in `go.mod`)
- **Node.js 22+** and npm 10+ (for the React frontend) - **Node.js 24 LTS** (the version pinned in `.nvmrc`) and npm 10+ (for the React frontend)
- **Git** - **Git**
- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below. - **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.
@@ -157,12 +157,13 @@ Panel navigation happens client-side through React Router, and per-route code is
Locale strings live in `internal/web/translation/<locale>.json`, **not** under `frontend/`. The Go binary embeds the same JSON and serves it to both backend templates and `react-i18next` (initialized in `src/i18n/react.ts`). When a new English key is added it must also land in **every** non-English locale — missing keys do not break the build, they just render the raw key in the UI. Locale strings live in `internal/web/translation/<locale>.json`, **not** under `frontend/`. The Go binary embeds the same JSON and serves it to both backend templates and `react-i18next` (initialized in `src/i18n/react.ts`). When a new English key is added it must also land in **every** non-English locale — missing keys do not break the build, they just render the raw key in the UI.
### Two dev workflows ### Dev workflows
| Goal | Command | | Goal | Command |
|------|---------| |------|---------|
| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and the WebSocket to the Go panel on `:2053`). Start the Go panel first. | | Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and the WebSocket to the Go panel on `:2053`). Start the Go panel first. |
| Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. | | Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
| Develop/preview a reusable component in isolation | `cd frontend && npm run storybook` (Storybook workbench + autodocs on `:6006`). |
The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/api/setting/*`, `/panel/api/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes. The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/api/setting/*`, `/panel/api/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
@@ -189,6 +190,7 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit
- **Document new endpoints.** Every new `g.POST`/`g.GET` in `internal/web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`). - **Document new endpoints.** Every new `g.POST`/`g.GET` in `internal/web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
- **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both. - **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — read the live version there rather than trusting a number quoted here — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward. - **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — read the live version there rather than trusting a number quoted here — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
- **Reusable components are documented in Storybook.** When you add or change a component in `frontend/src/components/`, add or update its co-located `<Component>.stories.tsx` (`tags: ['autodocs']`), documenting props via `argTypes` / `parameters.docs` string metadata rather than JSDoc. CI compile-checks every story via `npm run build-storybook` and runs each story as a headless-browser test via `@storybook/addon-vitest` (`npm run test`, needs `npx playwright install chromium`); run `npm run storybook` to preview locally.
### Project layout ### Project layout
@@ -277,7 +279,7 @@ CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml
### CI ### CI
`.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`test`/`build`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green. `.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`test`/`build`/`build-storybook`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green.
## Sending a pull request ## Sending a pull request
@@ -286,7 +288,7 @@ CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml
3. Run the relevant checks before pushing: 3. Run the relevant checks before pushing:
- `go build ./...` - `go build ./...`
- `go test ./...` (when Go code changed) - `go test ./...` (when Go code changed)
- `cd frontend && npm run typecheck && npm run lint && npm run test && npm run build` (when the frontend changed; CI runs this same set on every PR via `.github/workflows/ci.yml`) - `cd frontend && npm run typecheck && npm run lint && npm run test && npm run build && npm run build-storybook` (when the frontend changed; CI runs this same set on every PR via `.github/workflows/ci.yml`)
4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged. 4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged.
5. Open the PR against `main` with a brief description of what changed and how to test it. 5. Open the PR against `main` with a brief description of what changed and how to test it.
+1 -1
View File
@@ -32,7 +32,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
fi fi
mkdir -p build/bin mkdir -p build/bin
cd 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" unzip "Xray-linux-${ARCH}.zip"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
mv xray "xray-linux-${FNAME}" mv xray "xray-linux-${FNAME}"
+6 -2
View File
@@ -68,8 +68,12 @@ build-fe: ## Build the Vite bundles into internal/web/dist
build: build-fe ## Build the frontend then the Go binary build: build-fe ## Build the frontend then the Go binary
go build ./... go build ./...
.PHONY: build-storybook
build-storybook: ## Build the static Storybook (compile-checks all stories)
cd $(FRONTEND) && npm run build-storybook
# The PR gate. Matches ci.yml: codegen freshness, both linters, typecheck, # The PR gate. Matches ci.yml: codegen freshness, both linters, typecheck,
# both test suites, and a full build. # both test suites, a full build, and the Storybook compile-check.
.PHONY: verify .PHONY: verify
verify: gen-check lint typecheck test build ## Full local gate (mirrors CI) verify: gen-check lint typecheck test build build-storybook ## Full local gate (mirrors CI)
@echo "verify: OK" @echo "verify: OK"
+3 -3
View File
@@ -63,7 +63,7 @@ Two key ideas that explain most of the complexity:
**Frontend (`frontend/`):** **Frontend (`frontend/`):**
- **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**. - **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**.
- Data layer: **TanStack Query** (`@tanstack/react-query`) over the native **Fetch API**; **Zod 4** schemas. - 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 - **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), 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 `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 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
| `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB | | `@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 | | `@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 | | `@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` / `@monthly` | `periodic_traffic_reset_job(...)` | Weekly/monthly traffic resets | | `@weekly` | `periodic_traffic_reset_job("weekly")` | Weekly traffic resets |
| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable | | default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
| default `@daily` | `stats_notify_job` | Only if TG bot 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 | | `@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 Optionally cap total traffic and set an expiry date for the inbound, and choose a
periodic **traffic reset** schedule: `never` (default), `hourly`, `daily`, periodic **traffic reset** schedule: `never` (default), `hourly`, `daily`,
`weekly`, or `monthly`. `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> </Step>
</Steps> </Steps>
+1 -1
View File
@@ -19,7 +19,7 @@ browser in full.
| `webBasePath` | `/` | URL path the panel is served under (always normalized to `/…/`). | | `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**. | | `webCertFile` / `webKeyFile` | _(none)_ | TLS certificate + key. When both are set, the panel serves **HTTPS**. |
| `sessionMaxAge` | `360` | Session lifetime in **minutes** (default 6 hours). | | `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. | | `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 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. - **Leaked private key.** Only ever distribute the **public** key to clients.
- **Wrong flow.** REALITY + XTLS-Vision needs `flow = xtls-rprx-vision` on both - **Wrong flow.** REALITY + XTLS-Vision needs `flow = xtls-rprx-vision` on both
the inbound client entry and the share link. 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> </Callout>
+3
View File
@@ -40,6 +40,9 @@ TLS یا REALITY) را انتخاب کنید. به [انتقال‌ها](/docs/c
به‌صورت اختیاری می‌توانید کل ترافیک را محدود کنید و یک تاریخ انقضا برای ورودی به‌صورت اختیاری می‌توانید کل ترافیک را محدود کنید و یک تاریخ انقضا برای ورودی
تعیین کنید، و یک زمان‌بندی **بازنشانی ترافیک** دوره‌ای انتخاب کنید: `never` تعیین کنید، و یک زمان‌بندی **بازنشانی ترافیک** دوره‌ای انتخاب کنید: `never`
(پیش‌فرض)، `hourly`، `daily`، `weekly` یا `monthly`. (پیش‌فرض)، `hourly`، `daily`، `weekly` یا `monthly`.
برای بازنشانی `monthly`، روزی از ۱ تا ۳۱ انتخاب کنید. اگر آن روز در ماهی کوتاه‌تر
وجود نداشته باشد، بازنشانی در آخرین روز همان ماه انجام می‌شود.
</Step> </Step>
</Steps> </Steps>
+1 -1
View File
@@ -19,7 +19,7 @@ icon: SlidersHorizontal
| `webBasePath` | `/` | مسیر URLی که پنل زیر آن ارائه می‌شود (همیشه به شکل `/…/` نرمال‌سازی می‌شود). | | `webBasePath` | `/` | مسیر URLی که پنل زیر آن ارائه می‌شود (همیشه به شکل `/…/` نرمال‌سازی می‌شود). |
| `webCertFile` / `webKeyFile` | _(هیچ‌کدام)_ | گواهی + کلید TLS. وقتی هر دو تنظیم شوند، پنل با **HTTPS** ارائه می‌شود. | | `webCertFile` / `webKeyFile` | _(هیچ‌کدام)_ | گواهی + کلید TLS. وقتی هر دو تنظیم شوند، پنل با **HTTPS** ارائه می‌شود. |
| `sessionMaxAge` | `360` | طول عمر نشست بر حسب **دقیقه** (پیش‌فرض ۶ ساعت). | | `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 با نام مشخص. | | `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` هم در ورودیِ - **جریان نادرست.** REALITY + XTLS-Vision به `flow = xtls-rprx-vision` هم در ورودیِ
مدخل کلاینت و هم در لینک اشتراک‌گذاری نیاز دارد. مدخل کلاینت و هم در لینک اشتراک‌گذاری نیاز دارد.
- **هسته‌های قدیمی کلاینت به‌طور پیش‌فرض رد می‌شوند.** خالی گذاشتن
**حداقل نسخه کلاینت** به معنای «بدون محدودیت» نیست: Xray-core به حداقل داخلیِ
نسخهٔ هسته‌ای که اجرا می‌کنید (در نسخه‌های فعلی 26.3.27) بازمی‌گردد تا اثر انگشت‌های TLS کلاینت‌ها تازه
بمانند؛ در نتیجه هسته‌های شخص ثالث مانند Mihomo و sing-box حتی با پیکربندی
کاملاً درست در تأیید REALITY شکست می‌خورند — کلاینت‌ها تایم‌اوت می‌بینند و فقط
اپلیکیشن‌های مبتنی بر Xray-core وصل می‌شوند. تنها در صورت نیاز به پشتیبانی از
آن‌ها مقدار `1.0.0` را تنظیم کنید؛ این کار اثر انگشت‌های قدیمی را هم می‌پذیرد.
</Callout> </Callout>
+3
View File
@@ -41,6 +41,9 @@ icon: ArrowDownToLine
При необходимости ограничьте общий объём трафика и установите дату истечения для При необходимости ограничьте общий объём трафика и установите дату истечения для
входящего подключения, а также выберите расписание периодического **сброса трафика**: входящего подключения, а также выберите расписание периодического **сброса трафика**:
`never` (по умолчанию), `hourly`, `daily`, `weekly` или `monthly`. `never` (по умолчанию), `hourly`, `daily`, `weekly` или `monthly`.
Для сброса `monthly` выберите день от 1 до 31. Если выбранного дня нет в более
коротком месяце, сброс выполняется в последний день этого месяца.
</Step> </Step>
</Steps> </Steps>
+1 -1
View File
@@ -19,7 +19,7 @@ icon: SlidersHorizontal
| `webBasePath` | `/` | URL-путь, по которому обслуживается панель (всегда нормализуется к `/…/`). | | `webBasePath` | `/` | URL-путь, по которому обслуживается панель (всегда нормализуется к `/…/`). |
| `webCertFile` / `webKeyFile` | _(нет)_ | Сертификат TLS + ключ. Когда заданы оба, панель обслуживается по **HTTPS**. | | `webCertFile` / `webKeyFile` | _(нет)_ | Сертификат TLS + ключ. Когда заданы оба, панель обслуживается по **HTTPS**. |
| `sessionMaxAge` | `360` | Время жизни сессии в **минутах** (по умолчанию 6 часов). | | `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. | | `panelOutbound` | _(нет)_ | Маршрутизация собственного исходящего трафика панели (проверка обновлений, Telegram, запросы geo/подписок) через именованный исходящий канал Xray. |
После изменения порта или базового пути URL панели становится После изменения порта или базового пути 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` - **Неправильный поток.** Для REALITY + XTLS-Vision нужен `flow = xtls-rprx-vision`
как в записи клиента входящего подключения, так и в ссылке для подключения. как в записи клиента входящего подключения, так и в ссылке для подключения.
- **Старые ядра клиентов отклоняются по умолчанию.** Пустое поле
**Мин. версия клиента** не означает «без ограничений»: Xray-core использует
встроенный минимум используемой сборки ядра (26.3.27 в текущих релизах),
который поддерживает свежесть
TLS-отпечатков клиентов, поэтому сторонние ядра, такие как Mihomo и sing-box,
не проходят проверку REALITY даже при корректной конфигурации — клиенты видят
таймауты, а подключаются только приложения на базе Xray-core. Ставьте `1.0.0`,
только если они вам необходимы; это также допустит устаревшие отпечатки.
</Callout> </Callout>
+3
View File
@@ -37,6 +37,9 @@ icon: ArrowDownToLine
可选地为入站设置总流量上限和到期日期,并选择一个周期性的**流量重置**计划: 可选地为入站设置总流量上限和到期日期,并选择一个周期性的**流量重置**计划:
`never`(默认)、`hourly`、`daily`、`weekly` 或 `monthly`。 `never`(默认)、`hourly`、`daily`、`weekly` 或 `monthly`。
选择 `monthly` 时,可以指定每月 1 至 31 日重置。如果当月没有指定日期,
则在该月最后一天重置。
</Step> </Step>
</Steps> </Steps>
+1 -1
View File
@@ -15,7 +15,7 @@ icon: SlidersHorizontal
| `webBasePath` | `/` | 面板对外提供服务所使用的 URL 路径(始终规范化为 `/…/`)。 | | `webBasePath` | `/` | 面板对外提供服务所使用的 URL 路径(始终规范化为 `/…/`)。 |
| `webCertFile` / `webKeyFile` | _(无)_ | TLS 证书 + 密钥。两者都设置后,面板将以 **HTTPS** 提供服务。 | | `webCertFile` / `webKeyFile` | _(无)_ | TLS 证书 + 密钥。两者都设置后,面板将以 **HTTPS** 提供服务。 |
| `sessionMaxAge` | `360` | 会话有效期,单位为**分钟**(默认 6 小时)。 | | `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、地理/订阅拉取)。 | | `panelOutbound` | _(无)_ | 通过一个命名的 Xray 出站来路由面板自身的出口流量(更新检查、Telegram、地理/订阅拉取)。 |
更改端口或基础路径后,面板 URL 将变为 更改端口或基础路径后,面板 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 必须与目标站点的真实证书匹配,否则握手会暴露伪装。 - **SNI 不匹配。** SNI / server names 必须与目标站点的真实证书匹配,否则握手会暴露伪装。
- **私钥泄露。** 永远只把**公钥**分发给客户端。 - **私钥泄露。** 永远只把**公钥**分发给客户端。
- **流控设置错误。** REALITY + XTLS-Vision 要求在入站的客户端条目和分享链接上都设置 `flow = xtls-rprx-vision`。 - **流控设置错误。** REALITY + XTLS-Vision 要求在入站的客户端条目和分享链接上都设置 `flow = xtls-rprx-vision`。
- **旧客户端内核默认被拒。** **最小客户端版本**留空并不是“不限制”:Xray-core 会退回到所运行内核版本的内置最低值(当前版本为 26.3.27)以保证客户端 TLS 指纹的新鲜度,因此 Mihomo、sing-box 等第三方内核即使配置完全正确也会导致 REALITY 验证失败——表现为客户端超时,只有基于 Xray-core 的应用能连上。只有在必须支持它们时才填 `1.0.0`;这同时也会放行过时的指纹。
</Callout> </Callout>
+38 -1
View File
@@ -24,6 +24,7 @@ When rendering the template, the following variables are injected into the templ
* `{{ .sId }}`: Subscription ID (UUID). * `{{ .sId }}`: Subscription ID (UUID).
* `{{ .enabled }}`: Whether the subscription/client is enabled (boolean). * `{{ .enabled }}`: Whether the subscription/client is enabled (boolean).
* `{{ .isOnline }}`: Whether the subscription's client has a live connection right now (boolean). Computed from the panel's online-client tracking (local Xray plus any remote nodes) at render time.
* `{{ .download }}`: Formatted download traffic (e.g. "2.5 GB"). * `{{ .download }}`: Formatted download traffic (e.g. "2.5 GB").
* `{{ .upload }}`: Formatted upload traffic. * `{{ .upload }}`: Formatted upload traffic.
* `{{ .total }}`: Formatted total traffic limit. * `{{ .total }}`: Formatted total traffic limit.
@@ -40,5 +41,41 @@ When rendering the template, the following variables are injected into the templ
* `{{ .subTitle }}`: The subscription title configured in the panel (Subscription → Information). Useful for page branding/headings. May be empty. * `{{ .subTitle }}`: The subscription title configured in the panel (Subscription → Information). Useful for page branding/headings. May be empty.
* `{{ .subSupportUrl }}`: The support URL configured in the panel. Useful for a "Contact support" link. May be empty. * `{{ .subSupportUrl }}`: The support URL configured in the panel. Useful for a "Contact support" link. May be empty.
* `{{ .links }}`: A list (slice) of string configurations (VMess, VLESS, etc. URLs). You can loop through them using `{{ range .links }} ... {{ end }}`. * `{{ .links }}`: A list (slice) of string configurations (VMess, VLESS, etc. URLs). You can loop through them using `{{ range .links }} ... {{ end }}`.
* `{{ .emails }}`: A list (slice) of emails related to the subscription. * `{{ .emails }}`: A list (slice) of client emails, parallel to `links` — the email at index *i* owns the link at index *i*. May contain duplicates when one client has several links.
* `{{ .announce }}`: The announcement text configured in the panel (Settings → Subscription → Announce). May be empty.
* `{{ .datepicker }}`: Current calendar format used by the panel (e.g. "gregorian" or "jalali"). * `{{ .datepicker }}`: Current calendar format used by the panel (e.g. "gregorian" or "jalali").
## Live Status JSON (`?format=info`)
Every subscription URL also answers `GET <sub URL>?format=info` with the same view-model as JSON —
minus `links`, and with `emails` deduplicated — so a template can poll it and update usage or
online status live without reloading the page:
```json
{
"sId": "…",
"enabled": true,
"isOnline": true,
"used": "1.2 GB",
"remained": "8.8 GB",
"expire": 0,
"lastOnline": 1735680000000,
"…": "…"
}
```
Example polling snippet for a template:
```html
<span id="status"></span>
<script>
async function refreshStatus() {
const res = await fetch(window.location.pathname + '?format=info');
if (!res.ok) return;
const info = await res.json();
document.getElementById('status').textContent = info.isOnline ? 'Online' : 'Offline';
}
refreshStatus();
setInterval(refreshStatus, 10000);
</script>
```
+7 -1
View File
@@ -2,7 +2,7 @@ import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import { Heart } from 'lucide-react'; import { Heart } from 'lucide-react';
import { Logo } from '@/components/logo'; import { Logo } from '@/components/logo';
import { TelegramIcon } from '@/components/icons'; import { TelegramIcon } from '@/components/icons';
import { appName, productRepoUrl, telegramChannel, telegramChannelUrl, donateUrl } from './shared'; import { appName, productRepoUrl, telegramChannel, telegramChannelUrl, donateUrl, siteUrl } from './shared';
import { getSiteMessages } from './site-i18n'; import { getSiteMessages } from './site-i18n';
// Build locale-aware shared layout options. With `hideLocale: 'default-locale'`, // Build locale-aware shared layout options. With `hideLocale: 'default-locale'`,
@@ -28,6 +28,12 @@ export function baseOptions(lang: string): BaseLayoutProps {
url: `${prefix}/docs`, url: `${prefix}/docs`,
active: 'nested-url', active: 'nested-url',
}, },
// Live component workbench built from frontend/ and published alongside the docs.
{
text: 'Storybook',
url: `${siteUrl}/storybook/`,
external: true,
},
{ {
type: 'icon', type: 'icon',
label: `Telegram channel (@${telegramChannel})`, label: `Telegram channel (@${telegramChannel})`,
+19 -19
View File
@@ -9,7 +9,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"postinstall": "fumadocs-mdx", "postinstall": "fumadocs-mdx",
"gen:api": "node --experimental-strip-types scripts/gen-openapi.ts", "gen:api": "node scripts/gen-openapi.ts",
"typecheck": "fumadocs-mdx && next typegen && tsc --noEmit", "typecheck": "fumadocs-mdx && next typegen && tsc --noEmit",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write .", "format": "prettier --write .",
@@ -19,34 +19,34 @@
}, },
"dependencies": { "dependencies": {
"@orama/orama": "^3.1.18", "@orama/orama": "^3.1.18",
"fumadocs-core": "^16.10.5", "fumadocs-core": "^16.11.5",
"fumadocs-docgen": "^3.0.10", "fumadocs-docgen": "^3.1.0",
"fumadocs-mdx": "^15.0.12", "fumadocs-mdx": "^15.2.0",
"fumadocs-openapi": "^11.0.5", "fumadocs-openapi": "^11.2.2",
"fumadocs-ui": "^16.10.5", "fumadocs-ui": "^16.11.5",
"lucide-react": "^1.21.0", "lucide-react": "^1.25.0",
"mermaid": "^11.16.0", "mermaid": "^11.16.0",
"next": "16.2.9", "next": "16.2.11",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.7", "react": "^19.2.8",
"react-dom": "^19.2.7", "react-dom": "^19.2.8",
"react-qr-code": "^2.2.0", "react-qr-code": "^2.2.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.3.1", "@tailwindcss/postcss": "^4.3.3",
"@types/mdx": "^2.0.14", "@types/mdx": "^2.0.14",
"@types/node": "^26.0.1", "@types/node": "^26.1.1",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"eslint": "^9.39.4", "eslint": "^9.39.5",
"eslint-config-next": "16.2.9", "eslint-config-next": "16.2.11",
"postcss": "^8.5.15", "postcss": "^8.5.21",
"prettier": "^3.8.4", "prettier": "^3.9.6",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.3",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vitest": "^4.1.9" "vitest": "^4.1.10"
}, },
"packageManager": "pnpm@11.9.0" "packageManager": "pnpm@11.15.1+sha512.81350b07e53c9538a02f1f2303b4290fa2d7be04e56e2a970c4cc4b417dc761de196edabd49d55c7dc9580db81007c44143e4e3d7e462b3000d23c255122d065"
} }
+1919 -1585
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -6,6 +6,7 @@ allowBuilds:
# release — fixes GHSA-qx2v-qp2m-jg93 / CVE-2026-41305 (vulnerable < 8.5.10). # release — fixes GHSA-qx2v-qp2m-jg93 / CVE-2026-41305 (vulnerable < 8.5.10).
overrides: overrides:
'postcss@<8.5.10': '^8.5.15' 'postcss@<8.5.10': '^8.5.15'
'sharp@<0.35.0': '^0.35.3'
minimumReleaseAgeExclude: minimumReleaseAgeExclude:
- '@mermaid-js/parser@1.2.0' - '@mermaid-js/parser@1.2.0'
- mermaid@11.16.0 - mermaid@11.16.0
+8
View File
@@ -411,6 +411,9 @@
"maximum": 65535, "maximum": 65535,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
} }
}, },
"required": [ "required": [
@@ -479,6 +482,7 @@
"subPort", "subPort",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl", "subSupportUrl",
"subThemeDir", "subThemeDir",
"subTitle", "subTitle",
@@ -916,6 +920,9 @@
"maximum": 65535, "maximum": 65535,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
},
"subShowIdentityOnAllLinks": {
"type": "boolean"
} }
}, },
"required": [ "required": [
@@ -991,6 +998,7 @@
"subPort", "subPort",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl", "subSupportUrl",
"subThemeDir", "subThemeDir",
"subTitle", "subTitle",
+5 -1
View File
@@ -6,7 +6,11 @@ const config: StorybookConfig = {
options: {}, options: {},
}, },
stories: ['../src/**/*.stories.@(ts|tsx)'], stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: ['@storybook/addon-docs', '@storybook/addon-a11y'], addons: [
'@storybook/addon-docs',
'@storybook/addon-a11y',
'@storybook/addon-vitest'
],
viteFinal: (viteConfig) => { viteFinal: (viteConfig) => {
if (viteConfig.build) { if (viteConfig.build) {
viteConfig.build.outDir = undefined; viteConfig.build.outDir = undefined;
+6
View File
@@ -0,0 +1,6 @@
<script>
if (localStorage.getItem('dark-mode') === null) localStorage.setItem('dark-mode', 'false');
if (localStorage.getItem('isUltraDarkThemeEnabled') === null) {
localStorage.setItem('isUltraDarkThemeEnabled', 'false');
}
</script>
+14 -6
View File
@@ -1,9 +1,10 @@
import { useEffect } from 'react'; import { useLayoutEffect } from 'react';
import type { Decorator, Preview } from '@storybook/react-vite'; import type { Decorator, Preview } from '@storybook/react-vite';
import { ConfigProvider, theme as antdTheme } from 'antd'; import { ConfigProvider } from 'antd';
import i18next from 'i18next'; import i18next from 'i18next';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import { buildAntdThemeConfig } from '@/hooks/useTheme';
import enUS from '../../internal/web/translation/en-US.json'; import enUS from '../../internal/web/translation/en-US.json';
if (!i18next.isInitialized) { if (!i18next.isInitialized) {
@@ -16,13 +17,15 @@ if (!i18next.isInitialized) {
}); });
} }
const withTheme: Decorator = (Story, context) => { export const withTheme: Decorator = (Story, context) => {
const dark = context.globals.theme === 'dark'; const dark = context.globals.theme === 'dark';
useEffect(() => { useLayoutEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light'); document.body.classList.remove('dark', 'light');
document.body.classList.add(dark ? 'dark' : 'light');
document.documentElement.removeAttribute('data-theme');
}, [dark]); }, [dark]);
return ( return (
<ConfigProvider theme={{ algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm }}> <ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
<div style={{ padding: 24, minWidth: 320 }}> <div style={{ padding: 24, minWidth: 320 }}>
<Story /> <Story />
</div> </div>
@@ -49,11 +52,16 @@ const preview: Preview = {
}, },
parameters: { parameters: {
controls: { controls: {
expanded: true,
sort: 'requiredFirst',
matchers: { matchers: {
color: /(background|color)$/i, color: /(background|color)$/i,
date: /Date$/i, date: /Date$/i,
}, },
}, },
a11y: {
test: 'error',
},
}, },
}; };
+4
View File
@@ -63,5 +63,9 @@ Only standalone bundles (login/subpage) need a new `.html` + `src/entries/*` +
- `npm run typecheck` / `npm run lint` / `npm run test` / `npm run build`. - `npm run typecheck` / `npm run lint` / `npm run test` / `npm run build`.
- `npm run gen` = `gen:zod` (Go → `src/generated/`) + `gen:api` - `npm run gen` = `gen:zod` (Go → `src/generated/`) + `gen:api`
(`build-openapi.mjs``public/openapi.json`). (`build-openapi.mjs``public/openapi.json`).
- `npm run storybook` (workbench on :6006) / `npm run build-storybook` (CI
compile-checks every story). Reusable `src/components/` get a co-located
`<Component>.stories.tsx` with `tags: ['autodocs']`; document props via
`argTypes` / `parameters.docs` string metadata, never JSDoc.
- After `npm run build`, RESTART `go run .` (see the XUI_DEBUG gotcha in root - After `npm run build`, RESTART `go run .` (see the XUI_DEBUG gotcha in root
CLAUDE.md) before checking the panel. CLAUDE.md) before checking the panel.
+36 -3
View File
@@ -36,11 +36,13 @@ production-style links work without round-tripping through Go.
| `npm run lint` | ESLint flat config (`@typescript-eslint` + `react-hooks`) | | `npm run lint` | ESLint flat config (`@typescript-eslint` + `react-hooks`) |
| `npm run test` | Vitest single run (schema fixtures, link parsers, …) | | `npm run test` | Vitest single run (schema fixtures, link parsers, …) |
| `npm run test:watch` | Vitest watch mode | | `npm run test:watch` | Vitest watch mode |
| `npm run storybook` | Storybook dev server on `:6006` (component workbench + autodocs) |
| `npm run build-storybook` | Static Storybook build — CI compile-checks every story |
| `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` | | `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` |
| `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` | | `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` |
CI runs `typecheck`, `lint`, `test`, and `build` on every PR CI runs `typecheck`, `lint`, `test`, `build`, and `build-storybook` on
(see `../.github/workflows/ci.yml`). every PR (see `../.github/workflows/ci.yml`).
### One-off: scan for deprecated APIs ### One-off: scan for deprecated APIs
@@ -79,6 +81,7 @@ frontend/
│ # usages of APIs marked with JSDoc @deprecated │ # usages of APIs marked with JSDoc @deprecated
├── vitest.config.ts ├── vitest.config.ts
├── vite.config.js ├── vite.config.js
├── .storybook/ # Storybook config (main.ts, preview.tsx)
├── scripts/ ├── scripts/
│ └── build-openapi.mjs # endpoints.ts → openapi.json │ └── build-openapi.mjs # endpoints.ts → openapi.json
└── src/ └── src/
@@ -89,7 +92,7 @@ frontend/
│ ├── index/, login/, inbounds/, clients/, xray/, nodes/, │ ├── index/, login/, inbounds/, clients/, xray/, nodes/,
│ ├── settings/, api-docs/, sub/ │ ├── settings/, api-docs/, sub/
├── layouts/ # AdminLayout (sidebar + header + outlet) ├── layouts/ # AdminLayout (sidebar + header + outlet)
├── components/ # Cross-page React components ├── components/ # Cross-page React components (+ co-located *.stories.tsx)
├── hooks/ # useClients, useTheme, useWebSocket, … ├── hooks/ # useClients, useTheme, useWebSocket, …
├── api/ # fetch client + CSRF handling, TanStack Query bridge, ├── api/ # fetch client + CSRF handling, TanStack Query bridge,
│ # WebSocket client + queryClient.ts │ # WebSocket client + queryClient.ts
@@ -187,6 +190,36 @@ npx vitest run -u
Fixtures live in `src/test/golden/fixtures/` and are auto-discovered Fixtures live in `src/test/golden/fixtures/` and are auto-discovered
via `import.meta.glob`. via `import.meta.glob`.
## Storybook
Reusable components in `src/components/` are developed and documented in
**Storybook** (`@storybook/react-vite`). It is a component workbench, not part
of the shipped panel — nothing here is embedded into the Go binary. The built
Storybook is published with the docs site at
[docs.sanaei.dev/storybook](https://docs.sanaei.dev/storybook/) by
`.github/workflows/docs-deploy.yml`.
```sh
npm run storybook # dev server on http://localhost:6006
npm run build-storybook # static build; CI runs this to compile-check every story
```
Addons: `@storybook/addon-docs` renders an autodocs page per component,
`@storybook/addon-a11y` flags accessibility issues in the canvas, and
`@storybook/addon-vitest` runs every story as a headless-browser test under
`npm run test` (Playwright/Chromium — run `npx playwright install chromium` once
locally). The `.storybook/preview.tsx` decorator wraps every story in the AntD
`ConfigProvider` and adds a light/dark theme toggle to the toolbar.
Conventions for a story:
- Co-locate it with its component as `<Component>.stories.tsx`.
- Set `tags: ['autodocs']` so it gets a generated docs page.
- Document props via story metadata, not JSDoc (the repo bans `//` comments): a
component summary in `parameters.docs.description.component` and per-prop text
in `argTypes[prop].description`. `satisfies Meta<typeof Component>` keeps the
metadata type-checked.
## Adding a new page ## Adding a new page
Most new routes go inside the admin SPA (`index.html`) via Most new routes go inside the admin SPA (`index.html`) via
+33
View File
@@ -53,4 +53,37 @@ export default [
'jsx-a11y/no-autofocus': 'off', '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',
},
},
]; ];
+892 -1000
View File
File diff suppressed because it is too large Load Diff
+31 -25
View File
@@ -1,11 +1,11 @@
{ {
"name": "3x-ui-frontend", "name": "3x-ui-frontend",
"private": true, "private": true,
"version": "0.4.3", "version": "0.6.0",
"type": "module", "type": "module",
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).", "description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
"engines": { "engines": {
"node": ">=22.0.0", "node": ">=24.0.0",
"npm": ">=10.0.0" "npm": ">=10.0.0"
}, },
"scripts": { "scripts": {
@@ -19,7 +19,7 @@
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build", "build-storybook": "storybook build",
"gen": "npm run gen:zod && npm run gen:api", "gen": "npm run gen:zod && npm run gen:api",
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs", "gen:api": "node scripts/build-openapi.mjs",
"gen:zod": "cd .. && go run ./tools/openapigen", "gen:zod": "cd .. && go run ./tools/openapigen",
"prepare": "cd .. && husky frontend/.husky || true" "prepare": "cd .. && husky frontend/.husky || true"
}, },
@@ -30,49 +30,52 @@
"@ant-design/icons": "^6.3.2", "@ant-design/icons": "^6.3.2",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.5.7",
"@noble/hashes": "^2.2.0", "@noble/hashes": "^2.2.0",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.4",
"@tanstack/react-query-devtools": "^5.101.2", "@tanstack/react-query-devtools": "^5.101.4",
"antd": "^6.5.0", "antd": "^6.5.2",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"i18next": "^26.3.6", "i18next": "^26.3.6",
"otpauth": "^9.5.1", "otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5", "persian-calendar-suite": "^1.5.5",
"react": "^19.2.7", "react": "^19.2.8",
"react-dom": "^19.2.7", "react-dom": "^19.2.8",
"react-hook-form": "^7.81.0", "react-hook-form": "^7.83.0",
"react-i18next": "^17.0.9", "react-i18next": "^17.0.11",
"react-router-dom": "^7.18.1", "react-router": "^8.3.0",
"swagger-ui-react": "^5.32.8", "swagger-ui-react": "^5.32.11",
"uplot": "^1.6.32", "uplot": "^1.6.32",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@storybook/addon-a11y": "^10.5.0", "@storybook/addon-a11y": "^10.5.5",
"@storybook/addon-docs": "^10.5.0", "@storybook/addon-docs": "^10.5.5",
"@storybook/react-vite": "^10.5.0", "@storybook/addon-vitest": "^10.5.5",
"@storybook/react-vite": "^10.5.5",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/swagger-ui-react": "^5.18.0", "@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.4",
"@vitest/browser-playwright": "4.1.10",
"@vitest/coverage-v8": "^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-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.7.0", "globals": "^17.8.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^29.1.1", "jsdom": "^30.0.1",
"lint-staged": "^17.0.8", "lint-staged": "^17.2.0",
"msw": "^2.15.0", "msw": "^2.15.0",
"storybook": "^10.5.0", "playwright": "^1.62.0",
"typescript": "^6.0.3", "storybook": "^10.5.5",
"typescript-eslint": "^8.63.0", "typescript": "6.0.3",
"vite": "8.1.4", "typescript-eslint": "^8.65.0",
"vite": "8.1.5",
"vitest": "^4.1.10" "vitest": "^4.1.10"
}, },
"overrides": { "overrides": {
@@ -87,6 +90,9 @@
}, },
"swagger-ui-react": { "swagger-ui-react": {
"js-yaml": "^4.2.0" "js-yaml": "^4.2.0"
},
"@typeschema/valibot": {
"valibot": "^1.1.0"
} }
}, },
"allowScripts": { "allowScripts": {
File diff suppressed because it is too large Load Diff
+29 -4
View File
@@ -88,6 +88,28 @@ function encodeForm(data: unknown): string {
return parts.join('&'); 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( async function performFetch(
method: string, method: string,
url: string, url: string,
@@ -121,8 +143,8 @@ async function performFetch(
} }
const query = encodeForm(options.params); const query = encodeForm(options.params);
const fullUrl = basePathPrefix + url + (query ? `?${query}` : ''); const fullUrl = basePathPrefix + appendQuery(url, query);
const signal = options.timeout ? AbortSignal.timeout(options.timeout) : options.signal; const signal = requestSignal(options);
return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal }); return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
} }
@@ -152,8 +174,11 @@ export async function httpRequest(
if (res.status === 403 && !SAFE_METHODS.has(method.toUpperCase())) { if (res.status === 403 && !SAFE_METHODS.has(method.toUpperCase())) {
csrfToken = null; csrfToken = null;
const fresh = await ensureCsrfToken(); const fresh = await fetchCsrfToken();
if (fresh) res = await performFetch(method, url, data, options, fresh); if (fresh) {
csrfToken = fresh;
res = await performFetch(method, url, data, options, fresh);
}
} }
if (res.status === 401) { if (res.status === 401) {
+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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil, Msg } from '@/utils'; import { HttpUtil, Msg } from '@/utils';
@@ -6,8 +6,13 @@ import { parseMsg } from '@/utils/zodValidate';
import { AllSetting } from '@/models/setting'; import { AllSetting } from '@/models/setting';
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting'; import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
import { keys } from '@/api/queryKeys'; import { keys } from '@/api/queryKeys';
import { useServerDraft } from '@/hooks/useServerDraft';
type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>; type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
type SettingSaveResult = {
msg: Msg<unknown>;
saved?: AllSetting;
};
async function fetchAllSetting(): Promise<AllSettingInput | null> { async function fetchAllSetting(): Promise<AllSettingInput | null> {
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true }); 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() { export function useAllSettings() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [draft, setDraft] = useState<AllSetting>(() => new AllSetting());
const [extraSpinning, setExtraSpinning] = useState(false); const [extraSpinning, setExtraSpinning] = useState(false);
const query = useQuery({ const query = useQuery({
@@ -28,41 +32,54 @@ export function useAllSettings() {
}); });
const server = useMemo(() => new AllSetting(query.data), [query.data]); const server = useMemo(() => new AllSetting(query.data), [query.data]);
const { draft, setDraft, isDirty, markSaved } = useServerDraft(
useEffect(() => { query.data === undefined ? undefined : server,
if (query.data !== undefined) { (setting) => new AllSetting(setting),
setDraft(new AllSetting(query.data)); (left, right) => left.equals(right),
} );
}, [query.data]); const allSetting = draft ?? server;
const updateSetting = useCallback((patch: Partial<AllSetting>) => { const updateSetting = useCallback((patch: Partial<AllSetting>) => {
setDraft((prev) => { setDraft((prev) => {
const next = new AllSetting(prev); const next = new AllSetting(prev ?? server);
Object.assign(next, patch); Object.assign(next, patch);
return next; return next;
}); });
}, []); }, [server, setDraft]);
const saveMut = useMutation({ const saveMut = useMutation({
mutationFn: async (next: SettingSavePayload): Promise<Msg<unknown>> => { mutationFn: async ({ payload, saved }: { payload: SettingSavePayload; saved?: AllSetting }): Promise<SettingSaveResult> => {
const payload = { ...next }; const next = { ...payload };
const body = AllSettingSchema.partial().safeParse(payload); const body = AllSettingSchema.partial().safeParse(next);
if (!body.success) { if (!body.success) {
console.warn('[zod] setting/update body failed validation', body.error.issues); 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) => { onSuccess: ({ msg, saved }) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() }); if (!msg?.success) return;
if (saved) markSaved(saved);
queryClient.invalidateQueries({ queryKey: keys.settings.all() });
}, },
}); });
const saveAll = useCallback(() => saveMut.mutateAsync({ ...draft }), [saveMut, draft]); const saveAll = useCallback(async () => {
const savePayload = useCallback((payload: SettingSavePayload) => saveMut.mutateAsync(payload), [saveMut]); const saved = new AllSetting(allSetting);
const saveDisabled = useMemo(() => server.equals(draft), [server, draft]); 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 { return {
allSetting: draft, allSetting,
updateSetting, updateSetting,
fetched: query.data !== undefined, fetched: query.data !== undefined,
spinning: extraSpinning || saveMut.isPending, 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, root: () => ['settings'] as const,
all: () => ['settings', 'all'] as const, all: () => ['settings', 'all'] as const,
defaults: () => ['settings', 'defaults'] as const, defaults: () => ['settings', 'defaults'] as const,
factoryDefaults: () => ['settings', 'factoryDefaults'] as const,
}, },
inbounds: { inbounds: {
root: () => ['inbounds'] as const, root: () => ['inbounds'] as const,
+9
View File
@@ -190,3 +190,12 @@ export class WebSocketClient {
} }
} }
} }
let sharedClient: WebSocketClient | null = null;
export function getSharedWebSocketClient(): WebSocketClient {
if (sharedClient) return sharedClient;
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
sharedClient = new WebSocketClient(basePath);
return sharedClient;
}
+3 -17
View File
@@ -1,34 +1,19 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { WebSocketClient } from '@/api/websocket'; import { getSharedWebSocketClient } from '@/api/websocket';
import { keys } from '@/api/queryKeys'; import { keys } from '@/api/queryKeys';
import { isRecentLocalInvalidate } from '@/api/invalidationTracker'; import { isRecentLocalInvalidate } from '@/api/invalidationTracker';
type Handler = (payload: unknown) => void; type Handler = (payload: unknown) => void;
interface SharedClient {
connect(): void;
on(event: string, fn: Handler): void;
off(event: string, fn: Handler): void;
}
let sharedClient: SharedClient | null = null;
function getSharedClient(): SharedClient {
if (sharedClient) return sharedClient;
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
sharedClient = new WebSocketClient(basePath) as SharedClient;
return sharedClient;
}
let invalidateTimer: number | null = null; let invalidateTimer: number | null = null;
export function useWebSocketBridge() { export function useWebSocketBridge() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
useEffect(() => { useEffect(() => {
const client = getSharedClient(); const client = getSharedWebSocketClient();
const onInvalidate: Handler = (payload) => { const onInvalidate: Handler = (payload) => {
const p = payload as { type?: string } | undefined; const p = payload as { type?: string } | undefined;
@@ -46,6 +31,7 @@ export function useWebSocketBridge() {
}; };
const onOutbounds: Handler = (payload) => { const onOutbounds: Handler = (payload) => {
if (!Array.isArray(payload)) return;
queryClient.setQueryData(keys.xray.outboundsTraffic(), payload); queryClient.setQueryData(keys.xray.outboundsTraffic(), payload);
}; };
@@ -0,0 +1,14 @@
type ClientCardCommentProps = {
comment?: string;
className?: string;
};
export default function ClientCardComment({ comment, className = 'client-card-comment' }: ClientCardCommentProps) {
if (!comment) return null;
return (
<span className={className} title={comment}>
{comment}
</span>
);
}
@@ -6,6 +6,17 @@ const meta = {
title: 'Clients/ClientSpeedTag', title: 'Clients/ClientSpeedTag',
component: ClientSpeedTag, component: ClientSpeedTag,
tags: ['autodocs'], tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Blue tag showing a live upload/download speed for one client, formatted for readability. Shown next to online clients that have active traffic.',
},
},
},
argTypes: {
speed: { description: 'Live upload/download rate in bytes per second (`{ up, down }`).' },
},
} satisfies Meta<typeof ClientSpeedTag>; } satisfies Meta<typeof ClientSpeedTag>;
export default meta; export default meta;
@@ -2,6 +2,7 @@ import { Tag } from 'antd';
import { SizeFormatter } from '@/utils'; import { SizeFormatter } from '@/utils';
import type { ClientSpeedEntry } from '@/hooks/useClients'; import type { ClientSpeedEntry } from '@/hooks/useClients';
import { SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
export type { ClientSpeedEntry }; export type { ClientSpeedEntry };
@@ -11,11 +12,16 @@ export function isActiveSpeed(speed?: ClientSpeedEntry): speed is ClientSpeedEnt
interface ClientSpeedTagProps { interface ClientSpeedTagProps {
speed: ClientSpeedEntry; speed: ClientSpeedEntry;
tableCell?: boolean;
} }
export function ClientSpeedTag({ speed }: ClientSpeedTagProps) { export function ClientSpeedTag({ speed, tableCell = false }: ClientSpeedTagProps) {
return ( return (
<Tag color="blue"> <Tag
color="blue"
className={tableCell ? SPEED_TAG_CLASS_NAME : undefined}
style={tableCell ? SPEED_TAG_STYLE : undefined}
>
{SizeFormatter.speedFormat(speed.up)} {SizeFormatter.speedFormat(speed.up)}
{' / '} {' / '}
{SizeFormatter.speedFormat(speed.down)} {SizeFormatter.speedFormat(speed.down)}
@@ -0,0 +1,77 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { ThemeProvider } from '@/hooks/useTheme';
import ClientTrafficCell from './ClientTrafficCell';
const GiB = 1024 ** 3;
const meta = {
title: 'Clients/ClientTrafficCell',
component: ClientTrafficCell,
tags: ['autodocs'],
decorators: [
(Story) => (
<ThemeProvider>
<Story />
</ThemeProvider>
),
],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Traffic usage cell for the clients table: used bytes, a color-coded progress bar, and the quota (or an infinity icon for unlimited clients), with an upload/download/remaining breakdown in a hover popover.',
},
},
},
argTypes: {
up: { description: 'Uploaded bytes counted against the client.' },
down: { description: 'Downloaded bytes counted against the client.' },
total: { description: 'Traffic quota in bytes; 0 or less renders as unlimited.' },
enabled: { description: 'Grays the bar out when the client is disabled.' },
trafficDiff: { description: 'Headroom in bytes below the quota at which the bar shifts from green to orange.' },
compact: { description: 'Smaller bar and tighter layout for dense table rows.' },
},
} satisfies Meta<typeof ClientTrafficCell>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
up: 6 * GiB,
down: 35 * GiB,
total: 100 * GiB,
trafficDiff: 5 * GiB,
},
};
export const Unlimited: Story = {
args: {
up: 87 * GiB,
down: 940 * GiB,
total: 0,
},
};
export const Depleted: Story = {
args: {
up: 9 * GiB,
down: 42 * GiB,
total: 50 * GiB,
trafficDiff: 5 * GiB,
},
};
export const DisabledCompact: Story = {
args: {
up: 2 * GiB,
down: 11 * GiB,
total: 40 * GiB,
enabled: false,
compact: true,
},
};
@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Popover, Progress } from 'antd'; import { Popover, Progress } from 'antd';
@@ -17,7 +17,11 @@ export interface ClientTrafficCellProps {
compact?: boolean; 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, up = 0,
down = 0, down = 0,
total = 0, total = 0,
@@ -64,6 +68,7 @@ export default function ClientTrafficCell({
<span className="client-traffic-cell-used">{SizeFormatter.sizeFormat(display.used)}</span> <span className="client-traffic-cell-used">{SizeFormatter.sizeFormat(display.used)}</span>
<Progress <Progress
className="client-traffic-cell-bar" className="client-traffic-cell-bar"
aria-label={`${SizeFormatter.sizeFormat(display.used)} / ${display.isUnlimited ? t('subscription.unlimited') : SizeFormatter.sizeFormat(total)}`}
percent={display.percent} percent={display.percent}
showInfo={false} showInfo={false}
strokeColor={display.strokeColor} strokeColor={display.strokeColor}
@@ -72,7 +77,7 @@ export default function ClientTrafficCell({
/> />
<span className="client-traffic-cell-limit"> <span className="client-traffic-cell-limit">
{display.isUnlimited ? ( {display.isUnlimited ? (
<span className="client-traffic-cell-infinity" aria-label={t('subscription.unlimited')}> <span className="client-traffic-cell-infinity" role="img" aria-label={t('subscription.unlimited')}>
<InfinityIcon /> <InfinityIcon />
</span> </span>
) : ( ) : (
@@ -82,4 +87,6 @@ export default function ClientTrafficCell({
</div> </div>
</Popover> </Popover>
); );
} });
export default ClientTrafficCell;
@@ -12,6 +12,14 @@
min-width: 0; min-width: 0;
} }
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 { .config-block .ant-collapse-extra {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -34,6 +42,7 @@
word-break: break-all; word-break: break-all;
white-space: pre-wrap; white-space: pre-wrap;
padding: 6px 8px; padding: 6px 8px;
color: var(--ant-color-text);
background: var(--ant-color-fill-tertiary); background: var(--ant-color-fill-tertiary);
border-radius: 4px; border-radius: 4px;
user-select: all; user-select: all;
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
import ConfigBlock from './ConfigBlock'; import ConfigBlock from './ConfigBlock';
@@ -6,7 +7,24 @@ const meta = {
title: 'Clients/ConfigBlock', title: 'Clients/ConfigBlock',
component: ConfigBlock, component: ConfigBlock,
tags: ['autodocs'], tags: ['autodocs'],
parameters: { layout: 'padded' }, parameters: {
layout: 'padded',
docs: {
description: {
component:
'Collapsible panel that displays a client config or share link with copy, download, and QR-code actions. Used on the clients and inbounds pages to present generated links.',
},
},
},
argTypes: {
label: { description: 'Protocol/type badge shown on the panel header (e.g. `vless`, `trojan`).' },
text: { description: 'The config or share-link text to display, copy, download, and encode as a QR code.' },
fileName: { description: 'File name used when downloading the text.' },
qrRemark: { description: 'Optional remark embedded in the QR panel; falls back to `label`.' },
showQr: { description: 'Whether to show the QR-code action button.' },
tagColor: { description: 'Ant Design tag color for the header badge.' },
defaultOpen: { description: 'Whether the panel starts expanded.' },
},
} satisfies Meta<typeof ConfigBlock>; } satisfies Meta<typeof ConfigBlock>;
export default meta; export default meta;
@@ -18,6 +36,15 @@ const sampleLink = 'vless://11112222-3333-4444-5555-666677778888@panel.example.c
export const Collapsed: Story = { export const Collapsed: Story = {
args: { label: 'vless', text: sampleLink, fileName: 'client-config.txt' }, args: { label: 'vless', text: sampleLink, fileName: 'client-config.txt' },
play: async ({ canvas, userEvent }) => {
await expect(canvas.queryByText(/vless:\/\/11112222/)).not.toBeInTheDocument();
await userEvent.click(canvas.getByText('vless'));
const configText = await canvas.findByText(/vless:\/\/11112222/);
await waitFor(() => expect(configText).toBeVisible());
await expect(canvas.getByRole('button', { name: 'Copy' })).toBeVisible();
await expect(canvas.getByRole('button', { name: 'Download' })).toBeVisible();
await expect(canvas.getByRole('button', { name: 'QR Code' })).toBeVisible();
},
}; };
export const Expanded: Story = { export const Expanded: Story = {
@@ -68,6 +68,7 @@ export default function ConfigBlock({
{messageContextHolder} {messageContextHolder}
<Collapse <Collapse
className="config-block" className="config-block"
collapsible="header"
defaultActiveKey={defaultOpen ? ['cfg'] : []} defaultActiveKey={defaultOpen ? ['cfg'] : []}
items={[{ items={[{
key: 'cfg', key: 'cfg',
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor, within } from 'storybook/test';
import { Button } from 'antd'; import { Button } from 'antd';
import PromptModal from './PromptModal'; import PromptModal from './PromptModal';
@@ -8,6 +9,25 @@ const meta = {
title: 'Feedback/PromptModal', title: 'Feedback/PromptModal',
component: PromptModal, component: PromptModal,
tags: ['autodocs'], tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Modal that prompts for a single value — a plain input, a multi-line textarea, or a JSON editor. Confirms on Enter (input) or Ctrl+S (textarea) and returns the entered text.',
},
},
},
argTypes: {
open: { description: 'Whether the modal is visible.' },
title: { description: 'Modal title text.' },
okText: { description: 'Confirm button label; defaults to the translated "confirm".' },
type: { description: 'Editor variant: single-line `input` or multi-line `textarea`.' },
initialValue: { description: 'Value pre-filled when the modal opens.' },
loading: { description: 'Shows a loading state on the confirm button.' },
json: { description: 'Render a JSON editor instead of a plain field.' },
onConfirm: { description: 'Called with the entered value on confirm.' },
onClose: { description: 'Called when the modal is dismissed.' },
},
} satisfies Meta<typeof PromptModal>; } satisfies Meta<typeof PromptModal>;
export default meta; export default meta;
@@ -62,9 +82,25 @@ const placeholderArgs = {
export const Input: Story = { export const Input: Story = {
args: placeholderArgs, args: placeholderArgs,
render: () => <InputDemo />, render: () => <InputDemo />,
play: async ({ canvas, canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(canvas.getByRole('button', { name: 'Rename client' }));
const input = await body.findByRole('textbox');
await userEvent.type(input, 'new-name');
await userEvent.keyboard('{Enter}');
await expect(await canvas.findByText(/Last confirmed: new-name/)).toBeVisible();
},
}; };
export const Textarea: Story = { export const Textarea: Story = {
args: placeholderArgs, args: placeholderArgs,
render: () => <TextareaDemo />, render: () => <TextareaDemo />,
play: async ({ canvas, canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(canvas.getByRole('button', { name: 'Edit note' }));
const textarea = await body.findByRole('textbox');
await expect(textarea).toHaveValue('line one\nline two');
await userEvent.click(body.getByRole('button', { name: 'Confirm' }));
await waitFor(() => expect(body.queryByRole('dialog')).not.toBeInTheDocument());
},
}; };
@@ -72,6 +72,7 @@ export default function PromptModal({
) : type === 'textarea' ? ( ) : type === 'textarea' ? (
<Input.TextArea <Input.TextArea
ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }} ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
aria-label={title}
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
autoSize={{ minRows: 10, maxRows: 20 }} autoSize={{ minRows: 10, maxRows: 20 }}
@@ -80,6 +81,7 @@ export default function PromptModal({
) : ( ) : (
<Input <Input
ref={inputRef} ref={inputRef}
aria-label={title}
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeydown} onKeyDown={onKeydown}
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor, within } from 'storybook/test';
import { Button } from 'antd'; import { Button } from 'antd';
import TextModal from './TextModal'; import TextModal from './TextModal';
@@ -8,6 +9,23 @@ const meta = {
title: 'Feedback/TextModal', title: 'Feedback/TextModal',
component: TextModal, component: TextModal,
tags: ['autodocs'], tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Read-only modal for viewing generated text or JSON — a client config, subscription, or exported settings — with copy and optional download actions, plus optional tabs for multiple documents.',
},
},
},
argTypes: {
open: { description: 'Whether the modal is visible.' },
title: { description: 'Modal title text.' },
content: { description: 'Text shown when no `tabs` are provided.' },
fileName: { description: 'When set, adds a download button that saves the active content under this name.' },
json: { description: 'Render the content in a read-only JSON editor with syntax highlighting.' },
tabs: { description: 'Optional list of `{ key, label, content }` documents shown as tabs.' },
onClose: { description: 'Called when the modal is dismissed.' },
},
} satisfies Meta<typeof TextModal>; } satisfies Meta<typeof TextModal>;
export default meta; export default meta;
@@ -43,6 +61,13 @@ const placeholderArgs = {
export const PlainText: Story = { export const PlainText: Story = {
args: placeholderArgs, args: placeholderArgs,
render: () => <Demo fileName="client.txt" />, render: () => <Demo fileName="client.txt" />,
play: async ({ canvas, canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(canvas.getByRole('button', { name: 'Show config' }));
const content = await body.findByDisplayValue(/vless:\/\/uuid@example\.com/);
await waitFor(() => expect(content).toBeVisible());
await expect(body.getByRole('button', { name: /client\.txt/ })).toBeEnabled();
},
}; };
export const Json: Story = { export const Json: Story = {
@@ -75,6 +75,7 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
<JsonEditor value={activeContent} readOnly minHeight="240px" maxHeight="60vh" /> <JsonEditor value={activeContent} readOnly minHeight="240px" maxHeight="60vh" />
) : ( ) : (
<Input.TextArea <Input.TextArea
aria-label={title}
value={activeContent} value={activeContent}
readOnly readOnly
autoSize={{ minRows: 10, maxRows: 20 }} autoSize={{ minRows: 10, maxRows: 20 }}
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Typography } from 'antd';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { setDatepicker } from '@/hooks/useDatepicker';
import { ThemeProvider } from '@/hooks/useTheme';
import DateTimePicker from './DateTimePicker';
setDatepicker('gregorian');
function ClientExpiryDemo() {
const [value, setValue] = useState<Dayjs | null>(dayjs('2026-12-31 23:59:59'));
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<DateTimePicker value={value} onChange={setValue} placeholder="Expiry date" />
<Typography.Text type="secondary">
{value ? `user1@node-de expiryTime: ${value.valueOf()}` : 'user1@node-de expiryTime: 0 (never expires)'}
</Typography.Text>
</div>
);
}
function JalaliDemo() {
const [value, setValue] = useState<Dayjs | null>(dayjs('2026-12-31 23:59:59'));
useEffect(() => {
setDatepicker('jalalian');
return () => setDatepicker('gregorian');
}, []);
return <DateTimePicker value={value} onChange={setValue} placeholder="Expiry date" />;
}
const meta = {
title: 'Form/DateTimePicker',
component: DateTimePicker,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Calendar-aware date/time picker used for client and inbound expiry dates. Renders an AntD DatePicker by default and switches to a Persian (Jalali) calendar — with theme-matched colors and an overlaid clear button — when the panel datepicker setting is jalalian.',
},
},
},
decorators: [
(Story) => (
<ThemeProvider>
<Story />
</ThemeProvider>
),
],
argTypes: {
value: { description: 'Selected moment as a Dayjs instance, or null when unset.' },
onChange: { description: 'Called with the picked Dayjs value, or null when cleared.' },
showTime: { description: 'Include an hour/minute/second selector alongside the date.' },
format: { description: 'Display format for the Gregorian picker input.' },
placeholder: { description: 'Input placeholder shown while no value is set.' },
disabled: { description: 'Disables the input and hides the clear button.' },
},
} satisfies Meta<typeof DateTimePicker>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = {
args: {
value: null,
onChange: () => undefined,
placeholder: 'Leave blank to never expire',
},
};
export const ClientExpiry: Story = {
args: { value: null, onChange: () => undefined },
render: () => <ClientExpiryDemo />,
};
export const DateOnly: Story = {
args: {
value: dayjs('2026-08-01'),
onChange: () => undefined,
showTime: false,
format: 'YYYY-MM-DD',
placeholder: 'Start date',
},
};
export const JalaliCalendar: Story = {
args: { value: null, onChange: () => undefined },
parameters: { docs: { disable: true } },
render: () => <JalaliDemo />,
};
@@ -121,7 +121,9 @@ export default function DateTimePicker({
<DatePicker <DatePicker
value={value} value={value}
onChange={(next) => onChange(next || null)} onChange={(next) => onChange(next || null)}
onCalendarChange={(next) => onChange((Array.isArray(next) ? next[0] : next) || null)}
showTime={showTime ? { format: 'HH:mm:ss' } : false} showTime={showTime ? { format: 'HH:mm:ss' } : false}
needConfirm={false}
format={format} format={format}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled}
@@ -0,0 +1,63 @@
import { useRef, useState } from 'react';
import { Input, Typography } from 'antd';
import { HttpUtil } from '@/utils';
interface GoRegexInputProps {
value: string;
ariaLabel?: string;
placeholder?: string;
maxLength?: number;
onChange: (value: string) => void;
externalError?: string;
}
export async function validateGoRegex(value: string): Promise<string> {
const result = await HttpUtil.post(
'/panel/api/setting/validateRegex',
{ regex: value },
{ silent: true },
);
return result.success ? '' : result.msg || 'Invalid Go RE2 regular expression';
}
export default function GoRegexInput({
value,
ariaLabel,
placeholder,
maxLength = 2048,
onChange,
externalError,
}: GoRegexInputProps) {
const [error, setError] = useState('');
const validationSequence = useRef(0);
async function validate() {
const sequence = ++validationSequence.current;
const nextError = await validateGoRegex(value);
if (sequence === validationSequence.current) {
setError(nextError);
}
}
const displayError = externalError ?? error;
return (
<div style={{ width: '100%' }}>
<Input
value={value}
aria-label={ariaLabel}
placeholder={placeholder}
maxLength={maxLength}
status={displayError ? 'error' : undefined}
onChange={(event) => {
validationSequence.current += 1;
setError('');
onChange(event.target.value);
}}
onBlur={() => void validate()}
/>
{displayError && <Typography.Text type="danger">{displayError}</Typography.Text>}
</div>
);
}
@@ -0,0 +1,76 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import HeaderMapEditor, { type HeaderMapValue } from './HeaderMapEditor';
const meta = {
title: 'Form/HeaderMapEditor',
component: HeaderMapEditor,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Row-based editor for Xray HTTP header maps, used in the inbound/outbound stream forms. Mode `v1` emits one string per header name (WS / HTTPUpgrade / Hysteria masquerade); mode `v2` emits string arrays so headers can repeat (TCP HTTP camouflage request/response).',
},
},
},
argTypes: {
mode: { description: 'Wire shape: `v1` = string per name, `v2` = string[] per name (repeatable headers).' },
value: { description: 'Header map in the wire shape matching `mode`; converted to editable rows internally.' },
onChange: { description: 'Called with the rebuilt wire-shape map after every row edit, add, or remove.' },
},
} satisfies Meta<typeof HeaderMapEditor>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = {
args: { mode: 'v1', onChange: () => undefined },
};
export const WsHostHeaders: Story = {
args: {
mode: 'v1',
value: {
Host: 'cdn.example.com',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
onChange: () => undefined,
},
};
export const TcpCamouflageRequest: Story = {
args: {
mode: 'v2',
value: {
Accept: ['text/html,application/xhtml+xml', 'application/json'],
'Accept-Encoding': ['gzip, deflate'],
Connection: ['keep-alive'],
Pragma: ['no-cache'],
},
onChange: () => undefined,
},
};
function WireShapeDemo() {
const [value, setValue] = useState<HeaderMapValue>({
Accept: ['text/html', 'application/json'],
'X-Forwarded-For': ['203.0.113.7'],
});
return (
<div style={{ maxWidth: 560 }}>
<HeaderMapEditor mode="v2" value={value} onChange={setValue} />
<pre style={{ marginTop: 16, padding: 12, borderRadius: 8, background: 'rgba(128, 128, 128, 0.12)' }}>
{JSON.stringify(value ?? {}, null, 2)}
</pre>
</div>
);
}
export const LiveWireShape: Story = {
args: { mode: 'v2', onChange: () => undefined },
render: () => <WireShapeDemo />,
};
@@ -0,0 +1,132 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Typography } from 'antd';
import { ThemeProvider } from '@/hooks/useTheme';
import JsonEditor from './JsonEditor';
const warpOutbound = JSON.stringify(
{
tag: 'warp-out',
protocol: 'wireguard',
settings: {
secretKey: 'yFXfmXX3Zn5tnpNJ7HAcbLvqcMVioqPDGV1GXn2FeV0=',
address: ['172.16.0.2/32', '2606:4700:110:8f81::2/128'],
peers: [
{
publicKey: 'bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=',
allowedIPs: ['0.0.0.0/0', '::/0'],
endpoint: 'engage.cloudflareclient.com:2408',
},
],
mtu: 1280,
},
},
null,
2,
);
const realityStreamSettings = JSON.stringify(
{
network: 'tcp',
security: 'reality',
realitySettings: {
show: false,
dest: 'yahoo.com:443',
xver: 0,
serverNames: ['yahoo.com', 'www.yahoo.com'],
privateKey: 'wLc4dpQvRt8mK1nS9jH2fXaU7yEoB3iZ6vNqTgCkW5A',
shortIds: ['6ba85179e30d4fc2'],
},
},
null,
2,
);
const brokenInboundSettings = [
'{',
' "clients": [',
' {',
' "id": "9f4c3a2b-7d61-4e8a-b5c0-1f2e3d4a5b6c",',
' "email": "user1@node-de",',
' "flow": "xtls-rprx-vision",',
' }',
' ],',
' "decryption": "none"',
].join('\n');
function ControlledDemo() {
const [value, setValue] = useState(warpOutbound);
let parseError = '';
try {
JSON.parse(value);
} catch (err) {
parseError = err instanceof Error ? err.message : String(err);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<JsonEditor value={value} onChange={setValue} minHeight="220px" maxHeight="360px" />
<Typography.Text type={parseError ? 'danger' : 'success'}>
{parseError ? `Parse error: ${parseError}` : `Valid JSON (${value.length} chars)`}
</Typography.Text>
</div>
);
}
const meta = {
title: 'Form/JsonEditor',
component: JsonEditor,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'CodeMirror-based JSON editor with syntax highlighting, live parse linting, and theme-aware styling. The panel uses it for raw xray config snippets — inbound settings, stream settings, and outbound JSON in the modals and settings pages.',
},
},
},
decorators: [
(Story) => (
<ThemeProvider>
<Story />
</ThemeProvider>
),
],
argTypes: {
value: { description: 'JSON document text; the editor resyncs when this prop changes.' },
onChange: { description: 'Called with the full document text on every edit.' },
minHeight: { description: 'CSS min-height of the scrollable editor area.' },
maxHeight: { description: 'CSS max-height before the editor scrolls internally.' },
readOnly: { description: 'Disables editing while keeping selection and scrolling.' },
},
} satisfies Meta<typeof JsonEditor>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { value: warpOutbound },
};
export const ReadOnly: Story = {
args: { value: realityStreamSettings, readOnly: true, minHeight: '200px' },
};
export const LintErrors: Story = {
args: { value: brokenInboundSettings, minHeight: '220px' },
};
export const Controlled: Story = {
args: { value: '' },
parameters: {
a11y: {
config: {
rules: [{ id: 'scrollable-region-focusable', enabled: false }],
},
},
},
render: () => <ControlledDemo />,
};
@@ -120,6 +120,7 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
doc: value, doc: value,
extensions: [ extensions: [
basicSetup, basicSetup,
EditorView.contentAttributes.of({ 'aria-label': t('jsonEditor') }),
keymap.of([indentWithTab]), keymap.of([indentWithTab]),
json(), json(),
linter(jsonParseLinter()), linter(jsonParseLinter()),
@@ -0,0 +1,68 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import RemarkTemplateField from './RemarkTemplateField';
const meta = {
title: 'Form/RemarkTemplateField',
component: RemarkTemplateField,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Text input augmented with a {{VAR}} token picker (insert-at-caret) and a live sample-based preview of the expanded remark. The panel uses it in subscription settings for the global Remark Template.',
},
},
},
argTypes: {
value: { description: 'Current template string; any {{VAR}} token enables the live preview below the input.' },
onChange: { description: 'Called with the updated template on typing or token insertion.' },
maxLength: { description: 'Maximum template length; picker insertions are clamped to it.' },
placeholder: { description: 'Placeholder shown while the template is empty.' },
},
} satisfies Meta<typeof RemarkTemplateField>;
export default meta;
type Story = StoryObj<typeof meta>;
function InteractiveDemo() {
const [value, setValue] = useState('{{STATUS_EMOJI}} {{INBOUND}}-{{EMAIL}} | {{TRAFFIC_LEFT}}');
return <RemarkTemplateField value={value} onChange={setValue} maxLength={256} placeholder="{{INBOUND}}-{{EMAIL}}" />;
}
export const Empty: Story = {
args: {
value: '',
onChange: () => undefined,
placeholder: '{{INBOUND}}-{{EMAIL}}',
},
};
export const TokenTemplate: Story = {
args: {
value: '{{INBOUND}}-{{EMAIL}} | {{TRAFFIC_LEFT}} left | {{DAYS_LEFT}}d',
onChange: () => undefined,
maxLength: 256,
placeholder: '{{INBOUND}}-{{EMAIL}}',
},
};
export const PlainRemark: Story = {
args: {
value: 'Germany CDN node',
onChange: () => undefined,
maxLength: 256,
placeholder: '{{INBOUND}}-{{EMAIL}}',
},
};
export const Interactive: Story = {
args: {
value: '',
onChange: () => undefined,
},
render: () => <InteractiveDemo />,
};
@@ -0,0 +1,78 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button, Input, Popover, Typography } from 'antd';
import { previewRemark, wrapToken } from '@/lib/remark/remarkVariables';
import RemarkVarPicker from './RemarkVarPicker';
const meta = {
title: 'Form/RemarkVarPicker',
component: RemarkVarPicker,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Grouped, tooltipped chip list of the {{VAR}} tokens the backend substitutes per client in subscription remarks. The hosts page shows it in a popover beside the remark-template field so operators can insert placeholders like {{EMAIL}} or {{TRAFFIC_LEFT}}.',
},
},
},
argTypes: {
onPick: { description: 'Called with the bare token (e.g. "EMAIL") when a chip is clicked or activated via keyboard.' },
},
} satisfies Meta<typeof RemarkVarPicker>;
export default meta;
type Story = StoryObj<typeof meta>;
function TemplateBuilderDemo() {
const [template, setTemplate] = useState('{{INBOUND}}-{{EMAIL}} {{STATUS_EMOJI}} {{TRAFFIC_LEFT}} left');
return (
<div style={{ maxWidth: 520 }}>
<Input
value={template}
onChange={(e) => setTemplate(e.target.value)}
aria-label="Remark template"
style={{ fontFamily: 'monospace' }}
/>
<Typography.Paragraph type="secondary" style={{ margin: '8px 0 16px' }}>
Preview: {previewRemark(template)}
</Typography.Paragraph>
<RemarkVarPicker onPick={(token) => setTemplate((prev) => `${prev}${wrapToken(token)}`)} />
</div>
);
}
function PopoverDemo() {
const [lastPicked, setLastPicked] = useState('');
return (
<>
<Popover
content={<RemarkVarPicker onPick={(token) => setLastPicked(wrapToken(token))} />}
trigger="click"
placement="bottomRight"
title="Remark variables"
>
<Button>Insert variable</Button>
</Popover>
<div style={{ marginTop: 12 }}>Last picked: {lastPicked || '—'}</div>
</>
);
}
export const Default: Story = {
args: { onPick: () => undefined },
};
export const TemplateBuilder: Story = {
args: { onPick: () => undefined },
render: () => <TemplateBuilderDemo />,
};
export const InsidePopover: Story = {
args: { onPick: () => undefined },
render: () => <PopoverDemo />,
};
@@ -0,0 +1,132 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect } from 'storybook/test';
import { Select } from 'antd';
import SelectAllClearButtons from './SelectAllClearButtons';
const inboundOptions: Array<{ value: number; label: string }> = [
{ value: 1, label: 'VLESS Reality — 443' },
{ value: 2, label: 'VMess WS — 8443' },
{ value: 3, label: 'Trojan TCP — 2053' },
{ value: 4, label: 'Shadowsocks — 8388' },
];
const clientEmailOptions: Array<{ value: string; label: string }> = [
{ value: 'ava@corp.example', label: 'ava@corp.example' },
{ value: 'reza.mobile', label: 'reza.mobile' },
{ value: 'office-tv', label: 'office-tv' },
{ value: 'guest-42', label: 'guest-42' },
];
const meta = {
title: 'Form/SelectAllClearButtons',
component: SelectAllClearButtons,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Small "Select all" / "Clear all" button pair rendered above a multi-select. The panel places it over the attached-inbounds picker in the client form and the bulk attach/detach modals.',
},
},
},
argTypes: {
options: { description: 'Option list whose values define the "all" set; matches the AntD Select option shape.' },
value: { description: 'Currently selected values (controlled).' },
onChange: { description: 'Called with the union of the current selection and every option value, or with an empty array on clear.' },
selectAllLabel: { description: 'Override for the "Select all" button text; defaults to the translated inbound copy.' },
clearLabel: { description: 'Override for the "Clear all" button text; defaults to the translated inbound copy.' },
},
} satisfies Meta<typeof SelectAllClearButtons>;
export default meta;
type Story = StoryObj<typeof meta>;
function InboundPickerDemo() {
const [selected, setSelected] = useState<number[]>([1]);
return (
<div style={{ maxWidth: 360 }}>
<SelectAllClearButtons options={inboundOptions} value={selected} onChange={setSelected} />
<Select
mode="multiple"
style={{ width: '100%' }}
value={selected}
onChange={setSelected}
options={inboundOptions}
placeholder="Select inbounds"
aria-label="Select inbounds"
maxTagCount="responsive"
/>
</div>
);
}
function ClientEmailsDemo() {
const [selected, setSelected] = useState<string[]>(['ava@corp.example']);
return (
<div style={{ maxWidth: 360 }}>
<SelectAllClearButtons
options={clientEmailOptions}
value={selected}
onChange={setSelected}
selectAllLabel="Select all clients"
clearLabel="Deselect clients"
/>
<Select
mode="multiple"
style={{ width: '100%' }}
value={selected}
onChange={setSelected}
options={clientEmailOptions}
placeholder="Select clients"
aria-label="Select clients"
maxTagCount="responsive"
/>
</div>
);
}
const placeholderArgs = {
options: [],
value: [],
onChange: () => undefined,
};
export const PartiallySelected: Story = {
args: {
options: [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }],
value: [1, 3],
onChange: () => undefined,
},
};
export const AllSelected: Story = {
args: {
options: [{ value: 1 }, { value: 2 }, { value: 3 }],
value: [1, 2, 3],
onChange: () => undefined,
},
};
export const WithInboundSelect: Story = {
args: placeholderArgs,
render: () => <InboundPickerDemo />,
};
export const CustomLabels: Story = {
args: placeholderArgs,
render: () => <ClientEmailsDemo />,
play: async ({ canvas, userEvent }) => {
const selectAll = canvas.getByRole('button', { name: 'Select all clients' });
const clearAll = canvas.getByRole('button', { name: 'Deselect clients' });
await expect(selectAll).toBeEnabled();
await userEvent.click(selectAll);
await expect(selectAll).toBeDisabled();
await userEvent.click(clearAll);
await expect(clearAll).toBeDisabled();
await expect(selectAll).toBeEnabled();
},
};
+1
View File
@@ -1,6 +1,7 @@
export { default as DateTimePicker } from './DateTimePicker'; export { default as DateTimePicker } from './DateTimePicker';
export { default as JsonEditor } from './JsonEditor'; export { default as JsonEditor } from './JsonEditor';
export { default as HeaderMapEditor } from './HeaderMapEditor'; export { default as HeaderMapEditor } from './HeaderMapEditor';
export { default as GoRegexInput, validateGoRegex } from './GoRegexInput';
export { default as SelectAllClearButtons } from './SelectAllClearButtons'; export { default as SelectAllClearButtons } from './SelectAllClearButtons';
export { default as RemarkTemplateField } from './RemarkTemplateField'; export { default as RemarkTemplateField } from './RemarkTemplateField';
export { default as RemarkVarPicker } from './RemarkVarPicker'; export { default as RemarkVarPicker } from './RemarkVarPicker';
@@ -0,0 +1,210 @@
import { useEffect } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
import { Button, Form, Input, InputNumber, Select, Switch, Typography } from 'antd';
import { FormProvider } from 'react-hook-form';
import { z } from 'zod';
import { FormField } from './FormField';
import { useZodForm } from './useZodForm';
const GB = 1024 * 1024 * 1024;
const meta = {
title: 'Form/RHF/FormField',
component: FormField,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Bridges one Ant Design control into react-hook-form: wraps the child in a Controller plus Form.Item, normalizes the onChange payload, and surfaces resolver errors as translated help text. Every RHF panel form (client, inbound, outbound, and host modals) builds its fields with it.',
},
},
},
argTypes: {
name: { description: 'Field path — a dotted string or an array of segments joined with dots.' },
control: { description: 'Optional react-hook-form control; falls back to the surrounding FormProvider.' },
label: { description: 'Form.Item label.' },
tooltip: { description: 'Form.Item tooltip shown next to the label.' },
extra: { description: 'Helper text rendered below the input.' },
valueProp: { description: 'Prop the child receives the value on: `value` (default) or `checked` for switches.' },
transform: { description: 'Optional input/output mappers, e.g. bytes stored in the form but GB shown in the input.' },
onAfterChange: { description: 'Called with the stored value after every change.' },
rules: { description: 'Controller-level validation rules applied on top of the form resolver.' },
required: { description: 'Marks the label with the required asterisk.' },
noStyle: { description: 'Render the bare input without Form.Item chrome.' },
children: { description: 'The single Ant Design control to wire up.' },
},
} satisfies Meta<typeof FormField>;
export default meta;
type Story = StoryObj<typeof meta>;
const ClientSchema = z.object({
email: z.string(),
flow: z.string(),
enable: z.boolean(),
});
function ClientDemo() {
const methods = useZodForm(ClientSchema, {
defaultValues: { email: 'user1@example.com', flow: 'xtls-rprx-vision', enable: true },
});
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
<FormField name="email" label="Email" tooltip="Unique identifier used to match client traffic" required>
<Input placeholder="user1@example.com" />
</FormField>
<FormField name="flow" label="Flow" extra="Only applies to VLESS over raw TLS">
<Select
options={[
{ label: 'none', value: '' },
{ label: 'xtls-rprx-vision', value: 'xtls-rprx-vision' },
]}
/>
</FormField>
<FormField name="enable" label="Enable" valueProp="checked">
<Switch />
</FormField>
</Form>
</FormProvider>
);
}
const TrafficSchema = z.object({
totalBytes: z.number(),
});
function TrafficDemo() {
const methods = useZodForm(TrafficSchema, { defaultValues: { totalBytes: 50 * GB } });
const totalBytes = methods.watch('totalBytes');
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
<FormField
name="totalBytes"
label="Total traffic (GB)"
extra="Stored on the client as bytes; 0 means unlimited"
transform={{
input: (value) => (typeof value === 'number' ? value / GB : value),
output: (value) => (typeof value === 'number' ? value * GB : 0),
}}
>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<Typography.Text type="secondary">Form state: {totalBytes.toLocaleString()} bytes</Typography.Text>
</Form>
</FormProvider>
);
}
const InboundSchema = z.object({
remark: z.string().min(1, 'Remark is required'),
port: z
.number()
.min(1, 'Port must be between 1 and 65535')
.max(65535, 'Port must be between 1 and 65535'),
});
function ValidationDemo() {
const methods = useZodForm(InboundSchema, { defaultValues: { remark: '', port: 0 } });
useEffect(() => {
void methods.trigger();
}, [methods]);
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
<FormField name="remark" label="Remark" required>
<Input placeholder="vless-reality-443" />
</FormField>
<FormField name="port" label="Port" required>
<InputNumber style={{ width: '100%' }} />
</FormField>
<Button onClick={() => void methods.trigger()}>Validate</Button>
</Form>
</FormProvider>
);
}
const RealitySchema = z.object({
streamSettings: z.object({
realitySettings: z.object({
dest: z.string(),
serverNames: z.string(),
}),
}),
});
function NestedDemo() {
const methods = useZodForm(RealitySchema, {
defaultValues: {
streamSettings: {
realitySettings: { dest: 'yahoo.com:443', serverNames: 'yahoo.com,www.yahoo.com' },
},
},
});
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
<FormField
name={['streamSettings', 'realitySettings', 'dest']}
label="Dest"
tooltip="Camouflage target the REALITY handshake is proxied to"
>
<Input />
</FormField>
<FormField
name="streamSettings.realitySettings.serverNames"
label="Server names"
extra="Comma-separated SNI list offered to clients"
>
<Input />
</FormField>
</Form>
</FormProvider>
);
}
const placeholderArgs = {
name: 'email',
children: <Input />,
};
export const ClientFields: Story = {
args: placeholderArgs,
render: () => <ClientDemo />,
};
export const TrafficTransform: Story = {
args: placeholderArgs,
render: () => <TrafficDemo />,
play: async ({ canvas, userEvent }) => {
const input = canvas.getByRole('spinbutton');
await userEvent.clear(input);
await userEvent.type(input, '100');
await expect(await canvas.findByText(/107,374,182,400 bytes/)).toBeVisible();
},
};
export const ValidationErrors: Story = {
args: placeholderArgs,
render: () => <ValidationDemo />,
play: async ({ canvas, userEvent }) => {
const remarkError = await canvas.findByText('Remark is required');
await waitFor(() => expect(remarkError).toBeVisible());
await waitFor(() => expect(canvas.getByText('Port must be between 1 and 65535')).toBeVisible());
await userEvent.type(canvas.getByPlaceholderText('vless-reality-443'), 'vless-reality-443');
await userEvent.click(canvas.getByRole('button', { name: 'Validate' }));
await waitFor(() => expect(canvas.queryByText('Remark is required')).not.toBeInTheDocument());
await expect(canvas.getByText('Port must be between 1 and 65535')).toBeVisible();
},
};
export const NestedNames: Story = {
args: placeholderArgs,
render: () => <NestedDemo />,
};
@@ -1,4 +1,4 @@
import { cloneElement } from 'react'; import { cloneElement, useId } from 'react';
import type { CSSProperties, ReactElement, ReactNode } from 'react'; import type { CSSProperties, ReactElement, ReactNode } from 'react';
import { Controller } from 'react-hook-form'; import { Controller } from 'react-hook-form';
import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form'; import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form';
@@ -48,6 +48,9 @@ export function FormField<T extends FieldValues = FieldValues>({
}: FormFieldProps<T>) { }: FormFieldProps<T>) {
const { t } = useTranslation(); const { t } = useTranslation();
const dottedName = toDotted(name) as Path<T>; const dottedName = toDotted(name) as Path<T>;
const generatedId = useId();
const explicitId = (children as ReactElement<{ id?: string }>).props.id;
const fieldId = explicitId ?? generatedId;
return ( return (
<Controller <Controller
@@ -60,6 +63,7 @@ export function FormField<T extends FieldValues = FieldValues>({
? t(fieldState.error.message, { defaultValue: fieldState.error.message }) ? t(fieldState.error.message, { defaultValue: fieldState.error.message })
: undefined; : undefined;
const childProps: Record<string, unknown> = { const childProps: Record<string, unknown> = {
id: fieldId,
[valueProp]: displayValue, [valueProp]: displayValue,
onChange: (...args: unknown[]) => { onChange: (...args: unknown[]) => {
const raw = normalizeAntdOnChange(args, valueProp); const raw = normalizeAntdOnChange(args, valueProp);
@@ -73,6 +77,7 @@ export function FormField<T extends FieldValues = FieldValues>({
return ( return (
<Form.Item <Form.Item
label={label} label={label}
htmlFor={label ? fieldId : undefined}
tooltip={tooltip} tooltip={tooltip}
extra={extra} extra={extra}
required={required} required={required}
@@ -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>;
}
@@ -6,6 +6,18 @@ const meta = {
title: 'UI/InfinityIcon', title: 'UI/InfinityIcon',
component: InfinityIcon, component: InfinityIcon,
tags: ['autodocs'], tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Inline SVG infinity glyph used to denote an unlimited value (e.g. unlimited traffic or no expiry). Inherits the current text color.',
},
},
},
argTypes: {
width: { description: 'Icon width in pixels or any CSS length.' },
height: { description: 'Icon height in pixels or any CSS length.' },
},
} satisfies Meta<typeof InfinityIcon>; } satisfies Meta<typeof InfinityIcon>;
export default meta; export default meta;
@@ -7,6 +7,21 @@ const meta = {
title: 'UI/InputAddon', title: 'UI/InputAddon',
component: InputAddon, component: InputAddon,
tags: ['autodocs'], tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Prefix/suffix addon styled to sit flush against an Ant Design input. Becomes a keyboard-accessible button (role, tabIndex, Enter/Space) when `onClick` is provided.',
},
},
},
argTypes: {
children: { description: 'Addon content (text or an icon).' },
onClick: { description: 'When set, the addon becomes an activatable button.' },
ariaLabel: { description: 'Accessible label; used only when `onClick` is set.' },
className: { description: 'Extra CSS class appended to the addon.' },
style: { description: 'Inline styles for the addon element.' },
},
} satisfies Meta<typeof InputAddon>; } satisfies Meta<typeof InputAddon>;
export default meta; export default meta;
@@ -26,7 +41,7 @@ export const BesideInput: Story = {
render: () => ( render: () => (
<Space.Compact> <Space.Compact>
<InputAddon>https://</InputAddon> <InputAddon>https://</InputAddon>
<Input defaultValue="panel.example.com" style={{ width: 220 }} /> <Input defaultValue="panel.example.com" aria-label="Panel host" style={{ width: 220 }} />
</Space.Compact> </Space.Compact>
), ),
}; };
@@ -7,7 +7,22 @@ const meta = {
title: 'UI/SettingListItem', title: 'UI/SettingListItem',
component: SettingListItem, component: SettingListItem,
tags: ['autodocs'], tags: ['autodocs'],
parameters: { layout: 'padded' }, parameters: {
layout: 'padded',
docs: {
description: {
component:
'Two-column settings row: a title and description on the left, and a control (Switch, InputNumber, …) on the right. Associates the title with the control via `aria-labelledby` for accessibility.',
},
},
},
argTypes: {
title: { description: 'Setting name shown on the left.' },
description: { description: 'Secondary help text under the title.' },
control: { description: 'The control rendered on the right (Switch, InputNumber, …).' },
children: { description: 'Alternative to `control`; used when no explicit control is passed.' },
paddings: { description: 'Row density: `default` or the tighter `small`.' },
},
} satisfies Meta<typeof SettingListItem>; } satisfies Meta<typeof SettingListItem>;
export default meta; export default meta;
@@ -5,6 +5,7 @@ import './SettingListItem.css';
interface SettingListItemProps { interface SettingListItemProps {
paddings?: 'small' | 'default'; paddings?: 'small' | 'default';
title?: ReactNode; title?: ReactNode;
badge?: ReactNode;
description?: ReactNode; description?: ReactNode;
children?: ReactNode; children?: ReactNode;
control?: ReactNode; control?: ReactNode;
@@ -13,6 +14,7 @@ interface SettingListItemProps {
export default function SettingListItem({ export default function SettingListItem({
paddings = 'default', paddings = 'default',
title, title,
badge,
description, description,
children, children,
control, control,
@@ -28,7 +30,12 @@ export default function SettingListItem({
<Row gutter={[8, 16]} style={{ width: '100%' }}> <Row gutter={[8, 16]} style={{ width: '100%' }}>
<Col xs={24} lg={12}> <Col xs={24} lg={12}>
<div className="setting-list-meta"> <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>} {description && <div className="setting-list-description">{description}</div>}
</div> </div>
</Col> </Col>
+1
View File
@@ -1,3 +1,4 @@
export { default as InputAddon } from './InputAddon'; export { default as InputAddon } from './InputAddon';
export { default as InfinityIcon } from './InfinityIcon'; export { default as InfinityIcon } from './InfinityIcon';
export { default as SettingListItem } from './SettingListItem'; export { default as SettingListItem } from './SettingListItem';
export { default as DefaultSettingTag } from './DefaultSettingTag';
@@ -0,0 +1,85 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { AllSetting } from '@/models/setting';
import { EmailNotifications } from './EmailNotifications';
const meta = {
title: 'UI/Notifications/EmailNotifications',
component: EmailNotifications,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Grid of grouped event checkboxes on the settings page that picks which panel events (outbound/node health, Xray crashes, CPU/RAM thresholds, login attempts) trigger an SMTP email, stored as a comma-separated list in smtpEnabledEvents.',
},
},
},
argTypes: {
allSetting: {
description:
'Panel settings snapshot; smtpEnabledEvents holds the selected event keys and smtpCpu/smtpMemory the alert threshold percentages.',
},
updateSetting: {
description: 'Receives a partial settings patch when an event is toggled or a threshold input changes.',
},
},
} satisfies Meta<typeof EmailNotifications>;
export default meta;
type Story = StoryObj<typeof meta>;
function StatefulDemo({ initial }: { initial: AllSetting }) {
const [settings, setSettings] = useState(initial);
return (
<EmailNotifications
allSetting={settings}
updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
/>
);
}
const placeholderArgs = {
allSetting: new AllSetting(),
updateSetting: () => undefined,
};
export const NothingSelected: Story = {
args: placeholderArgs,
render: () => <StatefulDemo initial={new AllSetting()} />,
};
export const SystemThresholdAlerts: Story = {
args: placeholderArgs,
render: () => (
<StatefulDemo
initial={new AllSetting({ smtpEnabledEvents: 'cpu.high,memory.high', smtpCpu: 85, smtpMemory: 90 })}
/>
),
};
export const InfrastructureOnly: Story = {
args: placeholderArgs,
render: () => (
<StatefulDemo initial={new AllSetting({ smtpEnabledEvents: 'outbound.down,node.down,node.up,xray.crash' })} />
),
};
export const AllEventsEnabled: Story = {
args: placeholderArgs,
render: () => (
<StatefulDemo
initial={
new AllSetting({
smtpEnabledEvents:
'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
smtpCpu: 80,
smtpMemory: 80,
})
}
/>
),
};
@@ -10,7 +10,14 @@ const GROUPS: NotificationGroupConfig[] = [
icon: <CloudServerOutlined />, icon: <CloudServerOutlined />,
title: 'eventGroupOutbound', title: 'eventGroupOutbound',
events: [ events: [
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' }, {
key: 'outbound.down',
label: 'eventOutboundDown',
settingKey: 'outboundDownThreshold',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={1} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
),
},
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' }, { key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
], ],
}, },
@@ -37,16 +44,16 @@ const GROUPS: NotificationGroupConfig[] = [
key: 'cpu.high', key: 'cpu.high',
label: 'eventCPUHigh', label: 'eventCPUHigh',
settingKey: 'smtpCpu', settingKey: 'smtpCpu',
extra: ({ value, onChange }) => ( extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} /> <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
), ),
}, },
{ {
key: 'memory.high', key: 'memory.high',
label: 'eventMemoryHigh', label: 'eventMemoryHigh',
settingKey: 'smtpMemory', settingKey: 'smtpMemory',
extra: ({ value, onChange }) => ( extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} /> <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
), ),
}, },
], ],
@@ -8,7 +8,21 @@ const meta = {
title: 'UI/Notifications/NotificationCard', title: 'UI/Notifications/NotificationCard',
component: NotificationCard, component: NotificationCard,
tags: ['autodocs'], tags: ['autodocs'],
parameters: { layout: 'padded' }, parameters: {
layout: 'padded',
docs: {
description: {
component:
'Small outlined card that groups a notification channel — an icon and title in the header, a control in the top-right `extra` slot (typically a toggle), and the channel settings as its body.',
},
},
},
argTypes: {
icon: { description: 'Leading icon shown before the title.' },
title: { description: 'Channel name shown in the header.' },
extra: { description: 'Top-right slot, typically an enable/disable Switch.' },
children: { description: 'Card body — the channel settings.' },
},
} satisfies Meta<typeof NotificationCard>; } satisfies Meta<typeof NotificationCard>;
export default meta; export default meta;
@@ -19,7 +33,7 @@ export const Default: Story = {
args: { args: {
icon: <BellOutlined />, icon: <BellOutlined />,
title: 'Telegram', title: 'Telegram',
extra: <Switch defaultChecked />, extra: <Switch defaultChecked aria-label="Enable Telegram notifications" />,
children: <span>Push a message to the configured chat when an event fires.</span>, children: <span>Push a message to the configured chat when an event fires.</span>,
}, },
}; };
@@ -28,7 +42,7 @@ export const Disabled: Story = {
args: { args: {
icon: <BellOutlined />, icon: <BellOutlined />,
title: 'Email', title: 'Email',
extra: <Switch />, extra: <Switch aria-label="Enable email notifications" />,
children: <span>Email delivery is turned off for this channel.</span>, children: <span>Email delivery is turned off for this channel.</span>,
}, },
}; };
@@ -0,0 +1,77 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { InputNumber } from 'antd';
import { NotificationEvent } from './NotificationEvent';
const meta = {
title: 'UI/Notifications/NotificationEvent',
component: NotificationEvent,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Single toggleable notification event row used inside the Telegram and email notification groups on the settings page. Renders a checkbox with a translated label and, when checked, an optional indented extra control such as a threshold input.',
},
},
},
argTypes: {
label: { description: 'i18n key (or already-translated text) shown next to the checkbox.' },
checked: { description: 'Whether the event notification is enabled.' },
onToggle: { description: 'Called when the checkbox is clicked.' },
children: { description: 'Extra control rendered indented below the label while checked.' },
},
} satisfies Meta<typeof NotificationEvent>;
export default meta;
type Story = StoryObj<typeof meta>;
function CpuThresholdDemo() {
const [checked, setChecked] = useState(true);
const [threshold, setThreshold] = useState(80);
return (
<NotificationEvent
label="pages.settings.eventCPUHigh"
checked={checked}
onToggle={() => setChecked((prev) => !prev)}
>
<InputNumber
size="small"
min={0}
max={100}
value={threshold}
onChange={(v) => setThreshold(v ?? 0)}
aria-label="CPU usage threshold percent"
style={{ width: 80 }}
/>
</NotificationEvent>
);
}
export const Unchecked: Story = {
args: {
label: 'pages.settings.eventLoginAttempt',
checked: false,
onToggle: () => undefined,
},
};
export const Checked: Story = {
args: {
label: 'pages.settings.eventXrayCrash',
checked: true,
onToggle: () => undefined,
},
};
export const CpuThreshold: Story = {
args: {
label: 'pages.settings.eventCPUHigh',
checked: true,
onToggle: () => undefined,
},
render: () => <CpuThresholdDemo />,
};
@@ -0,0 +1,131 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { InputNumber } from 'antd';
import { CloudServerOutlined, DashboardOutlined } from '@ant-design/icons';
import { AllSetting } from '@/models/setting';
import { NotificationGroup } from './NotificationGroup';
import type { NotificationGroupConfig } from './types';
const systemGroup: NotificationGroupConfig = {
icon: <DashboardOutlined />,
title: 'eventGroupSystem',
events: [
{
key: 'cpu.high',
label: 'eventCPUHigh',
settingKey: 'tgCpu',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
),
},
{
key: 'memory.high',
label: 'eventMemoryHigh',
settingKey: 'tgMemory',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
),
},
],
};
const outboundGroup: NotificationGroupConfig = {
icon: <CloudServerOutlined />,
title: 'eventGroupOutbound',
events: [
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
],
};
const meta = {
title: 'UI/Notifications/NotificationGroup',
component: NotificationGroup,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Card for one notification event group (outbound, Xray, node, system, security) with a per-group select-all checkbox, a selected-count tag, and optional per-event threshold inputs. Composed by the Telegram and email notification tabs on the settings page.',
},
},
},
argTypes: {
config: { description: 'Group definition: icon, `pages.settings` title key, and the event rows to render.' },
selected: { description: 'Enabled event keys; drives each checkbox and the header count.' },
onToggle: { description: 'Called with the event key when a single checkbox is clicked.' },
onToggleAll: { description: 'Called with every event key in the group when the master checkbox is clicked.' },
allSetting: { description: 'Panel settings snapshot; threshold values such as `tgCpu` are read from it.' },
updateSetting: { description: 'Called with a partial settings patch when a threshold input changes.' },
},
} satisfies Meta<typeof NotificationGroup>;
export default meta;
type Story = StoryObj<typeof meta>;
function Demo() {
const [selected, setSelected] = useState<string[]>(['cpu.high']);
const [settings, setSettings] = useState(new AllSetting({ tgCpu: 85, tgMemory: 90 }));
return (
<NotificationGroup
config={systemGroup}
selected={selected}
onToggle={(key) =>
setSelected((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]))
}
onToggleAll={(keys) =>
setSelected((prev) => (keys.every((k) => prev.includes(k)) ? prev.filter((k) => !keys.includes(k)) : [...new Set([...prev, ...keys])]))
}
allSetting={settings}
updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
/>
);
}
export const AllSelected: Story = {
args: {
config: systemGroup,
selected: ['cpu.high', 'memory.high'],
onToggle: () => undefined,
onToggleAll: () => undefined,
allSetting: new AllSetting({ tgCpu: 85, tgMemory: 90 }),
updateSetting: () => undefined,
},
};
export const PartiallySelected: Story = {
args: {
config: systemGroup,
selected: ['cpu.high'],
onToggle: () => undefined,
onToggleAll: () => undefined,
allSetting: new AllSetting(),
updateSetting: () => undefined,
},
};
export const NoneSelected: Story = {
args: {
config: outboundGroup,
selected: [],
onToggle: () => undefined,
onToggleAll: () => undefined,
allSetting: new AllSetting(),
updateSetting: () => undefined,
},
};
export const Interactive: Story = {
args: {
config: systemGroup,
selected: [],
onToggle: () => undefined,
onToggleAll: () => undefined,
allSetting: new AllSetting(),
updateSetting: () => undefined,
},
render: () => <Demo />,
};
@@ -51,6 +51,7 @@ export function NotificationGroup({ config, selected, onToggle, onToggleAll, all
{event.extra?.({ {event.extra?.({
value: Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0, value: Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0,
onChange: (v) => updateSetting({ [event.settingKey]: v }), onChange: (v) => updateSetting({ [event.settingKey]: v }),
ariaLabel: t(`pages.settings.${event.label}`),
})} })}
</NotificationEvent> </NotificationEvent>
))} ))}
@@ -0,0 +1,104 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Checkbox } from 'antd';
import { NotificationHeader } from './NotificationHeader';
const meta = {
title: 'UI/Notifications/NotificationHeader',
component: NotificationHeader,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Selection summary for a notification group header — a `count/total` tag plus a tri-state master checkbox that selects or clears every event in the group. Rendered in the `extra` slot of the Telegram/email notification cards on the settings page.',
},
},
},
argTypes: {
count: { description: 'Number of events currently selected in the group.' },
total: { description: 'Total number of events the group offers.' },
allSelected: { description: 'Checks the master checkbox when every event is selected.' },
indeterminate: { description: 'Shows the dash state when only some events are selected.' },
onToggleAll: { description: 'Called when the master checkbox is clicked to select or clear all events.' },
},
} satisfies Meta<typeof NotificationHeader>;
export default meta;
type Story = StoryObj<typeof meta>;
export const NoneSelected: Story = {
args: {
count: 0,
total: 6,
allSelected: false,
indeterminate: false,
onToggleAll: () => undefined,
},
};
export const PartialSelection: Story = {
args: {
count: 3,
total: 6,
allSelected: false,
indeterminate: true,
onToggleAll: () => undefined,
},
};
export const AllSelected: Story = {
args: {
count: 6,
total: 6,
allSelected: true,
indeterminate: false,
onToggleAll: () => undefined,
},
};
const events = ['Panel login', 'Xray crashed', 'CPU high', 'Client depleted'];
function GroupDemo() {
const [selected, setSelected] = useState<string[]>(['Panel login', 'CPU high']);
const count = selected.length;
const total = events.length;
function toggleAll() {
setSelected(count === total ? [] : [...events]);
}
function toggle(name: string) {
setSelected((prev) => (prev.includes(name) ? prev.filter((e) => e !== name) : [...prev, name]));
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 260 }}>
<NotificationHeader
count={count}
total={total}
allSelected={count === total}
indeterminate={count > 0 && count < total}
onToggleAll={toggleAll}
/>
{events.map((name) => (
<Checkbox key={name} checked={selected.includes(name)} onChange={() => toggle(name)}>
{name}
</Checkbox>
))}
</div>
);
}
const placeholderArgs = {
count: 0,
total: 0,
allSelected: false,
indeterminate: false,
onToggleAll: () => undefined,
};
export const Interactive: Story = {
args: placeholderArgs,
render: () => <GroupDemo />,
};
@@ -0,0 +1,146 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { InputNumber, Space } from 'antd';
import {
CloudServerOutlined,
DashboardOutlined,
DesktopOutlined,
SafetyOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { NotificationLayout } from './NotificationLayout';
import { NotificationCard } from './NotificationCard';
import { NotificationEvent } from './NotificationEvent';
import { NotificationHeader } from './NotificationHeader';
const noop = () => undefined;
function OutboundGroup() {
return (
<NotificationCard
icon={<CloudServerOutlined />}
title="Outbound"
extra={<NotificationHeader count={1} total={2} allSelected={false} indeterminate onToggleAll={noop} />}
>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
<NotificationEvent label="Outbound went down" checked onToggle={noop} />
<NotificationEvent label="Outbound recovered" checked={false} onToggle={noop} />
</Space>
</NotificationCard>
);
}
function XrayGroup() {
return (
<NotificationCard
icon={<ThunderboltOutlined />}
title="Xray"
extra={<NotificationHeader count={1} total={1} allSelected indeterminate={false} onToggleAll={noop} />}
>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
<NotificationEvent label="Xray crashed" checked onToggle={noop} />
</Space>
</NotificationCard>
);
}
function NodeGroup() {
return (
<NotificationCard
icon={<DesktopOutlined />}
title="Nodes"
extra={<NotificationHeader count={0} total={2} allSelected={false} indeterminate={false} onToggleAll={noop} />}
>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
<NotificationEvent label="Node went offline" checked={false} onToggle={noop} />
<NotificationEvent label="Node back online" checked={false} onToggle={noop} />
</Space>
</NotificationCard>
);
}
function SystemGroup() {
return (
<NotificationCard
icon={<DashboardOutlined />}
title="System"
extra={<NotificationHeader count={2} total={2} allSelected indeterminate={false} onToggleAll={noop} />}
>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
<NotificationEvent label="CPU usage above threshold (%)" checked onToggle={noop}>
<InputNumber size="small" min={0} max={100} defaultValue={80} aria-label="CPU usage threshold percent" style={{ width: 80 }} />
</NotificationEvent>
<NotificationEvent label="Memory usage above threshold (%)" checked onToggle={noop}>
<InputNumber size="small" min={0} max={100} defaultValue={90} aria-label="Memory usage threshold percent" style={{ width: 80 }} />
</NotificationEvent>
</Space>
</NotificationCard>
);
}
function SecurityGroup() {
return (
<NotificationCard
icon={<SafetyOutlined />}
title="Security"
extra={<NotificationHeader count={1} total={1} allSelected indeterminate={false} onToggleAll={noop} />}
>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
<NotificationEvent label="Panel login attempt" checked onToggle={noop} />
</Space>
</NotificationCard>
);
}
const meta = {
title: 'UI/Notifications/NotificationLayout',
component: NotificationLayout,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Responsive auto-fit grid (min 260px columns) that arranges notification event-group cards; the Telegram and email notification tabs on the settings page render their groups inside it.',
},
},
},
argTypes: {
children: { description: 'Grid items, typically one NotificationCard per event group.' },
},
} satisfies Meta<typeof NotificationLayout>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AllEventGroups: Story = {
args: {
children: (
<>
<OutboundGroup />
<XrayGroup />
<NodeGroup />
<SystemGroup />
<SecurityGroup />
</>
),
},
};
export const TwoGroups: Story = {
args: {
children: (
<>
<SystemGroup />
<SecurityGroup />
</>
),
},
};
export const SingleGroup: Story = {
args: {
children: <OutboundGroup />,
},
};
@@ -0,0 +1,82 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { AllSetting } from '@/models/setting';
import { TelegramNotifications } from './TelegramNotifications';
const meta = {
title: 'UI/Notifications/TelegramNotifications',
component: TelegramNotifications,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Grid of event-group cards (outbound, Xray, node, system, security) that pick which panel events the Telegram bot reports, with per-group select-all and CPU/RAM threshold inputs. Used on the settings page Telegram tab to edit `tgEnabledEvents`.',
},
},
},
argTypes: {
allSetting: { description: 'Panel settings snapshot; reads `tgEnabledEvents` plus the `tgCpu`/`tgMemory` thresholds.' },
updateSetting: { description: 'Called with a partial settings patch when an event toggle or threshold changes.' },
},
} satisfies Meta<typeof TelegramNotifications>;
export default meta;
type Story = StoryObj<typeof meta>;
function Demo({ initial }: { initial: AllSetting }) {
const [settings, setSettings] = useState(initial);
return (
<TelegramNotifications
allSetting={settings}
updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
/>
);
}
const placeholderArgs = {
allSetting: new AllSetting(),
updateSetting: () => undefined,
};
export const NothingSelected: Story = {
args: placeholderArgs,
render: () => <Demo initial={new AllSetting()} />,
};
export const TypicalMonitoring: Story = {
args: placeholderArgs,
render: () => (
<Demo
initial={
new AllSetting({
tgBotEnable: true,
tgBotChatId: '123456789',
tgEnabledEvents: 'xray.crash,node.down,cpu.high,memory.high,login.attempt',
tgCpu: 85,
tgMemory: 90,
})
}
/>
),
};
export const EverythingEnabled: Story = {
args: placeholderArgs,
render: () => (
<Demo
initial={
new AllSetting({
tgBotEnable: true,
tgEnabledEvents:
'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
tgCpu: 70,
tgMemory: 75,
})
}
/>
),
};
@@ -10,7 +10,14 @@ const GROUPS: NotificationGroupConfig[] = [
icon: <CloudServerOutlined />, icon: <CloudServerOutlined />,
title: 'eventGroupOutbound', title: 'eventGroupOutbound',
events: [ events: [
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' }, {
key: 'outbound.down',
label: 'eventOutboundDown',
settingKey: 'outboundDownThreshold',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={1} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
),
},
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' }, { key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
], ],
}, },
@@ -37,16 +44,16 @@ const GROUPS: NotificationGroupConfig[] = [
key: 'cpu.high', key: 'cpu.high',
label: 'eventCPUHigh', label: 'eventCPUHigh',
settingKey: 'tgCpu', settingKey: 'tgCpu',
extra: ({ value, onChange }) => ( extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} /> <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
), ),
}, },
{ {
key: 'memory.high', key: 'memory.high',
label: 'eventMemoryHigh', label: 'eventMemoryHigh',
settingKey: 'tgMemory', settingKey: 'tgMemory',
extra: ({ value, onChange }) => ( extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} /> <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
), ),
}, },
], ],
@@ -4,7 +4,7 @@ export interface NotificationEventConfig {
key: string; key: string;
label: string; label: string;
settingKey: string; settingKey: string;
extra?: (props: { value: number; onChange: (v: number | null) => void }) => ReactNode; extra?: (props: { value: number; onChange: (v: number | null) => void; ariaLabel: string }) => ReactNode;
} }
export interface NotificationGroupConfig { export interface NotificationGroupConfig {
@@ -0,0 +1,138 @@
import { lazy, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Alert, Button, Card, Skeleton, Space, Switch, Tag, Typography } from 'antd';
import LazyMount from './LazyMount';
const meta = {
title: 'Utility/LazyMount',
component: LazyMount,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Mounts its children the first time `when` becomes true and keeps them mounted afterwards, wrapped in Suspense. The panel pairs it with React.lazy modal imports on heavy list pages so modals load on demand while their close animations still play.',
},
},
},
argTypes: {
when: { description: 'Children mount the first time this becomes true and stay mounted afterwards.' },
fallback: { description: 'Suspense fallback shown while a React.lazy child is still loading.' },
children: { description: 'Content to mount on demand, typically a lazily imported modal.' },
},
} satisfies Meta<typeof LazyMount>;
export default meta;
type Story = StoryObj<typeof meta>;
function MountBadge() {
const [mountedAt] = useState(() => new Date().toLocaleTimeString());
return <Tag color="green">mounted at {mountedAt}</Tag>;
}
function OnDemandDemo() {
const [visible, setVisible] = useState(false);
return (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Space>
<Switch checked={visible} onChange={setVisible} aria-label="Mount the client card" />
<Typography.Text>when = {String(visible)}</Typography.Text>
</Space>
<LazyMount when={visible}>
<Card size="small" title="alice@corp.example" style={{ maxWidth: 480 }}>
<Space orientation="vertical" size={4}>
<MountBadge />
<Typography.Text type="secondary">Traffic: 42.7 GB of 100 GB</Typography.Text>
<Typography.Text code copyable style={{ fontSize: 12 }}>
vless://b831381d-6324-4d53-ad4f-8cda48b30811@vpn.example.com:443?type=tcp&security=reality&fp=chrome&sni=yahoo.com#alice
</Typography.Text>
</Space>
</Card>
</LazyMount>
<Typography.Text type="secondary">
The card mounts the first time the switch turns on and stays mounted after turning it off; the mount time never changes.
</Typography.Text>
</Space>
);
}
const xrayConfigSnippet = JSON.stringify(
{
inbounds: [
{
tag: 'inbound-443',
protocol: 'vless',
port: 443,
settings: {
clients: [{ id: 'b831381d-6324-4d53-ad4f-8cda48b30811', email: 'alice@corp.example', flow: 'xtls-rprx-vision' }],
decryption: 'none',
},
streamSettings: { network: 'tcp', security: 'reality' },
},
],
},
null,
2,
);
function XrayConfigPreview() {
return (
<Card size="small" title="Generated xray config">
<pre style={{ margin: 0, overflowX: 'auto', fontSize: 12 }}>{xrayConfigSnippet}</pre>
</Card>
);
}
const SlowXrayConfigPreview = lazy(
() =>
new Promise<{ default: typeof XrayConfigPreview }>((resolve) => {
setTimeout(() => resolve({ default: XrayConfigPreview }), 1200);
}),
);
function LazyChildDemo() {
const [open, setOpen] = useState(false);
return (
<Space orientation="vertical" size="middle" style={{ width: '100%', maxWidth: 560 }}>
<Button type="primary" onClick={() => setOpen(true)} disabled={open}>
Load config preview
</Button>
<LazyMount when={open} fallback={<Skeleton active paragraph={{ rows: 4 }} />}>
<SlowXrayConfigPreview />
</LazyMount>
</Space>
);
}
const placeholderArgs = {
when: false,
children: null,
};
export const MountOnDemand: Story = {
args: placeholderArgs,
render: () => <OnDemandDemo />,
};
export const LazyChildWithFallback: Story = {
args: placeholderArgs,
render: () => <LazyChildDemo />,
};
export const MountedImmediately: Story = {
args: {
when: true,
children: (
<Alert
type="warning"
showIcon
title="Client depleted"
description="bob@corp.example used 100 GB of 100 GB and was disabled by the traffic job."
style={{ maxWidth: 480 }}
/>
),
},
};
@@ -0,0 +1,21 @@
import type { CSSProperties } from 'react';
export const SPEED_TAG_CLASS_NAME = 'table-speed-tag' as const;
export const SPEED_TAG_WIDTH = 200 as const;
export const SPEED_TABLE_CELL_INLINE_PADDING = 8 as const;
export const SPEED_TAG_STYLE = {
width: SPEED_TAG_WIDTH,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
whiteSpace: 'nowrap',
fontVariantNumeric: 'tabular-nums',
marginInlineEnd: 0,
boxSizing: 'border-box',
overflow: 'hidden',
textOverflow: 'ellipsis',
} as const satisfies CSSProperties;
export const SPEED_COLUMN_WIDTH = SPEED_TAG_WIDTH + SPEED_TABLE_CELL_INLINE_PADDING * 2;
@@ -9,7 +9,27 @@ const meta = {
title: 'Viz/Sparkline', title: 'Viz/Sparkline',
component: Sparkline, component: Sparkline,
tags: ['autodocs'], tags: ['autodocs'],
parameters: { layout: 'padded' }, parameters: {
layout: 'padded',
docs: {
description: {
component:
'Compact canvas line chart (uPlot) for CPU, memory, and traffic trends. Supports up to three series, optional axes/grid, a hover tooltip, min/max markers, reference lines, and light/dark theming.',
},
},
},
argTypes: {
data: { description: 'Primary series values, oldest to newest.' },
data2: { description: 'Optional second series (e.g. download vs upload).' },
data3: { description: 'Optional third series.' },
height: { description: 'Chart height in pixels.' },
name1: { description: 'Legend/tooltip label for the primary series.' },
name2: { description: 'Legend/tooltip label for the second series.' },
showAxes: { description: 'Render x/y axes and tick labels.' },
showGrid: { description: 'Draw horizontal grid lines.' },
showTooltip: { description: 'Show a value tooltip on hover.' },
extrema: { description: 'Highlight the min and max points (single-series only).' },
},
} satisfies Meta<typeof Sparkline>; } satisfies Meta<typeof Sparkline>;
export default meta; export default meta;
+17 -9
View File
@@ -48,6 +48,7 @@ interface SparklineProps {
yTickStep?: number; yTickStep?: number;
tickCountX?: number; tickCountX?: number;
showTooltip?: boolean; showTooltip?: boolean;
showLegend?: boolean;
valueMin?: number; valueMin?: number;
valueMax?: number | null; valueMax?: number | null;
yFormatter?: (v: number) => string; yFormatter?: (v: number) => string;
@@ -80,13 +81,23 @@ interface SparklineView {
extremaPoints: ExtremaResult | null; extremaPoints: ExtremaResult | null;
} }
function hexToRgba(hex: string, alpha: number): string { function hexToRgba(color: string, alpha: number): string {
let h = hex.trim(); 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.startsWith('#')) h = h.slice(1);
if (h.length === 3) h = h.split('').map((c) => c + c).join(''); 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); const int = Number.parseInt(h, 16);
if (Number.isNaN(int)) return hex; if (Number.isNaN(int)) return trimmed;
const r = (int >> 16) & 255; const r = (int >> 16) & 255;
const g = (int >> 8) & 255; const g = (int >> 8) & 255;
const b = int & 255; const b = int & 255;
@@ -129,6 +140,7 @@ export default function Sparkline(props: SparklineProps) {
yTickStep = 25, yTickStep = 25,
tickCountX = 4, tickCountX = 4,
showTooltip = false, showTooltip = false,
showLegend = true,
valueMin = 0, valueMin = 0,
valueMax = 100, valueMax = 100,
yFormatter = (v: number) => `${Math.round(v)}%`, yFormatter = (v: number) => `${Math.round(v)}%`,
@@ -542,10 +554,6 @@ export default function Sparkline(props: SparklineProps) {
); );
}, [points, hasSeries2, hasSeries3, valueMin, valueMax]); }, [points, hasSeries2, hasSeries3, valueMin, valueMax]);
useEffect(() => {
plotRef.current?.redraw(false);
});
useEffect(() => { useEffect(() => {
const redraw = () => plotRef.current?.redraw(false); const redraw = () => plotRef.current?.redraw(false);
const moBody = new MutationObserver(redraw); const moBody = new MutationObserver(redraw);
@@ -570,7 +578,7 @@ export default function Sparkline(props: SparklineProps) {
</span> </span>
</div> </div>
)} )}
{legendItems.length > 0 && ( {showLegend && legendItems.length > 0 && (
<div className="sparkline-legend" aria-hidden="true"> <div className="sparkline-legend" aria-hidden="true">
{legendItems.map((s) => ( {legendItems.map((s) => (
<span key={s.name} className="extrema-item" style={{ color: s.color }}> {s.name}</span> <span key={s.name} className="extrema-item" style={{ color: s.color }}> {s.name}</span>
+84 -1
View File
@@ -26,6 +26,7 @@ export const EXAMPLES: Record<string, unknown> = {
"ldapUserAttr": "", "ldapUserAttr": "",
"ldapUserFilter": "", "ldapUserFilter": "",
"ldapVlessField": "", "ldapVlessField": "",
"outboundDownThreshold": 1,
"pageSize": 0, "pageSize": 0,
"panelOutbound": "", "panelOutbound": "",
"remarkTemplate": "", "remarkTemplate": "",
@@ -35,6 +36,8 @@ export const EXAMPLES: Record<string, unknown> = {
"smtpEnable": false, "smtpEnable": false,
"smtpEnabledEvents": "", "smtpEnabledEvents": "",
"smtpEncryptionType": "", "smtpEncryptionType": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpHost": "", "smtpHost": "",
"smtpMemory": 0, "smtpMemory": 0,
"smtpPassword": "", "smtpPassword": "",
@@ -43,11 +46,13 @@ export const EXAMPLES: Record<string, unknown> = {
"smtpUsername": "", "smtpUsername": "",
"subAnnounce": "", "subAnnounce": "",
"subCertFile": "", "subCertFile": "",
"subClashAutoDetect": false,
"subClashEnable": false, "subClashEnable": false,
"subClashEnableRouting": false, "subClashEnableRouting": false,
"subClashPath": "", "subClashPath": "",
"subClashRules": "", "subClashRules": "",
"subClashURI": "", "subClashURI": "",
"subClashUserAgentRegex": "",
"subDomain": "", "subDomain": "",
"subEnable": false, "subEnable": false,
"subEnableRouting": false, "subEnableRouting": false,
@@ -55,18 +60,22 @@ export const EXAMPLES: Record<string, unknown> = {
"subHideSettings": false, "subHideSettings": false,
"subIncyEnableRouting": false, "subIncyEnableRouting": false,
"subIncyRoutingRules": "", "subIncyRoutingRules": "",
"subJsonAlwaysArray": false,
"subJsonAutoDetect": false,
"subJsonEnable": false, "subJsonEnable": false,
"subJsonFinalMask": "", "subJsonFinalMask": "",
"subJsonMux": "", "subJsonMux": "",
"subJsonPath": "", "subJsonPath": "",
"subJsonRules": "", "subJsonRules": "",
"subJsonURI": "", "subJsonURI": "",
"subJsonUserAgentRegex": "",
"subKeyFile": "", "subKeyFile": "",
"subListen": "", "subListen": "",
"subPath": "", "subPath": "",
"subPort": 1, "subPort": 1,
"subProfileUrl": "", "subProfileUrl": "",
"subRoutingRules": "", "subRoutingRules": "",
"subShowIdentityOnAllLinks": false,
"subSupportUrl": "", "subSupportUrl": "",
"subThemeDir": "", "subThemeDir": "",
"subTitle": "", "subTitle": "",
@@ -129,6 +138,7 @@ export const EXAMPLES: Record<string, unknown> = {
"ldapUserAttr": "", "ldapUserAttr": "",
"ldapUserFilter": "", "ldapUserFilter": "",
"ldapVlessField": "", "ldapVlessField": "",
"outboundDownThreshold": 1,
"pageSize": 0, "pageSize": 0,
"panelOutbound": "", "panelOutbound": "",
"remarkTemplate": "", "remarkTemplate": "",
@@ -138,6 +148,8 @@ export const EXAMPLES: Record<string, unknown> = {
"smtpEnable": false, "smtpEnable": false,
"smtpEnabledEvents": "", "smtpEnabledEvents": "",
"smtpEncryptionType": "", "smtpEncryptionType": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpHost": "", "smtpHost": "",
"smtpMemory": 0, "smtpMemory": 0,
"smtpPassword": "", "smtpPassword": "",
@@ -146,11 +158,13 @@ export const EXAMPLES: Record<string, unknown> = {
"smtpUsername": "", "smtpUsername": "",
"subAnnounce": "", "subAnnounce": "",
"subCertFile": "", "subCertFile": "",
"subClashAutoDetect": false,
"subClashEnable": false, "subClashEnable": false,
"subClashEnableRouting": false, "subClashEnableRouting": false,
"subClashPath": "", "subClashPath": "",
"subClashRules": "", "subClashRules": "",
"subClashURI": "", "subClashURI": "",
"subClashUserAgentRegex": "",
"subDomain": "", "subDomain": "",
"subEnable": false, "subEnable": false,
"subEnableRouting": false, "subEnableRouting": false,
@@ -158,18 +172,22 @@ export const EXAMPLES: Record<string, unknown> = {
"subHideSettings": false, "subHideSettings": false,
"subIncyEnableRouting": false, "subIncyEnableRouting": false,
"subIncyRoutingRules": "", "subIncyRoutingRules": "",
"subJsonAlwaysArray": false,
"subJsonAutoDetect": false,
"subJsonEnable": false, "subJsonEnable": false,
"subJsonFinalMask": "", "subJsonFinalMask": "",
"subJsonMux": "", "subJsonMux": "",
"subJsonPath": "", "subJsonPath": "",
"subJsonRules": "", "subJsonRules": "",
"subJsonURI": "", "subJsonURI": "",
"subJsonUserAgentRegex": "",
"subKeyFile": "", "subKeyFile": "",
"subListen": "", "subListen": "",
"subPath": "", "subPath": "",
"subPort": 1, "subPort": 1,
"subProfileUrl": "", "subProfileUrl": "",
"subRoutingRules": "", "subRoutingRules": "",
"subShowIdentityOnAllLinks": false,
"subSupportUrl": "", "subSupportUrl": "",
"subThemeDir": "", "subThemeDir": "",
"subTitle": "", "subTitle": "",
@@ -432,6 +450,7 @@ export const EXAMPLES: Record<string, unknown> = {
"tag": "in-443-tcp", "tag": "in-443-tcp",
"total": 0, "total": 0,
"trafficReset": "never", "trafficReset": "never",
"trafficResetDay": 1,
"up": 0 "up": 0
}, },
"InboundClientIps": { "InboundClientIps": {
@@ -478,7 +497,6 @@ export const EXAMPLES: Record<string, unknown> = {
"activeCount": 23, "activeCount": 23,
"address": "node1.example.com", "address": "node1.example.com",
"allowPrivateAddress": false, "allowPrivateAddress": false,
"apiToken": "abcdef0123456789",
"basePath": "/", "basePath": "/",
"clientCount": 27, "clientCount": 27,
"configDirty": false, "configDirty": false,
@@ -519,6 +537,71 @@ export const EXAMPLES: Record<string, unknown> = {
"xrayState": "", "xrayState": "",
"xrayVersion": "25.10.31" "xrayVersion": "25.10.31"
}, },
"NodeMutationRequest": {
"address": "",
"allowPrivateAddress": false,
"apiToken": null,
"basePath": "",
"clearApiToken": false,
"enable": false,
"id": 0,
"inboundSyncMode": "all",
"inboundTags": [
""
],
"name": "",
"outboundTag": "",
"pinnedCertSha256": "",
"port": 1,
"remark": "",
"scheme": "http",
"tlsVerifyMode": "verify"
},
"NodeView": {
"activeCount": 20,
"address": "node.example.com",
"allowPrivateAddress": false,
"basePath": "/",
"clientCount": 25,
"configDirty": false,
"configDirtyAt": 0,
"cpuPct": 12.5,
"createdAt": 1700000000,
"depletedCount": 1,
"disabledCount": 2,
"enable": true,
"guid": "node-guid",
"hasApiToken": true,
"id": 1,
"inboundCount": 3,
"inboundSyncMode": "all",
"inboundTags": [
"in-443-tcp"
],
"lastError": "",
"lastHeartbeat": 1700000000,
"latencyMs": 42,
"memPct": 45.2,
"name": "edge-1",
"netDown": 1048576,
"netUp": 2097152,
"onlineCount": 5,
"outboundTag": "direct",
"panelVersion": "v3.x.x",
"parentGuid": "",
"pinnedCertSha256": "",
"port": 2053,
"remark": "Primary edge",
"scheme": "https",
"status": "online",
"tlsVerifyMode": "verify",
"transitive": false,
"updatedAt": 1700003600,
"uptimeSecs": 86400,
"xrayError": "",
"xrayState": "running",
"xrayVersion": "25.10.31"
},
"OutboundTraffics": { "OutboundTraffics": {
"down": 0, "down": 0,
"id": 0, "id": 0,
+393 -5
View File
@@ -83,6 +83,11 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapVlessField": { "ldapVlessField": {
"type": "string" "type": "string"
}, },
"outboundDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"pageSize": { "pageSize": {
"maximum": 1000, "maximum": 1000,
"minimum": 0, "minimum": 0,
@@ -116,6 +121,12 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpEncryptionType": { "smtpEncryptionType": {
"type": "string" "type": "string"
}, },
"smtpFrom": {
"type": "string"
},
"smtpFromName": {
"type": "string"
},
"smtpHost": { "smtpHost": {
"type": "string" "type": "string"
}, },
@@ -144,6 +155,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subCertFile": { "subCertFile": {
"type": "string" "type": "string"
}, },
"subClashAutoDetect": {
"type": "boolean"
},
"subClashEnable": { "subClashEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -159,6 +173,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subClashURI": { "subClashURI": {
"type": "string" "type": "string"
}, },
"subClashUserAgentRegex": {
"type": "string"
},
"subDomain": { "subDomain": {
"type": "string" "type": "string"
}, },
@@ -180,6 +197,12 @@ export const SCHEMAS: Record<string, unknown> = {
"subIncyRoutingRules": { "subIncyRoutingRules": {
"type": "string" "type": "string"
}, },
"subJsonAlwaysArray": {
"type": "boolean"
},
"subJsonAutoDetect": {
"type": "boolean"
},
"subJsonEnable": { "subJsonEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -198,6 +221,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonURI": { "subJsonURI": {
"type": "string" "type": "string"
}, },
"subJsonUserAgentRegex": {
"type": "string"
},
"subKeyFile": { "subKeyFile": {
"type": "string" "type": "string"
}, },
@@ -218,6 +244,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subRoutingRules": { "subRoutingRules": {
"type": "string" "type": "string"
}, },
"subShowIdentityOnAllLinks": {
"type": "boolean"
},
"subSupportUrl": { "subSupportUrl": {
"type": "string" "type": "string"
}, },
@@ -340,6 +369,7 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapUserAttr", "ldapUserAttr",
"ldapUserFilter", "ldapUserFilter",
"ldapVlessField", "ldapVlessField",
"outboundDownThreshold",
"pageSize", "pageSize",
"panelOutbound", "panelOutbound",
"remarkTemplate", "remarkTemplate",
@@ -349,6 +379,8 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpEnable", "smtpEnable",
"smtpEnabledEvents", "smtpEnabledEvents",
"smtpEncryptionType", "smtpEncryptionType",
"smtpFrom",
"smtpFromName",
"smtpHost", "smtpHost",
"smtpMemory", "smtpMemory",
"smtpPassword", "smtpPassword",
@@ -357,11 +389,13 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpUsername", "smtpUsername",
"subAnnounce", "subAnnounce",
"subCertFile", "subCertFile",
"subClashAutoDetect",
"subClashEnable", "subClashEnable",
"subClashEnableRouting", "subClashEnableRouting",
"subClashPath", "subClashPath",
"subClashRules", "subClashRules",
"subClashURI", "subClashURI",
"subClashUserAgentRegex",
"subDomain", "subDomain",
"subEnable", "subEnable",
"subEnableRouting", "subEnableRouting",
@@ -369,18 +403,22 @@ export const SCHEMAS: Record<string, unknown> = {
"subHideSettings", "subHideSettings",
"subIncyEnableRouting", "subIncyEnableRouting",
"subIncyRoutingRules", "subIncyRoutingRules",
"subJsonAlwaysArray",
"subJsonAutoDetect",
"subJsonEnable", "subJsonEnable",
"subJsonFinalMask", "subJsonFinalMask",
"subJsonMux", "subJsonMux",
"subJsonPath", "subJsonPath",
"subJsonRules", "subJsonRules",
"subJsonURI", "subJsonURI",
"subJsonUserAgentRegex",
"subKeyFile", "subKeyFile",
"subListen", "subListen",
"subPath", "subPath",
"subPort", "subPort",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl", "subSupportUrl",
"subThemeDir", "subThemeDir",
"subTitle", "subTitle",
@@ -516,6 +554,11 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapVlessField": { "ldapVlessField": {
"type": "string" "type": "string"
}, },
"outboundDownThreshold": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"pageSize": { "pageSize": {
"maximum": 1000, "maximum": 1000,
"minimum": 0, "minimum": 0,
@@ -549,6 +592,12 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpEncryptionType": { "smtpEncryptionType": {
"type": "string" "type": "string"
}, },
"smtpFrom": {
"type": "string"
},
"smtpFromName": {
"type": "string"
},
"smtpHost": { "smtpHost": {
"type": "string" "type": "string"
}, },
@@ -577,6 +626,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subCertFile": { "subCertFile": {
"type": "string" "type": "string"
}, },
"subClashAutoDetect": {
"type": "boolean"
},
"subClashEnable": { "subClashEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -592,6 +644,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subClashURI": { "subClashURI": {
"type": "string" "type": "string"
}, },
"subClashUserAgentRegex": {
"type": "string"
},
"subDomain": { "subDomain": {
"type": "string" "type": "string"
}, },
@@ -613,6 +668,12 @@ export const SCHEMAS: Record<string, unknown> = {
"subIncyRoutingRules": { "subIncyRoutingRules": {
"type": "string" "type": "string"
}, },
"subJsonAlwaysArray": {
"type": "boolean"
},
"subJsonAutoDetect": {
"type": "boolean"
},
"subJsonEnable": { "subJsonEnable": {
"type": "boolean" "type": "boolean"
}, },
@@ -631,6 +692,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonURI": { "subJsonURI": {
"type": "string" "type": "string"
}, },
"subJsonUserAgentRegex": {
"type": "string"
},
"subKeyFile": { "subKeyFile": {
"type": "string" "type": "string"
}, },
@@ -651,6 +715,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subRoutingRules": { "subRoutingRules": {
"type": "string" "type": "string"
}, },
"subShowIdentityOnAllLinks": {
"type": "boolean"
},
"subSupportUrl": { "subSupportUrl": {
"type": "string" "type": "string"
}, },
@@ -780,6 +847,7 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapUserAttr", "ldapUserAttr",
"ldapUserFilter", "ldapUserFilter",
"ldapVlessField", "ldapVlessField",
"outboundDownThreshold",
"pageSize", "pageSize",
"panelOutbound", "panelOutbound",
"remarkTemplate", "remarkTemplate",
@@ -789,6 +857,8 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpEnable", "smtpEnable",
"smtpEnabledEvents", "smtpEnabledEvents",
"smtpEncryptionType", "smtpEncryptionType",
"smtpFrom",
"smtpFromName",
"smtpHost", "smtpHost",
"smtpMemory", "smtpMemory",
"smtpPassword", "smtpPassword",
@@ -797,11 +867,13 @@ export const SCHEMAS: Record<string, unknown> = {
"smtpUsername", "smtpUsername",
"subAnnounce", "subAnnounce",
"subCertFile", "subCertFile",
"subClashAutoDetect",
"subClashEnable", "subClashEnable",
"subClashEnableRouting", "subClashEnableRouting",
"subClashPath", "subClashPath",
"subClashRules", "subClashRules",
"subClashURI", "subClashURI",
"subClashUserAgentRegex",
"subDomain", "subDomain",
"subEnable", "subEnable",
"subEnableRouting", "subEnableRouting",
@@ -809,18 +881,22 @@ export const SCHEMAS: Record<string, unknown> = {
"subHideSettings", "subHideSettings",
"subIncyEnableRouting", "subIncyEnableRouting",
"subIncyRoutingRules", "subIncyRoutingRules",
"subJsonAlwaysArray",
"subJsonAutoDetect",
"subJsonEnable", "subJsonEnable",
"subJsonFinalMask", "subJsonFinalMask",
"subJsonMux", "subJsonMux",
"subJsonPath", "subJsonPath",
"subJsonRules", "subJsonRules",
"subJsonURI", "subJsonURI",
"subJsonUserAgentRegex",
"subKeyFile", "subKeyFile",
"subListen", "subListen",
"subPath", "subPath",
"subPort", "subPort",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks",
"subSupportUrl", "subSupportUrl",
"subThemeDir", "subThemeDir",
"subTitle", "subTitle",
@@ -1766,6 +1842,13 @@ export const SCHEMAS: Record<string, unknown> = {
], ],
"type": "string" "type": "string"
}, },
"trafficResetDay": {
"description": "Day of month for monthly traffic resets",
"example": 1,
"maximum": 31,
"minimum": 1,
"type": "integer"
},
"up": { "up": {
"description": "Upload traffic in bytes", "description": "Upload traffic in bytes",
"format": "int64", "format": "int64",
@@ -1792,6 +1875,7 @@ export const SCHEMAS: Record<string, unknown> = {
"tag", "tag",
"total", "total",
"trafficReset", "trafficReset",
"trafficResetDay",
"up" "up"
], ],
"type": "object" "type": "object"
@@ -1964,10 +2048,6 @@ export const SCHEMAS: Record<string, unknown> = {
"allowPrivateAddress": { "allowPrivateAddress": {
"type": "boolean" "type": "boolean"
}, },
"apiToken": {
"example": "abcdef0123456789",
"type": "string"
},
"basePath": { "basePath": {
"example": "/", "example": "/",
"type": "string" "type": "string"
@@ -2138,7 +2218,6 @@ export const SCHEMAS: Record<string, unknown> = {
"activeCount", "activeCount",
"address", "address",
"allowPrivateAddress", "allowPrivateAddress",
"apiToken",
"basePath", "basePath",
"clientCount", "clientCount",
"configDirty", "configDirty",
@@ -2177,6 +2256,315 @@ export const SCHEMAS: Record<string, unknown> = {
], ],
"type": "object" "type": "object"
}, },
"NodeMutationRequest": {
"description": "NodeMutationRequest is the node write/probe contract. ApiToken is accepted\nonly as input. On update, nil means keep the stored token; replacement and\nclearing are explicit and mutually exclusive.",
"properties": {
"address": {
"type": "string"
},
"allowPrivateAddress": {
"type": "boolean"
},
"apiToken": {
"nullable": true,
"type": "string"
},
"basePath": {
"type": "string"
},
"clearApiToken": {
"type": "boolean"
},
"enable": {
"type": "boolean"
},
"id": {
"type": "integer"
},
"inboundSyncMode": {
"enum": [
"all",
"selected"
],
"type": "string"
},
"inboundTags": {
"items": {
"type": "string"
},
"type": "array"
},
"name": {
"type": "string"
},
"outboundTag": {
"type": "string"
},
"pinnedCertSha256": {
"type": "string"
},
"port": {
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"remark": {
"type": "string"
},
"scheme": {
"enum": [
"http",
"https"
],
"type": "string"
},
"tlsVerifyMode": {
"enum": [
"verify",
"skip",
"pin",
"mtls"
],
"type": "string"
}
},
"required": [
"address",
"allowPrivateAddress",
"basePath",
"enable",
"id",
"inboundSyncMode",
"inboundTags",
"name",
"outboundTag",
"pinnedCertSha256",
"port",
"remark",
"scheme",
"tlsVerifyMode"
],
"type": "object"
},
"NodeView": {
"description": "NodeView is the browser/API read contract for nodes. Credentials are\nwrite-only: responses expose only whether a node has a token configured.",
"properties": {
"activeCount": {
"example": 20,
"type": "integer"
},
"address": {
"example": "node.example.com",
"type": "string"
},
"allowPrivateAddress": {
"example": false,
"type": "boolean"
},
"basePath": {
"example": "/",
"type": "string"
},
"clientCount": {
"example": 25,
"type": "integer"
},
"configDirty": {
"example": false,
"type": "boolean"
},
"configDirtyAt": {
"example": 0,
"format": "int64",
"type": "integer"
},
"cpuPct": {
"example": 12.5,
"type": "number"
},
"createdAt": {
"example": 1700000000,
"format": "int64",
"type": "integer"
},
"depletedCount": {
"example": 1,
"type": "integer"
},
"disabledCount": {
"example": 2,
"type": "integer"
},
"enable": {
"example": true,
"type": "boolean"
},
"guid": {
"example": "node-guid",
"type": "string"
},
"hasApiToken": {
"example": true,
"type": "boolean"
},
"id": {
"example": 1,
"type": "integer"
},
"inboundCount": {
"example": 3,
"type": "integer"
},
"inboundSyncMode": {
"example": "all",
"type": "string"
},
"inboundTags": {
"example": [
"in-443-tcp"
],
"items": {
"type": "string"
},
"type": "array"
},
"lastError": {
"type": "string"
},
"lastHeartbeat": {
"example": 1700000000,
"format": "int64",
"type": "integer"
},
"latencyMs": {
"example": 42,
"type": "integer"
},
"memPct": {
"example": 45.2,
"type": "number"
},
"name": {
"example": "edge-1",
"type": "string"
},
"netDown": {
"example": 1048576,
"format": "int64",
"type": "integer"
},
"netUp": {
"example": 2097152,
"format": "int64",
"type": "integer"
},
"onlineCount": {
"example": 5,
"type": "integer"
},
"outboundTag": {
"example": "direct",
"type": "string"
},
"panelVersion": {
"example": "v3.x.x",
"type": "string"
},
"parentGuid": {
"type": "string"
},
"pinnedCertSha256": {
"type": "string"
},
"port": {
"example": 2053,
"type": "integer"
},
"remark": {
"example": "Primary edge",
"type": "string"
},
"scheme": {
"example": "https",
"type": "string"
},
"status": {
"example": "online",
"type": "string"
},
"tlsVerifyMode": {
"example": "verify",
"type": "string"
},
"transitive": {
"example": false,
"type": "boolean"
},
"updatedAt": {
"example": 1700003600,
"format": "int64",
"type": "integer"
},
"uptimeSecs": {
"example": 86400,
"format": "int64",
"type": "integer"
},
"xrayError": {
"type": "string"
},
"xrayState": {
"example": "running",
"type": "string"
},
"xrayVersion": {
"example": "25.10.31",
"type": "string"
}
},
"required": [
"activeCount",
"address",
"allowPrivateAddress",
"basePath",
"clientCount",
"configDirty",
"configDirtyAt",
"cpuPct",
"createdAt",
"depletedCount",
"disabledCount",
"enable",
"guid",
"hasApiToken",
"id",
"inboundCount",
"inboundSyncMode",
"inboundTags",
"lastError",
"lastHeartbeat",
"latencyMs",
"memPct",
"name",
"netDown",
"netUp",
"onlineCount",
"outboundTag",
"panelVersion",
"pinnedCertSha256",
"port",
"remark",
"scheme",
"status",
"tlsVerifyMode",
"updatedAt",
"uptimeSecs",
"xrayError",
"xrayState",
"xrayVersion"
],
"type": "object"
},
"OutboundTraffics": { "OutboundTraffics": {
"description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.", "description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.",
"properties": { "properties": {
+82 -1
View File
@@ -32,6 +32,7 @@ export interface AllSetting {
ldapUserAttr: string; ldapUserAttr: string;
ldapUserFilter: string; ldapUserFilter: string;
ldapVlessField: string; ldapVlessField: string;
outboundDownThreshold: number;
pageSize: number; pageSize: number;
panelOutbound: string; panelOutbound: string;
remarkTemplate: string; remarkTemplate: string;
@@ -41,6 +42,8 @@ export interface AllSetting {
smtpEnable: boolean; smtpEnable: boolean;
smtpEnabledEvents: string; smtpEnabledEvents: string;
smtpEncryptionType: string; smtpEncryptionType: string;
smtpFrom: string;
smtpFromName: string;
smtpHost: string; smtpHost: string;
smtpMemory: number; smtpMemory: number;
smtpPassword: string; smtpPassword: string;
@@ -49,11 +52,13 @@ export interface AllSetting {
smtpUsername: string; smtpUsername: string;
subAnnounce: string; subAnnounce: string;
subCertFile: string; subCertFile: string;
subClashAutoDetect: boolean;
subClashEnable: boolean; subClashEnable: boolean;
subClashEnableRouting: boolean; subClashEnableRouting: boolean;
subClashPath: string; subClashPath: string;
subClashRules: string; subClashRules: string;
subClashURI: string; subClashURI: string;
subClashUserAgentRegex: string;
subDomain: string; subDomain: string;
subEnable: boolean; subEnable: boolean;
subEnableRouting: boolean; subEnableRouting: boolean;
@@ -61,18 +66,22 @@ export interface AllSetting {
subHideSettings: boolean; subHideSettings: boolean;
subIncyEnableRouting: boolean; subIncyEnableRouting: boolean;
subIncyRoutingRules: string; subIncyRoutingRules: string;
subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean;
subJsonEnable: boolean; subJsonEnable: boolean;
subJsonFinalMask: string; subJsonFinalMask: string;
subJsonMux: string; subJsonMux: string;
subJsonPath: string; subJsonPath: string;
subJsonRules: string; subJsonRules: string;
subJsonURI: string; subJsonURI: string;
subJsonUserAgentRegex: string;
subKeyFile: string; subKeyFile: string;
subListen: string; subListen: string;
subPath: string; subPath: string;
subPort: number; subPort: number;
subProfileUrl: string; subProfileUrl: string;
subRoutingRules: string; subRoutingRules: string;
subShowIdentityOnAllLinks: boolean;
subSupportUrl: string; subSupportUrl: string;
subThemeDir: string; subThemeDir: string;
subTitle: string; subTitle: string;
@@ -136,6 +145,7 @@ export interface AllSettingView {
ldapUserAttr: string; ldapUserAttr: string;
ldapUserFilter: string; ldapUserFilter: string;
ldapVlessField: string; ldapVlessField: string;
outboundDownThreshold: number;
pageSize: number; pageSize: number;
panelOutbound: string; panelOutbound: string;
remarkTemplate: string; remarkTemplate: string;
@@ -145,6 +155,8 @@ export interface AllSettingView {
smtpEnable: boolean; smtpEnable: boolean;
smtpEnabledEvents: string; smtpEnabledEvents: string;
smtpEncryptionType: string; smtpEncryptionType: string;
smtpFrom: string;
smtpFromName: string;
smtpHost: string; smtpHost: string;
smtpMemory: number; smtpMemory: number;
smtpPassword: string; smtpPassword: string;
@@ -153,11 +165,13 @@ export interface AllSettingView {
smtpUsername: string; smtpUsername: string;
subAnnounce: string; subAnnounce: string;
subCertFile: string; subCertFile: string;
subClashAutoDetect: boolean;
subClashEnable: boolean; subClashEnable: boolean;
subClashEnableRouting: boolean; subClashEnableRouting: boolean;
subClashPath: string; subClashPath: string;
subClashRules: string; subClashRules: string;
subClashURI: string; subClashURI: string;
subClashUserAgentRegex: string;
subDomain: string; subDomain: string;
subEnable: boolean; subEnable: boolean;
subEnableRouting: boolean; subEnableRouting: boolean;
@@ -165,18 +179,22 @@ export interface AllSettingView {
subHideSettings: boolean; subHideSettings: boolean;
subIncyEnableRouting: boolean; subIncyEnableRouting: boolean;
subIncyRoutingRules: string; subIncyRoutingRules: string;
subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean;
subJsonEnable: boolean; subJsonEnable: boolean;
subJsonFinalMask: string; subJsonFinalMask: string;
subJsonMux: string; subJsonMux: string;
subJsonPath: string; subJsonPath: string;
subJsonRules: string; subJsonRules: string;
subJsonURI: string; subJsonURI: string;
subJsonUserAgentRegex: string;
subKeyFile: string; subKeyFile: string;
subListen: string; subListen: string;
subPath: string; subPath: string;
subPort: number; subPort: number;
subProfileUrl: string; subProfileUrl: string;
subRoutingRules: string; subRoutingRules: string;
subShowIdentityOnAllLinks: boolean;
subSupportUrl: string; subSupportUrl: string;
subThemeDir: string; subThemeDir: string;
subTitle: string; subTitle: string;
@@ -410,6 +428,7 @@ export interface Inbound {
tag: string; tag: string;
total: number; total: number;
trafficReset: string; trafficReset: string;
trafficResetDay: number;
up: number; up: number;
} }
@@ -461,7 +480,6 @@ export interface Node {
activeCount: number; activeCount: number;
address: string; address: string;
allowPrivateAddress: boolean; allowPrivateAddress: boolean;
apiToken: string;
basePath: string; basePath: string;
clientCount: number; clientCount: number;
configDirty: boolean; configDirty: boolean;
@@ -501,6 +519,69 @@ export interface Node {
xrayVersion: string; xrayVersion: string;
} }
export interface NodeMutationRequest {
address: string;
allowPrivateAddress: boolean;
apiToken?: string | null;
basePath: string;
clearApiToken?: boolean;
enable: boolean;
id: number;
inboundSyncMode: string;
inboundTags: string[];
name: string;
outboundTag: string;
pinnedCertSha256: string;
port: number;
remark: string;
scheme: string;
tlsVerifyMode: string;
}
export interface NodeView {
activeCount: number;
address: string;
allowPrivateAddress: boolean;
basePath: string;
clientCount: number;
configDirty: boolean;
configDirtyAt: number;
cpuPct: number;
createdAt: number;
depletedCount: number;
disabledCount: number;
enable: boolean;
guid: string;
hasApiToken: boolean;
id: number;
inboundCount: number;
inboundSyncMode: string;
inboundTags: string[];
lastError: string;
lastHeartbeat: number;
latencyMs: number;
memPct: number;
name: string;
netDown: number;
netUp: number;
onlineCount: number;
outboundTag: string;
panelVersion: string;
parentGuid?: string;
pinnedCertSha256: string;
port: number;
remark: string;
scheme: string;
status: string;
tlsVerifyMode: string;
transitive?: boolean;
updatedAt: number;
uptimeSecs: number;
xrayError: string;
xrayState: string;
xrayVersion: string;
}
export interface OutboundTraffics { export interface OutboundTraffics {
down: number; down: number;
id: number; id: number;
+84 -1
View File
@@ -44,6 +44,7 @@ export const AllSettingSchema = z.object({
ldapUserAttr: z.string(), ldapUserAttr: z.string(),
ldapUserFilter: z.string(), ldapUserFilter: z.string(),
ldapVlessField: z.string(), ldapVlessField: z.string(),
outboundDownThreshold: z.number().int().min(1).max(100),
pageSize: z.number().int().min(0).max(1000), pageSize: z.number().int().min(0).max(1000),
panelOutbound: z.string(), panelOutbound: z.string(),
remarkTemplate: z.string(), remarkTemplate: z.string(),
@@ -53,6 +54,8 @@ export const AllSettingSchema = z.object({
smtpEnable: z.boolean(), smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(), smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(), smtpEncryptionType: z.string(),
smtpFrom: z.string(),
smtpFromName: z.string(),
smtpHost: z.string(), smtpHost: z.string(),
smtpMemory: z.number().int().min(0).max(100), smtpMemory: z.number().int().min(0).max(100),
smtpPassword: z.string(), smtpPassword: z.string(),
@@ -61,11 +64,13 @@ export const AllSettingSchema = z.object({
smtpUsername: z.string(), smtpUsername: z.string(),
subAnnounce: z.string(), subAnnounce: z.string(),
subCertFile: z.string(), subCertFile: z.string(),
subClashAutoDetect: z.boolean(),
subClashEnable: z.boolean(), subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(), subClashEnableRouting: z.boolean(),
subClashPath: z.string(), subClashPath: z.string(),
subClashRules: z.string(), subClashRules: z.string(),
subClashURI: z.string(), subClashURI: z.string(),
subClashUserAgentRegex: z.string(),
subDomain: z.string(), subDomain: z.string(),
subEnable: z.boolean(), subEnable: z.boolean(),
subEnableRouting: z.boolean(), subEnableRouting: z.boolean(),
@@ -73,18 +78,22 @@ export const AllSettingSchema = z.object({
subHideSettings: z.boolean(), subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(), subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(), subIncyRoutingRules: z.string(),
subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(),
subJsonEnable: z.boolean(), subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(), subJsonFinalMask: z.string(),
subJsonMux: z.string(), subJsonMux: z.string(),
subJsonPath: z.string(), subJsonPath: z.string(),
subJsonRules: z.string(), subJsonRules: z.string(),
subJsonURI: z.string(), subJsonURI: z.string(),
subJsonUserAgentRegex: z.string(),
subKeyFile: z.string(), subKeyFile: z.string(),
subListen: z.string(), subListen: z.string(),
subPath: z.string(), subPath: z.string(),
subPort: z.number().int().min(1).max(65535), subPort: z.number().int().min(1).max(65535),
subProfileUrl: z.string(), subProfileUrl: z.string(),
subRoutingRules: z.string(), subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(),
subSupportUrl: z.string(), subSupportUrl: z.string(),
subThemeDir: z.string(), subThemeDir: z.string(),
subTitle: z.string(), subTitle: z.string(),
@@ -149,6 +158,7 @@ export const AllSettingViewSchema = z.object({
ldapUserAttr: z.string(), ldapUserAttr: z.string(),
ldapUserFilter: z.string(), ldapUserFilter: z.string(),
ldapVlessField: z.string(), ldapVlessField: z.string(),
outboundDownThreshold: z.number().int().min(1).max(100),
pageSize: z.number().int().min(0).max(1000), pageSize: z.number().int().min(0).max(1000),
panelOutbound: z.string(), panelOutbound: z.string(),
remarkTemplate: z.string(), remarkTemplate: z.string(),
@@ -158,6 +168,8 @@ export const AllSettingViewSchema = z.object({
smtpEnable: z.boolean(), smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(), smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(), smtpEncryptionType: z.string(),
smtpFrom: z.string(),
smtpFromName: z.string(),
smtpHost: z.string(), smtpHost: z.string(),
smtpMemory: z.number().int().min(0).max(100), smtpMemory: z.number().int().min(0).max(100),
smtpPassword: z.string(), smtpPassword: z.string(),
@@ -166,11 +178,13 @@ export const AllSettingViewSchema = z.object({
smtpUsername: z.string(), smtpUsername: z.string(),
subAnnounce: z.string(), subAnnounce: z.string(),
subCertFile: z.string(), subCertFile: z.string(),
subClashAutoDetect: z.boolean(),
subClashEnable: z.boolean(), subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(), subClashEnableRouting: z.boolean(),
subClashPath: z.string(), subClashPath: z.string(),
subClashRules: z.string(), subClashRules: z.string(),
subClashURI: z.string(), subClashURI: z.string(),
subClashUserAgentRegex: z.string(),
subDomain: z.string(), subDomain: z.string(),
subEnable: z.boolean(), subEnable: z.boolean(),
subEnableRouting: z.boolean(), subEnableRouting: z.boolean(),
@@ -178,18 +192,22 @@ export const AllSettingViewSchema = z.object({
subHideSettings: z.boolean(), subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(), subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(), subIncyRoutingRules: z.string(),
subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(),
subJsonEnable: z.boolean(), subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(), subJsonFinalMask: z.string(),
subJsonMux: z.string(), subJsonMux: z.string(),
subJsonPath: z.string(), subJsonPath: z.string(),
subJsonRules: z.string(), subJsonRules: z.string(),
subJsonURI: z.string(), subJsonURI: z.string(),
subJsonUserAgentRegex: z.string(),
subKeyFile: z.string(), subKeyFile: z.string(),
subListen: z.string(), subListen: z.string(),
subPath: z.string(), subPath: z.string(),
subPort: z.number().int().min(1).max(65535), subPort: z.number().int().min(1).max(65535),
subProfileUrl: z.string(), subProfileUrl: z.string(),
subRoutingRules: z.string(), subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(),
subSupportUrl: z.string(), subSupportUrl: z.string(),
subThemeDir: z.string(), subThemeDir: z.string(),
subTitle: z.string(), subTitle: z.string(),
@@ -435,6 +453,7 @@ export const InboundSchema = z.object({
tag: z.string(), tag: z.string(),
total: z.number().int(), total: z.number().int(),
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']), trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
trafficResetDay: z.number().int().min(1).max(31),
up: z.number().int(), up: z.number().int(),
}); });
export type Inbound = z.infer<typeof InboundSchema>; export type Inbound = z.infer<typeof InboundSchema>;
@@ -491,7 +510,6 @@ export const NodeSchema = z.object({
activeCount: z.number().int(), activeCount: z.number().int(),
address: z.string(), address: z.string(),
allowPrivateAddress: z.boolean(), allowPrivateAddress: z.boolean(),
apiToken: z.string(),
basePath: z.string(), basePath: z.string(),
clientCount: z.number().int(), clientCount: z.number().int(),
configDirty: z.boolean(), configDirty: z.boolean(),
@@ -532,6 +550,71 @@ export const NodeSchema = z.object({
}); });
export type Node = z.infer<typeof NodeSchema>; export type Node = z.infer<typeof NodeSchema>;
export const NodeMutationRequestSchema = z.object({
address: z.string(),
allowPrivateAddress: z.boolean(),
apiToken: z.string().nullable().optional(),
basePath: z.string(),
clearApiToken: z.boolean().optional(),
enable: z.boolean(),
id: z.number().int(),
inboundSyncMode: z.enum(['all', 'selected']),
inboundTags: z.array(z.string()),
name: z.string(),
outboundTag: z.string(),
pinnedCertSha256: z.string(),
port: z.number().int().min(1).max(65535),
remark: z.string(),
scheme: z.enum(['http', 'https']),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
});
export type NodeMutationRequest = z.infer<typeof NodeMutationRequestSchema>;
export const NodeViewSchema = z.object({
activeCount: z.number().int(),
address: z.string(),
allowPrivateAddress: z.boolean(),
basePath: z.string(),
clientCount: z.number().int(),
configDirty: z.boolean(),
configDirtyAt: z.number().int(),
cpuPct: z.number(),
createdAt: z.number().int(),
depletedCount: z.number().int(),
disabledCount: z.number().int(),
enable: z.boolean(),
guid: z.string(),
hasApiToken: z.boolean(),
id: z.number().int(),
inboundCount: z.number().int(),
inboundSyncMode: z.string(),
inboundTags: z.array(z.string()),
lastError: z.string(),
lastHeartbeat: z.number().int(),
latencyMs: z.number().int(),
memPct: z.number(),
name: z.string(),
netDown: z.number().int(),
netUp: z.number().int(),
onlineCount: z.number().int(),
outboundTag: z.string(),
panelVersion: z.string(),
parentGuid: z.string().optional(),
pinnedCertSha256: z.string(),
port: z.number().int(),
remark: z.string(),
scheme: z.string(),
status: z.string(),
tlsVerifyMode: z.string(),
transitive: z.boolean().optional(),
updatedAt: z.number().int(),
uptimeSecs: z.number().int(),
xrayError: z.string(),
xrayState: z.string(),
xrayVersion: z.string(),
});
export type NodeView = z.infer<typeof NodeViewSchema>;
export const OutboundTrafficsSchema = z.object({ export const OutboundTrafficsSchema = z.object({
down: z.number().int(), down: z.number().int(),
id: z.number().int(), id: z.number().int(),
+116 -25
View File
@@ -73,7 +73,9 @@ export interface ClientQueryParams {
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 }; const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
const DEFAULT_SUMMARY: ClientsSummary = { 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 { export interface ClientSpeedEntry {
@@ -114,7 +116,63 @@ export function computeClientsSummary(
if (nearExpiry || nearLimit) expiring.push(email); if (nearExpiry || nearLimit) expiring.push(email);
else active += 1; 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 { function buildQS(p: ClientQueryParams): string {
@@ -142,7 +200,7 @@ async function fetchClientPage(params: ClientQueryParams): Promise<ClientPageRes
const qs = buildQS(params); const qs = buildQS(params);
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true }); 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'); 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'); if (!validated.obj) throw new Error('Empty clients response');
return validated.obj; return validated.obj;
} }
@@ -161,17 +219,31 @@ async function fetchDefaults(): Promise<Record<string, unknown>> {
return validated.obj || {}; 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 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 // setQuery shallow-compares so callers can pass a fresh object every render
// (the common React pattern) without triggering a re-fetch when nothing // (the common React pattern) without triggering a re-fetch when nothing
// actually changed. // actually changed.
const setQuery = useCallback((next: ClientQueryParams) => { const setQuery = useCallback((next: ClientQueryParams) => {
setQueryState((prev) => { setQueryState((prev) => {
if ( if (
prev.page === next.page prev
&& prev.page === next.page
&& prev.pageSize === next.pageSize && prev.pageSize === next.pageSize
&& (prev.search ?? '') === (next.search ?? '') && (prev.search ?? '') === (next.search ?? '')
&& (prev.filter ?? '') === (next.filter ?? '') && (prev.filter ?? '') === (next.filter ?? '')
@@ -193,8 +265,9 @@ export function useClients() {
}, []); }, []);
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: keys.clients.list(query), queryKey: keys.clients.list(query ?? DEFAULT_QUERY),
queryFn: () => fetchClientPage(query), queryFn: () => fetchClientPage(query ?? DEFAULT_QUERY),
enabled: withList && query !== null,
staleTime: Infinity, staleTime: Infinity,
// List is sorted/paged server-side, so the WS patch can't add new or // 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). // re-sort rows; poll the current page to keep it live (pauses when hidden).
@@ -205,6 +278,7 @@ export function useClients() {
const inboundOptionsQuery = useQuery({ const inboundOptionsQuery = useQuery({
queryKey: keys.inbounds.options(), queryKey: keys.inbounds.options(),
queryFn: fetchInboundOptions, queryFn: fetchInboundOptions,
enabled: withList,
staleTime: Infinity, staleTime: Infinity,
}); });
@@ -222,6 +296,7 @@ export function useClients() {
const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines'); const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines');
return Array.isArray(validated.obj) ? validated.obj : []; return Array.isArray(validated.obj) ? validated.obj : [];
}, },
enabled: withList,
staleTime: Infinity, staleTime: Infinity,
}); });
@@ -231,7 +306,11 @@ export function useClients() {
const allGroups = listQuery.data?.groups ?? []; const allGroups = listQuery.data?.groups ?? [];
const fetched = listQuery.data !== undefined || listQuery.isError; const fetched = listQuery.data !== undefined || listQuery.isError;
const fetchError = listQuery.error ? (listQuery.error as Error).message : ''; 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 // Showing kept-previous data for a new key (filter/sort/page) — drives the
// table overlay so the 5s background poll doesn't flash it. // table overlay so the 5s background poll doesn't flash it.
const transitioning = listQuery.isPlaceholderData; const transitioning = listQuery.isPlaceholderData;
@@ -264,19 +343,18 @@ export function useClients() {
const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000; const expireDiff = ((defaults.expireDiff as number) ?? 0) * 86400000;
const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824; const trafficDiff = ((defaults.trafficDiff as number) ?? 0) * 1073741824;
const pageSize = (defaults.pageSize as number) ?? 0; 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 [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({}); const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
const summary = useMemo<ClientsSummary>(() => { const summary = useMemo<ClientsSummary>(
const serverSummary = listQuery.data?.summary ?? DEFAULT_SUMMARY; () => pickClientsSummary(listQuery.data?.summary ?? DEFAULT_SUMMARY, allClientStats, new Set(onlines), expireDiff, trafficDiff),
if (allClientStats.length === 0) return serverSummary; [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary],
const live = computeClientsSummary(allClientStats, new Set(onlines), expireDiff, trafficDiff); );
return { ...live, total: serverSummary.total || live.total };
}, [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary]);
const invalidateAll = useCallback( const invalidateAll = useCallback(
() => { () => {
@@ -558,15 +636,23 @@ export function useClients() {
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients); queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
} }
if (Array.isArray(p.clientTraffics)) { 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> = {}; const next: Record<string, ClientSpeedEntry> = {};
for (const ct of p.clientTraffics) { for (const ct of p.clientTraffics) {
if (!ct || !ct.email) continue; if (!ct || !ct.email) continue;
const up = ct.up || 0;
const down = ct.down || 0;
if (up === 0 && down === 0) continue;
next[ct.email] = { next[ct.email] = {
up: (ct.up || 0) / TRAFFIC_POLL_INTERVAL_S, up: up / TRAFFIC_POLL_INTERVAL_S,
down: (ct.down || 0) / TRAFFIC_POLL_INTERVAL_S, down: down / TRAFFIC_POLL_INTERVAL_S,
}; };
} }
setClientSpeed(next); setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
} }
}, [queryClient]); }, [queryClient]);
@@ -574,12 +660,17 @@ export function useClients() {
if (!payload || typeof payload !== 'object') return; if (!payload || typeof payload !== 'object') return;
const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean }; const p = payload as { clients?: ClientStatRow[]; snapshot?: boolean };
if (!Array.isArray(p.clients) || p.clients.length === 0) return; 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>(); const byEmail = new Map<string, ClientTraffic>();
for (const row of p.clients) { for (const row of p.clients) {
if (row && row.email) byEmail.set(row.email, row); 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; if (!prev) return prev;
let touched = false; let touched = false;
const next = prev.items.slice(); const next = prev.items.slice();
@@ -617,7 +708,6 @@ export function useClients() {
setQuery, setQuery,
inbounds, inbounds,
onlines, onlines,
loading,
transitioning, transitioning,
fetched, fetched,
fetchError, fetchError,
@@ -627,6 +717,7 @@ export function useClients() {
expireDiff, expireDiff,
trafficDiff, trafficDiff,
pageSize, pageSize,
settingsReady,
refresh, refresh,
create, create,
bulkCreate, bulkCreate,
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const TITLE_KEYS: Record<string, string> = { 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 };
}
+39 -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 type { ReactNode } from 'react';
import { theme as antdTheme } from 'antd'; import { theme as antdTheme } from 'antd';
import type { ThemeConfig } from 'antd'; import type { ThemeConfig } from 'antd';
@@ -13,14 +13,18 @@ function readBool(key: string, fallback: boolean): boolean {
} }
function applyDom(isDark: boolean, isUltra: 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) { if (isUltra) {
document.documentElement.setAttribute('data-theme', 'ultra-dark'); document.documentElement.setAttribute('data-theme', 'ultra-dark');
} else { } else {
document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-theme');
} }
const msg = document.getElementById('message'); 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. // module load so the document is in the right theme before React mounts.
@@ -78,17 +82,48 @@ const STATISTIC_TOKENS = {
contentFontSize: 17, contentFontSize: 17,
titleFontSize: 11, titleFontSize: 11,
}; };
const LIGHT_CONTRAST_TOKENS = {
colorTextDescription: 'rgba(0, 0, 0, 0.58)',
colorTextTertiary: 'rgba(0, 0, 0, 0.58)',
colorTextPlaceholder: '#767676',
colorError: '#cf1322',
colorErrorText: '#cf1322',
colorSuccessText: '#237804',
};
const LIGHT_BUTTON_TOKENS = {
colorPrimary: '#0958d9',
colorPrimaryHover: '#2468e5',
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 { export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
if (!isDark) { if (!isDark) {
return { return {
...SHARED_STYLE_CONFIG,
algorithm: antdTheme.defaultAlgorithm, algorithm: antdTheme.defaultAlgorithm,
token: LIGHT_CONTRAST_TOKENS,
components: { components: {
Statistic: STATISTIC_TOKENS, Statistic: STATISTIC_TOKENS,
Button: LIGHT_BUTTON_TOKENS,
}, },
}; };
} }
return { return {
...SHARED_STYLE_CONFIG,
algorithm: antdTheme.darkAlgorithm, algorithm: antdTheme.darkAlgorithm,
token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS, token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
components: { components: {
@@ -127,7 +162,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const [isDark, setIsDark] = useState<boolean>(initialDark); const [isDark, setIsDark] = useState<boolean>(initialDark);
const [isUltra, setIsUltra] = useState<boolean>(initialUltra); const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
useEffect(() => { useLayoutEffect(() => {
applyDom(isDark, isUltra); applyDom(isDark, isUltra);
localStorage.setItem(STORAGE_DARK, String(isDark)); localStorage.setItem(STORAGE_DARK, String(isDark));
localStorage.setItem(STORAGE_ULTRA, String(isUltra)); localStorage.setItem(STORAGE_ULTRA, String(isUltra));

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